diff --git a/.github/PUBLISHING.md b/.github/PUBLISHING.md
index 06f4a79..87aa99d 100644
--- a/.github/PUBLISHING.md
+++ b/.github/PUBLISHING.md
@@ -1,16 +1,26 @@
# Secrets & publishing
-Release by pushing a version tag:
+## Branch flow
+
+- **`dev`** — day-to-day development; the `CI` workflow (lint + tests) runs on push and PRs.
+- **`main`** — release branch; merge `dev` → `main` after bumping versions with `scripts/set-version.sh`.
+- **`publish.yml`** — runs on push to `main` and on `v*` tags; publishes PyPI, npm, and NuGet packages.
+
+## Release
+
+Bump versions, merge to `main`, or push a version tag:
```bash
-git tag v0.1.0
-git push origin v0.1.0
+./scripts/set-version.sh 1.2.7
+git tag v1.2.7
+git push origin main --tags
```
-That triggers:
+That triggers `publish.yml`, which calls:
- `publish-pypi.yml` → PyPI package `fusion-framework`
- `publish-npm.yml` → npm package `fusion-framework`
+- `publish-nuget.yml` → NuGet package `Fusion-Framework`
## Required GitHub configuration
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3f4e68a..14d4365 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,9 +1,13 @@
+# CI runs on `dev` only. Feature branches open PRs against `dev`; releases merge `dev` → `main`
+# (see publish.yml). `main` does not run this workflow.
+
name: CI
on:
push:
- branches: [master, main]
+ branches: [dev]
pull_request:
+ branches: [dev]
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
@@ -13,8 +17,56 @@ env:
CARGO_TERM_COLOR: always
jobs:
+ lint:
+ name: Lint
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ components: rustfmt, clippy
+
+ - uses: Swatinem/rust-cache@v2
+
+ - name: rustfmt
+ run: cargo fmt --all -- --check
+
+ - name: clippy
+ # fusion-ffi uses raw FFI pointers; tighten to `-D warnings` once FFI lints are addressed.
+ run: cargo clippy --workspace --all-targets
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - name: Install ruff
+ run: python -m pip install ruff
+
+ - name: ruff check
+ run: ruff check crates/fusion-py/python
+
+ - name: ruff format
+ run: ruff format --check crates/fusion-py/python
+
+ # Node binding is a thin CommonJS wrapper over N-API; no eslint config in this repo.
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+
+ - name: node syntax check
+ run: node --check crates/fusion-node/index.js
+
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "10.0.x"
+
+ - name: dotnet format
+ run: dotnet format --verify-no-changes bindings/csharp/FusionFramework/FusionFramework.csproj
+
rust:
name: Rust workspace
+ needs: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -27,6 +79,7 @@ jobs:
python:
name: Python wheel (${{ matrix.os }})
+ needs: lint
strategy:
fail-fast: false
matrix:
@@ -51,6 +104,7 @@ jobs:
node:
name: Node addon (${{ matrix.settings.target }})
+ needs: lint
strategy:
fail-fast: false
matrix:
@@ -84,6 +138,7 @@ jobs:
csharp:
name: C# package
+ needs: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml
index db6e486..bc94d5f 100644
--- a/.github/workflows/publish-npm.yml
+++ b/.github/workflows/publish-npm.yml
@@ -1,9 +1,7 @@
name: Publish npm
on:
- push:
- tags:
- - "v*"
+ workflow_call:
workflow_dispatch:
# Trusted Publishing via OIDC — no NPM_TOKEN secret needed.
diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml
index 9a3f026..4415d80 100644
--- a/.github/workflows/publish-nuget.yml
+++ b/.github/workflows/publish-nuget.yml
@@ -1,9 +1,7 @@
name: Publish NuGet
on:
- push:
- tags:
- - "v*"
+ workflow_call:
workflow_dispatch:
# Trusted Publishing via OIDC — no NUGET_API_KEY secret needed.
diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml
index d4e1bb6..38e6395 100644
--- a/.github/workflows/publish-pypi.yml
+++ b/.github/workflows/publish-pypi.yml
@@ -1,9 +1,7 @@
name: Publish PyPI
on:
- push:
- tags:
- - "v*"
+ workflow_call:
workflow_dispatch:
# Trusted Publishing via OIDC — no PYPI_API_TOKEN secret needed.
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..5e1ee7c
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,36 @@
+# Release flow: merge `dev` → `main` after bumping versions with scripts/set-version.sh.
+# This workflow publishes PyPI, npm, and NuGet packages when code lands on `main` or on `v*` tags.
+# Tag pushes use the tag for package versions; `main` pushes use manifest versions as-is.
+# CI (lint + tests) runs on `dev` only — see ci.yml.
+
+name: Publish
+
+on:
+ push:
+ branches: [main]
+ tags: ["v*"]
+ workflow_dispatch:
+
+concurrency:
+ group: publish-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ pypi:
+ uses: ./.github/workflows/publish-pypi.yml
+ secrets: inherit
+
+ npm:
+ uses: ./.github/workflows/publish-npm.yml
+ secrets: inherit
+
+ nuget:
+ uses: ./.github/workflows/publish-nuget.yml
+ secrets: inherit
+
+ # TODO: enable when crates.io publishing and API token / trusted publishing are configured.
+ crates-io:
+ if: false
+ runs-on: ubuntu-latest
+ steps:
+ - run: echo "TODO cargo publish workspace crates (fusion-core is not published yet)"
diff --git a/Cargo.lock b/Cargo.lock
index 9bafb2f..69622de 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2,6 +2,20 @@
# It is not intended for manual editing.
version = 4
+[[package]]
+name = "ahash"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
+dependencies = [
+ "cfg-if",
+ "getrandom",
+ "once_cell",
+ "serde",
+ "version_check",
+ "zerocopy",
+]
+
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -133,6 +147,7 @@ dependencies = [
"hyper",
"hyper-util",
"serde_json",
+ "tera",
"tokio",
]
@@ -209,6 +224,18 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasip2",
+]
+
[[package]]
name = "h2"
version = "0.4.15"
@@ -529,6 +556,12 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "pulldown-cmark-escape"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
+
[[package]]
name = "pyo3"
version = "0.25.1"
@@ -600,6 +633,12 @@ dependencies = [
"proc-macro2",
]
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -789,6 +828,18 @@ version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
+[[package]]
+name = "tera"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52fca06a22977165c6821c26e2bf0387cd7e0822107a6854d5fbb787307c4194"
+dependencies = [
+ "ahash",
+ "itoa",
+ "pulldown-cmark-escape",
+ "serde",
+]
+
[[package]]
name = "tokio"
version = "1.53.1"
@@ -892,6 +943,12 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
[[package]]
name = "want"
version = "0.3.1"
@@ -907,6 +964,15 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+[[package]]
+name = "wasip2"
+version = "1.0.4+wasi-0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
+dependencies = [
+ "wit-bindgen",
+]
+
[[package]]
name = "windows-link"
version = "0.2.1"
@@ -951,6 +1017,32 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "zerocopy"
+version = "0.8.56"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.56"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
[[package]]
name = "zmij"
version = "1.0.23"
diff --git a/bindings/csharp/FusionFramework/ContentNegotiation.cs b/bindings/csharp/FusionFramework/ContentNegotiation.cs
new file mode 100644
index 0000000..e2d3756
--- /dev/null
+++ b/bindings/csharp/FusionFramework/ContentNegotiation.cs
@@ -0,0 +1,47 @@
+namespace FusionFramework;
+
+/// Shared content negotiation helpers (fusion-core parity).
+internal static class ContentNegotiation
+{
+ public static bool PrefersJson(string? accept, string? formatQuery)
+ {
+ if (string.Equals(formatQuery, "json", StringComparison.OrdinalIgnoreCase))
+ return true;
+
+ accept = accept?.Trim();
+ if (string.IsNullOrEmpty(accept))
+ return false;
+
+ var bestJson = -1.0f;
+ var bestHtml = -1.0f;
+
+ foreach (var part in accept.Split(','))
+ {
+ var tokens = part.Trim().Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
+ var media = tokens.Length > 0 ? tokens[0].ToLowerInvariant() : "";
+ var q = 1.0f;
+ for (var i = 1; i < tokens.Length; i++)
+ {
+ if (tokens[i].StartsWith("q=", StringComparison.OrdinalIgnoreCase)
+ && float.TryParse(tokens[i][2..], out var parsed))
+ {
+ q = parsed;
+ }
+ }
+
+ switch (media)
+ {
+ case "application/json":
+ case "text/json":
+ bestJson = Math.Max(bestJson, q);
+ break;
+ case "text/html":
+ case "application/xhtml+xml":
+ bestHtml = Math.Max(bestHtml, q);
+ break;
+ }
+ }
+
+ return bestJson > 0 && bestJson >= bestHtml;
+ }
+}
diff --git a/bindings/csharp/FusionFramework/FusionBaseApi.cs b/bindings/csharp/FusionFramework/FusionBaseApi.cs
index 388890c..c2f25fc 100644
--- a/bindings/csharp/FusionFramework/FusionBaseApi.cs
+++ b/bindings/csharp/FusionFramework/FusionBaseApi.cs
@@ -27,6 +27,22 @@ public abstract class FusionBaseApi
public IReadOnlyDictionary Query => Request.Query;
public IDictionary State => Request.State;
+ /// True when the client prefers JSON (Accept header or ?format=json).
+ public bool WantsJson()
+ {
+ string? accept = null;
+ foreach (var kv in Request.Headers)
+ {
+ if (string.Equals(kv.Key, "Accept", StringComparison.OrdinalIgnoreCase))
+ {
+ accept = kv.Value;
+ break;
+ }
+ }
+ Request.Query.TryGetValue("format", out var format);
+ return ContentNegotiation.PrefersJson(accept, format);
+ }
+
public object Response(object? body = null, int status = 200, IDictionary? headers = null)
{
var envelope = new Dictionary
diff --git a/bindings/csharp/FusionFramework/FusionBaseTemplate.cs b/bindings/csharp/FusionFramework/FusionBaseTemplate.cs
new file mode 100644
index 0000000..0672a51
--- /dev/null
+++ b/bindings/csharp/FusionFramework/FusionBaseTemplate.cs
@@ -0,0 +1,65 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace FusionFramework;
+
+/// HTML handlers using Tera templates (Python/Node parity).
+public abstract class FusionBaseTemplate : FusionBaseApi
+{
+ public static string Template { get; set; } = "";
+ public static string TemplateAddress { get; set; } = "";
+ public static string TemplatesDir { get; set; } = "";
+
+ public virtual Dictionary Context() => new();
+
+ public virtual object Get()
+ {
+ if (WantsJson())
+ return Context();
+ return Render();
+ }
+
+ public virtual string TemplateName()
+ {
+ var name = !string.IsNullOrEmpty(Template) ? Template : TemplateAddress;
+ if (string.IsNullOrEmpty(name))
+ throw new InvalidOperationException($"{GetType().Name} must set Template or TemplateAddress");
+ return name;
+ }
+
+ public virtual string TemplatesRoot()
+ {
+ if (!string.IsNullOrEmpty(TemplatesDir))
+ return TemplatesDir;
+ var fromSettings = SettingsStore.Current.Get("templates.dir", "templates");
+ return fromSettings is JsonValue v && v.TryGetValue(out var s) ? s : "templates";
+ }
+
+ public virtual object Render(
+ int status = 200,
+ IDictionary? headers = null,
+ IDictionary? context = null,
+ string? templateName = null)
+ {
+ var ctx = new Dictionary(Context(), StringComparer.Ordinal);
+ if (context != null)
+ {
+ foreach (var kv in context)
+ ctx[kv.Key] = kv.Value;
+ }
+ var html = Templates.Render(
+ templateName ?? TemplateName(),
+ ctx,
+ TemplatesRoot());
+ var hdrs = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["content-type"] = "text/html; charset=utf-8",
+ };
+ if (headers != null)
+ {
+ foreach (var kv in headers)
+ hdrs[kv.Key] = kv.Value;
+ }
+ return Response(html, status, hdrs);
+ }
+}
diff --git a/bindings/csharp/FusionFramework/FusionFramework.csproj b/bindings/csharp/FusionFramework/FusionFramework.csproj
index a23949d..1bd4cc0 100644
--- a/bindings/csharp/FusionFramework/FusionFramework.csproj
+++ b/bindings/csharp/FusionFramework/FusionFramework.csproj
@@ -25,5 +25,6 @@
+
diff --git a/bindings/csharp/FusionFramework/Middleware.cs b/bindings/csharp/FusionFramework/Middleware.cs
index 36fd0c6..ad1c598 100644
--- a/bindings/csharp/FusionFramework/Middleware.cs
+++ b/bindings/csharp/FusionFramework/Middleware.cs
@@ -53,32 +53,32 @@ public static void SetActiveGlobal(IEnumerable middlewares)
case null:
return null;
case Task task:
- {
- task.ConfigureAwait(false).GetAwaiter().GetResult();
- var t = task.GetType();
- if (t.IsGenericType)
{
- result = t.GetProperty("Result")?.GetValue(task);
- continue;
- }
- return null;
- }
- default:
- {
- var type = result.GetType();
- if (type == typeof(ValueTask))
- {
- ((ValueTask)result).ConfigureAwait(false).GetAwaiter().GetResult();
+ task.ConfigureAwait(false).GetAwaiter().GetResult();
+ var t = task.GetType();
+ if (t.IsGenericType)
+ {
+ result = t.GetProperty("Result")?.GetValue(task);
+ continue;
+ }
return null;
}
- if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ValueTask<>))
+ default:
{
- // ValueTask → Task then unwrap on next iteration.
- result = type.GetMethod("AsTask")!.Invoke(result, null);
- continue;
+ var type = result.GetType();
+ if (type == typeof(ValueTask))
+ {
+ ((ValueTask)result).ConfigureAwait(false).GetAwaiter().GetResult();
+ return null;
+ }
+ if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ValueTask<>))
+ {
+ // ValueTask → Task then unwrap on next iteration.
+ result = type.GetMethod("AsTask")!.Invoke(result, null);
+ continue;
+ }
+ return result;
}
- return result;
- }
}
}
diff --git a/bindings/csharp/FusionFramework/Native.cs b/bindings/csharp/FusionFramework/Native.cs
index 88123ee..8768268 100644
--- a/bindings/csharp/FusionFramework/Native.cs
+++ b/bindings/csharp/FusionFramework/Native.cs
@@ -186,6 +186,12 @@ public static extern IntPtr fusion_header_download(
[DllImport(LibName, CallingConvention = CallingConvention.Cdecl)]
public static extern IntPtr fusion_fingerprint_headers();
+ [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)]
+ public static extern IntPtr fusion_render_template(
+ [MarshalAs(UnmanagedType.LPUTF8Str)] string templateName,
+ [MarshalAs(UnmanagedType.LPUTF8Str)] string contextJson,
+ [MarshalAs(UnmanagedType.LPUTF8Str)] string? templatesRoot);
+
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate IntPtr FusionHandlerFn(
IntPtr userData,
diff --git a/bindings/csharp/FusionFramework/Swagger.cs b/bindings/csharp/FusionFramework/Swagger.cs
index ede2688..349a98f 100644
--- a/bindings/csharp/FusionFramework/Swagger.cs
+++ b/bindings/csharp/FusionFramework/Swagger.cs
@@ -18,6 +18,7 @@ public static void Mount(FusionApp app, FusionSettings settings)
var labels = ApplyVersionNavbar(swagger);
var combined = BuildOpenApi(swagger);
+ MountAssets(app, prefix);
app.AddRawRoute("GET", $"{prefix}/openapi.json", () => combined);
app.AddRawRoute("GET", prefix, () => Html(UiHtml(swagger, $"{prefix}/openapi.json")));
if (prefix != "/")
@@ -110,7 +111,7 @@ sealed class SwaggerConfig
["showCommonExtensions"] = false,
["syntaxHighlight"] = new JsonObject { ["activated"] = true, ["theme"] = "agate" },
["withCredentials"] = false,
- ["validatorUrl"] = "https://validator.swagger.io/validator",
+ ["validatorUrl"] = null,
};
var uiOverlay = AsObject(settings.Get("swagger.ui", new { }));
if (uiOverlay is not null)
@@ -400,7 +401,7 @@ static string UiHtml(SwaggerConfig swagger, string openapiUrl, string? primaryNa
"""
: "";
var standalone = navbarEnabled
- ? """"""
+ ? $""""""
: "";
var navbarJs = navbarEnabled ? "true" : "false";
@@ -411,12 +412,12 @@ static string UiHtml(SwaggerConfig swagger, string openapiUrl, string? primaryNa
{{title}}
-
+
{{hideUrlCss}}
-
+
{{standalone}}
`
+ ? ``
: ''
return `
@@ -854,12 +975,12 @@ function swaggerUiHtml(swagger, openapiUrl, primaryName = null) {
${title}
-
+
${hideUrlCss}
-
+
${standaloneScript}
'
+ f''
)
bootstrap = f"""
@@ -262,12 +298,12 @@ def _swagger_ui_html(swagger: dict[str, Any], openapi_url: str, primary_name: st
{title}
-
+
{hide_url_css}
-
+
{standalone_script}