Sky.Live architecture

Status: the Rust compiler (rust/, cargo build --release -p sky) is the primary Sky compiler; the Haskell compiler is preserved under legacy-haskell-compiler/. Verified by the example sweep + compiler test suite (cargo test + xtask gates). See ../history/compiler/journey.md for the changelog.

Technical reference for how Sky.Live dispatches events, renders, and diffs. For user-facing usage see overview.md.

Process flow

┌─────────────────┐         ┌───────────────────┐
│  browser        │         │  sky-live server  │
│                 │         │                   │
│  1. GET /       │────────▶│  initial render   │
│  ◀────HTML──────│         │  view model → dom │
│                 │         │                   │
│  2. open SSE    │         │                   │
│  ──EventSrc───▶ │─session │  session store    │
│                 │ created │  (mem/sqlite/...) │
│                 │         │                   │
│  3. click       │         │                   │
│  fetch /_sky/   │────────▶│  dispatch msg     │
│    event        │         │  update msg model │
│                 │         │                   │
│                 │         │  diff(vOld, vNew) │
│  4. patch       │◀────SSE─│  serialised patch │
│  apply to DOM   │         │                   │
│                 │         │                   │
│  5. cmd result  │◀────SSE─│  goroutine → msg  │
└─────────────────┘         └───────────────────┘

Session lifecycle

  1. Page load — server renders init (). The resulting model + view are cached under a session id taken from the session cookie (sky_sid for the host app; sub-apps mounted in-process use sky_<name>_sid; when the cookie is Secure the name carries the __Host- prefix, so __Host-sky_sid, and both spellings are read). A presented id that is not 32 lowercase hex, or that was retired by a session-id rotation, is never adopted: the page gets a fresh id. The cookie is set HttpOnly; SameSite=Lax (the CSRF cookie is separately SameSite=Strict). There is no query-param session path.
  2. SSE open — client connects to /_sky/sse. The session comes from the cookie; no cookie is a 400. Server locks the session and emits a hello event.
  3. Event post — client sends POST /_sky/event. The session is resolved from the cookie only — the body's sessionId is advisory and must match it, so a leaked session id cannot be used to drive someone else's session (see docs/skylive/input-authority-protocol.md §Request). Server decodes msg, locks the session, runs update, diffs, emits patch over SSE.

Session-id rotation. When the session's bound user changes (Live.bindSessionUser, sliding-auth auto-bind, an account switch) the session is re-keyed to a fresh id (runtime-go/rt/live_session_rotation.go). The same session object stays attached to the signing-in tab's SSE connection; that tab gets the new cookie through a one-time ticket on its SSE stream (POST /_sky/rotate) or on its next event POST / SSE connect / sky-nav. Every other SSE connection of the session is closed. The old id is kept in the session store as an alias: for 60 s a request carrying only the old cookie is answered X-Sky-Status: session-rotating (the client retries), then session-lost. A body sessionId that is an alias of the cookie's session is accepted and answered with X-Sky-Sid: <new id>. The durable snapshot moves to the new id. See docs/skylive/overview.md#session-ids-change-at-sign-in. Header session transport (no cookies). An app that opts in (App.withSessionTransport HeaderToken, Live.withSessionTransport "header", or SKY_LIVE_SESSION_TRANSPORT=header) carries the session in the X-Sky-Session header instead of a cookie. See Sessions without cookies below.

  1. Cmd dispatch — if update returned a non-none cmd, server spawns a goroutine per command. Each goroutine holds the session lock only to apply the resulting Msg, not while the task runs — so long-running HTTP requests don't block other events.
  2. TTL expiry — sessions expire after [live] ttl seconds of inactivity. The store sweeps expired rows periodically.

Runtime location

All the plumbing lives in runtime-go/rt/live.go (HTTP handlers, VNode diff, SSE encoding) and runtime-go/rt/live_store.go (session backends). These are embedded into every project's binary.

The Sky-facing Std.Live module exposes app + route; subscriptions / commands live in their own modules (Std.Sub.{none,every}, Std.Cmd.{none,perform,batch}); HTML primitives are in Std.Html / Std.Html.Attributes / Std.Html.Events; Std.Ui sits on top of those.

