Sky.Spa — client-side TEA (overview)

Status: supported feature. Sky.Spa is the client-wasm backend of Std.App — you write an App.app and select it with a client --target (web:app / mobile:* / tablet:*); Std.Spa is the low-level runtime the build drives, not a module you import. The runtime partition, the auto-split, and the Std.Bundle packaging story are built, tested, and stable. It targets desktop / mobile-embed webview first; the constraints below (e.g. web-as-a-first-class-target) are real current scope boundaries, not instability. This page documents what the Sky.Spa target is and what is not yet in scope.

Sky.Spa runs the Sky TEA loop on the client. You write the same Model / Msg / update / view you would write for Sky.Live, over the same renderer-agnostic Std.Ui.Element — but instead of the loop running server-side and streaming HTML patches over SSE, the whole loop compiles to GOOS=js GOARCH=wasm and runs in the browser. Pure update branches run client-side with zero round-trip; there is no per-user server Model, no session, no SSE.

Build & run — one command

An App.app entry built to a client --target (web:app / mobile:* / tablet:*) selects the Sky.Spa backend, which auto-splits into a wasm frontend

sky run   --target web:app  src/Main.sky  # split → build wasm frontend + native backend → run it
sky build --target web:app  src/Main.sky  # split + build both (artefacts under .split/)
sky build --embed --target mobile:ios …   # flags COMPOSE: --embed → backend PostgreSQL, --target → frontend shell

Pin the target once in sky.toml ([app] → target = "web:app") and a bare sky build / sky run picks it. sky run starts the backend, which serves the frontend + /_rpc + the dev console + metrics same-origin (one binary) — open the printed http://localhost:<port>/. sky check type-checks the shared source without splitting. The explicit generator (`sky spa-split --out

