kolu
Docs

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.