VNode shape

The view returns a tree of vnode values:

type vnode struct {
    kind     string            // "elem" | "text"
    tag      string            // div, span, ...
    attrs    map[string]string
    events   map[string]string // "click" -> msg-serial
    children []vnode
    text     string            // for kind="text"
    key      string            // for keyed diff
}

Sky-side Html.div [ Attr.class "x" ] [ Html.text "hi" ] produces a vnode literal.

Diff algorithm

diff(oldNode, newNode) is recursive:

Patches are encoded as JSON and streamed over SSE.

Widget islands

An element with data-sky-island (Ui.island, Html.island) is a widget island (runtime-go/rt/island_core.go). The diff patches only its attributes while its name and id hold, and never descends into it. A new name or id replaces the element. The server renders no children for it. The client keeps the island's element across every HTML swap (__skyReplaceHTMLPreservingFocus), children reconcile (__skyApplyKids) and element replace, like a same-src iframe, and restores the focus and the selection inside it. The island runtime (island_client.go, at the start of the client file) mounts, updates and destroys widgets from a MutationObserver. A widget event is the CustomEvent skyisland-<type>; the client sends its detail as JSON text, and the handler (islandEventHandler) runs the Sky decoder: a rejected payload is logged and dropped. Cmd.toIsland goes to every tab as the SSE event island. The island has no server input authority: its state lives in the browser, and a remount (a reload, a lost session) starts from props.

SSE transport: event: patches vs event: patch

(Cycle 3 P50 / Gap C11 — landed in v0.15.x hardening.)

The SSE channel carries TWO event types, chosen per render by the server-side chooseSSEFrame helper:

