diff --git a/.agents/skills/fusion-cli/SKILL.md b/.agents/skills/fusion-cli/SKILL.md index 01a4024..97cba71 100644 --- a/.agents/skills/fusion-cli/SKILL.md +++ b/.agents/skills/fusion-cli/SKILL.md @@ -152,6 +152,8 @@ C# (`asp-core`): `main.cs`, `*.csproj` (`net10.0`), `[Route]` / `[HttpGet]`. } ``` +For TypeScript apps, `commands.run` must invoke a TypeScript runner (e.g. `npx tsx main.ts`); plain `node main.ts` fails with `ERR_UNKNOWN_FILE_EXTENSION`. With `reload: true`, the framework reloader forwards `process.execArgv` so tsx loaders survive child respawns. Older CLI scaffolds that still emit `node main.ts` should be updated in **fusion-tool** (`environment.rs` / `structure.rs`). + `FUSION_ENV` selects `fusion..json` (default `dev`). Unresolved `HOST` placeholders must not crash listen — framework resolves safe defaults. ## Compatibility duties (framework ↔ CLI) diff --git a/Cargo.lock b/Cargo.lock index fbd8e55..e5ea81f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -168,7 +168,7 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "fusion-core" -version = "2.0.0" +version = "2.0.1" dependencies = [ "bytes", "console", @@ -184,7 +184,7 @@ dependencies = [ [[package]] name = "fusion-ffi" -version = "2.0.0" +version = "2.0.1" dependencies = [ "bytes", "fusion-core", @@ -194,7 +194,7 @@ dependencies = [ [[package]] name = "fusion-node" -version = "2.0.0" +version = "2.0.1" dependencies = [ "fusion-core", "napi", @@ -207,7 +207,7 @@ dependencies = [ [[package]] name = "fusion-py" -version = "2.0.0" +version = "2.0.1" dependencies = [ "bytes", "fusion-core", diff --git a/Cargo.toml b/Cargo.toml index 578a400..d1f9dfa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ ] [workspace.package] -version = "2.0.0" +version = "2.0.1" edition = "2024" [workspace.dependencies] diff --git a/bindings/csharp/FusionFramework/FusionBaseApi.cs b/bindings/csharp/FusionFramework/FusionBaseApi.cs index c2f25fc..69c0cf1 100644 --- a/bindings/csharp/FusionFramework/FusionBaseApi.cs +++ b/bindings/csharp/FusionFramework/FusionBaseApi.cs @@ -56,8 +56,11 @@ public object Response(object? body = null, int status = 200, IDictionary JsonSerializer.SerializeToNode(body), }, }; + // Keep Dictionary so middleware MergeResponseHeaders can + // preserve content-type (e.g. text/html from templates). Boxing as object + // made the typed merge check fail and dropped headers before the browser. if (headers is { Count: > 0 }) - envelope["headers"] = headers.ToDictionary(kv => kv.Key, kv => (object)kv.Value); + envelope["headers"] = new Dictionary(headers, StringComparer.OrdinalIgnoreCase); return envelope; } diff --git a/bindings/csharp/FusionFramework/FusionFramework.csproj b/bindings/csharp/FusionFramework/FusionFramework.csproj index 5b84461..c5e4eb8 100644 --- a/bindings/csharp/FusionFramework/FusionFramework.csproj +++ b/bindings/csharp/FusionFramework/FusionFramework.csproj @@ -7,7 +7,7 @@ FusionFramework FusionFramework Fusion-Framework - 2.0.0 + 2.0.1 CipherUnits CipherUnits Fusion Framework managed bindings (C#) over fusion-ffi / fusion-core @@ -28,6 +28,14 @@ - + + + + + + diff --git a/bindings/csharp/FusionFramework/Header.cs b/bindings/csharp/FusionFramework/Header.cs index ac0f64e..2169c93 100644 --- a/bindings/csharp/FusionFramework/Header.cs +++ b/bindings/csharp/FusionFramework/Header.cs @@ -90,7 +90,7 @@ public static Dictionary Fingerprint() { ["X-Powered-By"] = "Fusion Framework", ["X-Framework"] = "Fusion", - ["X-Fusion-Version"] = "2.0.0", + ["X-Fusion-Version"] = "2.0.1", }; } } diff --git a/bindings/csharp/FusionFramework/Middleware.cs b/bindings/csharp/FusionFramework/Middleware.cs index 6978304..cde4cfc 100644 --- a/bindings/csharp/FusionFramework/Middleware.cs +++ b/bindings/csharp/FusionFramework/Middleware.cs @@ -489,15 +489,20 @@ public static FusionMiddleware FrameworkHeaders() }; } + /// + /// Merge middleware headers into a response envelope. Existing headers win on key conflict. + /// Accepts string or object-valued header maps so template content-type is not dropped. + /// internal static object MergeResponseHeaders(object? result, IReadOnlyDictionary extra) { if (result is Dictionary dict) { var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var kv in extra) headers[kv.Key] = kv.Value; - if (dict.TryGetValue("headers", out var existing) && existing is IDictionary map) + if (dict.TryGetValue("headers", out var existing) && existing is not null) { - foreach (var kv in map) headers[kv.Key] = kv.Value; + foreach (var kv in CoerceHeaderMap(existing)) + headers[kv.Key] = kv.Value; } dict["headers"] = headers; return dict; @@ -511,6 +516,46 @@ internal static object MergeResponseHeaders(object? result, IReadOnlyDictionary< }; } + /// Normalize envelope headers from string or object dictionaries to string pairs. + static IEnumerable> CoerceHeaderMap(object existing) + { + if (existing is IDictionary map) + { + foreach (var kv in map) + yield return kv; + yield break; + } + + if (existing is IDictionary objNullable) + { + foreach (var kv in objNullable) + { + if (kv.Value is null) continue; + yield return new KeyValuePair(kv.Key, kv.Value.ToString() ?? ""); + } + yield break; + } + + if (existing is IDictionary objMap) + { + foreach (var kv in objMap) + { + if (kv.Value is null) continue; + yield return new KeyValuePair(kv.Key, kv.Value.ToString() ?? ""); + } + yield break; + } + + if (existing is System.Collections.IDictionary idict) + { + foreach (System.Collections.DictionaryEntry entry in idict) + { + if (entry.Key is not string key || entry.Value is null) continue; + yield return new KeyValuePair(key, entry.Value.ToString() ?? ""); + } + } + } + static object Error(int status, string detail) => new Dictionary { diff --git a/bindings/csharp/FusionFramework/Status.cs b/bindings/csharp/FusionFramework/Status.cs index 61a0f9e..6f5a819 100644 --- a/bindings/csharp/FusionFramework/Status.cs +++ b/bindings/csharp/FusionFramework/Status.cs @@ -85,9 +85,10 @@ public object ToResponse() ["body"] = body is string or JsonNode ? body : JsonSerializer.SerializeToNode(body), + // Same string-typed map as FusionBaseApi.Response so header merges keep values. ["headers"] = Headers.Count == 0 ? null - : Headers.ToDictionary(kv => kv.Key, kv => (object)kv.Value), + : new Dictionary(Headers, StringComparer.OrdinalIgnoreCase), }; } } diff --git a/bindings/csharp/FusionFramework/Swagger.cs b/bindings/csharp/FusionFramework/Swagger.cs index 394daea..a6f0216 100644 --- a/bindings/csharp/FusionFramework/Swagger.cs +++ b/bindings/csharp/FusionFramework/Swagger.cs @@ -464,7 +464,7 @@ static string UiHtml(SwaggerConfig swagger, string openapiUrl, string? primaryNa """ : ""; - var standalone = needsStandalone && File.Exists(Path.Combine(AssetsDirectory(), "swagger-ui-standalone-preset.js")) + var standalone = needsStandalone && TryLoadSwaggerAsset("swagger-ui-standalone-preset.js") is not null ? $"""""" : ""; var navbarJs = needsStandalone ? "true" : "false"; @@ -514,6 +514,7 @@ static readonly (string Name, string ContentType)[] SwaggerAssetFiles = ("swagger-ui.css", "text/css; charset=utf-8"), ]; + /// Directory next to the assembly (ProjectReference / copy-to-output layouts). static string AssetsDirectory() { var asmDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); @@ -524,15 +525,38 @@ static string AssetsDirectory() static string AssetUrl(string prefix, string name) => $"{prefix}/assets/{name}"; + /// + /// Load a Swagger UI asset from embedded resources (NuGet) or disk next to the DLL. + /// SDK embeds folder segments with '_' (static/swagger-ui → static.swagger_ui). + /// + internal static string? TryLoadSwaggerAsset(string name) + { + var asm = Assembly.GetExecutingAssembly(); + var resourceName = asm.GetManifestResourceNames() + .FirstOrDefault(n => + n.EndsWith($".{name}", StringComparison.OrdinalIgnoreCase) + || string.Equals(n, name, StringComparison.OrdinalIgnoreCase)); + if (resourceName is not null) + { + using var stream = asm.GetManifestResourceStream(resourceName); + if (stream is not null) + { + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } + } + + var filePath = Path.Combine(AssetsDirectory(), name); + return File.Exists(filePath) ? File.ReadAllText(filePath) : null; + } + static void MountAssets(FusionApp app, string prefix) { - var dir = AssetsDirectory(); foreach (var (name, contentType) in SwaggerAssetFiles) { - var filePath = Path.Combine(dir, name); - if (!File.Exists(filePath)) + var body = TryLoadSwaggerAsset(name); + if (body is null) continue; - var body = File.ReadAllText(filePath); app.AddRawRoute("GET", $"{prefix}/assets/{name}", () => new Dictionary { ["status"] = 200, diff --git a/crates/fusion-node/index.js b/crates/fusion-node/index.js index a639f84..4134794 100644 --- a/crates/fusion-node/index.js +++ b/crates/fusion-node/index.js @@ -135,7 +135,7 @@ header.fingerprint = () => : { 'X-Powered-By': 'Fusion Framework', 'X-Framework': 'Fusion', - ['X-Fusion-Version']: '2.0.0', + ['X-Fusion-Version']: '2.0.1', } function isThenable(value) { @@ -1667,6 +1667,15 @@ function snapshotMtimes(files) { return map } +/** + * Argv for the reload child process. + * Forwards `execArgv` so loaders like tsx (`--import` / `--require`) survive + * `spawn(process.execPath, …)` — unlike `fork()`, spawn does not inherit them. + */ +function reloadChildArgv(execArgv = process.execArgv, argv = process.argv) { + return [...execArgv, ...argv.slice(1)] +} + async function runWithReloader({ watchDirs } = {}) { const roots = watchDirs?.length ? watchDirs : [process.cwd()] console.log(`fusion: reload enabled (watching ${roots.join(', ')})`) @@ -1674,7 +1683,7 @@ async function runWithReloader({ watchDirs } = {}) { let child = null const spawnChild = () => { const env = { ...process.env, FUSION_RELOAD_CHILD: '1' } - child = spawn(process.execPath, process.argv.slice(1), { + child = spawn(process.execPath, reloadChildArgv(), { env, stdio: 'inherit', }) @@ -1989,6 +1998,7 @@ module.exports = { openapiSpec, routeVersions, hasUnversionedRoutes, + reloadChildArgv, getHttpMethods: () => HTTP_METHODS, apiResourceNameJs: native.apiResourceNameJs, resolveRoutePathJs: native.resolveRoutePathJs, diff --git a/crates/fusion-node/package.json b/crates/fusion-node/package.json index 2e5e612..11a8f1f 100644 --- a/crates/fusion-node/package.json +++ b/crates/fusion-node/package.json @@ -1,6 +1,6 @@ { "name": "fusion-framework", - "version": "2.0.0", + "version": "2.0.1", "description": "Class-based HTTP framework for Node.js, powered by a shared Rust core via N-API", "keywords": [ "fusion", diff --git a/crates/fusion-py/pyproject.toml b/crates/fusion-py/pyproject.toml index 15b8f93..0689a3d 100644 --- a/crates/fusion-py/pyproject.toml +++ b/crates/fusion-py/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "fusion-framework" -version = "2.0.0" +version = "2.0.1" description = "Class-based HTTP framework for Python, powered by a shared Rust core" readme = "README.md" requires-python = ">=3.9" diff --git a/crates/fusion-py/python/fusion_framework/middleware.py b/crates/fusion-py/python/fusion_framework/middleware.py index 2c31a78..abbd2f9 100644 --- a/crates/fusion-py/python/fusion_framework/middleware.py +++ b/crates/fusion-py/python/fusion_framework/middleware.py @@ -258,13 +258,13 @@ def framework_headers() -> Middleware: hdr, "FRAMEWORK_POWERED_BY", "Fusion Framework" ), getattr(hdr, "X_FRAMEWORK", "X-Framework"): getattr(hdr, "FRAMEWORK_ID", "Fusion"), - getattr(hdr, "X_FUSION_VERSION", "X-Fusion-Version"): "2.0.0", + getattr(hdr, "X_FUSION_VERSION", "X-Fusion-Version"): "2.0.1", } except Exception: extra = { "X-Powered-By": "Fusion Framework", "X-Framework": "Fusion", - "X-Fusion-Version": "2.0.0", + "X-Fusion-Version": "2.0.1", } def middleware(request: RequestDict, call_next: Callable[[RequestDict], Any]) -> Any: diff --git a/tests/csharp/FusionFramework.Tests/MiddlewareTests.cs b/tests/csharp/FusionFramework.Tests/MiddlewareTests.cs index 97f4d4c..9039852 100644 --- a/tests/csharp/FusionFramework.Tests/MiddlewareTests.cs +++ b/tests/csharp/FusionFramework.Tests/MiddlewareTests.cs @@ -106,4 +106,51 @@ public void StaticFiles_serves_asset_under_prefix() dir.Delete(recursive: true); } } + + [Fact] + public void SecurityHeaders_preserves_html_content_type_from_response() + { + // Template HtmlResponse uses Response(..., headers) then SecurityHeaders merges; + // content-type must survive so the browser renders HTML instead of raw source. + var api = new HeaderProbeApi(); + var envelope = api.Response( + "ok", + 200, + new Dictionary { ["content-type"] = "text/html; charset=utf-8" }); + + var result = Middleware.RunChain( + new FusionRequest { Method = "GET", Path = "/", Headers = new Dictionary() }, + new[] { Middleware.SecurityHeaders() }, + _ => envelope) as Dictionary; + + Assert.NotNull(result); + var headers = Assert.IsType>(result["headers"]); + Assert.StartsWith("text/html", headers["content-type"], StringComparison.OrdinalIgnoreCase); + Assert.True(headers.ContainsKey("X-Content-Type-Options")); + } + + [Fact] + public void MergeResponseHeaders_keeps_object_boxed_content_type() + { + // Defensive path: older envelopes boxed header values as object. + var result = Middleware.MergeResponseHeaders( + new Dictionary + { + ["status"] = 200, + ["body"] = "", + ["headers"] = new Dictionary + { + ["content-type"] = "text/html; charset=utf-8", + }, + }, + new Dictionary { ["X-Content-Type-Options"] = "nosniff" }) + as Dictionary; + + Assert.NotNull(result); + var headers = Assert.IsType>(result["headers"]); + Assert.Equal("text/html; charset=utf-8", headers["content-type"]); + Assert.Equal("nosniff", headers["X-Content-Type-Options"]); + } + + sealed class HeaderProbeApi : FusionBaseApi; } diff --git a/tests/csharp/FusionFramework.Tests/RouteTests.cs b/tests/csharp/FusionFramework.Tests/RouteTests.cs index 2957579..a67c876 100644 --- a/tests/csharp/FusionFramework.Tests/RouteTests.cs +++ b/tests/csharp/FusionFramework.Tests/RouteTests.cs @@ -52,6 +52,22 @@ public void Template_routes_are_omitted_from_openapi() Assert.True(v1["paths"]!.AsObject().ContainsKey("/v1/api/product")); } + [Fact] + public void Swagger_ui_assets_load_from_embedded_resources() + { + var css = SwaggerDocs.TryLoadSwaggerAsset("swagger-ui.css"); + var bundle = SwaggerDocs.TryLoadSwaggerAsset("swagger-ui-bundle.js"); + Assert.False(string.IsNullOrEmpty(css)); + Assert.False(string.IsNullOrEmpty(bundle)); + Assert.Contains("SwaggerUIBundle", bundle, StringComparison.Ordinal); + + // Prefer the embedded stream (NuGet path), not only CopyToOutputDirectory files. + var asm = typeof(SwaggerDocs).Assembly; + Assert.Contains( + asm.GetManifestResourceNames(), + n => n.EndsWith(".swagger-ui.css", StringComparison.OrdinalIgnoreCase)); + } + [Route("/api/[module]")] sealed class ProductModule : FusionBaseApi { diff --git a/tests/node/unit/bindings.test.js b/tests/node/unit/bindings.test.js index 34368b2..161a9ee 100644 --- a/tests/node/unit/bindings.test.js +++ b/tests/node/unit/bindings.test.js @@ -166,3 +166,14 @@ describe('middleware', () => { assert.equal(headers['content-type'], 'image/png') }) }) + +describe('reload child argv', () => { + it('forwards execArgv so tsx loaders survive spawn', () => { + const { reloadChildArgv } = fusion + const argv = reloadChildArgv( + ['--import', 'file:///tsx/loader.mjs'], + ['/usr/bin/node', 'main.ts'], + ) + assert.deepEqual(argv, ['--import', 'file:///tsx/loader.mjs', 'main.ts']) + }) +})