forma-server
Axum middleware for the Forma Stack — server-side rendering, content-hashed asset serving, and CSP headers. Renders pages from FMIR binary IR without a JavaScript runtime.
Install
[]
= "0.2.2"
This pulls in forma-ir automatically.
What It Does
try_render(config, ctx)— renders a page from an IR module + slot data and returnsResult<PageResponse, RenderError>: the page with its counts (nodes, bytes, list rows, walk time), or a coded error naming the route and component; never an empty shell in place of a failure, and a panic in the render comes back asRenderError::Faultrender_browser_shell(config, ctx)— the bundle and the same headers around an empty root, for a route the build declaresssr: falserender_page(config)— deprecated in 0.2.2: serves the empty Phase 1 shell with a 200 on any failurePageResponse— the page as axum'sIntoResponse: alwaysCache-Control: no-store, the strict policy for the page's nonce, and the document headers; a handler extends the policy with typedPolicyExtensions, never as textload_ir_modules_strict(manifest)— the module loader: loads and checks every.irfile the manifest declares at startup, or returns every failure (route, file and aLOAD-1xxcode) so the server refuses to start before its port binds; a route declaredssr: falseneeds no module. The lenientload_ir_modules, which serves a corrupt module's route as an empty shell, is deprecated in 0.2.2IrModules::<Assets>::load(manifest)— the same strict loading, holding the modules for you:get(route)returns the route's module, and in a debug build rereads it whenforma-build --watchrewrites it, so an edited page is served on the next request without a restart (a module that fails to load keeps the previous one and logs its code). A release build reads nothing after startsw::serve_swandsw::configure: the service worker is opt-in (0.2.2). By default no page registers one and/sw.jsserves a retiring worker that clears Forma's v1 caches and unregisters; an application that wants its worker callssw::configure(ServiceWorker::for_app("name")?.enabled())once at startserve_asset(filename)— serves assets with Brotli/gzip negotiation;Cache-Control: immutablefor content-hashed names, revalidation + ETag for the restgenerate_csp_nonce()— 256-bit cryptographically random nonce (ring CSPRNG), returned as a validatingCspNoncebuild_csp_header(nonce)— strict CSP: nonce +'strict-dynamic'scripts, nonce-locked style elements, nounsafe-eval
Quick Start
use StatusCode;
use ;
use ;
use Embed;
;
// At startup, before the port binds: a bad manifest or module stops the server.
let manifest = ?;
let =
.map_err?;
// Per request. PageConfig has no Default impl — every field is explicit.
let page = try_render;
let response: Response = match page ;
A page whose counts show more than about 12,500 nodes holds its thread past a millisecond (at the measured 12.5 million nodes a second); move that route's try_render into tokio::task::spawn_blocking (the recipe is in try_render's documentation). forma-server itself starts no threads and depends on no async runtime.
Two Rendering Modes
| Mode | What happens | When to use |
|---|---|---|
| Phase 1: Client Mount | Renders <div id="app"></div> — FormaJS mounts from scratch |
No IR available, or fallback |
| Phase 2: SSR Reconcile | Renders full HTML from IR walker — FormaJS hydrates | IR + slots available, fast first paint |
Security
- CSP headers: Strict policy with cryptographic nonces —
'strict-dynamic'scripts, nonce-locked style elements, nounsafe-eval. Style attributes are permitted viastyle-src-attr 'unsafe-inline', because compiledstyle:bindings emit them and a nonce cannot be attached to an attribute; it is the policy's only'unsafe-inline' - Trusted Types, reported (0.2.2): a page carries
Content-Security-Policy-Report-Only: require-trusted-types-for 'script'; trusted-types forma forma-runtimewhen the application names a reporting endpoint, and with none in development builds only (released, it would report to nobody, and WebKit logs an error for it on every page); 3.0.0 enforces it. Name a reporting endpoint once,PolicyExtensions::new().report_to("/csp-reports"), and every route that starts from it reports violations of both policies there (report-towithReporting-Endpoints, andreport-uri). Chrome and Firefox take the Reporting API only from HTTPS pages and then ignorereport-uri, so a page served over plain HTTP (development and CI,http://localhostincluded) usesreport_uri("/csp-reports"), which writesreport-urialone. A loopbackhttp://endpoint is accepted in development builds only. A page that registers its own Trusted Typesdefaultpolicy has it run on string writes from 0.2.2 wherever the header is sent: with an endpoint, or in a development build. A development build with no endpoint still makes WebKit (Safari) log a console error on every page ("… does not specify a 'report-to'; the policy will have no effect");report_uri("/csp-reports")silences it there too. A check that Trusted Types raise zero reports or violations must name an endpoint or run a debug build: a release build with no endpoint sends no Report-Only policy, so the check would prove nothing. "Development build" is forma-server's owndebug_assertions: a release profile with debug assertions on, or a[profile.release.package.forma-server] debug-assertions = trueoverride, gets the development behaviour. Your endpoint stores report URLs without their query strings, bounds each report's size and its request rate, and alerts per route and directive above a baseline, never a ticket per report. A route whose sinks have not moved can be markedrelax_trusted_types("why, owner, until"): logged, and an allowlist entry - Nonce generation: 256-bit entropy via ring CSPRNG, wrapped in a validating
CspNonce - Serve HTML and CSP together: the HTML embeds a per-request nonce, so it must be sent
Cache-Control: no-storeand never cached across requests.PageResponsewrites both, withCross-Origin-Opener-Policyand the other document headers, every time it is sent; its HTML is reachable apart from them only throughinto_parts_unchecked(). A page needing another image or frame source, a form target or a login popup adds a typedPolicyExtensionsentry; an invalid one fails the page rather than reaching a header.PageOutput's public fields,PageOutput::headers()andrender_page_with_nonceare deprecated in 0.2.2 - Asset serving:
rust-embedprevents path traversal, and requested names are checked for exact membership in the embedded list so debug and release builds behave identically;manifest.jsonand direct.br/.gzrequests are refused - Preview frame (0.2.2):
PageResponse::frameserves the document for content nobody reviewed, in aniframe sandbox="allow-scripts"the application ships. Its enforced policy (the only one rewritten) gives it an opaque origin (sandbox allow-scripts), lets only the inline runtime run (by its SHA-256), and closes its requests:connect-src 'none',img-src data:,style-srcby nonce,font-src,worker-srcandform-action 'none', since'self'still names the application in an opaque-origin document. Residuals: WebRTC, which no CSP directive closes, and navigations the host page'sframe-srcadmits, so name the frame's exact URL there (frame_src("https://studio.example/preview-frame")); even then the frame's own path with another query is admitted (CSP ignores the query), and reaches the frame route and its access log. Link hints (<link rel="preconnect">, which no CSP directive governs and which connected in WebKit) andsrcdocare removed by@getforma/core2.1.0's frame runtime before the module reaches the document. The host's side (oneMessageChannelport, onlyready/resize/error, teardown on a second load) is in theframemodule's docs - Template escaping: Interpolated values (URLs, class names, CSS, title) are HTML-escaped. Two fields are documented raw sinks:
config_script(raw JS, terminator-neutralised but trusted; deprecated in 0.2.2 and removed in 3.0.0) andbody_prefix(raw HTML). Useconfig_jsonfor data - One named raw-HTML type: markup written unescaped is a
RawHtml, made only byRawHtml::trusted(..), so a search for that name finds every raw entry; pass it asbody_prefix: Some(&raw). A plain string there still works in 0.2.x and warns once per process in debug builds (RAW-101); 3.0.0 accepts only aRawHtml. What is not your own markup has a typed home onRenderCtx: the route'smodulepreloadlinks, read from the build's manifest and nonced by forma-server (with_module_preloads, loaded at start byassets::try_load_module_preloads), and head links such as icons (with_head_link: a closed list of link types that load neither style nor script, every value escaped, script URLs refused) - Render failures are values:
try_renderreturns every failure as aRenderErrorwith a stable code (RENDER-101 to RENDER-399), and catches a panic in the render asRenderError::Fault, so one bad page answers one request with the application's error page and the process carries on. That needs the application to unwind on panic, as both templates do: underpanic = "abort"a fault still ends the process. Both crates denyunwrap,expectandpanic!outside tests - No unsafe code:
#![forbid(unsafe_code)]on the crate root — compiler-enforced, not asserted
When You Need This
Use it when: Your backend is Rust + Axum and you want the full Forma SSR pipeline with asset serving and CSP.
You don't need it if: You're only using FormaJS client-side (no Rust server), or you're using a non-Axum framework (use forma-ir directly).
Part of the Forma Stack
Frontend (TypeScript)
| Package | Description |
|---|---|
| @getforma/core | Reactive DOM library — signals, h(), islands, SSR hydration |
| @getforma/compiler | Vite plugin — h() optimization, server transforms, FMIR emission |
| @getforma/build | Production pipeline — bundling, hashing, compression, manifest |
Backend (Rust)
| Package | Description |
|---|---|
| forma-ir | FMIR parser, walker, WASM exports |
| forma-server | This crate — Axum middleware: SSR, asset serving, CSP |
Full Framework
| Package | Description |
|---|---|
| @getforma/create-app | npx @getforma/create-app — scaffolds Rust server + TypeScript frontend |
License
MIT