Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .agents/skills/fusion-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<env>.json` (default `dev`). Unresolved `HOST` placeholders must not crash listen — framework resolves safe defaults.

## Compatibility duties (framework ↔ CLI)
Expand Down
8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ members = [
]

[workspace.package]
version = "2.0.0"
version = "2.0.1"
edition = "2024"

[workspace.dependencies]
Expand Down
5 changes: 4 additions & 1 deletion bindings/csharp/FusionFramework/FusionBaseApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,11 @@ public object Response(object? body = null, int status = 200, IDictionary<string
_ => JsonSerializer.SerializeToNode(body),
},
};
// Keep Dictionary<string, string> 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<string, string>(headers, StringComparer.OrdinalIgnoreCase);
return envelope;
}

Expand Down
12 changes: 10 additions & 2 deletions bindings/csharp/FusionFramework/FusionFramework.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<RootNamespace>FusionFramework</RootNamespace>
<AssemblyName>FusionFramework</AssemblyName>
<PackageId>Fusion-Framework</PackageId>
<Version>2.0.0</Version>
<Version>2.0.1</Version>
<Authors>CipherUnits</Authors>
<Company>CipherUnits</Company>
<Description>Fusion Framework managed bindings (C#) over fusion-ffi / fusion-core</Description>
Expand All @@ -28,6 +28,14 @@
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="/" />
<Content Include="runtimes/**" Pack="true" PackagePath="runtimes" CopyToOutputDirectory="PreserveNewest" />
<Content Include="static/swagger-ui/**" Pack="true" PackagePath="static/swagger-ui/%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
<!-- Disk copy for ProjectReference / local builds; also pack beside the DLL for NuGet. -->
<Content Include="static/swagger-ui/**"
Pack="true"
PackagePath="lib/$(TargetFramework)/static/swagger-ui/%(RecursiveDir)%(Filename)%(Extension)"
CopyToOutputDirectory="PreserveNewest" />
<!-- Embedded so NuGet consumers still serve Swagger UI without copy-to-output. -->
<EmbeddedResource Include="static/swagger-ui/swagger-ui-bundle.js" />
<EmbeddedResource Include="static/swagger-ui/swagger-ui-standalone-preset.js" />
<EmbeddedResource Include="static/swagger-ui/swagger-ui.css" />
</ItemGroup>
</Project>
2 changes: 1 addition & 1 deletion bindings/csharp/FusionFramework/Header.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ public static Dictionary<string, string> Fingerprint()
{
["X-Powered-By"] = "Fusion Framework",
["X-Framework"] = "Fusion",
["X-Fusion-Version"] = "2.0.0",
["X-Fusion-Version"] = "2.0.1",
};
}
}
Expand Down
49 changes: 47 additions & 2 deletions bindings/csharp/FusionFramework/Middleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -489,15 +489,20 @@ public static FusionMiddleware FrameworkHeaders()
};
}

/// <summary>
/// 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.
/// </summary>
internal static object MergeResponseHeaders(object? result, IReadOnlyDictionary<string, string> extra)
{
if (result is Dictionary<string, object?> dict)
{
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var kv in extra) headers[kv.Key] = kv.Value;
if (dict.TryGetValue("headers", out var existing) && existing is IDictionary<string, string> 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;
Expand All @@ -511,6 +516,46 @@ internal static object MergeResponseHeaders(object? result, IReadOnlyDictionary<
};
}

/// <summary>Normalize envelope headers from string or object dictionaries to string pairs.</summary>
static IEnumerable<KeyValuePair<string, string>> CoerceHeaderMap(object existing)
{
if (existing is IDictionary<string, string> map)
{
foreach (var kv in map)
yield return kv;
yield break;
}

if (existing is IDictionary<string, object?> objNullable)
{
foreach (var kv in objNullable)
{
if (kv.Value is null) continue;
yield return new KeyValuePair<string, string>(kv.Key, kv.Value.ToString() ?? "");
}
yield break;
}

if (existing is IDictionary<string, object> objMap)
{
foreach (var kv in objMap)
{
if (kv.Value is null) continue;
yield return new KeyValuePair<string, string>(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<string, string>(key, entry.Value.ToString() ?? "");
}
}
}

static object Error(int status, string detail) =>
new Dictionary<string, object?>
{
Expand Down
3 changes: 2 additions & 1 deletion bindings/csharp/FusionFramework/Status.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>(Headers, StringComparer.OrdinalIgnoreCase),
};
}
}
34 changes: 29 additions & 5 deletions bindings/csharp/FusionFramework/Swagger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -464,7 +464,7 @@ static string UiHtml(SwaggerConfig swagger, string openapiUrl, string? primaryNa
</style>
"""
: "";
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
? $"""<script src="{AssetUrl(swagger.Path, "swagger-ui-standalone-preset.js")}"></script>"""
: "";
var navbarJs = needsStandalone ? "true" : "false";
Expand Down Expand Up @@ -514,6 +514,7 @@ static readonly (string Name, string ContentType)[] SwaggerAssetFiles =
("swagger-ui.css", "text/css; charset=utf-8"),
];

/// <summary>Directory next to the assembly (ProjectReference / copy-to-output layouts).</summary>
static string AssetsDirectory()
{
var asmDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
Expand All @@ -524,15 +525,38 @@ static string AssetsDirectory()

static string AssetUrl(string prefix, string name) => $"{prefix}/assets/{name}";

/// <summary>
/// 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).
/// </summary>
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<string, object?>
{
["status"] = 200,
Expand Down
14 changes: 12 additions & 2 deletions crates/fusion-node/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -1667,14 +1667,23 @@ 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(', ')})`)

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',
})
Expand Down Expand Up @@ -1989,6 +1998,7 @@ module.exports = {
openapiSpec,
routeVersions,
hasUnversionedRoutes,
reloadChildArgv,
getHttpMethods: () => HTTP_METHODS,
apiResourceNameJs: native.apiResourceNameJs,
resolveRoutePathJs: native.resolveRoutePathJs,
Expand Down
2 changes: 1 addition & 1 deletion crates/fusion-node/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion crates/fusion-py/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions crates/fusion-py/python/fusion_framework/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
47 changes: 47 additions & 0 deletions tests/csharp/FusionFramework.Tests/MiddlewareTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"<html><body>ok</body></html>",
200,
new Dictionary<string, string> { ["content-type"] = "text/html; charset=utf-8" });

var result = Middleware.RunChain(
new FusionRequest { Method = "GET", Path = "/", Headers = new Dictionary<string, string>() },
new[] { Middleware.SecurityHeaders() },
_ => envelope) as Dictionary<string, object?>;

Assert.NotNull(result);
var headers = Assert.IsType<Dictionary<string, string>>(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<string, object?>
{
["status"] = 200,
["body"] = "<html/>",
["headers"] = new Dictionary<string, object>
{
["content-type"] = "text/html; charset=utf-8",
},
},
new Dictionary<string, string> { ["X-Content-Type-Options"] = "nosniff" })
as Dictionary<string, object?>;

Assert.NotNull(result);
var headers = Assert.IsType<Dictionary<string, string>>(result["headers"]);
Assert.Equal("text/html; charset=utf-8", headers["content-type"]);
Assert.Equal("nosniff", headers["X-Content-Type-Options"]);
}

sealed class HeaderProbeApi : FusionBaseApi;
}
16 changes: 16 additions & 0 deletions tests/csharp/FusionFramework.Tests/RouteTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
Loading
Loading