kolu
Docs

@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,
    }),
});
FactShapeNotes
upgradeHeadersReadonlyArray<H>the allowlist. Empty by default — a header nobody named is not available at all
connection.headersReadonly<Partial<Record<H, string>>>the named headers this upgrade carried. Matched case-insensitively against the wire; keyed by the spelling in upgradeHeaders
connection.remoteAddressstring | undefinedthe 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.

ExportSubpathRole
serveSurfaceApp(opts)/servethe whole listener — shell, socket, gates, heartbeat, bind, teardown
SurfaceAppListenFailed/servethe one failure it reports, cause intact
SurfaceAppConnection/serveone accepted connection’s { id, url, remoteAddress, headers } — what a per-connection services layer is built from
SurfaceAppEvent/servethe tagged union onEvent narrates
SurfaceAppHttpMiddleware/servethe shape middleware takes — error and response types preserved, adding only HttpServerRequest
reportSurfaceAppEvent(event)/servethe 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:

_tagCarriesDefault (reportSurfaceAppEvent)
Connectedconnectionsilent — the accept a live-connection count increments on
Disconnectedconnection, code, reasonsilent — the same connection hung up, with the close frame’s own account of why
SocketErrorerror, urlloud console.error
StaleTabclaimedPid, urlsilent — a server that restarted while a tab was open is ordinary, not a fault
DisallowedOriginorigin, urlloud console.warn — a blocked hijack nobody can see is the one thing a shared gate must not ship
ServingFailedcause, connectionloud console.errordone 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).

ExportSubpathRole
surfaceAppLayer(opts)/serverthe no-store shell + immutable assets + (optional) manifest, in one layer
SurfaceAppLayerOptions/serverthat layer’s options (optional clientDist, manifest, serviceWorker, the freshness paths) — ServeSurfaceAppOptions extends it, so the shell half is named once
freshStaticLayer(opts)/serverfresh static + optional notification worker
pwaManifestLayer(manifest, path?)/serverthe 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.

ExportSubpathRole
surfaceApp()/viteVite plugin that injects the build commit onto the shell
buildSurfaceClient(opts)/bunthe Bun build: hashed assets, rewritten shell, siblings, splitting
SurfaceClientBuildOptions/bunits 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.

ExportRole
acceptSurfaceSocketaccept + reap + sequence, in one call
serveSurfaceSocketserve { group, handlers } over one accepted socket, with per-connection services
gateStaleSocketthe 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 · heartbeatSweepthe server-side liveness reaper and its one sweep
STALE_PROCESS_CLOSE_CODE · SERVER_PROCESS_ID_PARAMthe 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 connectSurfacesextraGroups — 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 }.

MemberMeaning
linkthe WebsocketLink{ dispatch, wire, dispose }. Read link.wire for the status stream / forceReconnect (was ws: PartySocket)
client / clientsthe 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
readoutthe 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
disposeasync: 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:

OptionEffect
retired: () => voidrun 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 WatchableWirelink.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).

OptionEffect
livenessProbethe 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: falseopt 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)
onProbeErrorsurface 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:

AccessorMeaning
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 fetch handler 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, plus retireServiceWorker() to retire any worker a prior build registered.
  • The one opt-in is a fetch-less notification worker (NOTIFICATION_SW_SOURCE), registered via registerOrRetireServiceWorker(). An app registers (notify) or retires (none) — never both.
  • Gate all service-worker logic on window.isSecureContext, never location.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>:

MethodRole
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.ready resolves 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 through registration.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.