kolu
Docs

How to choose a link

A link is how a client reaches a served surface. Every link produces the same SurfaceDispatch — one flat { unary, stream } pair keyed by wire tag — and therefore the same typed client, so the choice is purely about transport, and you can change it later without touching a single call site.

Four links, one clientFour transports — directLink, websocketLink, unixSocketLink, stdioLink — all produce the same typed client. The three wire links must pass through a watchdog that tells live from dead; directLink is always-live and may be used bare.directLinkin-process · no wirealways-live · usable barewebsocketLinkbrowser ⇄ NodeunixSocketLinksame-machine daemonstdioLinksubprocess / sshwatchdogconnectSurface /makeSessiontells live from deadno watchdog neededsurfaceClientthe SAME typed clientapp.cells.load.use(…)app.procedures.proc.kill(…)byte-identical across linksFour different pipes, one identical client shape. Choose the nearest transport now; swap it later without touching a call site.A wire link can silently half-open, so it needs the watchdog. directLink is honestly always-live and may go bare.
Every link produces the same typed client, so the choice is only about transport. The three wire links route through a watchdog that can tell live from dead; directLink is in-process and always-live, so it may be passed bare.

Pick by where the server is

Where the server runsLinkImport
In the same process (tests, single-process apps)directDispatch(served)@kolu/surface/links/direct
In a browser, talking to a Node serverwebsocketLink({ group, url, isTerminalClose })@kolu/surface/links/websocket
A daemon on the same machineunixSocketLink({ group, socketPath })@kolu/surface/links/unix-socket
A subprocess, or a remote box over sshstdioLink({ group, read, write })@kolu/surface/links/stdio

Match each to its serve side: nothing for directDispatch (it calls the handlers directly), serveSurfaceApp from @kolu/surface-app/serve for the browser — the turnkey one, which owns the whole listener (shell and socket, gates, heartbeat, bind and teardown) — serveOverUnixSocket for the daemon, serveOverStdio for the subprocess or ssh peer. Every one of those takes the same { group, handlers } pair implementSurface returns. Reach past it to the granular serveSurfaceSocket (@kolu/surface-app/server) only when the app owns its own listener — routing each upgrade to a different runtime by ?host=, say.

  • directDispatch is the identity element: hand it the served surface itself — directDispatch(implementSurface(surface, deps)) — and every call runs the handler in-process, with no serialization in either direction. Use it for tests and for the in-process phase of a service you will later put behind a socket.
  • Every wire link is async — building a protocol and its fibers is an effect — and returns { dispatch, dispose } (the websocket link adds the watchable wire). dispose() releases the link’s scope, so its dial, ping and response fibers go with it.
  • Every wire link takes the served surface’s group, which is what makes “the client and the wire agree about which members exist” true by construction rather than by review.
  • stdioLink carries both a local subprocess and an ssh peer; over ssh you usually drive it through makeSession (see Mirror over ssh) rather than wiring read/write by hand. For a test, createLoopbackPair() (@kolu/surface/loopback) gives you both ends of the same ndjson framing with no subprocess.

Because the client type is identical across links, a swap is a one-line change at the wiring seam. A surface that started in-process for tests:

// Was: in-process. `directDispatch` takes the served surface itself and calls
// its handlers directly — zero serialization, and the only dispatch
// `surfaceClient` accepts bare (no transport ⇒ it cannot half-open).
const app = surfaceClient(surface, directDispatch(runtime));

moves to the browser by changing only the link:

// Now: over a WebSocket, via the app layer's watchdog-backed connect. ASYNC —
// the dial is an effect — and the bound hooks below it are unchanged.
const { client } = await connectSurface({
  surface,
  url,
  retired: reloadForUpdate,
});
const app = client; // same bound hooks, same call sites

Everything downstream — app.cells.load.use(...), app.procedures.proc.kill(...) — is byte-identical. That is the point of the link abstraction: choose the nearest transport now, and let the surface follow the code out to a socket or a fleet later.