@kolu/surface-app reference
The shell delivered around a @kolu/surface app — the static, installable
layer for apps you run against your own server (kolu, drishti). Surface is the
live wire; surface-app is the shell served fresh around it. Exports are grouped
by subpath.
The whole listener, in one call
serveSurfaceApp (@kolu/surface-app/serve) is the turnkey seam: the shell over
HTTP and the surface over one WebSocket, in the one correct order — origin gate
→ upgrade → stale-tab check → heartbeat enrolment → serve. It owns the
http.Server (so the upgrade event stays yours), mounts surfaceAppLayer
alongside your own routes, stands up the ws server, binds, and registers its
whole teardown on the enclosing Scope. ws and @effect/platform-node are
peer dependencies of the package — an app serving on Node already declares
both, and a browser-only consumer of ./solid never pulls them in.
const url = yield* serveSurfaceApp({
group: runtime.group, // snapshotted at the call — or `live: () => ({ group, handlers })`
handlers: runtime.handlers,
clientDist, // omit in dev — no bundle means no static route, never a fake one
manifest: { name: "my app" },
routes: myOwnRoutes, // merged alongside the shell — an MCP route, media, …
host,
port,
allowedOrigins,
tls, // present → an `https.Server`, and the returned URL says `https://`
middleware, // wraps every HTTP request — an app's bridge to its own logger
upgradeHeaders, // request headers this app reads off the upgrade, by name — or a thunk of them
services, // (connection) => Layer — what THIS connection's handlers require
});
It returns the URL actually bound (so port: 0 reports the port the OS
gave), and fails with the typed SurfaceAppListenFailed, which carries the
cause verbatim — a consumer’s port policy reads cause.code === "EADDRINUSE"
rather than matching a message string. The surface runtime’s own lifetime is
not owned here: pass { group, handlers } and keep close/done at the
composition root that built them.
The generation is ServedGenerationSource
— { group, handlers, expose? } is the generation written at the call, and
{ live: () => ({ group, handlers, expose? }) } is re-read at each accept, as
a pair. A socket accepted after an
implementRootedSurfaces
mount is therefore indistinguishable from one accepted on a boot that already
had that sibling. A connection accepted before the roster moved keeps the
generation it was built over until the client redials.
Existing callers that pass values do not change. serveOverUnixSocket takes
the same source.
clientDist is optional, and its absence is the dev shape: no bundle means
no static route at all — an unmatched path 404s through the router — while the
manifest is still served, because a dev proxy forwards /manifest.webmanifest
to a server with no built client. The two mount independently.
Closing the scope drains before it drops, in this order: new upgrades are refused, every connection’s serving stack is closed and awaited (that is what releases its RPC fibers and every in-flight subscription it opened), the raw sockets are terminated, and the listening socket is closed last. So when the finalizer resolves, nothing this listener started is still running.
The path is fixed at SURFACE_WS_PATH (/rpc/ws); any other upgrade target is
destroyed. Both legs read that one constant, so the dial URL and the upgrade
handler cannot drift.
The upgrade’s headers — upgradeHeaders
A live wire is one websocket, so its one request is the upgrade. A header a
reverse proxy stamps there (Tailscale-User-Login, X-Forwarded-For) is
therefore the only per-connection claim about who is calling that the wire
can carry, and serveSurfaceApp — which owns the upgrade — is the only thing on
this path that can hand one on. (A hand-built listener on
acceptSurfaceSocket stands in its own upgrade handler
holding the request, so it reads what it needs directly; this option has nothing
to offer it. Same split as expose.)
upgradeHeaders names which ones. Each accepted connection carries those values
on SurfaceAppConnection.headers, keyed by the spelling used here, and a
services layer reads them per connection:
const url = yield* serveSurfaceApp({
// …
upgradeHeaders: ["Tailscale-User-Login"],
services: (connection) =>
Layer.succeed(Viewer)({
login: connection.headers["Tailscale-User-Login"],
peer: connection.remoteAddress,
}),
});
| Fact | Shape | Notes |
|---|---|---|
upgradeHeaders | UpgradeHeadersSource<H> — ReadonlyArray<H> | () => ReadonlyArray<H> | the allowlist. Empty by default — a header nobody named is not available at all. An array is read once at the bind; a thunk is re-read at each accept |
connection.headers | Readonly<Partial<Record<H, string>>> | the named headers this upgrade carried. Matched case-insensitively against the wire; keyed by the spelling in upgradeHeaders |
connection.remoteAddress | string | undefined | the direct TCP peer — the proxy itself, when there is one. Never a guess |
services | (connection: SurfaceAppConnection<H>) => Layer.Layer<Svc> | the per-connection Layer those facts are read into. Effect’s socket-server protocol forwards no per-request context, so a connection-scoped fact is provided, never carried on the request |
H is inferred from upgradeHeaders — the names are the keys, so
connection.headers["X-Forwarded-For"] does not compile when the allowlist said
"x-forwarded-for". That is deliberate: a header’s absence is a load-bearing
answer here, and an open index would hand a misspelling the same honest-looking
undefined a real absence has.
A named header the request did not carry is absent from the record, so
undefined (“not sent”) and "" (“sent empty”) stay different facts — which is
what a consumer deciding whether to trust a proxy claim needs. A repeated header
arrives folded into one comma-joined string, with the separator node itself uses
(", " — and it is a string, never an array). This seam reports that string; it
does not join. set-cookie — node’s one array-shaped exception, and the one
header whose values carry commas of their own — is refused at the allowlist
rather than folded into a string nothing could split back apart (RFC 6265 §5.2).
An array reaching this seam is that same defect and is refused, not joined.
A live allowlist, read at each accept
The list itself can move while the listener is up. An app whose identity part is
switched on at runtime does not know its headers at the bind, and a serve that
came up without that part used to answer its own procedures the moment it was
switched on while every socket — open and new — stayed anonymous until a
restart. Pass a thunk and the next accept reads the list that is live then, so a
socket accepted after the switch is indistinguishable from one accepted on a boot
that already had it — the same confluence claim
ServedGenerationSource makes for the served
set.
// re-read at each accept; annotated so `H` stays a literal union
upgradeHeaders: (): ReadonlyArray<"Tailscale-User-Login"> => identity().headers,
The two arms are told apart by typeof — an array is never callable, so unlike
the served generation there is nothing here to mistake for a thunk, and no
wrapper is needed. H infers from either — and on either arm it is only as
narrow as the list’s element type, so a ReadonlyArray<string> (a widely
typed variable as much as a thunk returning one) widens H to string and
every header read compiles again. A literal array, an as const, or the return
annotation above is what keeps the guarantee.
| Export | Subpath | Role |
|---|---|---|
serveSurfaceApp(opts) | /serve | the whole listener — shell, socket, gates, heartbeat, bind, teardown. Generation is ServedGenerationSource |
SurfaceAppListenFailed | /serve | the one failure it reports, cause intact |
SurfaceAppConnection | /serve | one accepted connection’s { id, url, remoteAddress, headers } — what a per-connection services layer is built from |
SurfaceAppEvent | /serve | the tagged union onEvent narrates |
SurfaceAppHttpMiddleware | /serve | the shape middleware takes — error and response types preserved, adding only HttpServerRequest |
checkUpgradeHeaders(names) | /upgrade-headers (also re-exported from /serve) | the allowlist’s grammar, for an app that ASSEMBLES its list — refuse a bad name where it is minted, rather than at the accept. Returns the names unchanged; throws |
UpgradeHeadersSource<H> | /upgrade-headers (also re-exported from /serve) | how a listener obtains its list: ReadonlyArray<H> (the app’s composition root, checked at the bind) or () => ReadonlyArray<H> (live, read at each accept) |
pickUpgradeHeaders(request, names) | /upgrade-headers | the READ half, for a hand-built listener holding its own IncomingMessage: the named headers that upgrade carried and nothing else. Case-insensitive, keyed by the spelling in names, absent when not sent, prototype-free, frozen — re-rolling it is how a listener grows back the constructor/__proto__ and array-value hazards this closes |
reportSurfaceAppEvent(event) | /serve | the default narration policy, exported so an onEvent can delegate to it |
SURFACE_WS_PATH | . | /rpc/ws, the one path a surface speaks on — both legs import it |
surfaceWsUrl(httpBaseUrl) | . | the dial URL from the http(s) base — the ONE https: → wss: swap, browser-safe |
thrownText(error) | . | an uncaught throw, printed for a bug report — the Safari/V8/DOMException normalization behind the fault boundary, framework-free and never empty |
Observation is one option, onEvent, taking one tagged union — because every
consumer has exactly one logger:
_tag | Carries | Default (reportSurfaceAppEvent) |
|---|---|---|
Connected | connection | silent — the accept a live-connection count increments on |
Disconnected | connection, code, reason | silent — the same connection hung up, with the close frame’s own account of why |
SocketError | error, url | loud console.error — a transport error |
GenerationRefused | error, url | loud console.error — a live generation restrictHandlers refused. The socket is terminated: there is no honest reduced thing to serve when the served set itself is unservable |
UpgradeHeadersRefused | error, url | loud console.error — a live upgradeHeaders could not be produced: it named something unreadable, or the thunk threw. The socket is served anyway, with no named headers, and the fault is reported at each such accept (why) |
StaleTab | claimedPid, url | silent — a server that restarted while a tab was open is ordinary, not a fault |
DisallowedOrigin | origin, url | loud console.warn — a blocked hijack nobody can see is the one thing a shared gate must not ship |
ServingFailed | cause, connection | loud console.error — done MUST be observed |
Connected/Disconnected are the pair a live-connection count is built from
(the package’s own example counts its serverStats.connections on them). They
carry the same SurfaceAppConnection object both times, so a consumer can pair
them by identity — or by its id, a listener-scoped ordinal that is what a log
line wants (kolu’s per-connection ws: field). services stays a separate
option: it returns a Layer, which is a different kind of thing from narration.
The sections below are the pieces serveSurfaceApp composes; reach for them
directly when an app’s listener needs something the primitive does not take
today — drishti’s per-host ?host= dispatch, which picks which runtime serves
a socket, is the in-tree case. Both kolu and this package’s example ride the
primitive.
Serving the fresh shell
surfaceAppLayer (@kolu/surface-app/server) serves the shell: a no-store
index.html that names a content-hashed bundle, immutable /assets/*, a
404-on-miss for a stale hash, an SPA fallback, /sw.js, and the web manifest.
Staleness is structurally impossible because the one document that names the
bundle is always re-fetched.
These are HttpRouter layers (effect/unstable/http), not framework
installers: merge them into your app layer in any order — routes are ranked by
specificity, so your /rpc/* route always beats the static GET /* catch-all.
freshStaticLayer and surfaceAppLayer require the platform services that read
files — FileSystem, Path, HttpPlatform — which on Node come from
NodeHttpServer.layerHttpServices. Both throw at composition time if assetPrefix is malformed, or would
capture a shell path (that would put a compressed index.html sibling on the
wire; kolu#1319).
| Export | Subpath | Role |
|---|---|---|
surfaceAppLayer(opts) | /server | the no-store shell + immutable assets + (optional) manifest, in one layer |
SurfaceAppLayerOptions | /server | that layer’s options (optional clientDist, manifest, serviceWorker, the freshness paths) — ServeSurfaceAppOptions extends it, so the shell half is named once |
freshStaticLayer(opts) | /server | fresh static + optional notification worker |
pwaManifestLayer(manifest, path?) | /server | the web manifest |
const app = Layer.mergeAll(
rpcRoutes,
surfaceAppLayer({ clientDist, manifest: { name: "my app" } }),
);
const server = createServer();
server.on(
"request",
await Effect.runPromise(
Effect.gen(function* () {
const httpEffect = yield* HttpRouter.toHttpEffect(app);
return yield* NodeHttpServer.makeHandler(httpEffect, { scope });
}).pipe(Scope.provide(scope), Effect.provide(NodeHttpServer.layerHttpServices)),
),
);
Building the client dist
buildSurfaceClient(opts) (@kolu/surface-app/bun) is the Bun-runtime build:
it produces the dist the static layer above is built to serve, and nothing about
that dist is the app’s to remember. Content-hashed /assets/* (entry, split
chunks, extra assets), the no-store shell rewritten to name them, the commit
injected onto that shell, a <link rel="modulepreload"> for each chunk the entry
statically imports, and — since the socket was completed — the precompressed
siblings and the code splitting every consumer used to bolt on afterwards.
| Export | Subpath | Role |
|---|---|---|
surfaceApp() | /vite | Vite plugin that injects the build commit onto the shell |
buildSurfaceClient(opts) | /bun | the Bun build: hashed assets, rewritten shell, siblings, splitting |
SurfaceClientBuildOptions | /bun | its options — entrypoint, distDir, the HTML template + placeholders, extraAssets, publicDir, minify, commit, assetPrefix |
SurfaceClientBuildResult | /bun | { jsHref, assetHrefs, assetPrefix, preloadHrefs, assets } — every hashed URL the shell now names (the JS entry, one per extra asset, and the entry’s static chunks in load order), plus an AssetReport per compressible asset in the hashed dir (sourcemaps and already-compressed media are skipped, and absent from the report) |
AssetReport | /bun | { file, bytes, siblings } — identity size and each sibling’s size, keyed by Content-Encoding |
injectShellHead(html, { preloadHrefs, commit }) | . | the whole head prelude in one splice — preload links first, then the build identity — for a caller templating its own shell from a build result |
injectShellCommit(html, commit) | . | that same splice with nothing to preload: the build identity alone |
PRECOMPRESSED_ENCODINGS | . | the [encoding, suffix] table BOTH halves read: freshStaticLayer to negotiate, the builder to emit |
assertAssetPrefix(assetPrefix?) | . | a hashed-asset request prefix, checked and handed back — the one place its shape is judged, so a prefix the build refuses is not one the server accepts |
assetDirOf(assetPrefix?) | . | …and the dist-relative directory that prefix names, which the Bun build reads to choose its outdir |
HASHED_NAMING | . | the naming template every hashed output is emitted under ([name]-[hash].[ext]). /bun hands it to Bun.build’s naming for entry, chunk and asset alike, so the rule is spelled once |
chunkPattern(module, ext?) | . | that template read backwards — what the split chunk for module is CALLED, anchored to the whole filename, for a caller that must name the file BEFORE the build has run. Built FROM HASHED_NAMING rather than re-spelled, so the two directions cannot drift. ext defaults to js; pass "css" to name a hashed stylesheet. A RegExp, because callers hand it to a route matcher and print it in a diagnosis |
chunkUrlPattern(module, assetPrefix?, ext?) | . | the same rule as a REQUEST under the hashed prefix — the filename half is chunkPattern’s, not a second spelling of it — taken through assertAssetPrefix so a prefix the build refuses is not one a matcher accepts |
const { jsHref, assets } = await buildSurfaceClient({
entrypoint: resolve(clientDir, "main.tsx"),
distDir,
htmlTemplate: resolve(clientDir, "index.html"),
entryHtmlPlaceholder: `src="./main.tsx"`,
plugins: [solidJsx],
});
for (const a of assets) console.log(a.file, a.bytes, a.siblings);
Accepting a browser socket
acceptSurfaceSocket (@kolu/surface-app/server) accepts a browser WebSocket
onto a served surface. It owns the server-side liveness reaper and sequences
stale-gate → enrol → serve in one accept(...), so a socket can never be
served un-enrolled. It is the server twin of the client’s connectSurface
watchdog.
serveSurfaceSocket({ group, handlers, socket, services? }) is the serve half —
the browser-facing counterpart of serveOverStdio / serveOverUnixSocket,
taking the same { group, handlers } pair implementSurface returns (it replaces
the oRPC RPCHandler(...).upgrade(ws) call). services is a Layer supplying
whatever this one connection’s handlers require — the seam a per-viewer fact
rides (kolu’s viewerAddress / forwardedFor, taken off the upgrade request’s
own socket and headers), because Effect’s socket-server protocol has no
per-request header channel.
| Export | Role |
|---|---|
acceptSurfaceSocket | accept + reap + sequence, in one call |
serveSurfaceSocket | serve { group, handlers } over one accepted socket, with per-connection services |
gateStaleSocket | the WS-upgrade handshake gate (error-handler-first; close 4001). It takes no live id: it compares the echoed pid against this process’s surfaceProcessId() (@kolu/surface/identity) — the same value the reserved system/identity member answers with, so the two sides cannot be pointed at different strings |
startWsHeartbeat · heartbeatSweep | the server-side liveness reaper and its one sweep |
STALE_PROCESS_CLOSE_CODE · SERVER_PROCESS_ID_PARAM | the stale-tab vocabulary the gate and the client’s close classifier share |
Connecting from the client
connectSurface / connectSurfaces (@kolu/surface-app/solid) are the turnkey
single- and multi-surface seams: each builds a socket, a websocketLink, the
surfaceClient(s), and a default-on liveness heartbeat in one call. The
heartbeat probes the framework-reserved system/live tag
(@kolu/surface/liveness) over the very dispatch it guards, so no app supplies —
or forgets — a probe.
Both seams are async: the dial is websocketLink, and building a protocol and
its fibers is an effect. Each takes the surface (or the sibling map), never a
separate group — the wire is built from surface.group / composeSurfaceContracts
right here, so a client and a wire that disagreed about which members exist is
unspellable. connectSurfaces refuses a call that passes nothing — no root
and no siblings — because such a wire carries no member at all and there is no
reserved tag for the watchdog to probe.
url is optional on both seams: omitted, it defaults to
surfaceWsUrl(location.origin) — the page’s own origin through the one
scheme-swap + path derivation, the value a browser consumer spells by hand
otherwise (never a choice: a browser app dials the origin that served it).
Omitting it with no location in scope (a Node caller) throws loudly, and throws
before anything is dialled — pass the URL you actually mean there.
connectSurfaces required it until #2222,
so an app with a multi-surface wire spelled at its call site the one line the
single-surface seam gave it free.
Tags that ride the same wire but are not sibling surfaces go in
connectSurfaces’ extraGroups — a keyed map’s group (for
connectSurfaceMap(map, conn.transport)) and a host’s hand-written root
procedures (reached through conn.transport.dispatch). The wire’s RpcGroup
carries every tag’s payload/success schemas, and Effect RPC’s flat client resolves
a call by looking its tag up there, so a tag the group never minted cannot be
dispatched at all. Each entry must be disjoint from the root, from the composed siblings and from
every other entry: the merge is mergeDisjointGroups
(@kolu/surface/define), which claims
every half’s tags before merging and throws naming the tag and the two halves
that claimed it — and it names each sibling by key (surfaces.<key>), never
the composed bundle as one half, so a collision report says which sibling.
An extraGroups entry goes in as it is: the option takes an open element
union, because RpcGroup is invariant in that union and a precisely-typed group
(a host’s hand-written root procedures, spelled member by member) is not
assignable to the erased RpcGroup<Rpc.Any> even though every element is one.
The seam takes that erasure on itself — the same law mergeDisjointGroups
adopted — so no caller writes a double cast at the door onto a safety proof.
The rooted bundle
A composed wire may also carry an unprefixed root surface, through
connectSurfaces’ core: { surface, name } slot. The siblings are tagged
surface/<key>/<member>/<verb>; the root keeps the bare
surface/<member>/<verb> a standalone surface has. It is a slot on the existing
seam rather than a second constructor, because a root is data about one wire, not
a second kind of wire.
With a core present:
- its members join the one dialled group, through the same counted merge
extraGroupsrides; - it gets its own typed
surfaceClient, returned asconn.core; - it joins the health fold — and therefore the readout — under
name, so a stopped root subscription is named (<name>/<sub>) exactly like a sibling’s; - both reserved round-trips address the root’s bare tags —
surface/system/identityfor the echo andsurface/system/livefor the watchdog — which is the pathcreateSurfaceSocketandcreateLiveSignalalready implement by omittingsiblingKey. The root is on every serve this wire can reach, so it is the one probe target a wire whose sibling set varies per serve can trust.
const conn = await connectSurfaces({
surfaces: enabledPlugins, // may be {} — a root-only wire is ordinary
core: { surface: coreSurface, name: "olai" },
retired: reloadForUpdate, // url defaults to this page's origin
});
conn.core.cells.plugins.use(); // the root's own typed client
conn.clients.kolu.cells.terminals.use();
surfaces may then be empty: a wire that carries only its root is an ordinary
wire. Omit core and every one of those sentences reverts — the probes address
the first sibling’s tags, conn.core is undefined, and the seam behaves exactly
as it did before the slot existed.
Whether a wire has a root is a call-site fact, not a runtime one: the seam is
two overloads, so core: enabled ? { … } : undefined is a type error rather than
a conn.core that types as a definite client and is undefined at runtime. A
caller who genuinely wants both shapes branches the call — the two hand back
different types.
Three miswirings are refused at the seam, all of the quiet kind: a core.surface
that is a sibling-scoped surface (the client face mints standalone tags
whatever prefix the value carries, so such a wire would connect and then answer
nothing — exposeRootedFaces states the same law on the serve side); a
core.name that is also a sibling key (the health fold is keyed by that word, so
one of the two clients would vanish from it in silence); and a core.name that is
empty or carries a /. core.name is a label and not a tag segment — the
root’s members keep their bare tags, so the word never reaches the wire and is
held to no tag grammar — but it is the word a degraded readout says, prefixed
onto every stopped subscription as <name>/<sub>, so a readout that cannot say it
(/sub, or a word indistinguishable from a sibling path) is refused at the door.
A roster change follows in place — conn.redial(surfaces)
A new wire is dialled, and that part is not negotiable. Effect RPC resolves a
call’s payload/success/error schemas by looking its tag up in the RpcGroup
its client was built over, and that group is fixed at the dial (openWireLink
does RpcClient.make(group, …) once, over a protocol whose fibers live in the
link’s own scope). A sibling that joins the roster brings tags that group never
minted, so no client built before it can dispatch them — and the far end has the
same constraint, because each accepted socket builds its own RpcServer over the
generation it was handed at accept.
What is not a fact about the transport is that the connection had to go with
it. It used to: redial handed back a replacement, and clients, core,
transport, readout and health were all dead the moment it resolved. So
every standing subscription had to be reopened by the app, which meant rebuilding
the reactive tree, which meant losing local state that has nothing to do with the
roster — a half-typed editor, an open pane, a scroll position. Now only the wire
is replaced:
const conn = await connectSurfaces({
surfaces: {},
core: { surface: core, name: "olai" },
retired: reloadForUpdate, // …and the rest, as above
});
// …the served roster moved. Same connection, new wire underneath:
await conn.redial({ kolu: koluSurface });
conn.clients.kolu.cells.terminals.use(); // the arrival is on the map you hold
clientsis the same map, mutated: an arriving sibling appears on it, a departing one is dropped — and the departing one’s client, which a still-mounted component may hold, refuses in words on its next call rather than dialling tags this generation does not serve. A key whoseSurfacevalue was replaced (an edited plugin rebuilt at a new chunk) counts as a departure and an arrival: its client is rebuilt, because one built over the old spec binds members the new one may not have. Mutated in place is what keeps the identity, and the price is that the map is not reactive — bindconn.healthorconn.readout(which read the bundle’s own roster accessor; see Consuming in SolidJS) for anything that must follow the roster, and read the map itself for the clients, after theredialresolves.- A roster this connection is already on dials nothing. “Already on” is the
client bundle’s own comparison — every key present with the same
Surfacevalue, which is the same test that decides who survives a move — so the door and the move cannot disagree. It matters because the documented pattern is to publish the roster as a cell and driveredialoff its changes, where a redundant call is ordinary; dialling anyway would fail every call in flight and re-open every standing subscription on the page to arrive back where it started. Refusals still come first: a no-op roster that is also illegal is still refused. core,transport,link,readoutandhealthkeep their identity, so a consumer holdingconn.link.wireorconn.transportat module scope — as drishti does, forcreateServerLifecycleandconnectSurfaceMap— keeps holding something live. Andreadoutnever readsretiredfor a roster move: a move is not a retirement, and saying it was is the second thing every consumer had to work around.- Standing subscriptions re-open themselves. The wire underneath is a
followingWire, whose supersession fails what was in flight with the transport error the per-subscription retry fence already retries on — so the next frame each subscription sees is its fresh snapshot from the new generation. No app code re-subscribes, and there is no second recovery path beside the fence. Not instantaneously, and that is the price of one path rather than two: the fence waits one retry interval (STREAM_RETRY_DELAY_MS, 1 s — a schedule sized for a flapping socket, now also carrying this deliberate move), so every standing subscription readspendingacross a roster move and its first fresh frame lands about a second later.
redial re-uses every option this connection was dialled with — the url
(thunk included), the heartbeat tuning, extraGroups, onClientError, the
socket options — so a consumer cannot drift them by re-spelling the call, which
is the same failure connectSurfaces exists to stop. And it owns the order:
every refusal the new roster earns is raised before anything is dialled, the
replacement wire is dialled before the old one is given up, and the handover
itself — adopt the wire, move the clients, re-fold the health — is synchronous. A
dial that throws costs you nothing: the connection is untouched and still on its
current roster. (“Resolved” is the seam’s own await, not an open socket:
connectSurfaces hands back a connection whose wire may still be connecting,
exactly as a first dial does.)
A dispose() that lands while the dial is in flight is terminal and wins: the
replacement wire is released and the redial rejects, rather than adopting a wire
onto a connection the caller has already given up.
The root does not move: only the siblings are re-rostered. The root is the
member on every serve this wire can reach, which is what makes it the reserved
probes’ target on a bundle whose sibling set varies; a core that could change
would make this a second connectSurfaces. Dial a different root by calling
connectSurfaces again. On a rootless wire the probes address the first
sibling, and “first” moves with the roster — so the watchdog re-reads its target
per probe rather than resolving it at the dial.
Two things a caller still has to know. A redial while another is in flight is
refused (this connection dials one wire at a time, so a queue belongs to the
caller that has two rosters in hand), as is a redial after dispose. And the
returned value is the honest half of an in-place move: it is this same
connection, retyped to the roster it now carries, so a binding still typed on the
old roster keeps claiming departed keys exist. Re-bind through the result —
conn = await conn.redial(next) — which costs nothing at runtime and keeps the
type truthful.
What a roster move does not hide — conn.connectionEpoch
Following the roster in place hides almost everything, which is the point: a
standing subscription heals itself onto the new wire, and readout reads live
throughout, because the page never stopped being connected.
That is right for anything computed over the connection, and wrong for anything computed from it. A viewer identity resolved from the WebSocket upgrade’s headers is the case that asked for this (juspay/olai#522): it is stale the instant a different socket carries the calls, and nothing else on the connection will ever mention that — the subscriptions are fine and the light is green.
conn.connectionEpoch is that fact, as a Solid accessor:
// re-resolve who we are whenever a different socket is carrying the calls
const [viewer] = createResource(conn.connectionEpoch, () =>
Effect.runPromise(conn.core.procedures.who.get({})),
);
It counts a usable connection being established, and only that:
| counts | |
|---|---|
| a reconnect inside the current wire generation | ✔ |
| a redial that lands on a connection — including the ordinary case where the replacement was already open, which the transport status has nothing to say about | ✔ |
a redial whose replacement is still connecting | ✔ when it opens, not when it is adopted |
| a redial whose dial failed | ✘ — no new connection was established, and the one you had is the one you still have |
It is monotonic for the connection’s whole life: it does not restart with the underlying link. And it is not a rebuild signal — re-fetch what the connection itself answered, and leave the tree standing.
connectSurface, the single-surface seam, carries no such member and needs
none: with no generations, its link.wire.onStatus open edges are the
establishments, undeduplicated.
A disposed connection is the one that answers about nothing: its readout
reads retired and its health reads not-live, rather than freezing on whatever
the disposed memo last computed (which would be live — green over a closed
wire).
The serve side’s half of the same contract is
implementRootedSurfaces,
which is live — a server’s siblings own stores, channels and running sources
that must survive a roster change. serveSurfaceApp and serveOverUnixSocket
take a live thunk of the generation; serveSurfaceSocket reads the pair per
accepted connection. See the caution on that page.
// ASYNC: the dial is an effect. `link` is the `{ dispatch, wire, dispose }` the
// websocket link minted; `dispose()` releases its scope (dial/ping/response
// fibers) as well as stopping the watchdog.
const { link, client, readout, dispose } = await connectSurface({
surface,
url,
// REQUIRED: what happens when the server retires this wire (a tab bound to a
// process that is gone — the link will never dial again). No default, so a
// connection that compiles has an answer. `reloadForUpdate` is the one-liner;
// pass your own handler to take the screen instead.
retired: reloadForUpdate,
});
connectSurface returns { link, client, readout, dispose };
connectSurfaces returns
{ link, clients, core, transport, connectionEpoch, readout, health, redial, dispose }.
| Member | Meaning |
|---|---|
link | the WebsocketLink — { dispatch, wire, dispose, diagnostics }. Read link.wire for the status stream / forceReconnect (was ws: PartySocket). On the multi seam it is the standing wire: dispatch and wire are the same values across a redial, so a consumer may hold them; diagnostics is the one leg that belongs to a generation, and reads through to whichever is current; and link.dispose() is conn.dispose() — the wire is the connection’s resource and its state gates it, so there is no second, ungated door onto the same release |
client / clients | the reactive surface client, or one scoped client per sibling. clients is one object for the connection’s life — a redial mutates it in place |
core | (multi only) the root surface’s own client when core was passed, and typed undefined when it was not — so a siblings-only caller reads no optional it has to check. It needs no tag-scoping wrapper: the root’s tags are already the bare ones the combined dispatch carries |
transport | (multi only) the branded LiveSignalHandle — pass it whole to connectSurfaceMap(map, conn.transport), which slices the sibling by map.name after the guard so the map inherits this wire’s watchdog live. Stable across a redial, so a map built over it keeps working |
connectionEpoch | (multi only) how many usable connections this wire has established — 0 before the first, monotonic for the connection’s life — as a Solid accessor, so a createResource can key on it. See below |
readout | the readout — the wire’s own four states folded with the subscription-health fact into the five an indicator may report, memoized. Replaces the transport-only status this used to hand back |
health | (multi only) the client bundle’s own fact over every sibling — and, when a core was passed, the root under its name — their subs AND-reduced with the shared transport leg. Reactive about the roster too, so a memo bound to it re-folds when a sibling arrives or leaves |
redial | (multi only) async: take a NEW sibling roster in place — a new wire is dialled over this connection’s own options and adopted, the root does not move, and every handle above keeps its identity. Idempotent: a roster already carried dials nothing. See above. Returns this same connection, retyped |
dispose | async: stop the heartbeat, tear down standing subscriptions, release the link’s scope |
The readout, not a status
A transport status answers a question about a socket; an indicator makes a
claim about a page. Those come apart in exactly one place, and it is the
place that matters: a socket can be open and answering while a subscription
riding it is dead. The fact that knows — client.health() — was a second,
droppable call, and dropping it is what apps did: a collection whose keys stream
had died rendered as a collection with nothing in it, under a green light.
So both seams fold the two facts themselves and hand back readout, whose live
is the conjunction. degraded names what stopped (a non-empty list, so “something
isn’t arriving” is not spellable), pending never degrades, and needsReload marks
the one state a reload — and only a reload — recovers. What each state is called
stays yours: type your wording table Record<SurfaceReadoutStatus, …> and a new
state becomes a type error in your own file.
// The five states an indicator may report. `Record`, not a function: a state with
// no wording of its own is a type error HERE, in the app's own table, which is
// where the words belong. The framework decides which state is TRUE — including
// `degraded`, the one the transport cannot see (a live socket over a subscription
// that has stopped) — and this app decides what each is called.
const LABEL: Record<SurfaceReadoutStatus, string> = {
connecting: "connecting",
live: "live — everything this page reads is arriving",
degraded: "partly live",
reconnecting: "reconnecting — showing the last thing the server said",
retired: "the server was replaced — reload this page",
};
// `degraded` NAMES what stopped, and its list is non-empty by type, so this
// sentence can never come out with a hole in it.
const label = (): string => {
const now = readout();
return now.status === "degraded"
? `${LABEL.degraded} — nothing is arriving on ${now.stopped.join(", ")}`
: LABEL[now.status];
};
// The one bit that says a reload is the ONLY recovery (`retired`), read rather
// than re-derived — a page never has to keep its own list of terminal states.
const offerReload = (): boolean => readout().needsReload;
A consumer that genuinely wants the socket rather than the page still has it:
link.wire.status() / link.wire.onStatus, and (multi only) the branded
transport.status.
There is deliberately no heartbeat: false on these seams: they mint the
watchdog-backed brand surfaceClient requires, and a disabled watchdog would mint
a branded-but-blind signal. A wire whose liveness another layer owns simply does
not use this seam.
The stale-tab handshake, and the one option you must spell
Both seams own the pid handshake end to end. On every wire open they probe
the framework-reserved system/identity member over the link’s own dispatch and
feed the processId it answers into the echo their URL thunk appends — so every
reconnect carries ?pid=<the process that served this page>, and the server’s
gateStaleSocket closes a tab bound to a process that is gone. No app code
observes an identity, and there is no returned echo for an app to feed. (An app
that shares ONE echo across several wires still creates it —
createProcessIdEcho() from @kolu/surface-app/connect — and passes it in.)
retired is required, and has no default:
| Option | Effect |
|---|---|
retired: () => void | run once when the server retires this wire — this tab is bound to a process that no longer exists, the link has stopped dialling for good, and every call on it now fails with SurfaceTransportRetired. retired: reloadForUpdate is the one-liner; pass your own handler to take the screen instead |
Requiring it does not make an app render anything — nothing at the type level can —
but it does make the terminal state impossible to be unaware of: a wire that
compiles has been asked what happens when it dies, and answered. The two values a
connection used to hand back for an app to remember to use — the echo and the
status — were both droppable, and an app that dropped both shipped a tab that
sat on a dead server looking healthy forever. The echo is no longer an app’s to
drop; the terminal state is no longer an app’s to ignore. The third droppable
value was client.health(), and the readout
above is that one paid off: an app can still decline to render a connection, but
it can no longer render a green one that isn’t true.
createServerLifecycle({ wire, probe }) derives the connection lifecycle
(connecting → connected → disconnected → reconnected / restarted) from the
wire’s own status stream plus a processId probe — pass
() => probeSurfaceIdentity(client.rpc) (@kolu/surface/identity), the
framework-reserved round-trip every surface answers. surface-app declares no
identity member of its own: it used to ship an identity.info beside buildInfo,
which meant two per-process ids in one server and a stale-tab gate that only
worked while a consumer kept them in step. It observes a
WatchableWire — link.wire — not a socket object, so the same lifecycle
rides any transport that can report status and force a re-dial. Deriving a
lifecycle cannot leave the wire un-watched: it owns a default-on half-open
watchdog that probes on an interval and calls wire.forceReconnect() when a
probe times out — catching a socket that is TCP-dead with no FIN/RST (laptop
sleep, Wi-Fi roam, NAT idle-eviction).
| Option | Effect |
|---|---|
livenessProbe | the watchdog’s round-trip — pass () => probeSurfaceLive(client.rpc) so it asks the one reserved question, not an app-nominated verb. probe answers which process; this answers is it answering at all |
heartbeat: false | opt out of the default-on watchdog (only when you wire your own createHeartbeat); an object tunes intervalMs / timeoutMs / onStale, and observes every definitive verdict with onProbeSettled(ok, atMs) |
onProbeError | surface a failed identity probe instead of silently holding the prior state |
There is no onStaleRestart and no restartCloseCode. The link owns the
close-code classifier now: a 4001 close retires the wire (status retired,
retry schedule halted, every call failing with SurfaceTransportRetired), and the
lifecycle reads that terminal status as a definitive restarted. Nothing is left
for a consumer to tear down at the decode site.
onClientError?: (policy, err) => void is the app’s client error interpreter
— the single seam an app registers so a member’s spec-declared client.onError
policy (a surface built via
defineSurfaceWithPolicy)
reaches app code on a subscription failure. Every door in the family takes it in
the same position: connectSurface (to its one client), connectSurfaces (threaded
inward to every sibling and the root, via surfaceClients → each
buildSurfaceClient), connectSurfaceMap, and surfaceClient itself for a
hand-built client. So the app spells one interpreter, not one per surface. It is
optional at the type; a policy-free surface needs none, but a member that does
carry a policy makes buildSurfaceClient throw at construction if it was
omitted — a declared policy can never route nowhere.
The client model
SurfaceAppProvider (@kolu/surface-app/solid) is the turnkey source that
handles the stale-tab handshake; its connection source is a union of
{ wire, probe } (the provider derives the lifecycle off the link’s
WatchableWire — there is no socket shape to satisfy) or { status } (you
already did). It takes a typed controlPlane whose buildInfo cell yields build
identity, plus clientCommit={shellCommit()} — and a required fault, the
LOOK of an uncaught render throw (below).
useSurfaceApp() is the headless model — no styled components ship:
| Accessor | Meaning |
|---|---|
status() | "live" | "reconnecting" | "restarted" | "down" |
presentingDown() | grace-windowed down state |
server() | { commit, … } server identity |
reload() | reload for an update |
setAttention(n) | OS app badge + title |
canInstallPwa() | true only in a secure context and not already installed |
The fault boundary: a throw is not a white tab
A client that throws while drawing is not running the code that would draw any
in-app error surface — Solid unmounts the subtree that faulted, and what a
reader gets is a blank page with the truth in a console nobody opened. So the
composition root takes the answer as a required prop, the way the connect
seams require retired:
<SurfaceAppProvider fault={(text) => <Fault text={text} />} …>
The provider wraps its children in SurfaceFaultBoundary, which owns three
verbs — it catches (an ErrorBoundary around the whole shell), records
(one console.error naming the moment: a boundary swallows, and Solid
re-throws only when nothing catches, so without that line a faulted page fails
a browser test as a bare timeout with its “no page errors” assertion green),
and prints (thrownText, exported on .: the fault arrives as unknown —
a string, an undefined, a DOMException — and the printer normalizes it,
puts a Safari-lost message back on the front of its stack, and never returns
the empty string). The app supplies only the LOOK (FaultLook —
(text: string) => JSX.Element), handed the printed text verbatim: that text
is what a bug report is made of, and a LOOK that summarised it would be the
white tab with extra steps.
An app whose root plumbing does not ride the provider composes the boundary directly:
render(
() => (
<SurfaceFaultBoundary fault={(text) => <Fault text={text} />}>
<App />
</SurfaceFaultBoundary>
),
root,
);
Requiring the prop does not make an app render anything well — nothing at the type level can — but a composition root that compiles has been asked what an uncaught throw looks like, and answered.
Build identity
Build identity is an interface. defineBuildInfo({ schema, default, isStale? })
declares it — schema is an Effect WireSchema<T>, and the minted cell is
verbs: ["get"], so build identity is read-only on the wire and no client
can fabricate or hide stale-client state (the server still writes it through
ctx.cells.buildInfo.set). buildInfoServer({ buildInfo? }) and
surfaceAppServer({ buildInfo?, equals? }) (@kolu/surface-app/surface /
/server) implement it.
Service worker stance
- No caching service worker, ever — the ban is on a
fetchhandler that intercepts the network. Definitional for this class of app. - By default the package ships
SW_SOURCE, a self-destructing retirement worker at/sw.js, plusretireServiceWorker()to retire any worker a prior build registered. - The one opt-in is a fetch-less notification worker (
NOTIFICATION_SW_SOURCE), registered viaregisterOrRetireServiceWorker(). An app registers (notify) or retires (none) — never both. - Gate all service-worker logic on
window.isSecureContext, neverlocation.protocol === "https:".
OS notifications
createNotify<D>(parse) (@kolu/surface-app/notify) is the last hop of
cross-host attention: showing an OS notification from a PWA through the origin’s
one service worker (the fetch-less NOTIFICATION_SW_SOURCE above). D is the
app’s own click-routing payload shape — kolu’s is a kind-discriminated union,
{ kind: "host"; host; id } | { kind: "terminal"; host; terminalId }, so the one
click router can never cross-deliver the two, and both carry host so a click
switches to the originating host before focusing. parse(data): D | undefined
validates each incoming click envelope; a malformed or stale (pre-upgrade) payload
is dropped, never routed. It returns a Notify<D>:
| Method | Role |
|---|---|
requestPermission() | request OS permission; resolves true when granted (idempotent). Delivery is a no-op until granted |
show({ tag, title, body?, icon?, data }) | show — or replace, by tag — a notification. A no-op (never a throw or a hang) where there is no worker, no active worker, or no permission; an operational failure is caught and logged, so a caller may fire-and-forget |
onClick(handler) | subscribe to clicks; the handler receives the clicked notification’s validated data. Covers the live path (the worker retries postMessage under an ACK handshake so a still-LOADING window that has not installed its listener yet does not drop the click — the page acks the EXACT delivering worker via event.source, so the ack lands even mid worker-replacement) and the cold-start URL handoff (no window open → opens one carrying the payload in NOTIFICATION_DATA_PARAM plus a click id in NOTIFICATION_CLICK_ID_PARAM, read once at startup; a loading window that never acks is navigated to the same URL as a durable fallback). The click id dedups a live route against a fallback-navigate re-delivery (a bounded sessionStorage FIFO), so one click fires exactly one action. Exactly-once is structural, not best-effort: the page routes the LIVE message only when it can both ack the delivering worker (event.source present) AND durably record the id (sessionStorage writable); if either is missing it stays silent and lets the worker’s URL-fallback navigation be the single route (a storage failure is warned, never swallowed into a double-fire). Returns an unsubscribe fn |
Two landmines are why delivery is a framework piece — one seam at the origin’s one service worker — rather than N windows each attempting their own:
getRegistration(), never.ready.navigator.serviceWorker.readyresolves only when an active worker exists — in any context where none registers (a dev server, a degraded boot) it hangs forever, silently killing the notification path.getRegistration()answers honestly, including “there isn’t one”.- The worker shows it, never
new Notification(). In an installed (standalone) PWA the page-context constructor throws “Illegal constructor” — delivery must go throughregistration.showNotification().
The tag carries the multi-window discipline: two open windows must not both ping
you, and a tag-keyed show makes the OS replace the same-tag notification
instead of stacking a duplicate. The click payload comes back to onClick as the
opaque data the app routes — the framework does not know what clicking attention
means.