How to serve a map of surfaces
Mirroring one surface fronts a single host. A fleet
app fronts N of them, of the same shape, with the set changing at runtime. This
guide serves that fleet as one @kolu/surface-map map: define the entry surface
once, hold a binding per host, and bridge those bindings into the map’s
MapRegistry. drishti — a browser fronting an ssh fleet — is the worked example
throughout.
1. Define the map
defineSurfaceMap({ key, entry, codec, failure }) takes the per-host entry surface and
produces its map form: every member’s input gains a key so one socket carries all
instances. The codec bridges your Key to the wire string every channel is
keyed on; for a plain-string key it is the identity pair. The required failure
schema fixes the domain failure value a failed entry carries — the Failure type is
inferred from it.
const HostKeySchema = Schema.String;
const identityCodec: KeyCodec<string> = { encode: (k) => k, decode: (s) => s };
// The domain failure schema validates the value a failed entry publishes — a
// failed member cannot exist without one (there is no fabricated fallback cause).
const hostFailureSchema = Schema.Struct({ reason: Schema.String });
type HostFailure = typeof hostFailureSchema.Type;
const hostMap = defineSurfaceMap({
key: HostKeySchema,
entry,
codec: identityCodec,
failure: hostFailureSchema,
});
2. Build one binding per host
Hold one binding per host: a makeSession over sshConnector (per
Mirror over ssh), a pump that folds the agent’s frames
into a local mirror, and an in-process link the map forwards member calls to.
Its state() projects the session’s connection state onto the map’s per-entry
state, so a fault on one box surfaces on exactly that box’s chip — warming while
it retries, failed only on a standing fault — never as a fleet-wide crash.
function buildHostBinding(host: string, agentDrv: string): HostBinding {
// The connector takes the SURFACE as a value — Effect RPC builds its client
// from `surface.group` and the face is re-nested from `surface.spec`.
const session: Session<AgentClient, SshProv> = makeSession({
initialConnection: "probing",
connectOnce: sshConnector({
surface: entry,
host,
binary: "fleet-top-agent",
// Policy-free: the consumer composes the localhost arm's spawn env, keeping only
// the keys that are SET (an empty HOME/PATH would misdirect lookups). kolu uses
// kolu-pty's `composeSpawnEnv`. Never the caller's ambient `process.env`; unused for ssh.
localEnv: Object.fromEntries(
(["HOME", "PATH"] as const)
.map((k): [string, string | undefined] => [k, process.env[k]])
.filter((e): e is [string, string] => e[1] !== undefined),
),
resolveDrvPath: () =>
// deferred per dial; the cache names where binaries prefetch from
Promise.resolve(directAgentDerivation(agentDrv, EXAMPLE_BINARY_CACHE)),
}),
});
const processes = new Map<Pid, Proc>();
const runtime = implementSurface(entry, {
cells: { load: { store: inMemoryStore(DEFAULT_LOAD) } },
collections: {
processes: {
readAll: () => processes,
upsert: (k, v) => {
processes.set(k, v);
},
remove: (k) => {
processes.delete(k);
},
},
},
procedures: {
proc: {
// `kill` forwards to the CURRENT live agent client — a kill can land
// across a reconnect, so never a per-spawn stub. This procedure declares
// no error channel, so an upstream failure is UNDECLARED and `orDie` keeps
// it a loud defect rather than something a caller could branch on.
kill: ({ input }) =>
Effect.gen(function* () {
const pending = session.currentClient();
if (pending === null) throw new Error("no live agent link");
const client = yield* Effect.promise(() => pending);
const kill = client.surface.proc?.kill as UnaryEffect<
{ pid: number },
{ ok: boolean },
never
>;
return yield* Effect.orDie(kill(input));
}),
},
},
});
// Fold the agent's frames into the local runtime; the first `load` frame is
// the handshake that flips the session to `connected`.
let firstLoad = true;
void pumpRemoteSurface({
source: entry,
session,
makeSink: ({ seq: _seq }) => {
firstLoad = true;
return {
cells: {
load: (v) => {
if (firstLoad) {
firstLoad = false;
session.markConnected();
}
runtime.ctx.cells.load.set(v);
},
},
collections: {
processes: {
upsert: (k, v) => runtime.ctx.collections.processes.upsert(k, v),
remove: (k) => runtime.ctx.collections.processes.remove(k),
},
},
};
},
});
// `directDispatch` takes the served surface itself and calls its handlers
// in-process — the map never learns whether the dispatch crosses a wire.
const dispatch = directDispatch(runtime);
let latest: SessionState<SshProv> = {
phase: "probing",
log: [],
sinceMs: 0,
campaignEpoch: 0,
};
const unsub = session.onState((s) => {
latest = s;
});
return {
dispatch,
state: () => projectState(latest),
onStateChange: (cb) => session.onState(() => cb()),
destroy: () => {
unsub();
session.destroy();
},
};
}
3. Bridge the bindings into a MapRegistry
serveSurfaceMap takes a MapRegistry — the one writer of membership. Build it
over your bindings: members()/has() answer from the binding set, subscribe
fires on any host’s state change, and resolve(host) hands the map that host’s
dispatch + projected state (or a fault for an unknown key). The map forwards
member calls over that dispatch by tag and never learns what minted it — a
wire link, a directDispatch, a mirror. There is no second writer of membership.
// The hand-built MapRegistry is the ONE writer of membership; `resolve(host)`
// hands the map each host's dispatch + projected connection state.
const registry: MapRegistry<string, "copying", HostFailure> = {
members: () => [...bindings.keys()],
subscribe: (onChange) => {
changeCbs.add(onChange);
return () => changeCbs.delete(onChange);
},
has: (k) => bindings.has(k),
resolve: (
k,
): EntrySession<"copying", HostFailure> | EntryFault<HostFailure> => {
const b = bindings.get(k);
if (b === undefined)
return { kind: "fault", failure: { reason: `unknown host: ${k}` } };
return { kind: "session", dispatch: b.dispatch, state: b.state() };
},
};
4. Serve the map
serveSurfaceMap(map, registry) publishes entries and derives each entry’s
EntryStatus by projecting its session’s connection state — you never write
status yourself. It returns the same { group, handlers } pair
implementSurface returns — hand it straight to a transport (a directDispatch
in-process, or a WebSocket serve path), or merge it into a host’s own served
surface. A tag carries its own route, so there is nothing to finalize and nothing
to re-prefix at the mount site.
// `serveSurfaceMap` returns the SAME `{ group, handlers }` pair
// `implementSurface` does — a host merges one value pair into its own served
// surface, and a tag carries its own route, so nothing is re-prefixed at the
// mount site.
const { group, handlers, dispose } = serveSurfaceMap(hostMap, registry);
The client half — reading this map back as chips and a switchable canvas — is The client half; the exact registry and wire types are in the @kolu/surface-map reference.