surface-connection — the WebSocket connection, owned upstream
Lift the partysocket + oRPC connection assembly both kolu and drishti hand-rolled — the pid-echo, the socket construction, and the server upgrade gate — into three composable @kolu/surface-app primitives, leaving each app only its URL, socket topology, and lifecycle ownership.
The stale-tab handshake (
#1231 ) upstreamed the gate into @kolu/surface-app — the pid param, the 4001 close code, rejectStaleProcess, retireSocket, the lifecycle. But it left the connection assembly itself — the pid-echo plumbing, the new PartySocket(...) construction, and the server-side upgrade gate — hand-rolled in each consumer, byte-for-byte identical between kolu and drishti. This change lifts that last duplicated layer into three composable primitives, so a consumer brings only its URL, its socket topology, and its lifecycle ownership — never the plumbing.
Status: implemented · kolu #1234 · paired drishti PR · follow-up to #1231 .
The duplication both consumers hand-rolled
Client — kolu’s wire.ts, and drishti’s makeSocket (called once per host + once for the admin socket):
// the pid-echo mutable + URL threading — hand-rolled in BOTH wire.ts files
let lastServerProcessId: string | null = null;
export function rememberServerProcessId(id: string) { lastServerProcessId = id; }
const ws = new PartySocket(() =>
lastServerProcessId ? `${base}?${SERVER_PROCESS_ID_PARAM}=${lastServerProcessId}` : base);
// …and, for a socket no lifecycle watches (drishti's per-host), the self-retire:
ws.addEventListener("close", (e) => {
if (e.code === STALE_PROCESS_CLOSE_CODE) retireSocket(ws);
});
Server — kolu’s single /rpc/ws connection handler, and drishti’s host-dispatching httpServer.on("upgrade") — both on the same ws stack, differing only in dispatch:
ws.on("error", …); // FIRST — or a post-close peer error crashes
if (rejectStaleProcess(url.searchParams.get(SERVER_PROCESS_ID_PARAM), liveProcessId)) {
ws.close(STALE_PROCESS_CLOSE_CODE, "stale server process"); return; // gate BEFORE upgrade
}
The error-handler-before-gate ordering is the footgun the gauntlet caught on
#1231 — and drishti’s hand-rolled upgrade handler didn’t have it, installing its error listeners after the gate’s early return. A latent crash, exactly the kind a shared owner removes for good.
The three primitives
The seam splits cleanly into three independently-owned concerns. Two are framework-free client transport (@kolu/surface-app/connect); one is the server gate (@kolu/surface-app/server). None bundles the lifecycle or the clients — those stay with the consumer, because that is where the two genuinely differ.
createProcessIdEcho + createSurfaceSocket @kolu/surface-app/connect
The echo owns the pid threading; the socket owns the new PartySocket(...) with that echo’d URL plus the optional stale-close self-retire. kolu builds one socket (private echo); drishti builds many sharing one echo.
// kolu — one socket, private echo
const { ws, echo } = createSurfaceSocket({ url: wsBaseUrl });
export const rememberServerProcessId = echo.remember; // fed to rpc.ts's lifecycle
// drishti — ONE shared echo across per-host + admin sockets
const echo = createProcessIdEcho();
const makeSocket = (host, retire) => createSurfaceSocket({
url: () => `…/rpc/ws?host=${host}`, // echo appends &pid= on top
echo, // shared — fed by the admin socket's lifecycle
socketOptions: { connectionTimeout: 60_000 },
retireOnStaleClose: retire, // per-host sockets self-retire (no provider watches them)
}).ws;
The echo’s appendTo respects an existing query string (drishti’s ?host= → &pid=), so both URL shapes work. The pid mutable, the URL threading, and the self-retire listener are now the library’s — the three lines each consumer used to own.
gateStaleSocket @kolu/surface-app/server
The server gate, in the one correct order — error-handler-first baked in so no consumer can re-introduce the crash. It works for both topologies because it’s just the gate, not the dispatch.
// kolu — single handler
if (
gateStaleSocket(ws, url, serverProcessId, {
onError: (e) => connLog.error({ err: e }),
onReject: (pid) => connLog.info({ pid }),
})
)
return;
// drishti — first line of the host-dispatched upgrade
if (
gateStaleSocket(ws, url, admin.processId, {
onReject: () => log("rejecting stale ws"),
})
)
return;
liveProcessId is surfaceAppServer().processId — the same id the identity.info probe reports, so the gate and the probe single-source. drishti gains the error-ordering fix it never had.
What stays per-consumer
The parts that genuinely differ — which is exactly why they don’t graduate:
- Lifecycle ownership — kolu derives
createServerLifecycleinrpc.ts; drishti delegates to<SurfaceAppProvider>’s turnkey{ ws, probe }source. The echo’srememberplugs into either viaonProcessId. - Socket topology — kolu: one socket. drishti: per-host sockets (self-retiring) + one admin socket (provider-retired), all sharing the one echo.
- The clients assembly —
surfaceClients(websocketLink(ws), surfaces)is already a one-liner from@kolu/surface; it stays at the consumer. - The overlay chrome — surface-app provides the model (
useSurfaceApp().updateReady()/reload()); each app renders its own card.
The lens question — committing to partysocket
This makes @kolu/surface-app explicitly partysocket+oRPC: createSurfaceSocket is the one new PartySocket(...) in the package, and partysocket is now a declared dependency.
Status
One phase, shipped as a kolu PR + a paired drishti PR — the ③ proof every step, mirroring #1201 and #1231 .
- kolu
#1234 — adds
@kolu/surface-app/connect(createProcessIdEcho,createSurfaceSocket,retireOnStaleClose) +gateStaleSocketin/server; rewireswire.tsand the server gate; unit-tests the echo, the self-retire, and the gate. implemented - drishti — adopts all three: one shared echo across its per-host + admin sockets, the per-host self-retire via
retireOnStaleClose, andgateStaleSocketin its upgrade handler (gaining the error-ordering fix). implemented
Origin: follow-up to #1231 . Model: @kolu/surface-app · electricity. ③ proof: srid/drishti.