← the Atlas

surface-connection — the WebSocket connection, owned upstream

feature · budding ·implemented ·

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.

consumer (kolu · drishti)@kolu/surface-app/connect (framework-free)@kolu/surface-app/serverstays with the consumer (differs per app)URL(s) · socket topology · lifecycle ownership · clients assemblycreateProcessIdEcho() — the shared pid echo (one per app; drishti shares it across N sockets)createSurfaceSocket() — new PartySocket(echo'd URL thunk) + optional self-retiregateStaleSocket(ws, url, liveProcessId) — error-handler-first · rejectStaleProcess · close(4001)lifecycle — kolu: rpc.ts · drishti: SurfaceAppProviderclients — surfaceClients(link, surfaces) url + topologylive processIdowns directlyappended on every reconnectlifecycle.onProcessId → echo.remember
Three primitives, each one concern. The echo and socket are client transport; the gate is server. The lifecycle (createServerLifecycle / SurfaceAppProvider) and the clients (surfaceClients) stay with the consumer — kolu and drishti own those differently, so they don't graduate.

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:

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 .


Origin: follow-up to #1231 . Model: @kolu/surface-app · electricity. ③ proof: srid/drishti.