@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,
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
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.
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 | ReadonlyArray<H> | the allowlist. Empty by default — a header nobody named is not available at all |
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.
| Export | Subpath | Role |
|---|---|---|
serveSurfaceApp(opts) | /serve | the whole listener — shell, socket, gates, heartbeat, bind, teardown |
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 |
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 |
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 |
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 an empty map: with no sibling there is no
tag for the watchdog to probe.
connectSurface’s url is optional: 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 — pass
the URL you actually mean there.
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 composed siblings and from
every other entry: the merge counts the result and throws if a collision swallowed
a tag.
// 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, transport, readout, health, dispose }.
| Member | Meaning |
|---|---|
link | the WebsocketLink — { dispatch, wire, dispose }. Read link.wire for the status stream / forceReconnect (was ws: PartySocket) |
client / clients | the reactive surface client, or one scoped client per sibling |
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 |
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) surfaceClientsHealth(clients) — every sibling’s subs AND-reduced with the shared transport leg |
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.
ConnectSurfacesOptions.onClientError?: (policy, err) => void is the app’s
client error interpreter — the single seam a multi-surface app registers so a
member’s spec-declared client.onError policy (a surface built via
defineSurfaceWithPolicy)
reaches app code on a subscription failure. connectSurfaces threads it inward to
every sibling client (surfaceClients → each buildSurfaceClient), so the app
spells one interpreter here, not one per surface. It is optional at the type; a
policy-free surface bundle needs none, but a sibling 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.