A page declares its data in TypeScript. A Rust handler derives the same contract and binds it before the application opens its listener. The renderer accepts that verified handle and checked request values; application code does not search or fill slot names.
import { h, createSignal, createList } from '@getforma/core';
type Props = { title: string; rows: { id: number; label: string }[]; flash?: string };
export function Home(props: Props) {
const [title] = createSignal(props.title);
return h('main', null, title(), props.flash,
createList(() => props.rows, row => row.id, row => h('p', null, row.label)),
createList(() => props.rows, row => row.id, row => h('b', null, row.id)));
}use forma_ir::PageProps;
use forma_server::{assets, load_ir_modules, RenderCtx};
#[derive(PageProps)]
struct Row { id: u64, label: String }
#[derive(PageProps)]
struct HomeProps { title: String, rows: Vec<Row>, flash: Option<String> }
let manifest = assets::try_load_manifest::<Assets>()?;
let pages = load_ir_modules::<Assets>(&manifest)?;
let home = pages.bind::<HomeProps>("home")?;
pages.finalize()?;
// Only after finalization succeeds should application startup open a port.
let response = home.render(&props, &RenderCtx::new().with_title("This request").with_body_class("home"))?;bind accepts an exact route pattern or the manifest's logical <page>.ir alias. An alias used by multiple routes is ambiguous; bind each exact route. Unknown and explicit browser-only routes refuse SSR binding. The title defaults to the supplied page name and can be overridden per request. Existing typed head links, preloads and policy extensions remain on RenderCtx; the handle exposes no raw script or HTML string fields.
The closed type set is bool/boolean, string, finite number, signed epoch-millisecond Date/SystemTime, exact string-literal enum sets/unit enums, arrays/Vec, optional/null/Option and nested named structs/objects. Rust names default to snake_case→camelCase; #[props(rename = "wireName")] overrides a field or enum variant. Relative TypeScript aliases, imported interfaces and named re-exports resolve before JSX lowering; diagnostics retain declaration positions.
Every TypeScript field must exist in Rust metadata, including optional fields. Option<T> cannot satisfy required T; required Rust T can satisfy optional TypeScript T. A present None omits an object field; inside arrays it retains a null position. Extra Rust fields warn at bind and are omitted from the value projection, including unsafe values in those unused fields. This explicit DR-3/comparer-table rule takes precedence over shorthand that all additions/removals fail. Comparison uses names and enum sets, so declaration order does not define compatibility.
Integers must fit ±(2^53−1) before conversion. Floats must be finite. Dates truncate sub-millisecond precision toward zero and must remain within JavaScript's ±8.64e15 millisecond Date range; ordinary SystemTime::now() is supported. Each request checks values and reports the route, declared path (including array indices) and available source positions before any response is returned.
Direct nested fields, renamed child props, declared prop-seeded signals, createShow, direct/reactive ternaries and createList use the existing root slots and PROP instructions. Repeated lists share one declared array input and own separate row temporaries. Local signals and scratch slots are CLIENT-owned and are never handler-filled. Boolean literals are omitted like core's h(); a provably boolean-valued text expression refuses BUILD-614 and needs an explicit textual representation. Direct object/array/Date text or attributes, runtime coercion and other unsupported computed declared expressions refuse BUILD-610. Pass a computed presentation value as a declared scalar. Unsupported schemas and binding expressions fail even without --strict; no placeholder output substitutes for declared data.
A handle is cloneable and Send+Sync and compares the contract once. In development, corrupt/missing module writes retain the last good module and its matching asset manifest. An invalid or missing manifest freezes that entire pair, even if a module is edited at the same filename; restoring valid manifest bytes resumes reload, including restoration of the exact prior bytes. A valid new schema is bound again; incompatibility returns RENDER-405 until the handler/page is corrected. Release builds use immutable embedded modules. Finalization verifies every SSR route, seals binding and freezes the route topology; low-level try_render and the tuple loader have been removed. Browser state decoding and activation preserve separate identities for two independently mutable signals seeded from one prop, even though SSR reads one server value; see the island contract.
Startup completion and document extensions#
After all pages.bind/bind_with calls, call pages.finalize() before opening the listener. Unbound SSR routes fail startup; Browser routes need no typed binding. Handles refuse rendering until finalization succeeds. Failed finalization permits binding the missing routes and retrying; success seals registration. bind_with sets page limits and RenderCtx inherits them unless explicitly overridden.
The final handle preserves request JSON, branding CSS and explicit RawHtml through with_config_json, with_personality_css and with_body_prefix. It accepts no raw script or nonce. The legacy try_render/tuple loader/SSR PageConfig fields are removed. Walker timing excludes props conversion and document assembly; measure complete requests separately.