`) is the low-level form for when you want the `frontend/`/`backend/`/`shared/` trees kept at a chosen path; see [`docs/tooling/cli.md`](../tooling/cli.md) and [auto-split.md](auto-split.md).

When to use Sky.Spa vs Sky.Live

Sky.Live keeps the loop on the server: per-user Model, a live SSE per session, a full server-side re-render each interaction. It scales, but the ceiling is the stateful fleet (sticky sessions, session store, SSE fan-out). Sky.Spa moves the loop to the client, which changes the trade:

Sky.LiveSky.Spa
Where update runsserver (trusted)client (untrusted — see Security)
Pure UI transitionround-trips to the serverclient-local, zero round-trip
Backendstateful (session + SSE per user)stateless — auth + effects + durable data only
Scaling axissticky sessions / SSE fan-outhorizontal stateless API; DB is the only shared axis
First-paint costserver HTML (light)wasm bundle (~2.5 MB gzip today)
Target todayweb, terminal, desktopdesktop / mobile-embed; web = v2

Reach for Sky.Spa when pure UI transitions should be instant and local (rich client-side interaction), the backend can be a stateless API, and the delivery target is a desktop/mobile-embed webview where a one-time wasm download is fine. Stay on Sky.Live for a browser web app today — its first paint is server HTML, not a multi-megabyte wasm blob.

The programming model — same as Sky.Live

An app is written exactly like a Sky.Live / web App.app; only the build --target (a client wasm backend) and a Model-shape convention change. The four TEA fields go in App.app; routing and the server boundary are attached with App.withX builders (exactly like the web target's optionals):

appDef =
    App.app
        { init = Model.init
        , update = Update.update      -- pure branches run on the CLIENT
        , view = View.view            -- Std.Ui Element, painted client-side to the DOM
        , subscriptions = Subs.subs   -- Sub.every timers, reconciled after each update
        }
        |> App.withRoutes
            [ App.route "/" All
            , App.route "/active" Active
            , App.route "/completed" Completed
            ]
        |> App.withNotFound NotFound


main =
    App.run appDef       -- built --target web:app  (runner-direct: App.runSpa appDef)

view is Std.Ui (the default — see the pinned defaults in AGENTS.md), not Std.Html: the Sky.Spa client renderer paints any Element tree to the DOM, so the same view could target Sky.Live (web), Sky.Tui (terminal), or Sky.Webview (desktop).

Model = { ui, data } — source of truth, not "where it lives"

In Sky.Spa the entire Model is client-owned (the loop runs on the client), so the useful declaration is source of truth, expressed structurally:

type alias Model =
    { page : Page       -- the routed page (the router sets it)
    , ui   : Ui         -- client-owned, ephemeral, NEVER serialized (no codec)
    , data : DataCache  -- a cached projection of server truth (has a Std.Codec)
    }

The wire boundary falls out of the types: things in data have a Std.Codec (they cross the network and hit the DB); things in ui are plain Sky types with no codec (they never leave the client). "Has a codec ⇒ server-backed" is the boundary. Sky removed RemoteData pre-v1, so model the fetch lifecycle with an explicit ADT (Loading | Loaded a | Failed Error | Stale a) rather than a magic wrapper.

v1 does not auto-enforce the { ui, data } split — that is the v2 auto-split (auto-split.md). v1 apps follow the discipline by hand, which keeps them forward-compatible with the v2 mechanism.

The server boundary — generated, with an explicit low-level form

Under a client --target the auto-split derives the boundary for you: an ordinary effectful update branch becomes a generated POST /_rpc/<Msg> (pure → client, any effect → server; see auto-split.md) over a Std.Codec shared with the backend — one type, one codec, one wire contract, no OpenAPI/TS drift. The low-level, explicit form — talk to a stateless Sky backend by hand with Std.Spa.getJson / postJson, decoding with that same codec — is what the generated code uses under the hood:

Refresh ->
    ( { model | ui = setStatus Loading model.ui }
    , Spa.getJson todosCodec "/api/todos" GotTodos )

GotTodos (Ok todos) -> ( { model | data = { todos = todos } }, Cmd.none )
GotTodos (Err e)    -> ( { model | ui = setStatus (Failed e) model.ui }, Cmd.none )

The idiom that makes the wire contract literally one file: put the shared types + codecs in a single Shared.sky and symlink it into both the client and server projects. Add a field there and both the client and server stop compiling — that is the whole point.

getJson / postJson are ordinary Sky over Cmd.perform + Http + Codec (no new runtime kernel). They hand update a Result Error a directly (a 2xx + decoded body, or an Err — a non-2xx status, a decode failure, and a network failure are all Err), so the app writes one case, not two.

How the client paints and handles events

The client renders with the same diff as Sky.Live (input-authority protocol §Patch operations): children are matched by key (Std.Ui.Keyed, a named field) or by shape, and a matched child keeps its DOM node, so a focused input keeps its focus, caret and typing when something is inserted above it. Event payloads follow Sky.Live's convention: onKeyDown / onKeyUp / onKeyPress get event.key, onCheck gets the checkbox's Bool, input / change get the value. A control's value is written only when the rendered value changes (Elm semantics): when update refuses an edit, the field keeps what the user typed. While an IME composition is open the field's input events are not dispatched; the committed text is, once. A server-painted first page is adopted (hydrated) only when it shows exactly what the client's first view says; otherwise the client builds the page itself. Adjacent texts (text "Hello, " next to text name) arrive as one browser text node, and hydration splits that node at the client's boundaries; the server's nodes stay in the page.

A widget island (Ui.island, Std.Ui overview) works the same as on Sky.Live. The boot loader carries the island runtime, so a widget file loaded with <script defer> can register before the wasm boots. The client keeps the widget's element when it rebuilds the island's parent, hands a widget event's detail to the Sky decoder, and delivers Cmd.toIsland after the update (a microtask). A Cmd.toIsland returned by a server branch cannot reach the browser: the backend logs SpaIslandCommandOnServer. Any other CustomEvent with a detail reaches its handler decoded the way the Sky.Live server decodes a wire argument.

App.withHead applies on every page. On an SSR page the backend renders it. On the static dist/index.html shell (an app whose backend has no per-request work, or a static host) the client applies it once at boot, from the first model, as Sky.Live does on its first load. Before v0.27.0 the head was dropped on the static shell.

Msg order and server calls — the ordering contract

The client keeps Sky.Live's TEA contract: every Msg's update runs exactly once, in arrival order, and the Cmds it returns run. A server branch (an auto-split POST /_rpc/<Msg>) keeps that contract; it never makes another Msg run twice or out of order, and it never drops a Cmd. There are two kinds, and the split picks the kind from the arm (runtime-go/rt/spa_rpcqueue.go):

What this rules out, and why it matters: before v0.27.0 the client applied Msgs during an RPC and then re-ran them on top of the response. A client arm that spends a single-use value (a Noise.encrypt on the model's transport) failed the second time ("this state value was already used"), and a Msg that decided on a field the response changed (a timer tick that starts a call when busy is False) decided again, differently, and its new Cmd never ran.

Per-arm guarantees, in one list:

Write a long wait as an async arm. A long poll or a slow relay read belongs in a Cmd.perform from an arm whose model write reads no server data; the client then keeps running while it waits, and other server calls go out beside it. An arm that writes a server value into the model holds the client for its whole round trip.

A failed task is a result, not a console error. A client-local Cmd.perform whose task fails (Task.fail, a Std.Native call in a browser with no native shell, a client Http call) delivers its Err to the Msg you gave it, like an Ok; the client logs nothing. A server call that fails (a 5xx, a response the codec cannot decode) goes to App.withRpcError when the app declares it. Without that handler the client keeps the model and writes [sky.spa] RPC failed; kept last good model (no app-level handler …) once (the generated Applied<Msg> (Err e) arm returns Spa.reportRpcFailure e). A network error is re-sent by the runtime (see Network resilience below) and shows the Retry bar only once its retry budget is spent.

Network resilience — offline, sleep and overload are the runtime's job

A phone that switches apps, a laptop that sleeps, a radio that wakes up late, a proxy that answers 503 for a second during a deploy: each fails one request at the network level. The client handles this by itself; the app writes no retry code and no network banner (v0.27.3, runtime-go/rt/spa_retry.go).

Sub.every is a wake-up, not a clock: read the time the tick carries. A browser or a webview throttles timers in a background tab, so a counter of ticks drifts. Use Sub.everyWithTime 100 Tick (Tick : Int -> Msg receives the epoch milliseconds) and derive elapsed time from it. A tick whose update calls the server is treated as a poll: it is skipped while its previous call is unsettled, and while the page is hidden only its first call goes out; when the page is visible again one fresh tick runs at once. A tick that only changes the client model (a clock, a stopwatch) is never held back.

WebSocket from the client

A Sky.Spa client can hold its own WebSocket: Sky.Core.WebSocket (connect, send, sendBinary, receive, receiveWithin, forEachMessage, close, and the onOpen / onMessage / onClose / onError Subs) runs in the wasm client over the browser WebSocket API. The split classifies it as a client effect, so an arm that connects or sends runs in the client, not behind an RPC.

Connect ->
    ( model, Cmd.perform (WebSocket.connect "/ws") Connected )

subscriptions model =
    case model.sock of
        Just s -> WebSocket.onMessage s Got
        Nothing -> Sub.none

A path URL ("/ws") connects to the page's own origin (ws: or wss: after the page's scheme), so it passes a strict connect-src 'self'. Serve it from the backend with the server API, mounted with App.api:

wsHandler req =
    Ws.upgrade req (Ws.defaultCfg |> Ws.withOnFrame echo)

App.withRoutes [ App.route "/" Home, App.api "GET /ws" wsHandler ]

The upgrade's origin check admits the page's own origin; in production set Ws.withOriginPatterns. What a browser socket cannot do returns an Err instead of being ignored: request headers (withHeaders; the browser sends the session cookie itself, or use a query parameter) and a close code other than Normal or Custom 3000-4999. The browser answers pings itself, so withPingInterval has no effect. One reader per socket, as on the server: a Sub or a Task, not both. Proven in Chromium and WebKit by scripts/spa-websocket-e2e.sh.

Routing — App.withRoutes (History API)

Routing is opt-in via the App.withX builders (a single-view app needs none). The names read exactly like the web target:

Internal <a href> clicks are intercepted (History pushState, no reload); Back/Forward (popstate) is honoured; an external host, target="_blank", a download, a sky-external mark, or a modified click is left to the browser.

A same-origin link to a path the server owns is a full browser navigation, not a client route: a path no client route matches, a path under /_sky/ or /_rpc/, and a path registered with Spa.serverRoute "GET /admin/logout" (for a path that is both a client route and a server route). A sign-out link, the Sky Console and a static file then reach the server. Nav.pushUrl and Nav.replaceUrl follow the same rule. One click runs only the handlers of the view it was delivered to; a nested outer onClick carries the Msg of that view.

The full surface (typed signatures + summaries) is sky doc Std.App.

Client persistence and identity

A web:app client writes its whole model to localStorage after each update and restores it on a full page load, so a reload keeps client scratch state (a form draft, a counter, a dismissed banner). localStorage is shared by every tab of the origin, and it holds ONE model: the last one any tab wrote.

A stored model restores only for the identity it was stored under. The identity is the value of the session field(s): the model fields of type Session / Maybe Session that a server branch writes, which the backend signs into the sky_spa cookie and the SSR seed carries. On a full load the client compares the identity of the stored model with the identity of the seed:

Stored model → seedResult
same identity, or signed out → signed outthe stored model restores over the seed
one identity → another identitynothing restores, the stored copy is removed
signed out → signed innothing restores, the stored copy is removed (a basket filled before sign-in is not carried over)
signed in → signed outnothing restores, the stored copy is removed

So two tabs with two identities (a practitioner in one, a patient link in the other) never show each other's data. Each tab's full load after the other tab wrote boots from its own seed: the tab loses its unsaved scratch state, never its server data. When an update signs out (clears the session field), the client removes the stored copy and does not write the signed-out model for the rest of that page's life, because that model still holds the signed-in user's other fields. A page served without an SSR seed (a static deploy, a native shell) has no server identity to compare, and restores the stored model as it is. An app with no session field has one identity, and restores as before.

Shared computers. The stored model stays in the browser until the next change of identity, sign-out in the page, or a full load under another identity. A user who closes the tab without signing out leaves their last model in localStorage, where the next user of that browser profile can read it with the developer tools (the app never shows it to them). Keep sensitive data in server state, not in client scratch fields, if the app runs on shared machines.

Before v0.25.18 the model was stored under sky:spa:model with no identity check. The client deletes that key on the first load and restores it only for an app with no session field. The current key is sky:spa:model:v2.

Sky.Live has no such store. A Sky.Live model lives on the server, keyed by the sky_sid session; the browser holds only the cookie. Two Sky.Live tabs with the same cookie share one session by design, and nothing of the model is written to the browser.

Security — the untrusted client is a first-class rule

In Sky.Live update runs on the server → trusted. In Sky.Spa update runs on the user's machine → untrusted. Therefore, unavoidably:

Content-Security-Policy

A Sky.Spa page runs under script-src 'self' 'wasm-unsafe-eval' with no hashes and no 'unsafe-inline'. The build writes the wasm loader as a file, dist/spa-boot.<hash>.js, next to wasm_exec.js and main.<hash>.wasm, and precompresses it (.gz, and .br when brotli is installed) with them. Both dist/index.html and the SSR first-paint page load it the same way:

<script src="/wasm_exec.js"></script>
<script src="/spa-boot.<hash>.js" data-wasm="/main.<hash>.wasm"></script>

The loader reads the wasm URL from its own data-wasm attribute, so the file is the same for every build and its name changes only when the loader changes. The #sky-model seed stays a <script type="application/json"> block, which a browser never executes. The backend serves /spa-boot.<hash>.js itself, from memory, so the SSR page boots whether or not the backend can reach dist/. A static host (nginx, Caddy, a CDN) that serves the dist/index.html shell can serve the dist copy: the bytes are the same. 'wasm-unsafe-eval' is required because it allows WebAssembly.instantiateStreaming. It does not allow JS eval. Set SKY_CSP=strict to make the backend send the policy itself. See the Sky.Live architecture notes.

Deploying behind a proxy

A common layout runs the backend binary from its own directory and puts a proxy (Caddy, nginx) in front of it. The proxy serves the wasm from the frontend dist/ and forwards every other path to the backend. This is the contract for each asset the SSR page and dist/index.html name:

PathWho serves it
/, every app route, /_rpc/*, /_sky/subThe backend. These must reach it.
/spa-boot.<hash>.js (the wasm loader)The backend, from memory. A static host may also serve the dist copy.
/_sky/console/…, /_sky/console-shell.<hash>.jsThe backend, from memory (the Sky Console).
/main.<hash>.wasm, /wasm_exec.jsA dist/ file. The backend serves it from ../frontend/dist when that directory is reachable from its working directory. Otherwise the static host must serve both. The SSR page names the wasm from a value sky build writes into the backend, so the backend does not need the dist to name it.
/index.html (the static shell)A dist/ file, as above. The SSR page at / does not need it.
/<static prefix>/… (the app's declared static dir, e.g. /static/logo.png)The backend. It serves its own copy of the dir (runtime uploads land there) and falls back to dist/<static prefix>/ for a committed file it does not have.

A Sky.Spa app must be served at the root of its origin. The SSR page and dist/index.html use root-absolute URLs, so a sub-path mount is not supported. (A Sky.Live app can run under a sub-path with SKY_LIVE_BASE_PATH behind a proxy that strips the prefix.)

The SSR page inlines its base CSS. The backend emits no other script, style sheet or font. The wasm pair stays a dist file because an app can host the wasm on a CDN, and wasm_exec.js must match the Go toolchain that built that wasm.

A Caddy example for a backend on port 8951 whose dist is at /srv/app/frontend/dist:

example.com {
    @wasm path *.wasm /wasm_exec.js
    handle @wasm {
        root * /srv/app/frontend/dist
        file_server {
            precompressed br gzip
        }
    }
    handle {
        reverse_proxy 127.0.0.1:8951
    }
}

A request for a Sky-owned asset name that the backend cannot serve (a stale /spa-boot.<hash>.js, a /_sky/*.js from an older build, or the wasm pair when dist/ is not reachable) is a 404. It is never the HTML of an app page, so a stale page fails loudly in the browser console instead of running HTML as script. A root route parameter (App.route "/:slug") no longer captures the wasm pair either: those names go to the dist file server first.

v0.25.19 did not serve the loader from the backend. Behind the layout above the backend answered /spa-boot.<hash>.js with HTML, the browser refused to run it, and the page never became interactive. On v0.25.19, add /spa-boot.*.js to the paths the proxy serves from dist/, or upgrade.

Native device capabilities

Std.Native capabilities are client effects: the auto-split keeps them in the wasm client, and on a native shell they reach the device through the shell's bridge. The secure store (Native.secureSet / secureGet / secureRemove) and the biometric prompt (Native.authenticate) have no web API, so in a plain browser they are Err Unavailable, never a localStorage fallback. The camera code scanner (Native.scanCode) is the same: the native shells have one, a browser does not (scan there with a widget island). Secret.fromString and Secret.reveal are pure and run in the client, so a client branch can use a secret it read from the device; Secret.fromEnv reads the server's environment and stays on the server. A Secret has no codec and never crosses the wire. Purpose strings, entitlements and sky package --release: docs/skyapp/native.md.

Honest limits

These are real, current scope boundaries — not roadmap optimism:

See also