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 ../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). 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.
  4. 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.
  5. 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.

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 (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.

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 — the sky_sid cookie is the affinity key. 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.).

Session store interface

type SessionStore interface {
    Get(ctx context.Context, id string) (*Session, error)
    Put(ctx context.Context, id string, s *Session) error
    Delete(ctx context.Context, id string) error
    Sweep(ctx context.Context, olderThan time.Duration) error
}

Implementations:

Sessions are serialised as JSON. The model itself is always any-boxed Sky data structures, encoded via SkyEncode.

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.

Client-side runtime

The client is not a separate file and is not served at a URL. It is a fmt.Sprintf template inlined into every HTML response (runtime-go/rt/live.go:4397, from liveJSWithCfgAndCsrfWithBase, live.go:7113), whose body spans roughly live.go:7114-9031.

This paragraph used to read "runtime-go/rt/live_client.js (embedded, served at /_sky/live.js) — about 2 KB gzipped". There is no .js file anywhere under runtime-go/ (find runtime-go -name '*.js' is empty), nothing serves /_sky/live.js, and ~1,900 lines of inlined JS source is an order of magnitude past "about 2 KB gzipped". It ships on every full-page response, so its size is a per-page-load cost, not a cached-asset one.

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.