Sky.Live architecture
Status: the Rust compiler (
rust/,cargo build --release -p sky) is the primary Sky compiler; the Haskell compiler is preserved underlegacy-haskell-compiler/. Verified by the example sweep + compiler test suite (cargo test+ xtask gates). See../history/compiler/journey.mdfor 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
- Page load — server renders
init (). The resulting model + view are cached under a session id taken from the session cookie (sky_sidfor the host app; sub-apps mounted in-process usesky_<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 setHttpOnly; SameSite=Lax(the CSRF cookie is separatelySameSite=Strict). There is no query-param session path. - SSE open — client connects to
/_sky/sse. The session comes from the cookie; no cookie is a400. Server locks the session and emits ahelloevent. - Event post — client sends
POST /_sky/event. The session is resolved from the cookie only — the body'ssessionIdis advisory and must match it, so a leaked session id cannot be used to drive someone else's session (seedocs/skylive/input-authority-protocol.md§Request). Server decodesmsg, locks the session, runsupdate, 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.
- Cmd dispatch — if
updatereturned a non-nonecmd, server spawns a goroutine per command. Each goroutine holds the session lock only to apply the resultingMsg, not while the task runs — so long-running HTTP requests don't block other events. - TTL expiry — sessions expire after
[live] ttlseconds 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:
- Same tag + same attrs → recurse into children.
- Different tag → emit a
replacepatch. - Different attrs → emit
attr-set/attr-del. - Keyed children → LCS-style reordering via
keyattribute. - Non-keyed children → positional.
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:
| Event | Envelope shape | Used 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.)
- Ingress + relay.
sess.sseChis the session's single ingress channel every producer (Cmd.perform completion, Time.every tick, pub/sub delivery, the tab-unload batch, the WebSocket bridge) writes to. A per-session relay goroutine — started once by the firsthandleSSE, exits when the session'sdonecloses — drainssseChand broadcasts each frame to every registered connection. Before this, a single shared channel handed each pushed frame to ONE random connection, so a second tab never saw server pushes. - Per-connection channels. Each
handleSSEregisters a private buffered channel (capacitySKY_LIVE_SSE_BUFFER) keyed by a connection id + the client's per-pagetabid (the?tab=query param). Delivery is non-blocking per connection: a full buffer drops that frame for that connection only (counted viasky_live_sse_drops_total; it recovers on its next reconnect-resync) without stalling the relay or the other tabs. - Dispatch mirror. A
POST /_sky/eventstill replies with the acting tab's patch on its HTTP response for latency; it ALSO mirrors the frame (sameseq,clientState = nil) to the OTHER tabs so they reflect the shared model. The originating tab is excluded by itstabid (and the client seq guard would drop the duplicate regardless), so the common single-tab dispatch runs no extra diff/marshal. - Navigation mirror. A
sky-navfetch / popstate / initial load is aGETserved byhandleInitial, which mutates the shared Model's page. It fans a full-body frame to the OTHER tabs (a page change is structural, so a full swap converges any tab regardless of its prior page — no diff-baseline dependency). The requesting tab is excluded via theX-Sky-Tabrequest header the client sends on nav fetches; a bare browser load carries no header and has no SSE yet, so it is naturally excluded. Gated on a sibling tab being connected, so a lone tab / first load pays nothing. - Who-wins is unchanged. The per-session mutex still serializes dispatches
(serialized last-writer-wins, no lost update). Fan-out only makes every
connection see the resolved state, so all tabs converge. In-flight typing
in an observer tab is preserved by the client's
__skyIsDirtyauthority — mirrored frames carryclientState = nil, identical to a Cmd/tick push. - Ordering. Every frame carries the session's monotonic
seq; the relay preserves channel FIFO, and the client drops any frame withseq ≤the last it applied. A newly-connected tab receives a full-body reconnect-resync at the current state, then only later frames — so a late joiner never applies a stale diff.
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.
__skyOpenSSEis idempotent: it closes any existing__skySSEbefore opening a new one, so a reconnect race can never orphan a live stream.- A
pagehidehandler closes theEventSourcethe instant the page navigates away, freeing the slot before the next page opens its own.pageshow(bfcache restore) reopens it. Without this, an app that navigates via full-page loads (plain<a href>links — a fresh SSE per page) overlaps the closing stream with the next page's new one; rapid clicking piles them up until the 6-connection limit is hit and the tab freezes (spinner stuck, all clicks no-op). - The reopened connection carries the tab's current URL:
/_sky/sse?tab=<id>&path=<location.pathname>. A full-document reconnect (bfcache Back/Forward, a reload, or a full-reload nav) does NOT re-run the route handler, so the server'smodel.Pagecan be stale relative to the URL the browser is showing.handleSSEthereforeapplyRoutes the?path(when it matches a registered route) before the resync render, so the tab lands on the page its URL names instead of the last page the session navigated to. Without this, pressing Back restored the previous page from bfcache and the resync immediately pushed the last page's body over it — the "Back bounces onto the page I just left" bug. The full GET and sky-nav paths already reconcile viaapplyRoute; this closes the reconnect gap.sky-nav+data-sky-pathavoids the churn entirely (one persistent SSE, no reconnect per navigation) and remains the recommended default.
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.
- All TABS of one session share the cookie, so affinity keeps them on one instance → the Phase 1 per-session fan-out (in-process) reaches them all and the mutex serializes their dispatches. Correct with zero cross-instance machinery.
- If a session MOVES instances (instance dies, deploy, LB reshuffle): the
new instance loads the current Model from the shared session store
(every dispatch does
store.Set, so the store is always current), the browser'sEventSourcereconnects and lands on the new instance via the cookie, and the reconnect-resync repaints at the current state. No distributed lock needed — because only one instance owns the session at a time. - Without affinity, two instances would each load + mutate the same session's Model independently → lost updates on the store and split in-process fan-out. Cross-instance frame fan-out would NOT fix this (the Model is still split); single ownership is the only sound fix. SkyDeploy sets affinity automatically; on your own LB, enable sticky sessions keyed on the session cookie.
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):
- Publish: re-stamp the event's
globalSeqfrom THIS instance's counter, deliver to local subscribers, thenPUBLISHthe gob-encoded event tosky:live:topic:<topic>tagged with this instance's id. - Receive (one loop per instance): read every subscribed channel,
DROP messages tagged with our own instance id (already delivered
locally — no double delivery), re-stamp
globalSeqfrom this instance's counter, and deliver locally. - Per-topic subscribe: the Redis channel is subscribed on the 0→1 local-subscriber transition and unsubscribed on 1→0, so an instance only receives traffic for topics it actually has subscribers for.
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
| Env | Effect |
|---|---|
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=inprocess | Escape 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:
memoryStore—sync.Map; lost on restart.sqliteStore— single-node persistence.redisStore— multi-instance via shared Redis.postgresStore— shared SQL backend.
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
- One
Securerule, for the runtime's cookies and yours alike.cookieSecureFor(runtime-go/rt/cookie_secure.go) is the only place the question is answered. A cookie carriesSecurewhen any of these holds: the request arrived over HTTPS (direct TLS,X-Forwarded-Proto: https, orX-Forwarded-Ssl: on); the process is in production (ENV, else<PREFIX>_ENV, set to anything other thandev/development/local); the cookie's name carries the__Host-/__Secure-prefix; or it is sentSameSite=None. The last two are spec requirements, not policy. - The runtime's own cookies. The session cookie is
Path=/; HttpOnly; SameSite=Lax, named__Host-sky_sidwhen it is Secure andsky_sidotherwise (writeSessionCookieinruntime-go/rt/live.go);SKY_LIVE_FRAME_ANCESTORS/<PREFIX>_LIVE_FRAME_ANCESTORSswitches it toSameSite=None, which forcesSecure. The built-in CSRF cookie (__sky_csrf) isSameSite=Strict(runtime-go/rt/csrf_middleware.go);Sky.Http.Middleware.withCsrf's__Host-sky_csrfisPath=/; Secure; SameSite=Laxunconditionally — the__Host-prefix mandatesSecure— and is deliberately notHttpOnly. - Cookies your own code sets get the same treatment.
Server.addCookie (Server.cookie name value) respemitsPath=/; HttpOnly; SameSite=Laxand picks upSecurefrom the rule above, including the HTTPS signal — the decision runs where the response is written, so the request is in hand. It used to run only at mint time, where it was not, and theENVpredicate was all a user cookie could consult; an auth token set over HTTPS on a non-production tier therefore went out withoutSecurewhilesky_sidon the same response had it. To pin the attributes yourself, use the four-argument form — seedocs/skyauth/overview.md. - Event payload size cap: configurable via
[live] maxBodyBytes/SKY_LIVE_MAX_BODY_BYTES(default5242880= 5 MiB; bump forEvent.onFile/Event.onImageuploads). Larger payloads are rejected with HTTP 413.
Two bullets were removed here because they were false, and a reader planning a deployment would have relied on them.
"Rate limit: per-IP + per-session token bucket; configurable viaSky.Live has no rate limiter. There is no token bucket in[live]."runtime-go/rt/live.go, and[live]accepts exactlyport, static, store, storePath, ttl, maxBodyBytes, input(rust/crates/project/src/build.rs:1084-1092) — a[live] rateLimitkey would raise an unknown-config-key build warning.Sky.Http.Middleware.withRateLimitis real, but it is a per-route middleware you call yourself, not a Sky.Live default."CORS: off by default. Turn on by configuring allowed origins explicitly."There is no CORS configuration. No allowed-origins key exists in[live]and nothing inlive.goreads one. "Off by default" is accurate only in the sense that nothing implements it; there is no supported way to turn it on.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.
- A page load mints a session token (16 random bytes, 32 hex characters).
The page carries it in its boot config (the non-executable
<script type="application/json" id="sky-live-cfg">block,"tok"), which the same-origin client script reads under a strict CSP, and in theX-Sky-Sessionresponse header. - The client sends the token in the
X-Sky-Sessionheader on every event POST, sky-nav fetch, rotation exchange and live stream. Nosky_sidcookie is set or read. A session cookie that a request presents is ignored, even one that names a live session. EventSourcecannot set a header, so the client reads the SSE stream withfetch()and aReadableStream. Where streamingfetchis not available it falls back to a one-time SSE ticket:POST /_sky/sse-ticket(with the header) returns a ticket that is single-use, bound to the session and to the tab, and expires in 10 seconds; the client opensEventSource("/_sky/sse?...&tk=<ticket>"). A dropped ticket stream asks for a new ticket; a ticket is never replayed. Tickets live in the memory of the replica that issued them (Sky.Live is already sticky; see Sessions are single-owner).- A navigation (a reload, a typed URL, a bookmark) cannot carry a header, so a
full page load starts a new session. In-app navigation (
sky-nav, Back / Forward) is a fetch and keeps the session. Keep state that must outlive a reload in the app's own store, keyed by the signed-in user.
What the server stores. The store is keyed by `SHA-256("sky.live.session:"
- token)`, truncated to the usual 32-hex id. The token itself exists only in the client, and in memory while a request that presented it is served. A leaked session store (a database dump, a Redis snapshot) names no usable token. The runtime never logs the token; the SSE ticket is in the stream URL, where a proxy access log can see it, which is why it is single-use and lives 10 seconds.
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.
- The acting tab gets the
rotateframe with a one-time ticket on its stream, andPOST /_sky/rotate(with the old token in the header) answers with the new token in theX-Sky-Sessionheader and the JSON body ("token"). The client swaps it in. - That tab's next event POST, stream connect or sky-nav fetch that still
presents the old token inside the 60 s grace window gets the new token in
the
X-Sky-Sessionresponse header. - Any other request with the old token gets
session-rotating, thensession-lost, exactly as in cookie mode. - A rotation with no token in scope (a
Time.everytick binding a user) gets a random id: no client can derive its token, and the session ends after the grace window.
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.
- Sky.Spa. A
web:appbuild authenticates/_rpc/<Msg>and/_sky/subwith thesky_sidcookie (verified<Field>_helpers), so the header transport is Sky.Live only:App.withSessionTransportfails the--target web:appbuild with that reason, and a server withServer.rpcroutes refuses to start whenSKY_LIVE_SESSION_TRANSPORT=headeris set. - The Sky Console (
/_sky/console) keeps its own cookie-based login. - Your own auth cookies.
Std.Auth/Live.withAuthSlidingset cookies of their own; in a cookie-less host, carry the user in the model after a sign-inupdate, and bind it withLive.bindSessionUser.
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:
- Open SSE, reconnect with exponential backoff.
- Apply VNode patches to the DOM.
- Intercept form submits, clicks, input events — POST to
/_sky/event. - 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:
- Behind a proxy. Send the header from Caddy, nginx or a CDN. The
runtime never overwrites a
Content-Security-Policythat the app or a middleware already set. - Directly. Set
SKY_CSP=strict. The runtime then sends this policy on every page, API and static response that has no policy yet:default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'(or theSKY_LIVE_FRAME_ANCESTORSlist). WhenSKY_CSPis unset (the default), the headers do not change. Seedocs/sky-toml.md.
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.