Browser Programs
Elm-style frontend shape: one immutable model, typed messages, model-only updates, pure views, and compiler-managed channels for commands, outcomes, and subscriptions.
Design target with a first Rust slice: single-entry program targets, Vite artifact generation, static embed syntax. Richer compiler-owned embed delegation is still future work.
Browser programs are JavaScript-target programs. They share Ship source semantics and typechecking with native code but need no native runtime representation. Browser.* is diagnosed outside browser-capable targets — not polyfilled. The Web profile lives at client/<App>/init.shp with exactly one nameless program(Web) { ... }. Reusable modules under client/src/ use plain program { ... } and paths under Client.*. App targets are terminal and cannot be imported or embedded.
Program Contract
// client/Counter/init.shp
program(Web) {
type t = {
count: Int;
}
type message = [
| :clicked_increment
| :clicked_decrement
]
let init = fn() {
{ count = 0 }
};
let update = (model, message) => match message {
| :clicked_increment -> { model with count = model.count + 1 }
| :clicked_decrement -> { model with count = model.count - 1 }
};
let view = (model) => Html.main([
Html.button(on_click = :clicked_decrement, ["-"]),
Html.text(Int.to_string(model.count)),
Html.button(on_click = :clicked_increment, ["+"]),
]);
}
Generated shapes: init/update → t, view → Html(message). view is always fn(model) — messages come from HTML events, not parent callbacks. Parents wrap child HTML with Html.map(child_view, f = (message) => parent_message).
on_click is a message, not a DOM callback. The runtime preventDefaults click unless the gesture is native navigation on <a> / <area> (modifier-click, non-left button, target, download). Typed in-app links are open: 162-client-app-links-and-typed-routes.md.
Program Channels
type command = [
| :schedule_reset { after: Duration }
]
type outcome = [
| :saved(User)
]
message, command, and outcome are exact open-row aliases: finite and exhaustively matched, structural so embed can compose parent/child channels.
let update = (model, message) => match message {
| :clicked_reload -> {
command(TodoApi.Client.todos.list(
f = (todos: Vector(Todo)) => :loaded_todos(todos),
on_error = (fetch_error: Browser.Fetch.Error) => :request_failed(fetch_error),
));
{ model with status = "Loading..." }
}
| :loaded_todos(todos) -> { model with status = "Loaded"; todos }
| :request_failed(fetch_error) -> { model with status = "Request failed: " ++ fetch_error.message }
};
command(...) emits one-shot runtime work interpreted after update returns. Multi-statement arms use a tail expression for the next model. For api TodoApi(Http), bind a local facade:
program(Web) {
client TodoClient : TodoApi {
on_error = (fetch_error: Browser.Fetch.Error) => :request_failed(fetch_error);
}
}
Facade calls wrap browser fetch commands; the model stores typed values, not JSON strings. Browser.Fetch is browser-only command data — rejected outside browser program targets. Portable HTTP / HttpClient live in 18-http.md. Browser update stays Elm-style: return model + command data; do not become an inline-await workflow. view only renders.
Embedding
// client/src/Counter.shp — reusable
program {
type t = { count: Int; }
type message = [ | :clicked_increment ]
let init = fn() { { count = 0 } };
let update = (model, message) => match message {
| :clicked_increment -> { model with count = model.count + 1 }
};
let view = (model) => Html.button(on_click = :clicked_increment, ["+"]);
}
// client/BrowserCounter/init.shp
program(Web) {
embed Client.Counter :counter;
let view = (model) => Html.div([
Html.map(Client.Counter.view(model.counter), f = (message) => :counter(message)),
]);
}
Only programs embed. Grammar: embed Program :tag; or embed Program :tag { ... } (body-less ends with ;; bodied ends at }). Contributions:
model.counter: Client.Counter:counter(Client.Counter.message)/ command routinglifted child subscriptions and routed outcomes
unmatched
:counter(...)falls back toClient.Counter.updateembednever inserts HTML — the parent places the child view. Child programs may exposelet subscribe = fn(dispatch) { ... };; static embeds synthesize a parent subscribe that wraps child messages through the embed tag.
Keyed Children
embed Client.Counter :counter {
many = model.rows;
model = (row) => row.counter;
key = (row) => row.id;
update = fn(model, row, message) {
return update_counter(model, row.id, message);
};
}
Routed message: :counter { id: CounterId; message: Client.Counter.message }. Stale keyed messages after row removal drop safely (devtools/tests may surface). Optional update overrides default child-model-only routing; without it, Ship updates only the field selected by model = ... via Child.update.
Incoming Outcomes
embed Client.Login :login {
incoming = incoming;
}
let incoming = fn(model, incoming) {
match incoming {
| :succeeded_login(session) -> {
command(:save_session(session));
return LoggedIn.init(session);
}
}
};
Ordinary functions over the child outcome type — Ship’s answer to Elm/Foldkit OutMsg without Option(outcome) on every update return.
Subscriptions
program subscription declarations in ordinary .shp modules own reusable subscription behavior. Their structured body declares the settings type, message type, default settings constructor, and start function:
program subscription T {
type Settings = Browser.Pointer.Settings
type message = Browser.Pointer.message
let default = Browser.Pointer.default;
let start = fn(settings: Settings, send) {
return [
Browser.Event.on(
settings.target,
"pointermove",
(event) => send(:move(Browser.Pointer.decode(event))),
),
];
};
}
Applications select that authored declaration and override fields from default():
subscription Browser.Pointer :pointer(model.canvas_id) when model.is_drawing {
target = :document;
move = True;
up = True;
}
The compiler resolves the declaration from source; it has no catalog of subscription module names or event semantics. Runtime identity includes both the explicit key and evaluated settings. A changed token, target, preference, or key starts the new subscription and cleans up the old one. Equivalent identity remains active, and stopped scopes ignore stale sends.
Keyed embeds start one subscription scope per key. Live DB query subscriptions (subscription some_live_query(model.arg) :tag;) use the same keyword but synthesize a Synced field — see 19-sync.md.
Standard browser sources: Browser.Navigation, Browser.EventSource, Browser.Selection, Browser.Pointer, Browser.Mouse, Browser.Keyboard, Browser.Drag, Browser.AnimationFrame, and Browser.Window.Focus.
Browser.RealtimeKit is an optional browser-only source backed by Cloudflare RealtimeKit Web Core 2.x:
subscription Browser.RealtimeKit :meeting {
auth_token = model.auth_token;
audio = False;
video = True;
}
let view = (_model) => Html.video(
id = "participant-video",
autoplay = True,
muted = True,
plays_inline = True,
[],
);
It sends joined snapshots, participant add/update/remove messages, and typed failures. Commands set local audio/video, leave, and attach or detach a participant video by stable participant id plus DOM element id. SDK meetings and participant objects stay in the optional JavaScript provider registry; models receive serializable metadata and ids only. Safe Markdown remains normal VDOM via Markdown.view(Markdown.parse(Bytes.of_string(source)), on_link). Focus/select is a one-shot Browser.Dom command, not a subscription.
Managed resources (model-scoped handles) are deferred — 165-managed-program-resources.md.
Acceptance Pressure
The Foldkit pixel-art comparison is the acceptance target: one model, messages as facts, pure update/view, runtime-owned commands/subscriptions, Vite as packaging only. If building that example exposes a missing foundation, fix Browser/program — do not add example-local workarounds. Example subscriptions:
subscription Browser.Pointer :pointer when model.is_drawing {
target = :document;
up = True;
move = True;
cancel = True;
}
subscription Browser.Keyboard :keyboard {
target = :window;
down = True;
}
Dev Workflow
just dev examples/program-embedded-counters
just dev -- --open examples/program-embedded-counters
Root just dev rebuilds the compiler when needed and runs ship dev, which emits Vite packages under build/ship/browser/<Target>/. Finite smoke: ship dev --once --target browser <path>.
Hand-written runtime templates under crates/ship-js/templates/ stay plain ESM .js with JSDoc + ambient .d.ts; just js-check typechecks that surface. Generated emit bodies are not typechecked. Vite is packaging only — JavaScript is a substrate, not a public TypeScript/npm FFI.
Deferred
Command runtime/provider surface —
121-effect-command-surface.mdIntermediate outcome forwarding across embed layers
Future embed policy hooks beyond
many/model/key/update/include/incoming—164-program-composition.mdManaged program resources —
165-managed-program-resources.mdTyped in-app links / page routes —
162-client-app-links-and-typed-routes.md
CSS
Views style with first-party css { … } → CSS.Class + extracted stylesheet. See 20-css.md. Prefer classes (and CSS variables) over reallocating style = [...] lists in view.