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.
directLink is in-process and always-live, so it may be passed bare.Pick by where the server is
| Where the server runs | Link | Import |
|---|---|---|
| In the same process (tests, single-process apps) | directDispatch(served) | @kolu/surface/links/direct |
| In a browser, talking to a Node server | websocketLink({ group, url, isTerminalClose }) | @kolu/surface/links/websocket |
| A daemon on the same machine | unixSocketLink({ group, socketPath }) | @kolu/surface/links/unix-socket |
| A subprocess, or a remote box over ssh | stdioLink({ 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.
directDispatchis 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 watchablewire).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. stdioLinkcarries both a local subprocess and an ssh peer; over ssh you usually drive it throughmakeSession(see Mirror over ssh) rather than wiringread/writeby hand. For a test,createLoopbackPair()(@kolu/surface/loopback) gives you both ends of the same ndjson framing with no subprocess.
Swap a link without touching call sites
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.