Production build pipeline for FormaJS applications. Handles esbuild bundling, CSS generation, content hashing, Brotli/gzip compression, asset manifest generation, SSR IR emission — all from a single config.
This replaces writing your own build script. It provides content hashing and optional native Rust SSR from one config.
Install#
These examples target the published, coordinated 3.0.0 package set. Select that version explicitly:
npm install -D @getforma/build@3.0.0This installs esbuild automatically. For SSR features (FMIR emission), also install the compiler:
npm install -D @getforma/compiler@3.0.0Quick Start#
// build.ts
import { build } from "@getforma/build";
await build({
app: "my-app",
entryPoints: [
{ entry: "src/app.tsx", outfile: "app.js" },
],
routes: {
"/": { js: ["app"], css: [] },
},
outputDir: "dist",
});npx tsx build.tsThis bundles your app with esbuild, content-hashes all assets, generates Brotli + gzip compressed versions, and writes an asset manifest.
Optional shared ESM graph#
Set codeSplitting: true to share one runtime across entries and preserve native
import() boundaries. Split JavaScript keeps esbuild's linked graph filenames
(page.ABC12345.js, chunk.DEF67890.js); do not rename those files afterward.
manifest.assets includes entries and chunks, while each route's modulepreload
contains only transitive static imports. Servers can preload those files without
fetching lazy tools. total_size_br includes static dependencies but not deferred
imports. Manifest 2 records immutable: true for production graph assets,
including uppercase esbuild hashes; forma-server reads that metadata rather than
guessing from filenames. Development files have immutable: false. serverInlined copies retain valid relative imports but are not
standalone scripts, so split ESM should normally be served as external modules.
With ssr: true, an entry can set ssr: false to keep only that route browser-only.
Watch builds publish generation-specific JS, chunk and IR filenames through
an atomic manifest update. Within a running watch session, earlier generation
files remain available for pages and readers using the previous manifest.
Starting a new watch session clears the output directory; a successful production
build replaces it with the production output.
Verified on-demand asset caching#
Production builds default to serviceWorker: 'on-demand'. Installation downloads
nothing. The worker's exact URL table comes from the finalized manifest's full
SHA-384 digests and immutable metadata, including dynamic JS/CSS/WASM chunks.
It never uses hash-like filename spelling as evidence of immutability.
Both cache hits and network replacements must have eligible immutable response metadata and bytes matching that digest. A bad cache entry is evicted best effort and refetched with the HTTP cache bypassed; it is never served, even if deletion fails. Bad network bytes, unreadable responses or unavailable Web Crypto fail the protected fetch. Storage/quota failure still permits a verified network response, and a valid cached asset remains available offline. The digest covers decoded, uncompressed bytes, including assets delivered with gzip or Brotli.
Navigation, APIs, writes, query URLs, explicit cache-bypass requests, unlisted or
mutable files, HTML, fonts, the worker and the manifest are not cached by this
worker. Responses with private/no-store headers, redirects or unsuitable MIME
are refused for protected assets. The server still opts into registration; merely
generating a worker does not enable it. precache is removed and is a configuration
error before output changes. Development remains network-only.
Per-application workers (app)#
Every manifest names its application. Set app explicitly, or omit it only when
the nearest Cargo.toml has a package name already matching
[a-z0-9][a-z0-9-]{0,62}. Invalid or missing names fail before output writes
(BUILD-815). The build never normalizes an inferred Cargo name. The scaffolder
uses a separate deterministic application name for otherwise valid project names
containing underscores or more than 63 characters.
await build({ ...config, app: 'ledger', serviceWorker: 'on-demand' });An enabled server worker must be configured against the validated manifest before binding the listener:
let manifest = forma_server::assets::try_load_manifest::<Assets>()?;
forma_server::sw::configure_for_manifest(
forma_server::sw::ServiceWorker::for_app("ledger")?.enabled(),
&manifest,
)?;A different manifest app is refused, naming both values. sw::configure remains
the off-worker convenience; it refuses an enabled worker without a manifest.
Reading the manifest does not seal the default worker setting. This startup call
is a source migration for applications enabling a worker.
The existing production cache name is forma:<app>:<build>, using the first 12
hex digits of the current build identifier. Activation removes its own older
caches and legacy Forma caches, never another named application's caches. It does
not skip waiting or claim open pages. Development workers stay network-only.
The final deployment identity and retained-build protocol remain FORMA-186.
Verified by: packages/build/tests/compatibility.test.ts,
packages/create-app/tests/scaffold.test.ts, and
crates/forma-server/tests/manifest_compatibility.rs.
Compatibility before output writes#
The compiler peers on core and the build optionally peers on the compiler at
^3.y.0. Coordinated prereleases add only their exact version. An incompatible
required compiler/core peer fails installation. npm may warn or accept an
optional build/compiler conflict, including a silent recorded-lock npm ci;
build() therefore checks an installed compiler itself and refuses skew before
writing output. Browser builds without a compiler remain supported; SSR requires
one (BUILD-801).
The build parses the nearest Cargo.lock from its working directory. The resolved
server must share the writer's major and have at least its minor, for both the
manifest writer and any SSR compiler. Patch numbers do not narrow the window.
Missing lockfiles warn; malformed, unreadable or ambiguous locks fail. A lock
without forma-server fails SSR and warns for a standalone browser build. Upgrade
the renderer first. Watch mode rechecks lock changes and preserves the last valid
bundle when they fail. See the compatibility contract.
Every production build also reports multiple physical copies of core (BUILD-816),
even when their versions match and previewFrame is off. One package reached by
symlinks is one copy. This uses retained bundler inputs and real package roots;
it cannot identify core hidden inside an arbitrary prebundled third-party file.
Verified by: packages/build/tests/manifest-compatibility.test.ts and
scripts/compatibility-package.test.mjs (actual packed code and installs).
The preview frame's runtime (previewFrame)#
forma-server's preview frame (PageResponse::frame, FORMA-125) renders content
nobody reviewed in an opaque-origin document whose one script is core's frame
runtime, written inline and allowed by its SHA-256 hash. It finds that runtime
the way it finds every asset: PreviewFrame::load reads the manifest's
aliases["forma-runtime.js"] from the embedded output at start, and refuses to
start the frame without it (FRAME-101). An application that serves the frame
turns the setting on:
await build({ ...config, previewFrame: true });The build then writes dist/formajs-frame.global.js from the
@getforma/core the bundles import (core 2.1.0 and later), minified, as
forma-runtime.<hash>.js, and names it in the manifest:
"aliases": { "app.js": "app.1797a84b.js", "forma-runtime.js": "forma-runtime.e7a8cc12.js" }- The core the bundles import, so the preview renders a module with the
core the page mounts it with. Before anything is written, each entry is
bundled in memory with the bundles' own esbuild options, and every file it
loads is mapped to its package (a directory whose package.json is named
@getforma/core). The core is therefore the one the bundles reach, however they reach it:formaAlias, whose target esbuild resolves from the working directory (so a build run from a monorepo's root takes the root's core, as its bundles do); tsconfigpathsto a vendored copy; a linked or pnpm-installed core, by its real path. When the bundles import cores whose frame runtimes differ (entries from two packages, each with its own core, or a library bundling its own), the build refuses rather than pick one (BUILD-807). Equal frame bytes may be copied once, but independent physical core copies still produceBUILD-816. When the bundles import no core, it copies the one installed where the build runs (the working directory, as Node finds a package). The entries must bundle at start, in watch mode too. - Never core's
dist/forma-runtime.js. That file shares the target's name, but it is the CDN alias of the auto-mounting runtime, which would bind the whole frame document instead of rendering the host's module in tier (c). - Minified in the output, as published in core (FORMA-190, from the A2
audit's F1). forma-server writes this runtime into every preview document
under
no-store, so each load pays its bytes: core's file is 164 KB readable. Core's published file stays readable (anything published to npm does); a production build writes an esbuild transform of it (whitespace and names, ES2022, deterministic) intodist, before the hashing pass, so the file's name, its SRI in the manifest and the SHA-256 forma-server computes for the frame's policy are those of the bytes served, and the script the browser runs is the one hashed. The size report's frame row reports those served bytes beside core's file. No source map is written: the script is inlined in an opaque-origin frame withconnect-src 'none', so a map could not be fetched, and its comment would be part of the hashed bytes. Watch mode copies core's bytes unchanged (a development build, readable in the browser's tools): that is the copy to debug. It is inlined, never fetched: no route loads it, it gets no.bror.gzcopy, and the worker's lists leave it out. - It refuses what forma-server would refuse (FRAME-103), before anything is
written, so the previous output stays as it was:
BUILD-805when the core the bundles import has no frame runtime (older than 2.1.0) or no core is found,BUILD-806when the runtime is empty, not UTF-8, or contains</scriptor<!--(in any case), a carriage return or a NUL, andBUILD-807as above, andBUILD-823when esbuild cannot minify it. TheBUILD-806check runs on core's file and again on the minified bytes. Each fails the build with or without strict mode (docs/DIAGNOSTICS.md). - Watch mode writes it unhashed as
forma-runtime.js, and the dev manifest names it as itself, as it does every watched asset. - Off (the default, or
false), nothing is read and the output is byte-identical to a build without the setting. While it is on, no entry may take the nameforma-runtime.jsin any case (Forma-Runtime.jsis the same file on macOS and Windows); any value buttrueorfalseis a config error.
The size report: route classes and ceilings#
A production build classifies every route and holds it to its class's
ceilings (N2; decision brief L-64, DR-35). Sizes are gzip at level 9, a KB
being 1,000 bytes and a KiB 1,024; Brotli is reported beside them. A route over
a ceiling fails the build with BUILD-819, naming the route, its class and
the bytes, before anything is written.
| Class | Which routes | Forma's code | All of the route's JavaScript |
|---|---|---|---|
no-interactive-region | A server-rendered page with no island, or a route that lists no script | none | none (a service worker counted) |
islands | A server-rendered page with islands | 28,100 B | 75 KiB (76,800 B), 365 KiB for a canvas |
whole-page-mount | An entry declared ssr: false, or any route of an application built without ssr | 28,100 B | as islands |
L-64 authorizes this ceiling for cooperative activation only after the full 20-run performance campaign passes the original 50 ms N3 task ceiling. Source constants alone do not establish release qualification.
Forma's code is @getforma/core and alien-signals as bundled, attributed
from the bundler's metafile, plus the identity banner Forma's build writes at
the top of every JavaScript file (about 30 B gzip for each file the route
loads cold); the application's own code is outside that ceiling. A route's JavaScript is the files the manifest lists for it and the
generated sw.js when the route lists any script (forma-server registers the
worker only on such pages).
canvas. A page that hosts many modules and is JavaScript-heavy by design
declares it on its route:
routes: {
"/studio": { js: ["studio"], css: ["studio"], canvas: true },
},A canvas may ship 365 KiB of JavaScript instead of 75 KiB; Forma's code stays
under 28,100 B either way, and the declaration gives nothing to a route
without JavaScript. Only true or false is accepted (anything else is a
config error, reported before anything is written). It is provisional public
configuration at 3.0.0: declare it for a page that really is a canvas, and trim
or split any other page over 75 KiB.
The report is printed after the bundle summary, returned as
BuildResult.sizeReport (BuildResult.sizes is its routes) and written,
without the build's times, as size-report.json beside manifest.json: for the
same sources the same bytes, since the application embeds the directory, and
forma-server refuses to serve it. The times by phase (bundle, hash,
compress, manifest) are printed and in BuildResult.sizeReport.times. With
previewFrame, a frame-document row reports the runtime forma-server inlines
into every preview document. Watch mode reports nothing: its bytes are not what
a route ships.
What It Does#
| Step | What happens |
|---|---|
| Validate | Rejects an unknown serviceWorker value, an app name forma-server cannot take, a previewFrame that is not a boolean, two entries sharing an outfile, an outfile that is a path (or forma-runtime.js, in any case, with previewFrame), a route naming an asset no entry produces, or a route missing its js or css list, before touching outputDir; with previewFrame, finds the core the bundles import and reads and checks its frame runtime (BUILD-805 to BUILD-807, and BUILD-823 when it cannot be minified) |
| Frame runtime | (Optional, previewFrame: true) Writes the dist/formajs-frame.global.js of the @getforma/core the bundles import, minified (core's file stays readable; watch mode copies it unchanged), as forma-runtime.js, hashed with the other assets and left uncompressed |
| Bundle | esbuild bundles each entry point with JSX transform (jsxFactory: "h") |
| CSS | Runs Tailwind CLI or concatenates CSS files |
| Hash | SHA-256 content hash appended to filenames (app.a1b2c3d4.js) |
| Compress | Brotli (level 11) + gzip (level 9) for .js and .css |
| Manifest | Writes schema 2: writer/app/build, aliases, route modes and filename-keyed immutable/SHA-384 metadata |
| SSR | (Optional) Emits one <page>.ir FMIR binary per entry for Rust server-side rendering |
| Budget | Warns if route brotli size exceeds threshold (default 200KB) |
| Size report | Per route: its class (no interactive region, islands, whole-page mount), what it loads cold in raw, gzip -9 and Brotli bytes, Forma's code (@getforma/core and alien-signals as bundled, attributed from the bundler's metafile, plus the identity banner Forma's build writes at the top of every JavaScript file) beside the application's, and the generated sw.js a route with JavaScript registers; with previewFrame, a frame-document row; the build's times by phase. Printed, returned as BuildResult.sizeReport (BuildResult.sizes is its routes), and written without the times as size-report.json beside the manifest (forma-server never serves it). The gate: a route over its class's ceiling fails the build with BUILD-819 before anything is written, naming the route, its class and bytes (decision brief L-64, DR-35; the ceilings are not configurable): Forma's code at most 28,100 B gzip -9; all of a route's JavaScript at most 75 KiB, or 365 KiB if the route is declared canvas: true; none at all on a route with no interactive region |
| Swap in | A production build writes all of the above into a staging directory beside outputDir (.<name>.forma-staging-…) and swaps it in only when every step has succeeded: a build that fails part way leaves outputDir exactly as the last good build left it, so the application never embeds half a build. Watch mode publishes browser outputs only after a successful build and replaces files by rename; failed compatibility checks preserve the previous outputs |
Configuration#
import { build, type BuildConfig } from "@getforma/build";
const config: BuildConfig = {
// Required (app may instead come from a valid nearest Cargo package name)
app: "my-app",
entryPoints: [
{ entry: "src/home/app.tsx", outfile: "home.js" },
{ entry: "src/dashboard/app.tsx", outfile: "dashboard.js" },
],
routes: { // each route needs both js and css; [] for none
"/": { js: ["home"], css: ["home"] },
// canvas: true declares a page that hosts many modules: its JavaScript may
// be 365 KiB gzip -9 instead of 75 KiB (the size report's ceilings)
"/dashboard": { js: ["dashboard"], css: ["dashboard"], canvas: true },
},
outputDir: "dist",
// Optional
cssEntries: [
// Tailwind: runs @tailwindcss/cli on the first input
{ input: "src/app.css", outfile: "app.css", tailwind: true },
// Otherwise: concatenates the inputs in order
{ input: ["src/reset.css", "src/theme.css"], outfile: "dashboard.css" },
],
fontDir: "src/fonts", // Copy .woff2 files to dist
ssr: true, // Enable FMIR emission
ssrEntryPoints: { // page name (outfile minus .js) -> entry
home: "src/home/app.tsx", // the file with mount()/activateIslands()
dashboard: "src/dashboard/app.tsx",
},
// strict: true, // Legacy no-op: 3.0 always refuses unsupported SSR
// diagnosticsJson: "ssr-diagnostics.json", // Also write them as JSON, outside outputDir
// previousDist: "../previous-release/dist", // Keep the live release's assets for open tabs; see "Retention"
// security: true, // This release fixes a vulnerability: retain nothing (or FORMA_SECURITY_RELEASE=1)
previewFrame: false, // true: write core's frame runtime, minified, as forma-runtime.js (forma-server's PageResponse::frame)
watch: false, // Rebuild on change; writes a dev manifest and, with ssr, each page's .ir
budgetThreshold: 200_000, // Warn at 200KB brotli per route
formaAlias: "@getforma/core", // What bare 'formajs' imports resolve to
serverInlined: ["app.js"], // Also keep the unhashed name on disk
};
await build(config);formaAlias remains a migration bridge throughout 3.x. If the build's source
graph imports formajs or one of its subpaths, one warning lists the importing
files and the @getforma/core replacement. The same warning appears in
BuildResult.warnings; duplicate imports and the two production identity passes
do not repeat it. Erased TypeScript imports and re-exports are included. A
build() call with no legacy imports is quiet, including its initial watch
build. The alias still resolves as configured; omitting it does not make the
unavailable bare package resolve.
Retention: a tab opened before a deploy#
While a deploy rolls out, and for as long as a tab stays open, a page of the
previous release asks for hashed files (a lazily imported chunk) that the new
binary does not embed. Your pipeline keeps the previous release's dist/ as a
release artifact (the one that is live) and passes its path as previousDist.
The build copies that release's own hashed assets into the new dist/, listed in
manifest.json as { "immutable": true, "retained": true, "integrity": ... }, and
forma-server serves them as it serves any immutable asset, so you embed one
dist/ and a one-deploy-old tab still loads its chunks.
- One generation. What the previous release itself retained is not copied
again, so the assets at most double and a tab two deploys old gets a 404 for a
chunk it asks for, which your code handles as it handles any failed
import(). - Never copied:
manifest.json,sw.js, the.irmodules, and files that are not immutable (unhashed fonts). A file the new build also emitted under the same name is its own and is not flagged. - Checked. Each copied file's bytes must match the SHA-384 the previous
manifest recorded; a missing, linked or altered file fails the build with
BUILD-821. A path that is not a usable previous release fails it withBUILD-820: it must be another directory thanoutputDir(which the build replaces whole), of the sameapp, not a watch build. A previous dist whose manifest is not schema 2 (your first 3.0.0 deploy over a 2.x release) retains nothing, and the build says so. - First deploy: leave
previousDistout; nothing is copied. - Security releases (L-16, SR45): mark every release that fixes a
vulnerability, a Forma security upgrade included, with
security: trueorFORMA_SECURITY_RELEASE=1(afalsein the config does not unmark it). The build then retains nothing and the revoked build's files are not served; a revoked build's open tabs meet 404s for their lazy chunks. - Trust. The SHA-384 check catches a damaged or altered file, not someone who can
also rewrite the previous
manifest.json: the previousdist/is trusted like your source. Keep it as a protected release artifact. FORMA_SECURITY_RELEASEis read likeFORMA_STRICT:1ortrue(any case, trimmed) marks; unset, empty,0orfalsedoes not; anything else fails the build (BUILD-822).- Watch mode ignores both options.
The identity does not depend on the retained set: the same inputs give the same
build, the same non-retained entries and, with the same previous release, a
byte-identical dist/.
Output Structure#
dist/
├── home.a1b2c3d4.js # Content-hashed bundle
├── home.a1b2c3d4.js.br # Brotli compressed
├── home.a1b2c3d4.js.gz # Gzip compressed
├── home.e5f6g7h8.css
├── home.e5f6g7h8.css.br
├── dashboard.i9j0k1l2.js
├── home.m3n4o5p6.ir # FMIR binary (if ssr: true)
├── forma-runtime.y5z6a7b8.js # Core's preview-frame runtime, minified and uncompressed (if previewFrame: true)
├── inter.woff2 # Copied fonts (never hashed)
├── sw.js # Service worker (generated last, never hashed)
└── manifest.json # Asset manifest (generated last, never compressed)Nothing else is written. In particular an SSR build emits only <page>.ir
per entry: no island registry, no island metadata sidecar. (Through 0.1.9 it
also wrote <page>.islands.js and <page>.islands.json, which every consumer
had to delete by hand — see the CHANGELOG.)
The compiler's successful entry product determines whether an SSR page needs
browser code. A page with no reachable island definitions keeps its native IR,
CSS and fonts, but its manifest has mode: "ssr", js: [] and no module
preloads. Conditional islands still require JavaScript even when one request
renders no island. Explicit ssr: false entries retain normal browser output.
The first configured route entry remains its native owner before filtering.
Keep that name in routes[route].js; do not replace the configuration with the
filtered manifest array. Additional route entries must declare ssr: false.
A static owner cannot request extra scripts or executable serverInlined
output: those contradictions fail with BUILD-611. Other explicitly inlined
resources remain available to their configured consumers.
Split output keeps every static and dynamic JavaScript dependency of active entries, plus CSS/font resources; chunks used only by a removed static entry are omitted. Watch retains old generation files for existing documents. A candidate classification becomes accepted only when its complete graph, IR and manifest publication succeeds; a failed rebuild keeps the prior route policy.
Asset Manifest#
Manifest 2 separates logical aliases from metadata keyed by emitted filename.
Every route declares ssr or browser; an SSR route without a module fails the
build (BUILD-817). This abbreviated example omits measured digests and sizes:
{
"version": 2,
"writer": "3.0.0",
"app": "ledger",
"build": "5f0c9a1e7d2b4c86",
"aliases": {"home.js": "home.a1b2c3d4.js", "home.ir": "home.a1b2c3d4.ir"},
"assets": {
"home.a1b2c3d4.js": {"immutable": true},
"home.a1b2c3d4.ir": {"immutable": true}
},
"routes": {
"/": {"mode": "ssr", "js": ["home.a1b2c3d4.js"], "css": [],
"fonts": [], "ir": "home.a1b2c3d4.ir", "total_size_br": 0,
"budget_warn_threshold": 200000}
}
}The actual build includes integrity: "sha384-<base64>" for each asset, computed
from its final uncompressed bytes. Compression sidecars, the manifest and the
worker are excluded. Integrity enforcement is FORMA-111; emission alone does not
provide that enforcement.
The deployment identity (build)#
build is the build's deployment identity: 16 lowercase hexadecimal characters
(dev in watch mode). A production build computes it first, from every output of
a first bundling pass (the JavaScript and CSS bundles, the other emitted files and
the .ir modules, plus the css entries and fonts) and the writers' versions, and
only then compiles it in:
- as the constant
__FORMA_BUILD__(esbuilddefine), which@getforma/corecompares with thedata-forma-buildattribute forma-server writes on the page's root before it activates anything; - as a
/*forma-build:<id>*/banner in every JavaScript output, so the names, which hash content, change with the identity: every deploy renames every bundle.
The same inputs give the same identity wherever they are built from (esbuild
hashes paths relative to the working directory, so build from the project root).
An edit that changes no output byte, a comment, changes nothing and disturbs no
open tab. The identity does not depend on a previous dist/. The first pass
costs one more bundling run; the compiler's lines for it are not printed twice.
forma-server validates the schema, writer window, app, references and route modes
before loading modules. It resolves logical names through aliases and reads
cache immutability from metadata, including retained entries. Schema 1 and missing
app are refused. Rebuild assets with the aligned 3.x package set.
Verified by: packages/build/tests/manifest-compatibility.test.ts,
crates/forma-server/tests/manifest_compatibility.rs, and
crates/forma-server/tests/manifest_cache.rs.
Watch mode is a development build#
Watch mode resolves each package's development export condition, as Vite's
dev server does: it passes esbuild conditions: ['development', 'module']
(module because listing any condition drops esbuild's automatic one) and
defines process.env.NODE_ENV as "development". @getforma/core points the
condition at a build that keeps its development warnings (hydration
mismatches, dropped props, the FORMA_W_DEPRECATED_ codes), so they reach
the browser console while you develop. A production build passes neither: it
resolves the files it always did and logs none of them.
Watch mode and server-rendered pages#
Watched JS/chunks use esbuild's linked generation filenames, and IR filenames include their SHA-256. New bytes are staged before an atomic manifest rename selects the complete generation. Within the running watch session, earlier files stay available for readers holding an earlier manifest or HTML response. Starting a new watch session clears the output directory; a successful production build replaces it with the production output. A failed rebuild within the running watch session keeps the accepted manifest and its referenced bytes. Always resolve output through manifest aliases; watched entry and IR filenames are no longer stable logical names.
With ssr: true, each browser context also watches the source and type inputs
of its SSR entry (ssrEntryPoints, else its browser entry). A successful cycle
returns JS and IR together; an SSR-only edit triggers the same paired cycle.
Compiler and publication errors keep the last accepted generation. Each native
rewrite logs its logical name and time, for example
IR rewritten: home.ir in 36 ms; the manifest holds its actual hashed filename.
In debug builds, forma-server validates the new manifest and module together.
A refused update retains the previous module and asset snapshot. Route names
and modes are frozen after startup finalization; those changes need a restart.
Removed server functions#
A "use server" directive at module or function scope, including in an imported
module, fails every build with BUILD-808, regardless of strict. Move server
work into an authenticated Rust route and call it with fetch. The initial
source check runs before output is replaced; watch rebuilds run the same guard.
This removal concerns automatic TypeScript server-function RPC, not ordinary
Rust functions or HTTP handlers. Axum routes can run application logic, use
databases and call external services. The wasm configuration key and walker
packaging step were removed in 3.0.0. Compiled islands activate with
activateIslands() and update through signals; server rendering uses the native
Rust walker.
Verified by tests/server-functions-removed.test.ts (directive and near-miss fixtures).
SSR diagnostics and the server-renderable subset#
SSR builds always enforce the published subset.
Every unsupported construct is reported with its stable code, original source
file, line, column, reason and fix. build() collects every page's diagnostics
before refusing publication; a failure leaves the previous output unchanged.
The direct compiler plugin applies the same gate.
strict: true, strict: false and the legacy FORMA_STRICT values 1, true,
0, false or empty are compatibility inputs. They all select the same strict
behavior. The build reads no command-line flags. A watch rebuild that fails keeps
the page's last good product, then publishes normally after the source is fixed.
diagnosticsJson: "<path>" writes { version, strict: true, diagnostics }, including
on failure. Each diagnostic carries code, severity, page, file, line,
column, message, why and fix. Its path must be outside outputDir, including
through symlinks. Additive report fields do not change the schema version;
renaming, removing or changing a field's meaning does.
Use a registered browserOnly() component for an explicitly browser-rendered
subtree, or ssr: false on an entry for a whole browser-only route. An unsupported
SSR expression cannot opt out of the gate merely by setting strict: false.
Development service worker#
Watch builds emit a network-only worker for same-origin /_assets/ requests,
including generation-specific asset URLs. It bypasses
both CacheStorage and the browser's HTTP cache, immediately activates, and
deletes every cache named forma-*; caches named otherwise are left alone.
Production builds keep content-hashed assets cache-first in the worker's own
cache. If CacheStorage fails they answer from the network, and a failed cache
write (a quota error, say) never fails the response.
Every production build requires a validated application name, either explicit
or inferred from Cargo as described above. Production caches are named
forma:<app>:<build>; activation removes older caches for that application and
the exact legacy Forma cache names, while preserving other named applications.
The development worker separately removes names beginning with forma- and
bypasses caching for asset requests.
When upgrading a running development app from the old cache-first worker,
rebuild the frontend, restart any server that embeds build output, then reload
so /sw.js updates. Reload once more after the new worker takes control if the
first navigation started under the old controller. No cache-clearing workaround
is needed for subsequent rebuilds. A server must actually serve the new asset
bytes; this worker change does not restart Rust or watch CSS on its behalf.
Run the controlled-page regressions with npm run test:browser -w packages/build
from the workspace root: the development worker's takeover, and a
per-application worker through upgrade, rollback and upgrade with two tabs open. It uses installed Chrome when bundled Chromium is
unavailable, or the explicit FORMA_BROWSER_CHANNEL override.
Raw-entry lint (forma-raw-lint)#
Raw HTML and the other ways out of escaping and Forma's policy are named, so
review and CI can find every one (DR-19, L-37). forma-raw-lint is the check
an application runs in CI (SR8): it fails on any use of those exits that its
allowlist does not list with an owner and an end date, and on any listed entry
past its end date. The allowlist becomes the inventory of the application's
raw entries.
npx forma-raw-lint src crates# .github/workflows/ci.yml
- run: npx forma-raw-lint --report raw-entries.json src cratesIt reads TypeScript, JavaScript (.ts, .tsx, .mts, .cts, .js, .jsx,
.mjs, .cjs; not .d.ts), Rust (.rs) and HTML (.html, .htm) under
the files and directories it is given, skipping node_modules, target and
any file or directory whose name starts with .. Skips and --exclude are
applied before anything is read. A symbolic link is followed; one that leads
nowhere or loops is a BUILD-814 (exclude it if it is not source), and a
file reached by two paths is read once. It needs nothing beyond this package:
an application that does not build with @getforma/build (a Rust-only one,
or one that uses @getforma/core alone) runs it with
npx -p @getforma/build@3.0.0 forma-raw-lint … after final publication.
Point it at source, not build output. A bundle that includes core carries
core's own raw entries: the registry key survives minification, and a
development build carries data-bind:srcdoc in a warning. Name the source
directories, or leave committed bundles out with --exclude (for example
--exclude 'crates/*/assets/**' --exclude 'dist/**').
--allowlist <file> The allowlist (default: ./forma-raw-allowlist.json, if it exists)
--root <dir> What paths are relative to (default: the allowlist's directory, else .)
--exclude <glob> Do not scan matching files or directories (**, *, ?); repeatable
--today <date> Check end dates against this day, YYYY-MM-DD (default: today, UTC)
--json Print the report as JSON instead of text
--report <file> Also write the JSON report to this file
--exits List the exits an entry's "exit" can nameExit codes. 0, it passes; 1, a raw entry not on the allowlist
(BUILD-810), an entry past its end date (BUILD-811) or a source file it
cannot parse (BUILD-814); 2, a usage error or an allowlist it cannot use
(BUILD-812). An entry that lists fewer uses than its count, or none
(BUILD-813), is a note. The codes
are in the build diagnostics catalogue, docs/DIAGNOSTICS.md.
BUILD-810 error body_prefix in src/server/mod.rs:772:9 (in index_handler): body_prefix: body_prefix set to a value not traced to RawHtml::trusted (a string still works in 0.2.x); not on the allowlist
forma-raw-lint: 6 raw entries in 123 files: 5 listed, 1 not on the allowlist; 0 allowlist entries past the end date; 0 allowlist entries listing fewer uses than they say. Fail.The allowlist#
forma-raw-allowlist.json, version 1:
{
"version": 1,
"entries": [
{
"path": "src/server/mod.rs",
"symbol": "index_handler",
"exit": "body_prefix",
"owner": "studio",
"until": "2027-01-31",
"reason": "the hand-written app shell; moves to RawHtml::trusted (appendix E)"
},
{
"path": "client/pages/DocsPage.ts",
"line": 751,
"exit": "dangerouslySetInnerHTML",
"owner": "docs",
"until": "2027-01-31",
"reason": "sanitised search snippets; wrap in trustedHtml under kmd's policy"
}
]
}Each entry has:
path: the file, relative to the allowlist's directory, with/.lineorsymbol, exactly one: the line, or the innermost named function, method, class,implor module, as the lint prints it afterin(FormaStage.update,RawHtml::from_static). A symbol survives edits around it; a line pins one use.exit: one of the names below.owner: who answers for it.until:YYYY-MM-DD, the last day the entry is valid, in UTC (the run's date is UTC's, as on a CI runner, so west of UTC an entry ends some hours before the local day does). The day after, the run fails until the owner removes the raw entry or renews the date. It may be at most a year after the run's date (an entry further out is refused,BUILD-812), so every entry is revisited at least yearly.reason: why it is raw, and where it is going.count(optional, default 1): how many uses of that exit the entry lists. A second use in the same function is not on the allowlist until the count says so, and an entry that lists fewer uses than its count is aBUILD-813note, so a count never leaves room for an unreviewed use.
An unknown field, a missing one, an exit the lint does not know or a date
that is not a real day stops the run with BUILD-812, naming the entry and
the field. The --json report's rawEntries lists every raw entry found,
listed or not, with its file, line, column, symbol and exit: the list an
allowlist starts from.
The exits#
TypeScript and JavaScript, inline scripts in HTML included:
| Exit | What the lint finds |
|---|---|
trustedHtml | Each call of trustedHtml(), under any name it is imported or destructured as (@getforma/core, ./runtime, the CDN's FormaRuntime), and each reference to it that is not a call, or that exports it under another name. |
dangerouslySetInnerHTML | Any __html key, wherever it is written, whose value is not a trustedHtml(…) call or a const in scope bound to one (3.0 refuses a plain string at this sink); and the prop itself set to a value the lint cannot read as { __html: … }: by JSX, h(), a quoted or computed key (a const holding the name included), shorthand, obj.prop =, obj['prop'] = or Reflect.set(obj, 'prop', …). |
setHTMLUnsafe | Core's setHTMLUnsafe(el, markup) given markup not traced to trustedHtml(). |
template | Core's template(markup), likewise. |
templateMany | Core's templateMany(markup), likewise. |
reconcile | A legacy runtime reconcile(el, markup) call, likewise. The lint recognizes this removed API for migration audits; use a mountMarkup handle's update in 3.0. |
mountMarkup | The runtime's mountMarkup(root, markup, options) with an explicit tier, likewise: 3.0 requires trusted markup. |
MountHandle.update | update(markup) on the handle mountMarkup() returns, given markup not traced to trustedHtml(): on a variable, field or property assigned the handle (=, ??=, ||=, &&=, or an arm of a ternary), by dot or by bracket (w.update(…), w['update'](…)), chained on the call (mountMarkup(…).update(…)), or destructured from it (const { update } = mountMarkup(…), { update: up }). The follow is by name, file-wide: a second object held under a handle's name, or another update destructured in the same file, is reported too, and an entry on the list says so. |
srcdoc | A srcdoc prop, attribute or property (any case) set to a value not traced to trustedHtml(): JSX, h(), a quoted or computed key, setAttr/setAttribute(…, 'srcdoc', …) (a template literal or a const holding the name included), .srcdoc =, ['srcdoc'] =, Reflect.set and Object.defineProperty. |
core-reexport | export * from '@getforma/core' (or a subpath, or export * as x from …): every raw sink of core's behind the application's own module, where the lint does not follow its importers. A named re-export of a sink (export { mountMarkup } from '@getforma/core/runtime', or a sink imported from core and exported) is reported under the sink's own exit. |
h('script') | A script element built by core's h(): h('script', …) (any case), under a name h is imported as, on core's namespace, or JSX <script>. Its src runs under 'strict-dynamic'; 3.0 refuses it in development and production (FORMA-109). |
Markup, in HTML files and in string literals in either language:
| Exit | What the lint finds |
|---|---|
data-bind:srcdoc | The HTML runtime's data-bind:srcdoc binding, which no trustedHtml() can reach. |
list-row-srcdoc | A srcdoc attribute whose value carries {item, a list row's binding, after character references ({, {) and string escapes (\u{7b}, \x7b) are decoded, as the runtime reads it. |
trusted-types-registry | The string @getforma/core#trusted-types, the key of core's registry, through which the forma policy can mint without trustedHtml(). |
Rust:
| Exit | What the lint finds |
|---|---|
RawHtml::trusted | Each RawHtml::trusted, path-qualified, as <RawHtml>::trusted, as Self::trusted in an impl for it, or through an alias of it from any file. |
RawHtml-alias | RawHtml under another name: use … RawHtml as X, type X = …RawHtml, any = RawHtml (an associated type, a generic default). |
r#html | The raw identifier r#html, the name of RawHtml's private field. |
body_prefix | A body_prefix other than None that is not Some(&x) (or x.as_str(), &*x) where x's last binding in scope is RawHtml::trusted(..) as its whole value or is declared RawHtml, or self.x declared RawHtml by the impl's own struct: a string still works in 0.2.x. |
config_json | config_json set to anything but None: data's typed home. |
config_script | config_script other than None: deprecated, handler text in a nonced script, removed in 3.0.0. |
into_parts_unchecked | PageResponse::into_parts_unchecked. |
with_module_preloads | RenderCtx::with_module_preloads, the typed home for modulepreload links. |
with_head_link | RenderCtx::with_head_link, the typed home for head links. |
relax_trusted_types | PolicyExtensions::relax_trusted_types, a route's Trusted Types relaxation. |
The three fields are found where a struct literal or an assignment sets
them (field: v, x.field = v), through an Option method that writes them
(.replace, .insert, .get_or_insert, .get_or_insert_with, .as_mut,
.as_deref_mut), and through a mutable borrow (&mut c.body_prefix, as
mem::replace takes).
A typed use is one raw entry, its constructor: { __html: trustedHtml(md) }
lists trustedHtml, and let shell = RawHtml::trusted(..) with
body_prefix: Some(&shell) lists RawHtml::trusted.
How it reads, and what it misses#
TypeScript and JavaScript are parsed by esbuild, as the build parses them: a
file that does not parse fails the run (BUILD-814) rather than pass unseen
(a .js file is also tried as JSX and as TypeScript). The lint reads
esbuild's output, where types are gone, JSX is h() calls and comments are
dropped, so a name in a comment, a type, JSX text or a string is never read
as a use; positions are mapped back through esbuild's source map. Rust is a
token scan, not a parse: it knows Rust's lexical grammar (nested comments, raw
strings, byte and C strings, char literals against lifetimes, raw
identifiers) and pairs brackets, so a struct literal's fields, a struct's
declared fields, a use tree and a pattern are told apart. HTML is read in
one pass, tag by tag: a comment starts only in text, never inside a quoted
value; an inline script is parsed as JavaScript (one that does not parse is a
BUILD-814, and the rest of the file is still read); a script of another
type (text/x-template, application/json) is read as markup, as a string
literal is. A string literal's escapes are decoded before its markup is
read.
What it cannot see, and review must:
- A sink reached through core's module inside a callback
(
import('@getforma/core').then((m) => m.template(s)), or.then(({ template }) => …)).template,templateMany,reconcile,mountMarkupandsetHTMLUnsafeare followed from core's own module names andFormaRuntime: through named, namespace and default imports,requireandawait import, destructuring and aliases of the namespace (const { template } = F,const G = F), and bracket access (F['template']).trustedHtmlis matched by name wherever it comes from. - Provenance across files or through a function: a sink passes only when its
argument is a
trustedHtml(…)call or aconstbound to one in scope (a parameter,let,varor any nearer binding of the same name shadows it, and only aconstis trusted), andbody_prefixonly when the last binding of its name in scope makes or declares aRawHtml(a shadowinglet, a parameter or a closure or pattern binding in between undoes it), or forself.xthat theimpl's own struct declaresRawHtml. Everything else is reported, so the lint errs toward an entry on the list. - A name built at run time: the registry key from variables
(
`${pkg}#trusted-types`) or found withObject.getOwnPropertySymbols, and an attribute name such as`data-bind:${attr}`. A key or name written whole, or as a sum of literals esbuild folds ('@getforma/core' + '#trusted-types'), is found. - A Rust name assembled by a macro (
paste!), andbody_prefixcarried in by struct update (PageConfig { ..base }). - Props built from data at run time (
JSON.parse('{"dangerouslySetInnerHTML": …}'), a key assembled from variables): the lint reads the names written in the source. h()given a tag only known at run time (h(tag, …)).update()on a mount handle the file does not see made or does not hold under a plain name: one passed in as a parameter or reached in a callback (Promise.resolve(mountMarkup(…)).then((h) => h.update(s))); one kept in a collection or a property ([mountMarkup(…)][0].update(s),map.get('w').update(s),{ w: mountMarkup(…) }.w.update(s),const [w] = [mountMarkup(…)]); one returned by a wrapper in the same file (function mount(r, s) { return mountMarkup(r, trustedHtml(s), …) }, thenconst w = mount(r, s); w.update(s)ormount(r, s).update(s): the wrapper's own call is listed once, and everyupdateafter it is unseen); andReflect.get(w, 'update')(s)or a key held in a variable (w[k](s)).- The browser's own sinks (
innerHTML,insertAdjacentHTML,document.write,Element.setHTMLUnsafe,document.createElement('script')): not Forma's exits, and what Trusted Types refuses in the browser (DR-20).
When Do You Need This?#
| Scenario | Need @getforma/build? |
|---|---|
| Learning / prototyping | No — use Vite |
| Production with Vite only | No — Vite handles it |
| Production with content hashing + compression | Yes |
Rust SSR with forma-server | Yes — emits FMIR + manifest |
| Multiple route entry points | Yes — handles multi-page builds |
Compiler vs Build#
@getforma/compiler | @getforma/build | |
|---|---|---|
| What it is | Vite/esbuild plugins | Full build pipeline |
| Use case | Add to existing Vite config | Replace your build script |
h() → template() transform | Yes (Vite plugin) | No — bundles your source as written |
| Content hashing | No | Yes |
| Compression | No | Yes (Brotli + gzip) |
| Manifest | No | Yes |
| SSR IR emission | Plugin only | Integrated (loads the compiler when ssr: true) |
| Install | On its own | Alongside the compiler if you need SSR (optional peer dependency) |
Part of the Forma Stack#
Frontend (TypeScript)#
| Package | Description |
|---|---|
| @getforma/core | Reactive DOM library — signals, h(), islands, SSR hydration |
| @getforma/compiler | FMIR compiler and esbuild SSR plugin |
| @getforma/build | This package — bundling, hashing, compression, manifest |
Backend (Rust)#
| Package | Description |
|---|---|
| forma-ir | FMIR binary format — parser and native HTML walker |
| forma-server | Axum middleware — SSR page rendering, asset serving, CSP headers |
Full Framework#
| Package | Description |
|---|---|
| @getforma/create-app | npx @getforma/create-app — scaffolds a Rust server + TypeScript frontend project |
License#
MIT
The recurring Chromium worker checks run the real build() output, including a
lazy module without a script tag. They verify cache replacement, network refusal,
offline reads and gzip/brotli decoding, then exercise two tabs through alternating
deployments, upgrade, rollback and retirement using forma-server's actual
off-worker response. The release rehearsal injects its unpacked build package into
the same harness and requires every named observation. Its HTTP-cache clearing is
limited to the fresh browser context so HTTP caching cannot mask CacheStorage.
When codeSplitting is enabled, one physical entry may be configured both as an
SSR entry and with ssr: false. The Browser root keeps its whole-page mount;
the SSR root keeps only its activation policy. Both use the same captured
compiler sources and share ordinary dependencies, including core, in one graph.
Each logical Browser root executes its own entry body. Ordinary imports of that
physical entry retain its SSR identity; a re-exporting facade does not opt into
a second whole-page mount. Keep reusable components and module singletons in
ordinary shared modules and declare the mount source itself as the Browser
entry.
JSX SVG namespaces#
With the default h JSX factory imported from @getforma/core (or the legacy
formajs alias), lexical SVG children such as <svg><a><title>Icon</title></a></svg>
are created in the SVG namespace. HTML inside foreignObject, desc and title
and the existing MathML integration points keep their original namespace.
The build emits a private per-file factory; it adds no code to core. Props,
spreads and children evaluate once in their original order, before element
creation. The compiler composes these edits with adoption hooks on the same
captured source for SSR and Browser entries, leaving native IR unchanged.
Inline function children and control-flow callbacks within the JSX subtree inherit
that lexical namespace; JSX in an attribute expression does not.
This applies to lexical JSX. A separately declared component returning <a>
has no known lexical SVG parent; use the existing svg() scope or a known-parent
reactive child for that case. Handwritten h calls are unchanged. An authored
custom JSX factory, automatic JSX runtime, or a file with an inner custom h
binding retains its own factory behavior. The existing script construction
refusal and the deferred dynamic SVG title adoption limitation still apply.
The actual native/CSR/adoption namespace vectors are exercised by
e2e/ssr/namespaces.spec.ts; unit controls cover evaluation order, source drift,
SSR/Browser ownership, custom factories and generated-name collisions.