Server HTML and Datastar
Ship supports a server-driven frontend path alongside browser program(Web). The two paths share domain types and checked api contracts, but they do not share a rendering representation or state owner.
Ownership
ServerHTMLis escaped server-rendered markup. It is not the browserHtml(message)VDOM and has no message, command, subscription, keyed-node, or hydration semantics.Datastar's upstream browser bundle owns DOM morphing, client signals, action requests, and SSE consumption. Ship pins and self-hosts that bundle rather than reproducing it.
Ship owns safe markup construction, typed API actions, signal and region identities, patch values, response framing, and server stream lifetime.
Datastar signals are untrusted browser input. Authorization and durable state stay in server services, sessions, and databases.
Safety layers
Server HTML escapes text and attribute values by default. Raw markup is a conspicuous unsafe operation.
ServerHTML.Attribute is a structured application, not pre-rendered text. Ordinary named and boolean attributes, explicit external class tokens, and typed CSS.Class values are folded at element construction. Rendering emits at most one escaped class and one escaped style attribute; dynamic CSS classes contribute only their compiler-owned custom properties. Custom-property values receive declaration-context CSS escaping before the complete attribute receives HTML escaping, so a string hole cannot terminate its generated declaration.
ServerHTML.Document owns structured <html>, <head>, and <body> data. Head assets are typed and deduplicated in stable order. Fragment deliberately contains markup only: a fragment may be rendered into a full page or a Datastar patch without accidentally re-emitting document assets.
Styles and assets
Backend css { ... } uses the same checked class identity and stylesheet artifact as browser code, but not the browser VDOM. A page applies the opaque class directly:
let panel = css {
padding: 1rem;
border: 1px solid #e7ddd0;
};
let body = ServerHTML.main(
[
ServerHTML.class(panel),
],
[
ServerHTML.text("Ship"),
],
);
Backend builds extract one content-addressed application stylesheet. The full document links the exact build-owned URL once; the native server serves that artifact and static generators write it under their configured static root. Development output retains readable class tokens, source provenance, and a debug manifest. Rule contents affect the asset digest, not DOM class identity.
${expr} holes remain static stylesheet rules plus request values in scoped CSS custom properties. This gives correct first paint without runtime rule injection, but it requires a CSP policy that allows style attributes.
Datastar's pinned module script is also a document asset. Patches stay asset-free and therefore cannot duplicate the script or stylesheet. Native server bundles also carry checked bound-browser artifacts, so production pages need no separate Node asset host.
HTTP adapters classify these responses through canonical Http.Response.Representation facts checked from source. ServerHTML.Document, and finite/streaming Datastar responses declare their own representation; adapters consume the resulting closed route fact and renderer witness. They do not rediscover semantics from response type-name strings.
Signal schemas begin as ordinary Ship products. Derived field paths identify signal values without string names, and an explicit scope supplies initial values and a naming prefix. Reusable fragments accept the scope as an ordinary argument rather than depending on an ambient state effect.
Page rendering creates that root with Datastar.scope(prefix, initial). Action handlers use Datastar.existing_scope(prefix) to refer to the scope already owned by the browser; they do not re-encode untrusted request data just to recover the typed signal and patch namespace.
Stable DOM targets are phantom-typed Target(region) values. Datastar.region constructs a RegionFragment(region) carrying that target's ID, and patch_target requires the same region type. The renderer can therefore use Datastar's matching-ID fat morph without accepting a raw selector.
Backend actions come from generated facts for an api route. The route value is indexed by path, query, body, and response types; the first action constructor uses its checked method and path and requires a scope matching the body type. Application source does not construct an @post string or duplicate a route URL.
The checked server route owns a closed request-ingress fact. Datastar GET and DELETE actions carry their JSON signal payload in Datastar's protocol-owned query parameter; POST, PUT, and PATCH carry it in the HTTP body. A bodyless route has no payload ingress. Server adapters consume that fact directly and never recover it from the HTTP method or response representation.
Http.Route and Product.Field are opaque. Their unsafe_* constructors are the explicit compiler-generator boundary; ordinary application code consumes generated route and field values and cannot forge their phantom indices.
The adapter's wire output is ordinary data. A concrete Datastar.Patch currently covers element, removal, and signal patches. Product field witnesses keep signal patches typed:
let busy = Datastar.patch_signal(
task_scope,
TaskSignals.Field.busy,
True,
[],
);
An application can stream its own exact patch row instead: Datastar.Stream(TaskPatch) renders each TaskPatch through Http.Response.Renderer(TaskPatch), found like any canonical behavior (run-datastar-patch-rows). Finite handlers return a Datastar.Response patch plan. Request-owned streams send Datastar.Patch repeatedly. Both are inspectable and snapshot-testable before any command-effect convenience is added.
Action routes
Use one API route per action by default:
api TaskUi(Http) {
group tasks {
path /tasks;
route create =
#post(/create, body = CreateTask)
-> Datastar.Response;
route rebuild =
#post(/rebuild, body = RebuildInput)
-> Datastar.Stream(Datastar.Patch);
}
}
Distinct routes retain independent Datastar cancellation keys, route steps/authentication, error contracts, metrics, and logs. An open action variant inside one route is still useful when those cases deliberately share the same HTTP policy and cancellation domain.
Streams
Datastar action streams are request-owned. The initial surface returns an ordered Vector(Datastar.Step(Datastar.Patch)); each step contains a patch and an optional Duration delay. The response ends when the step plan ends. Disconnecting the request stops future writes.
This is separate from database Sync. Do not adapt Datastar actions by publishing onto a process-global named stream.
Prefer complete, stable-region "fat morphs" where an interrupted or retried request can safely reassert current server truth. Patch IDs and signal values may assist presentation; they are not authority.
Effects
Signal references are explicit data, not a generic State(...) effect.
Ordered patch emission could later use an effect command whose payload is the same page patch row:
effect command TaskOutput {
emit: pure fn(patch: TaskPatch) -> Unit;
}
A finite response could interpret the command by collecting patches, while an SSE response could interpret it by flushing each patch. The current slice does not add that effect: the explicit Response and Stream values already make ordering, recording, cancellation, and tests visible, while a command would need a runtime writer provider and a proven application patch renderer. Add the effect only when data-dependent work between writes requires it.
First route-action boundary
Datastar.action consumes a generated Http.Route fact and a Scope whose product exactly matches the route body. The first constructor supports routes whose path and query types are both Unit; Datastar sends the selected signal scope. action_without_body covers bodyless routes and emits an explicit empty payload so upstream Datastar cannot substitute whole-page signals.
Typed path expansion, query products, and payload projections should extend generic route facts. They must not become Datastar-specific string builders.
Escape hatches
The normal surface uses typed HTML constructors, route facts, signal fields, events, modifiers, patch modes, and regions. Unsupported upstream features may use explicit unsafe raw HTML, attribute, or expression operations.
An escape hatch should remain raw when it is truly application-specific. If multiple official Datastar examples need the same escape, promote that pattern into a source-owned typed adapter rather than adding a compiler name special-case.