surface-app — the app shell for surface apps
A library for the class of @kolu/surface app that is really a desktop application you run against your own server — fresh delivery, server/build identity, the connection/update lifecycle, and desktop install, all owned by one shell.
A library for a specific class of @kolu/surface app: the ones that are really desktop applications you run against your own server (kolu, drishti, the next one). Where surface is the live reactive wire, surface-app is the app shell around it — delivered fresh, installed like a desktop app, and always aware of its relationship to the server it’s bound to.
Status: implemented · shipped in kolu #1154 (packages/surface-app) · adopted by drishti (srid/drishti#47) · renamed from surface-pwa.
What it is
The class of app it serves
This is not a library for “any web app that can be installed.” It’s for a specific, recognizable shape — the one kolu and drishti share:
- You run the server. Your machine, your homelab, your tailnet — not a CDN, not multi-tenant SaaS. Identity is per named host (
Kolu [host], drishti’s per-host tabs). - Always-connected. The live WebSocket is the app; there is no meaningful offline mode. This is why there’s no caching service worker — not as an opinion, but by nature.
- Desktop-class. Installed, long-lived, feels native — an app window, not a browser tab you re-find.
- You’re usually the deployer. You redeploy your own server often, so a long-lived installed client going stale against a just-deployed server is the defining pain — the four-times-relitigated bug (#696, #1125, #1135, #1149).
That class is almost the opposite of a generic PWA (public, multi-tenant, CDN-served, offline-capable, SEO-shaped) — which is why “pwa” was the wrong word and the name is now surface-app.
| Layer | What it owns |
|---|---|
| @kolu/surface — the wire | The Cell / Collection / Stream / Event / Procedure lattice. How data reaches the app. |
| @kolu/surface-app — the app shell | Fresh delivery · server identity · connection & update lifecycle · desktop install. How the app itself reaches the user and stays in step with the server. |
The unifying insight
The contract
Five invariants
#1 is load-bearing; the rest are graceful degradation.
- One mutable entry point; everything else immutable. The shell (
index.html) is the only never-cached resource (Cache-Control: no-store); every content-hashed asset isimmutable; a missing/assets/*hash 404s rather than falling through to the shell. The one document that names the bundle is always re-fetched, so staleness is structurally impossible. (immutablepresumes content-hashed names; unhashed shell assets stayno-cache.) - Build identity is first-class and single-sourced — and rides the shell, never a hashed asset. Client and server stamp the same commit; the server exposes it. Bundler-agnostic: server reads
SURFACE_APP_COMMITfrom env; the client commit rides theno-storeshell aswindow.__SURFACE_APP_COMMIT__(injected by thesurfaceApp()Vite plugin orbuildSurfaceClientfor Bun, and read viashellCommit()) — never a bundler define baked into a content-hashed/assets/*file. A define would pin the sha inside a year-immutablebundle whose name doesn’t change on a stamp-only deploy, so the rewritten bytes never re-fetch and every returning browser stays stuck on the old stamp, looping the update prompt forever (the bug fixed in #1324); the always-fresh shell carries identity instead. The env-var name and the flake-rev resolution are single-sourced for nix consumers innix/commit-stamp.nix(kept equal toresolveCommit’sDEFAULT_COMMIT_ENV_VAR), so a nix-built client and the server wrapper stamp from the same var — no hardcoded literal downstream. - Skew is visible and recoverable, on every form factor. When client ≠ server, a durable indicator shows and a reload that lands fresh is one tap away — desktop rail and mobile.
- A service worker is an opt-in you own end-to-end — or, for this class, none. A SW intercepts navigations in front of the network, so the cache contract (#1) can’t reach it; gate on
window.isSecureContext, neverlocation.protocol === "https:". An always-connected app has no offline mode, so surface-app ships no caching worker and actively retires any it finds; the one opt-in is a fetch-less notification worker (freshStaticLayer({ serviceWorker: "notify" })+registerServiceWorker(), #1216) that registers no fetch handler, so the cache contract (#1) never sees it — kolu opts in for OS notifications. - The client always knows its relationship to the server it’s bound to. Which host, which build, and the live status (connected / reconnecting / server-restarted / stale-build) are first-class — surfaced as a model the app renders. This is the invariant that makes surface-app an app shell, not just a cache policy.
The old triage rule (normal reload stale, hard reload fresh → a cached shell or a service worker; confirm in the browser, not by reasoning about the origin) is a debugging heuristic, not an encoded invariant — it lives in the library’s README review checklist.
The app shell’s parts
- Fresh delivery — cache policy (no-store shell · immutable hashed assets · no-cache
sw.js· 404 asset-miss) + static-serve + SPA fallback. - Server & build identity — which host + which build the client is bound to; per-host name/theme; the
buildInfocell (extensible interface). - Connection & update lifecycle — surface-app derives the lifecycle (
connecting → connected → disconnected → reconnected / restarted, +stale-build) from the WebSocket transport’s open/close plus a server-identity probe (processIdtells a transient drop from a restart) — lifting kolu’s genericrpc.tswholesale, so kolu deletes its lifecycle layer and drishti gets the WS indicator for free. The app passes thewshandle and renders its own chrome from the model — the instant header dot fromuseSurfaceApp().status(), but the full-screen “Disconnected” overlay from the grace-windowedpresentingDown()(status() === "down"held back ~1s so a sub-second forced reconnect — the half-open watchdog recovering, a Wi-Fi roam — never flashes the alarm, while a sustained outage still surfaces) — no hand-wired connection state. (Commit (skew) and processId (restart) stay distinct axes —buildInfois commit-only; the restart probe is its own.) Since #1231 the server also gates the WS handshake by processId (rejectStaleProcess/STALE_PROCESS_CLOSE_CODE), so a stale tab is told to reload instead of storm-reconnecting. - Desktop install & feel — the manifest as app-window identity (per-host name/theme/icons) and an attention model that fans out to App Badging (installed Chromium, Win/macOS) → document title (
setAttention(n), best-effort, degrades per browser). Install state now ships in the model —isInstalled/canInstallPwaonSurfaceAppModel(#1199); the one-click prompt UI lives in@kolu/solid-pwa-install. A canvas favicon remains a documented future affordance. (Badging needs a trusted secure context; the core works without — see below.) - Service-worker stance — never ship a caching worker; retire legacy ones (
retireServiceWorker()+ self-destructingsw.js, the default), or opt into the fetch-less notification worker (serviceWorker: "notify"+registerServiceWorker(), #1216) for OS notifications. - Commit propagation — one resolver (
SURFACE_APP_COMMITenv →git rev-parse --short HEAD→"dev") feeding a Vite plugin (which injects the commit onto theno-storeshell aswindow.__SURFACE_APP_COMMIT__, plus its type declaration) and the server cell. The client reads it viashellCommit(); it is never a bundler define in a hashed asset (see invariant #2). No app ever writes a sha literal.
Secure context for the desktop layer (HTTPS)
Two layers, two requirements. The freshness core (delivery, skew over the wire, reload) works on plain HTTP and ws:// — no secure context needed, so kolu on a plain-HTTP LAN keeps working. The desktop-feel layer (install, Badging) is gated on window.isSecureContext, which a self-hosted app reached by bare hostname or private/tailnet IP over plain HTTP does not have. The confirmed facts (citations in cache-bug.md):
- Install no longer needs a service worker — Chrome dropped that requirement (108 mobile / 112 desktop); a valid manifest over a secure context is installable. So invariant #4 (ship no SW) and “be installable” don’t conflict.
- …but the in-page Install button is best-effort. Chrome’s automatic
beforeinstallpromptstill references a fetch handler, so without a SW it may not fire. Manual install (browser menu / address-bar icon) always works. → wire the prompt as progressive enhancement, detect already-installed viadisplay-mode: standalone(+ iOSnavigator.standalone), and fall back to “install from your browser menu” / iOS “Add to Home Screen” copy. Chromium-only; Safari/Firefox degrade. localhostis exempt (localhost/127.0.0.1/*.localhost, port-independent) — local dev just works. LAN IPs and bare hostnames over HTTP are not secure contexts.
So the desktop layer needs a trusted HTTPS origin. The recommended paths:
| Path | Trusted, no warning? | Per-device setup | Auto-renew | Best for |
|---|---|---|---|---|
| tailscale serve | ✓ — real LE cert on *.ts.net |
none (every tailnet device) | ✓ | recommended (tailnet) |
| mkcert / local CA | ✓ where the CA is installed | per device | n/a (~825d) | single LAN device |
Caddy tls internal |
✓ where the CA is installed | per device | ✓ | multi-service LAN |
self-signed (@kolu/dev-tls) |
✗ — warns (click-through) | per device, every time | at startup | localhost dev only |
Decisions: (1) surface-app does not provide TLS — cert acquisition is a different volatility (deployment/infra), so it stays out. surface-app feature-detects isSecureContext and, when the desktop layer is unavailable, surfaces an actionable hint (not a hard block — the core still works): “reachable, but install/badge need HTTPS — try tailscale serve.” (2) kolu’s existing self-signed generator (packages/server/src/tls.ts, the selfsigned package) is to be extracted into a tiny optional @kolu/dev-tls for the localhost/dev escape hatch only — a deferred follow-up. (3) The trusted recipes are documented, not implemented. This also closes the saga loop: a real cert via tailscale serve gives a genuine secure context — removing any reason for the Chrome insecure-origin flag that orphaned the service worker in the first place.
The pieces
| Piece | The question it answers | Entrypoint | Inv. |
|---|---|---|---|
surfaceAppLayer · freshStaticLayer · pwaManifestLayer |
“Serve the SPA fresh, the manifest, AND /sw.js (retirement, or the opt-in fetch-less notification worker via serviceWorker: "notify") — one composed HttpRouter layer, merged not installed, so registration order carries no meaning.” |
/server | #1,2,4 |
buildInfoServer() · surfaceAppServer() |
“The buildInfo cell’s server impl (+ the identity.info probe impl) — the deps bundle for the surface-app sibling entry in implementSurfaces; commit auto-resolved.” |
/server | #2,5 |
surfaceAppSurface · surfaceAppSurfaceWith |
“surface-app’s complete standalone surface (buildInfo cell + identity.info probe) — served as a sibling via implementSurfaces / surfaceClients / composeSurfaceContracts.” |
/surface | #2,5 |
buildInfo · defineBuildInfo |
“The buildInfo cell definition — composed into the surface-app surface; extensible.” | /surface | #2,5 |
useSurfaceApp() · SurfaceAppProvider · retireServiceWorker() |
“The headless model (status/presentingDown/stale/server/reload/setAttention) + SW retirement. You render the UI.” | /solid | #3,4,5 |
surfaceApp() vite plugin · buildSurfaceClient() · resolveCommit() |
“Build the client fresh: content-hashed /assets/*, the commit injected onto the no-store shell as window.__SURFACE_APP_COMMIT__ (read via shellCommit(), never a hashed-asset define), the no-store shell rewrite — Vite plugin or Bun helper. Resolve the commit once (env→git→dev).” |
/vite · /bun | #1,2 |
cacheControlFor · clientIsStale · SW_SOURCE |
“The pure, framework-free kernels.” | utils/ | #1,2 |
The design historical (merge era)
The headless model
Everything the app shell knows about its server is one hook; the app renders whatever chrome it wants from it. The library provides the model, the apps (kolu, drishti) define the UI — a future PR may consolidate the UI back into surface-app if a shared shape proves out.
const app = useSurfaceApp(); // the relationship to the server you're bound to:
app.status() // "live" | "reconnecting" | "restarted" | "down"
app.stale() // boolean — running bundle is behind the server's build
app.server() // T | undefined — what am I bound to (default { commit }; kolu extends)
app.clientCommit // string — this bundle's baked-in commit
app.reload() // land the deployed build
app.setAttention(n) // OS app badge if installed (best-effort) + document.title — degrades per browser
Build-skew is one status among connection states — the insight made concrete. status() is derived by the library from the ws handle (open/close) + a processId probe (reconnected vs restarted) — this is kolu’s rpc.ts lifecycle, encapsulated, so the WS indicator + dim overlay that kolu renders today drop straight into drishti. The shape is extensible: kolu’s server() also carries pty-host info (its second staleness axis); drishti’s is just { host, commit, name }. As shipped, createServerLifecycle({ ws, probe }) derives it in-library; the example proves it end-to-end — live → down → restarted on a real server restart.
Build identity is an interface
What “the build” means is the one thing apps vary. Default is the commit; kolu adds a pty-host axis; the staleness predicate is part of the interface. Apps extend rather than fork:
// default — drishti uses exactly this
export const buildInfo = defineBuildInfo({
schema: z.object({ commit: z.string() }),
isStale: (srv, cli) => clientIsStale(srv.commit, cli.commit),
});
// kolu extends — adds the pty-host axis without losing the commit one
defineBuildInfo({ schema: z.object({ commit: z.string(), ptyHost: PtyHostRefSchema }),
isStale: (srv, cli) => clientIsStale(srv.commit, cli.commit) || ptyHostDiverged(srv.ptyHost, cli.ptyHost) });
The matching server impl is a fragment too: buildInfoServer() (commit auto-resolved) spreads into implementSurface; kolu passes its pty-host source. Definition ⊕ impl are composed, never hand-written in the app.
Compose, don’t hand-wire
The spike’s failing — fairly called out in review — was making the example re-implement what the library should own: a hand-written buildInfo store, a hand-rolled /sw.js route, a hardcoded commit string, a per-app __SURFACE_APP_COMMIT__ define and its type. The fix is the principle surface itself follows: the library ships fragments, an app is their composition, and there is no bespoke glue. Build identity is one concept with composable faces the app stitches together — never re-derives:
| Face | Library fragment | App composes… |
|---|---|---|
| definition | buildInfo (cell schema) |
into defineSurface |
| restart probe | serverIdentity (procedure, surfaceApp.info) |
into defineSurface |
| surface merge | surfaceAppSurface / surfaceAppSurfaceWith + composeSurfaces |
one merge of cell + probe into defineSurface |
| server impl | surfaceAppServer() + implementSurfaceApp() |
one call (owns buildInfo.connect) |
| client model | useSurfaceApp() |
under <SurfaceAppProvider> |
| client build + commit | surfaceApp() Vite plugin · buildSurfaceClient() (Bun) · resolveCommit() |
into the client build & server boot |
| nix commit stamp | nix/commit-stamp.nix (envVar · revFromSelf · exportLine) |
into the flake / derivation / server wrapper |
And the commit is resolved once — SURFACE_APP_COMMIT env, else git rev-parse --short HEAD, else "dev" (which clientIsStale already treats as never-stale) — then fed to both the client define and the server cell. No app writes a sha.
Every removed line was the app doing the library’s job; every surviving line composes a fragment. That’s the whole principle.
The API historical (merge era)
One package, sub-path entrypoints mirroring @kolu/surface’s exports map. Pure kernels under utils/. The composeSurfaces / implementSurfaceApp / surfaceApp.info snippets in this section are the superseded merge API — the current sibling API is in Composing surfaces.
/server @kolu/surface-app/server
import {
implementSurfaceApp,
surfaceAppLayer,
surfaceAppServer,
} from "@kolu/surface-app/server";
// `implementSurfaceApp` — the server-side counterpart to `composeSurfaces`: ONE
// call merges the fragment's buildInfo cell + `surfaceApp.info` probe impls into
// your own deps, runs implementSurface, and flows `buildInfo.connect` internally.
// The app passes only its OWN cells/procedures — no hand-spread, no seed→connect.
const { router, ctx } = implementSurfaceApp(surface, surfaceAppServer(), {
channel,
cells: { /* yours only */ },
procedures: { /* yours only */ },
});
// one LAYER serves the shell fresh + the manifest + /sw.js (retirement). Granular pieces exported too.
const appLayer = surfaceAppLayer({ clientDist, manifest: { name: `Kolu [${host}]`, themeColor, icons } });
/surface — compose the library’s surface @kolu/surface-app/surface
import { surfaceAppSurface, composeSurfaces } from "@kolu/surface-app/surface";
// ONE merge: the buildInfo cell + the `surfaceApp.info` restart probe, composed
// with your own spec. (`surfaceAppSurfaceWith(myBuildInfoDef)` for an extender.)
export const surface = defineSurface(
composeSurfaces(surfaceAppSurface, { cells: { /* yours … */ }, procedures: { /* yours … */ } }),
);
composeSurfaces ships in @kolu/surface-app (a field-wise merge of two SurfaceSpecs — cells/collections/streams/events shallow, procedures two-level, throwing on a duplicate key). surface-app’s contributions are namespaced under surfaceApp (probe at surface.surfaceApp.info) so they never collide with the app’s own keys — the app wires the cell + probe as one fragment, not two separate spreads.
/solid — behaviour + headless model @kolu/surface-app/solid
import { retireServiceWorker, SurfaceAppProvider, useSurfaceApp } from "@kolu/surface-app/solid";
retireServiceWorker();
// pass your control-plane client + this bundle's baked commit (the bundler define):
<SurfaceAppProvider controlPlane={app} clientCommit={__SURFACE_APP_COMMIT__}> …your app… </SurfaceAppProvider>
// then render your own chrome from useSurfaceApp() — README has the snippets.
No styled components ship: a tailwind app (kolu) and a different-CSS app (drishti) render their own from the same model. controlPlane takes one client; a many-client app passes its control-plane client, since the model is global.
/vite · /bun — the client build, owned upstream @kolu/surface-app/vite · /bun
// Vite path — the plugin resolves + injects the commit
import { surfaceApp } from "@kolu/surface-app/vite";
plugins: [solid(), surfaceApp()] // injects __SURFACE_APP_COMMIT__ + ships its type — no define, no env.d.ts, no sha literal.
// Bun path — buildSurfaceClient owns hash-naming + the define + the no-store shell rewrite
import { buildSurfaceClient } from "@kolu/surface-app/bun";
await buildSurfaceClient({ entrypoint, distDir, htmlTemplate, entryHtmlPlaceholder, plugins, extraAssets, publicDir });
Both stamp the same commit via resolveCommit (env → git → "dev"). The Bun consumer (drishti) composes buildSurfaceClient rather than hand-rolling Bun.build + hashing + shell rewrite — the content-hashed /assets/* layout (the prerequisite for immutable) is the library’s job, not the app’s. One resolver, one source of truth.
Using it — kolu & drishti
Both are SolidJS servers on effect/unstable/http — each owns its http.Server and hands the request event a handler built from HttpRouter.toHttpEffect, so the ws upgrade seam stays the app’s alone — and both vendor @kolu/* via npins (zero flake inputs), so adding surface-app is one merged layer each. Same model + same UX; kolu only differs by extending build identity for its pty-host axis.
// drishti — surface: ONE merge of surface-app's fragment (cell + surfaceApp.info probe) + its own
export const adminSurface = defineSurface(composeSurfaces(surfaceAppSurface, { collections: { hosts }, procedures: { hosts } }));
// drishti — server: ONE call composes the surface-app impls (buildInfo cell + surfaceApp.info probe)
// into the app's own deps, runs implementSurface, and flows the cell's connect (commit auto-resolved; /sw.js served by the lib)
const { router, ctx } = implementSurfaceApp(adminSurface, surfaceAppServer(), { channel, collections: { hosts }, procedures: { hosts } });
const appLayer = surfaceAppLayer({ clientDist, manifest: { name: "drishti", themeColor: "#0e7490", icons } });
// drishti — vite: one plugin resolves + injects the commit
plugins: [solid(), surfaceApp()]
// drishti — client: provider (this bundle's baked commit) + your own chrome from useSurfaceApp()
retireServiceWorker();
<SurfaceAppProvider controlPlane={adminClient} clientCommit={__SURFACE_APP_COMMIT__}> <Header>drishti <DrishtiStatus/></Header> </SurfaceAppProvider>
Zero hardcoded commits, defines, stores, or /sw.js routes — every line composes a library fragment. kolu is identical but passes its pty-host source to buildInfoServer + the extended defineBuildInfo. To see skew in dev, boot the server with SURFACE_APP_COMMIT=<other> — a real deploy-simulating override, not a sha baked into the client.
drishti’s adoption (srid/drishti#47) landed the content-hashed Bun.build prerequisite first — its old Bun.build emitted unhashed filenames (main.js), which can’t be cached immutable (#1) — then sourced @kolu/surface-app hermetically through Nix and ported the identity rail onto the shared model.
UI — example, not shipped
The library ships the headless model, not styled components. These are the chrome you’d build from useSurfaceApp() — kolu in tailwind, drishti in its own CSS. The README carries the wiring.
A server-identity rail — host · build, live status, and skew + reload as one state. A single row showing the server build (d5aed3c) versus the client build (617b80d), a durable ≠ srv chip when they diverge, and a ⟳ Reload button:
● SRV d5aed3c · CLIENT 617b80d [≠ srv] ⟳ Reload
A prominent one-tap recovery prompt (durable, not a transient toast):
⟳ A new version is available
This tab is running an older build (617b80d → d5aed3c). [ Reload ]
On mobile the rail collapses to the chip + reload; the prompt becomes a bottom banner. Both are built from the same useSurfaceApp() model, so kolu’s reflect pty divergence and drishti’s reflect commit skew with no change to the library.
Composing surfaces — multiple surfaces, one transport
Status: implemented · #1201 · kolu#1197 · dissolves the four composition seams above without touching SurfaceSpec. surface-app shrinks; only a thin plural layer is added to surface core.
The XY unwind
The issue (#1197) prescribed nested sub-surfaces (mounts) — recurse a whole Surface inside SurfaceSpec. That solves the stated Y (one merged spec) but at the cost of making the core spec recursive and changing every derivation. The real X — a library can’t be handed over as one unit because its cell is flat and its probe is two-level — dissolves once you stop merging: a standalone surface is one unit, and “compose” just means “serve more than one.” SurfaceSpec, defineSurface, the contract derivation, the client proxy, and the channel-key construction are all untouched.
The seam
Each surface is defined exactly as today — no new field. surface core gains a thin plural layer — composeSurfaceContracts (contract), implementSurfaces (server), surfaceClients (client) — each reading one shared keyed surfaces map, so the keys can’t drift across the three.
// each is a normal, standalone surface — ZERO change to defineSurface / derivation / client proxy
const surfaceAppSurface = defineSurface({ cells: { buildInfo }, procedures: { identity: { info } } });
const adminSurface = defineSurface({ collections: { hosts }, procedures: { hosts } });
// ONE keyed map — the single (browser-safe) source of which surfaces exist under which keys
const surfaces = { surfaceApp: surfaceAppSurface, admin: adminSurface };
const contract = composeSurfaceContracts(surfaces); // → { surface: { surfaceApp, admin } }
// SERVER — reuse `surfaces`; add only the server-only deps, keyed the same way (no { surface, deps } wrapper)
const { router, ctx } = implementSurfaces(surfaces, { channel }, {
surfaceApp: surfaceAppServer(), // the lib's deps bundle; its async buildInfo connect fires in the runtime
admin: adminImpl,
});
// CLIENT — reuse `surfaces`; one connection split into a per-key client bundle
const c = surfaceClients(link, surfaces);
c.surfaceApp.cells.buildInfo.use(); // surface.surfaceApp.buildInfo
c.admin.collections.hosts.use(); // surface.admin.hosts...
What it dissolves
| Seam today | After |
|---|---|
composeSurfaces / ComposedSurfaceSpec |
gone — no merge; the surfaces are siblings (the name ComposedSurfaceSpec was later reintroduced by SR5 for an unrelated concept — extendSurface’s flat spec merge; see archived notes) |
assertNoOverride dup-key throw |
gone — each surface is its own top key; nothing to collide |
implementSurfaceApp() |
gone — implementSurfaces(surfaces, base, deps) takes each surface’s deps in a keyed map; surfaceAppServer() survives as surface-app’s per-key deps bundle |
app-visible connect (republish late buildInfo) |
gone — connect? is a cell-impl dep the runtime fires once after wiring |
The plural layer wraps the existing singular walk once per surface, handling the two real mechanics:
// 1. namespace each surface's channel bus by its key so two surfaces' `buildInfo:changed` can't collide
walkSurface(t.surface[key], surface, { ...deps, channel: (n) => baseChannel(`${key}/${n}`) })
// 2. key each surface's router + ctx under its registration name (instead of the hardcoded single `surface`)
surface-app’s /surface and /server entrypoints lose composeSurfaces, surfaceAppServer, implementSurfaceApp, assertNoOverride; the surfaceAppSurface definition + its server impl stay (now served as a sibling). The headless model, surfaceAppLayer, and the client build are untouched.
Why this is the right boundary
- Hickey (simplicity): siblings don’t complect — there is no merge at all, so the four merge-glue constructs have nothing to do. One namespacing mechanism (the registration key), zero recursion in the spec.
- Lowy (volatility): the unit of contribution is a whole surface, encapsulated; a new library (
surface-auth) is just another entry in the map. Smallest blast radius — the core derivation, the client proxy, and channel-key construction don’t move; only a thin plural layer is added. connectis a separate volatility (a cell’s value arriving async at boot). It stays internal to each surface’s ownimplementSurface, never folded into composition and never app-visible.
Why the wire break is free
Wire paths gain the surface key (surface.buildInfo → surface.surfaceApp.buildInfo; channel buildInfo:changed → surfaceApp/buildInfo:changed). A hard break for a long-lived client — except this is the one app class where client and server ship together and a skewed client is designed to reload to the deployed build (invariant #1). No compat shim: kolu, the surface examples, and drishti all re-derive in lockstep with the server they ship beside.
Scope & migration
A @kolu/surface change that adds composeSurfaceContracts + implementSurfaces + surfaceClients and leaves SurfaceSpec / defineSurface / the contract derivation / the client proxy alone. All three read one shared keyed surfaces map. Then every consumer splits its one merged surface into siblings:
- kolu — its
surfaceAppsurface (extending build identity for the pty-host axis, as today) becomes a sibling of itskolusurface; deletes itscomposeSurfaces/implementSurfaceAppcalls.app = clients.kolukeeps every existing call site unchanged. - the surface examples — re-expressed as sibling surfaces against the plural API (a
surfaceAppsibling + the example’s owndemosibling). - drishti (srid/drishti) — adopts the plural API in a paired PR (the
(should include drishti PR)ask): asurfaces = { admin: adminSurface, surfaceApp: surfaceAppSurface }map inadmin-surface.ts,implementSurfaces(surfaces, …)inadmin-router.ts,clients.surfaceApp.cells.buildInfoand thesurfaceAppProbe(clients.surfaceApp)identity probe on the client.
A pure refactor in behavior — no on-screen change, no new user-facing surface — so existing surface + surface-app tests are the safety net (extended to cover the plural layer’s per-key channel namespacing and router/ctx keying).
Rationale & status
What it absorbs
- The
no-cache-isn’t-enough shell — a 1970Last-Modifiedearns years of heuristic freshness and replays on normal reload. →no-store. - The asset-miss that becomes HTML — caches the wrong MIME
immutablefor a year. → 404 the miss. - The SW gate on
protocol === "https:"— misseslocalhost+ flag-secured origins, orphaning a worker. →isSecureContext. - The un-retired worker — gating new registration leaves an existing SW intercepting navigations. →
retireServiceWorker()+ self-destructsw.js. - The transient update signal — a “restarted” event a backgrounded tab misses. → the durable skew backstop in the model.
Why no offline / no service worker
For this class a caching worker is definitionally wrong, not an opinion — and the rationale ships so the next engineer doesn’t re-add one. The sanctioned exception is the fetch-less notification worker (#1216): it registers no fetch handler, so the interception downside below can’t reach it; the lifecycle liability still applies, which is why surface-app owns the worker end-to-end (it self-destructs any caches it finds).
- No offline to gain. A surface app needs its live WebSocket — no wire, no app.
- No speed to gain. Hashed assets are already
immutable-cached; a precache just adds a stale-prone layer. - Real downside. A SW is a second interception layer
no-storecan’t reach; owning its lifecycle is a standing liability (the whole saga). - Install survives without it — Chrome dropped the service-worker requirement for installability (108 mobile / 112 desktop); a valid manifest over a secure context installs. (The automatic in-page prompt is best-effort without a SW — manual browser-menu install always works.)
Direction
- Surface-native. Depends on
@kolu/surfaceand rides it for the build-identity model. - Batteries-included behaviour, headless UI. Owns delivery, identity, commit stamping, SW retirement, install affordance, and the relationship-to-server model; apps render the chrome. The README ships the wiring.
- Interfaces where it varies — build identity (default
{ commit }; kolu extends). - Settled questions. Install needs no service worker; cert/TLS lives outside surface-app; the desktop layer is secure-context-gated with graceful degradation. The connection-lifecycle model ships in-library as
createServerLifecycle; the client build ships assurfaceApp()(Vite) /buildSurfaceClient()(Bun); and surface-app ships its contribution as a complete standalone surface (surfaceAppSurface/surfaceAppSurfaceWith, with theidentity.infoprobe) — served as a sibling via@kolu/surface’simplementSurfaces/surfaceClients/composeSurfaceContracts, not merged into the app’s own surface. (The earlier merge approach —composeSurfaces+implementSurfaceApp— was dissolved; see Composing surfaces.) One follow-up remains deferred: whether@kolu/dev-tlsis extracted as its own optional package. - A living library, not a finished one. The boundary is settled, but the composition surface keeps tightening as more apps adopt it — each “compose, don’t hand-wire” seam was added because drishti’s adoption surfaced a place an app was still hand-rolling the library’s job. The first cut (
buildSurfaceClient,nix/commit-stamp.nix, and the now-removed merge seamscomposeSurfaces/implementSurfaceApp) is now superseded by the sibling model — surface-app is just a sibling surface, so the merge glue dissolved entirely. Expect the pattern to continue: a third consumer will find the next seam to lift upstream.
Name
@kolu/surface-app — “the app shell for a surface wire.” Dropped “pwa”: it connotes offline / installable-for-offline, the opposite of this always-connected class.
Phasing
- Build
@kolu/surface-app(packages/surface-appin the kolu monorepo) —/server,/surface,/solid(behaviour + headlessuseSurfaceApp()),/lifecycle,/vite,utils/, commit-stamp helper, and the desktop-feel affordances behind the secure-context gate. Rewired kolu, extending build identity for pty-host. Shipped the README (no-SW rationale + triage checklist + UI-wiring snippets). Registered in the electricities tracker. done — kolu #1154. - Adopt in drishti (separate PRs, drishti repo) — first a small PR for content-hashed
Bun.buildoutput; then port drishti onto the library (default identity, same UX). done — srid/drishti#47. - Extract
@kolu/dev-tls— pull kolu’s self-signed generator (packages/server/src/tls.ts) into a tiny optional package for the localhost/dev escape hatch; document the trusted recipes (tailscale serve/ mkcert / Caddy). surface-app itself only feature-detects the secure context and hints. deferred - Blog post — sibling to surface-framework, seeded by cache-bug.md. deferred
- Compose surfaces as siblings in
@kolu/surface—implementSurfaces/surfaceClientsserve a keyed map of standalone surfaces over one transport, so surface-app is just a sibling surface andcomposeSurfaces/implementSurfaceApp/ the dup-key throw / the app-visibleconnectall dissolve — with no change toSurfaceSpec. A thin plural layer on surface core (paired drishti adoption to follow). Plan of record: Composing surfaces. implemented — kolu #1201 · kolu#1197.
Origin: #1149. Model: @kolu/surface. Bug saga + design history (incl. the surface-pwa → surface-app pivot): cache-bug.md. Sibling: electricity.html. · Shipped 2026-06-04.