EventEnvelope shapeUsed when
event: patches{seq, ackInputs, patches: [...]} (mirrors writeEventJSON's HTTP reply)A structural diff between the previous tree and the just-rendered tree fits in a small patch list. Typical 200-1000 B per frame.
event: patch{seq, ackInputs, body: "<html>..."} (legacy full-body shape)First render after session creation (no previous tree to diff against); reconnect-resync (server has the model but the client may have lost DOM state); the diff degenerated to a single root-level innerHTML replace (patchesAreFullReplace). Typical 5-50 KB per frame.

The client routes via two addEventListener calls on the same EventSource:

__skySSE.addEventListener("patches", function(e) {
  var frame = JSON.parse(e.data);
  __skyHandleResponse(frame.seq, frame.ackInputs, function() {
    __skyApplyPatches(frame.patches);
  });
});
__skySSE.addEventListener("patch", function(e) {
  // legacy full-body shape — __skyPatch() driven by frame.body
});

Both consumers route through __skyHandleResponse for the same monotonic seq guard the HTTP path uses, so out-of-order frames (e.g. a stale patches frame arriving after a fresher patch frame across a brief network blip) are dropped at the same point.

Input-authority preservation on the SSE path. SSE producers pass nil as clientState to diffTrees — server-driven renders (Cmd.perform completion, Time.every tick) carry no fresh client inputState. The client-side __skyApplyPatches filter (__skyIsDirty(el)) drops value/checked/selected attrs on dirty inputs, so in-flight typing is preserved without server-side alignment. See input-authority-protocol.md.

Backwards compatibility. A pre-P50b client (no patches listener) is unaffected: EventSource silently no-ops events without a registered listener, and the producer's fallback path (first-render / full-replace) still uses event: patch so the client receives a full-body frame for those cases. The producer NEVER ships event: patches to a session that hasn't yet seen a prev tree.

Per-session fan-out — every tab of one session mirrors one shared view

A session (the sky_sid / __Host-sky_sid cookie) holds ONE server-side Model; multiple tabs of the same browser share the cookie, so they share that Model. As of v0.18 the tabs of a session mirror one shared view: they always show the same page AND the same state. Every committed frame — an action's patch, a server push, AND a navigation — is fanned out to all live connections of the session.

This is a deliberate semantic, and it is what makes the fan-out sound. Because every tab is kept at the shared sess.prevTree, a broadcast diff always targets a DOM that matches its baseline. If navigation did NOT mirror, one tab could drift onto a different page than the shared Model, and a later action's diff (computed against the shared page) would target sky-ids that don't exist in the stale tab's DOM — silent corruption. Mirroring navigation closes that. The consequence to know: navigating one tab (or opening a new tab at a URL) moves ALL tabs of that session — they are one logical window. (Two people who must browse independently are two different sessions, not two tabs; see Same-user, different sessions under Horizontal scale.)

Zero config, zero app-code change: default-on at every store tier. Horizontal scale across instances (a shared store + a cross-process broker so fan-out crosses instances, and same-user cross-session sync) is the follow-on work; the Broker interface is already the seam for it.

SSE connection lifecycle + scaling

Each loaded page opens exactly ONE EventSource to /_sky/sse and holds it open for pushed frames. A streaming SSE connection consumes one of the browser's ~6-connections-per-host HTTP/1.1 budget, so the connection lifecycle is managed on both ends:

Client — one connection, released on navigation.

Server — prompt cleanup, no per-session supersede. handleSSE returns as soon as r.Context().Done() fires (the client's TCP connection closed), so a navigated-away or closed tab frees its goroutine + connection immediately. The server does NOT try to bound connections to one-per-session: two live tabs share a session (same cookie), and EventSource auto-reconnects when a 200 stream ends — so closing one same-session connection just makes the tabs ping-pong reconnecting. Per-tab bounding belongs on the client (idempotent open + release-on-pagehide, above); server-side scale is Go's cheap goroutine-per-connection model + prompt disconnect cleanup. At N concurrent tabs the server holds ~N SSE connections — Go handles this well; raise the file-descriptor limit (ulimit -n) for large N, and terminate over HTTP/2 (below) so the browser side isn't the bottleneck.

For multi-page apps, prefer sky-nav over full-page links. A sky-nav link keeps ONE persistent SSE for the whole session and swaps the body via a client-side patch, instead of tearing down + reopening an SSE on every page. Fewer connections, no per-navigation reconnect/resync, and no exposure to the per-host limit at all. Reach for plain <a href> (full-page) only when you genuinely want a fresh document.

In production, terminate over HTTP/2. HTTP/2 multiplexes many streams over one TCP connection, so SSE no longer consumes a scarce per-host slot and the 6-connection limit stops applying — the robust answer for high-navigation or many-tab usage. A TLS front (Cloud Run, nginx, Caddy) gives you this for free.

A stream is not cut by the server's request deadlines. Server.listen (a Sky.Http.Server app, and every Sky.Spa backend) builds its HTTP server with 30 s read and write deadlines (SKY_HTTP_READ_TIMEOUT / SKY_HTTP_WRITE_TIMEOUT). Every long-lived response lifts them for its own connection before it writes (runtime-go/rt/stream_deadline.go): the Sky.Live SSE (the embedded console mounted in such a host included), a Sky.Http.Server.Stream response (the Sky.Spa push topic rides on it), and a WebSocket. Before v0.25.20 nothing lifted them, so on a Server.listen host every stream was cut mid-body at 30 s: a proxy logged the upstream body as truncated (Caddy: aborting with incomplete response … unexpected EOF), the browser saw net::ERR_HTTP2_PROTOCOL_ERROR on a 200, and the page showed "Reconnecting" about every 33 s. A middleware that wraps the ResponseWriter must implement Unwrap() http.ResponseWriter, or the deadline cannot be reached through it (the runtime logs stream.deadline_not_released once).

A lost session gets one classified answer, and the page recovers. The server can lose a page's session while the page stays open: a restart with the memory store (every redeploy), a request that lands on a replica that never saw the session, TTL expiry or eviction. An EventSource cannot read the status or body of a non-200 answer, so the client sends sl=1 on the SSE URL and the server answers such a request 200 text/event-stream with ONE event and a clean end of stream:

event: session-lost
data: {"reason":"unknown-session"}

reason is unknown-session, no-session-cookie, session-evicted (sent on an open stream when the session is evicted), or auth-required (an in-process sub-app's gate — the console login — refused the stream). The client stops its live channel, shows "Session ended. Reloading…" (or "Signed out. Reloading…") and reloads once, which mints a fresh session or shows the login form. A third loss within 60 s means a reload does not restore a session (for example requests spread over replicas with no shared store and no sticky routing); the page then stops and says so instead of reloading in a loop. The server logs each case at info as live.sse.session_lost with the reason. A page loaded before this change (no sl=1) keeps the old 404 / X-Sky-Status: session-lost answer, which its client handles through its probe. Gates: runtime-go/rt/live_sse_session_lost_test.go and scripts/console-live-e2e.sh.

Horizontal scale — many instances (Phase 2)

Sky.Live scales to N app instances behind a load balancer with two rules, one about session ownership and one about broadcast fan-out.

Sessions are single-owner — route sticky by cookie (load-bearing)

A session (sky_sid) holds ONE authoritative Model, mutated under ONE per-session mutex that serializes dispatches (serialized last-writer-wins, no lost update). That guarantee only holds while the session lives on ONE instance at a time. The load balancer MUST route by session affinity. Prefer an affinity cookie the proxy issues itself (Caddy lb_policy cookie, nginx sticky): the session cookie's VALUE changes at sign-in (session-id rotation) and its NAME is __Host-sky_sid over HTTPS, so a proxy hashing sky_sid moves the session once at sign-in (safe with a shared store, which carries the alias of the old id to every replica), and must hash the __Host- name on HTTPS. This is the same model as Phoenix LiveView (a LiveView process lives on one node) or Rails ActionCable; it is the correct architecture for server-held session state, not a limitation to engineer around.

Cross-instance pub/sub — the Redis broker

Cmd.publish / Std.PubSub.publish / Sub.subscribeTopic fan out through a Broker. Single-instance uses the in-process registry. Multi-instance uses the cross-instance Redis broker, which is selected automatically when the session store is Redis (runtime-go/rt/live_redis_broker.go):

Why globalSeq is re-stamped per instance, not shared. The browser dedupes broadcast frames with a monotonic watermark (drop globalSeq ≤ last applied). That only has to be monotonic per subscriber STREAM — i.e. per instance. Re-stamping every locally-delivered event (local- AND remote-origin) from one per-instance counter keeps each stream monotonic with no cross-instance sequencer, no Redis INCR on the hot path, and no global bottleneck. Ordering stays best-effort exactly as the in-process broker already is — a rarely-reordered broadcast is superseded by the next one.

Payloads cross the wire via the same gob machinery the DB stores use for the Model, plus eager registration of the common typed-Dict/List shapes so a Dict String String payload round-trips on every instance from startup. A payload that can't be gob-encoded degrades to LOCAL-only delivery with a logged-once warning — never a panic.

Graceful degradation. A Redis PUBLISH/SUBSCRIBE error never breaks local delivery; the cross-instance hop is logged-once and skipped. Since the session store is Redis too in this tier, a Redis outage takes the whole deployment down regardless — so "Redis down → cross-instance fan-out pauses" is consistent with the rest of the tier.

Configuration

EnvEffect
SKY_LIVE_STORE=redis + SKY_LIVE_STORE_PATH=<url>Shared session store AND (by default) the cross-instance broker. The scalable-by-default path: deploy multi-instance ⇒ sessions must be shared ⇒ pub/sub crosses instances with no extra config.
SKY_LIVE_BROKER_URL=<redis-url>Run a Redis broker even when sessions are NOT on Redis (e.g. Postgres sessions + Redis pub/sub). The broker is app-scoped, so the two are legitimately decoupled.
SKY_LIVE_BROKER=inprocessEscape hatch — force the in-process registry back on a single-instance Redis deploy or when debugging.

A native Postgres LISTEN/NOTIFY broker (zero-config cross-instance for Postgres-only deploys) is the next backend; today a Postgres-store deploy opts into cross-instance pub/sub via SKY_LIVE_BROKER_URL.

Same-user, different sessions (two devices)

Two browsers signed into one account are two DIFFERENT sessions (different sky_sid → different Models), possibly on different instances. They sync by publishing to a user-scoped topic keyed on the stable auth identity (e.g. "user:" ++ userId): every one of that user's sessions subscribes to it, and — via the Redis broker — the broadcast reaches them across instances. This is opt-in by design: different sessions may be on different pages with different view state, so the APP decides which shared state syncs (typically re-read the account row + re-render), rather than blindly replicating a whole Model. Conflicts resolve at the DB (last-writer-wins), the same as any two writers to shared rows.

Event serialisation

Sky closures can't cross the wire. Event handlers are serialised to string tags:

onClick Increment          -- serialises as "Increment"
onInput (\s -> SetName s)  -- serialises as "SetName@<slot>"

The server stores a per-session event-handler table. When the client posts a tagged event, the server looks up the handler closure and applies it to the decoded payload (input value, form data, etc.).

Any event the view binds is dispatched (Events.on "<name>"). A CustomEvent that carries a detail (a third-party element's own event) sends [detail] as its argument; the server decodes it into the handler's parameter type, like any wire argument. A widget island's event sends its detail as JSON text for the Sky decoder (see Widget islands).

Event dispatch — handler ids and view identity

One handler id per event. A handler id is <sky-id>.<event> and the client derives it for each event from the element's sky-id. An element with several handlers (onChange + onEnter, onClick + onMouseOver) sends each event to its own handler. The element still carries one data-sky-hid (its first event, sorted) for tools that scrape a handler id from a page; dispatch does not read it. An unnamed handler (a closure) renders sky-<event>="_", never "", because a patch value of "" means "remove the attribute".

Every event the view declares is bound. The client reads the event names from the sky-<event> attributes in the DOM (not from a fixed list), so contextmenu, scroll, reset, select, load, error and custom events dispatch. Ui.onFile / Ui.onImage dispatch by handler id too.

A click resolves against the render it was made on. Every render has a content id (view, a hash of the body, live_view_version.go). The page and every reply carry it; every event carries the id of the body the DOM showed when the user acted. The server keeps the handler maps of the last 16 distinct renders, and of every render younger than 30 seconds (up to 256), and resolves the handler id in THAT render — so a burst of taps queued on one render still resolves. A click on row b made before the reply to a click on row a arrived deletes b, not the row that now sits where b was. An id the session no longer holds is a desync (the client is refreshed, the action dropped), never a different Msg. The same applies to the unload beacon and the retry queue. Because the id is a content hash, it survives a restart and a replica move: the rebuilt render of the same model has the same id. A request with no view (older clients) resolves against the current render, as before.

Delta frames name their base. A patches frame and a JSON event reply carry base (the render the diff was computed against) and view (the render it produces). The client applies a delta only on top of its base. A delta that overtakes its predecessor is held and applied in order when the predecessor lands; if the base never arrives within 1.5 s the client asks for a resync (__skyResync, a full body, no Msg). Out-of-order frames are no longer dropped.

Events are sent in order. Event POSTs are serialised (each waits for the previous reply), and a pending debounced input is sent before any other event, so update sees the typed text before the Enter or click that follows it.

Input authority is seq/ack based, not focus based. A tracked input (one with an input handler) is dirty while it has keystrokes waiting for their debounce, keystrokes the server has not acked, or an IME composition in progress. Once acked, a model value applies even while the input has focus (a clear, a normalisation). An untracked input stays protected while it has focus and the user has typed into it. Removing value / checked / selected / disabled also resets the DOM property. The DOM is written only when the rendered value changes (Elm semantics): when update rejects an edit or ignores a toggle, the render does not change and the control keeps what the user left in it (see the input-authority protocol). A number field sends its text, so a cleared field sends "". During an IME composition no input Msg is sent; the committed text is sent once on compositionend.

Tabs of one session. A tab's SSE sends its URL path only when its document was loaded (first open, bfcache restore); a reconnect after a network blip is not a navigation and does not re-route the session's shared page under the other tabs. Those tabs' clicks resolve against the render they show.

Every dispatch path persists. A Cmd.perform completion, the beacon batch, a Sub.every tick, pub/sub, stream and WebSocket deliveries write the session to its store, not only user events. A session restored from a store resumes its seq above a wall-clock floor (Unix ms x 1000), so a browser that applied frames from the previous process does not drop the new ones; the SSE hello carries a process epoch (pe) and the client resets its broadcast guard when it changes.

Subscriptions. Every Sub.every leaf runs, reconciled by interval: a timer still requested keeps running with its phase across dispatches. After a restart or a replica move, the SSE connect re-establishes the session's subscriptions. A dispatch sets up subscriptions before it runs the Cmds, so a Cmd.publish reaches a subscription the same update opened.

Not found and failures. An app with withNotFound renders its not-found page on every request, not only the first request of a session. A classified panic in update shows a small runtime banner on every tab of the session, whether or not the model has a Notification field.

Session store interface

type SessionStore interface {
    Get(sid string) (*liveSession, bool)
    Set(sid string, s *liveSession)
    Delete(sid string)
    NewID() string
    Close() error
    Broker() Broker
    Ping() error
}

(See runtime-go/rt/live_store.go.) Get returns (session, found) — no error, no context — and Set is the upsert; there is no separate Put. TTL sweeping is not a method on the interface: each durable store runs its own cleanupLoop goroutine internally, so there is no Sweep(ctx, olderThan) for callers to drive.

Implementations:

Sessions are serialised with encoding/gob, not JSON (encodeSession / decodeSession in live_store.go). The persisted storableSession carries the TEA Model plus the auth-identity, revocation binding, analytics identity and out-seq, so a cache-cold instance that decodes it resumes the full session — the basis for the cross-replica session MOVE described above. The model itself is any-boxed Sky data structures, so every concrete type reachable behind an any field is registered for gob (gobRegisterAll at first render) before it can round-trip through a durable store.

Concurrency

Each session has a sync.Mutex. Events and command-callback dispatches both lock the session before running update. The view + diff happen while the lock is still held, so the patch stream is always consistent with the dispatched messages.

Commands (Cmd.perform) run their Task outside the session lock, then re-acquire it to dispatch the result. This means long-running HTTP requests don't block other events.

Security defaults

Two bullets were removed here because they were false, and a reader planning a deployment would have relied on them.

Rate limiting and origin control are the deployer's to add in front of the app (reverse proxy / ingress), or per-route with Sky.Http.Middleware.

Sessions without cookies: the header transport

Some hosts cannot keep cookies: a native shell whose custom-scheme handler drops Set-Cookie (a WKWebView custom scheme has no cookie store), and some embedded web views. For them an app opts into the header session transport (runtime-go/rt/live_session_header.go):

app =
    App.app { init = init, update = update, view = view, subscriptions = subscriptions }
        |> App.withNotFound NotFound
        |> App.withSessionTransport HeaderToken

Live.withSessionTransport "header" is the same for a Std.Live app. The operator variable SKY_LIVE_SESSION_TRANSPORT (cookie / header) wins over the builder. An unknown value keeps cookies and prints a warning. Nothing changes for an app that does not opt in.

A complete program (scripts/doc-examples.sh checks it):

module Main exposing (main)

import Sky.Core.Prelude exposing (..)
import Sky.Core.String as String
import Std.App as App exposing (SessionTransport(..))
import Std.Cmd as Cmd
import Std.Sub as Sub
import Std.Ui as Ui exposing (Element)

type alias Model =
    { count : Int }

type Msg
    = Increment


init : a -> ( Model, Cmd Msg )
init _ =
    ( { count = 0 }, Cmd.none )


update : Msg -> Model -> ( Model, Cmd Msg )
update _ model =
    ( { model | count = model.count + 1 }, Cmd.none )


view : Model -> Element Msg
view model =
    Ui.button
        []
        { onPress = Just Increment
        , label = Ui.text ("Clicked " ++ String.fromInt model.count ++ " times")
        }


main =
    App.app
        { init = init
        , update = update
        , view = view
        , subscriptions = \_ -> Sub.none
        }
        |> App.withNotFound ()
        |> App.withSessionTransport HeaderToken
        |> App.run

How the session travels.

What the server stores. The store is keyed by `SHA-256("sky.live.session:"

Rotation. Session-id rotation on a change of bound user works the same way, with one change: the server keeps no token to hand back, so the new id is derived. newToken = HMAC-SHA256(key = oldToken, "sky.live.rotate:" + salt), and the new id is the hash of newToken. The alias record keeps only the salt. The token of the acting request is carried on the goroutine trace context (like the origin tab), so a rotation started by that request's Cmd.perform can derive from it.

Each page load is its own session in this mode, so "every tab follows the sign-in" does not apply: a sign-in in one tab does not sign in another.

CSRF. The X-Sky-Session header is the CSRF defence. A cross-site form cannot set a request header, and a cross-origin fetch that sets one needs a CORS preflight the runtime never grants. So header mode issues no double-submit CSRF cookie. A state-changing request (POST / PUT / PATCH / DELETE) is accepted only when it carries the header (or an Authorization header, or matches a CSRF exemption such as a Live.api route), and it passes the same Origin / Sec-Fetch-Site check as Server.rpc: Sec-Fetch-Site: same-origin / none passes, otherwise the Origin must be the app's public origin (SKY_PUBLIC_URL, else the request's scheme and Host); Origin: null is refused. A native shell that loads the app from a custom scheme and sends a cross-site Origin lists that origin in SKY_PUBLIC_URL. SKY_CSRF=off turns the check off, as in cookie mode.

What it does not cover.

The regression gates are runtime-go/rt/live_session_header_test.go, live_js_header_session_test.go (the client in node) and scripts/header-session-e2e.sh (Chromium with every cookie blocked, strict CSP: counter, pushes, a dropped stream, a sign-in rotation, the ticket fallback).

Client-side runtime

The client is one same-origin, content-hashed file: /_sky/live.<hash>.js (liveClientJS + liveClientPath in runtime-go/rt/live_client_asset.go; a sub-app such as the Sky Console serves it under its base, /_sky/console/_sky/live.<hash>.js). The hash is the first 12 hex digits of the SHA-256 of the script, so the response is Cache-Control: public, max-age=31536000, immutable: a browser downloads it once per runtime version, not once per page load.

The page carries no executable inline script. Per-page values (session id, CSRF token, base path, view id, reconnect-banner settings) are in a data block that the browser never executes:

<div id="sky-root">…</div>
<script type="application/json" id="sky-live-cfg">{"sid":"…","csrf":"…","base":"",…}</script>
<script src="/_sky/live.3f9c2a1b7d4e.js"></script>

The client reads the block with JSON.parse at start-up. json.Marshal escapes <, > and &, so a value cannot close the block.

Before v0.25.19 the client was a fmt.Sprintf template inlined into every HTML response, with the session id and the CSRF token spliced in as JS literals. A reverse proxy that sends script-src 'self' blocked it, and the page (the Sky Console included) was dead. See Content-Security-Policy below.

Responsibilities:

  1. Open SSE, reconnect with exponential backoff.
  2. Apply VNode patches to the DOM.
  3. Intercept form submits, clicks, input events — POST to /_sky/event.
  4. Handle navigation (pushState / popState) when the server routes it.

No framework dependency. No bundle step.

Content-Security-Policy

Sky works under a strict policy with no hashes, no nonces, no 'unsafe-inline' and no 'unsafe-eval':

default-src 'self'; script-src 'self' 'wasm-unsafe-eval';
style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'

Every script that Sky serves is a same-origin file: the Sky.Live client (/_sky/live.<hash>.js), the Sky.Spa boot loader (/spa-boot.<hash>.js, next to wasm_exec.js and main.<hash>.wasm), and the fallback console shell (/_sky/console-shell.<hash>.js). Per-page data is in <script type="application/json"> blocks. No runtime path calls eval or new Function. The old data-sky-eval attribute is removed: use data-sky-path, or an event that the app handles. Only Sky.Spa needs 'wasm-unsafe-eval' (it allows WebAssembly.instantiate, not JS eval). style-src 'unsafe-inline' stays because Std.Ui renders style attributes.

There are two ways to get the policy:

scripts/csp-e2e.sh drives the Sky Console, a Sky.Live page, a Std.Ui form and both Sky.Spa boot paths under the policy (through a proxy, and with SKY_CSP=strict). It fails on any securitypolicyviolation.