@kolu/surface-map reference
A dynamic keyed map of remote surfaces — one entry surface, typed once, keyed at
runtime, served as one. It depends only on @kolu/surface and has three
subpaths: the contract, the server, and the client.
The three halves
| Export | Subpath | Role |
|---|---|---|
defineSurfaceMap(keySchema, entry, codec) |
. |
build a map from a key schema, an entry Surface<ES>, and the key’s string codec; returns a SurfaceMap |
serveSurfaceMap(map, registry) |
/server |
back the map with a MapRegistry; returns { router, dispose } |
connectSurfaceMap(map, transport, siblingKey?) |
/client |
consume the map; returns a SurfaceMapClient (with the Solid useEntry) |
defineSurfaceMap’s keySchema need not be a branded string — its z.infer is
Key, which can be any validated value (kolu’s HostKey is a discriminated-sum
object). Its third type parameter is Cause extends string = string, the failure
discriminant. The SurfaceMap carries { keySchema, entry, contract, entriesSpec, codec };
entriesSpec is the membership collection’s wire spec, always string-keyed.
connectSurfaceMap’s transport must be the branded parent handle (e.g.
conn.transport from connectSurfaces) — a bare or pre-sliced wire link throws.
When siblingKey is given, the map slices the scoped sibling after the
half-open guard, so the scope inherits the parent’s watchdog by construction.
const HostKeySchema = z.string();
const identityCodec: KeyCodec<string> = { encode: (k) => k, decode: (s) => s };
const hostMap = defineSurfaceMap(HostKeySchema, entry, identityCodec);
The client
SurfaceMapClient exposes entries (the one membership authority), live
(resolved transport liveness), codec (the one key-identity authority), and two
lenses onto an entry.
| Member | Returns |
|---|---|
entries |
ReadOnlyBoundCollection<Key, EntryStatus> — the single membership authority; upsert/delete only |
live |
Accessor<boolean> — resolved transport liveness for the membership strip |
codec |
KeyCodec<Key> — the one key-identity authority; encode/decode the canonical wire string |
entry(key) |
Entry<ES> — the pure point lens (no owner, no I/O, total) |
useEntry(keyAccessor) |
Entry<ES> — the Solid lens; re-keys on key change; throws ownerless |
dispose() |
tear down |
An Entry<ES> carries cells / collections / streams / events (the same
bound hooks a single surface has), plus:
| Member | Returns |
|---|---|
rpc |
the entry’s procedure client; folds { mapKey } per call so the caller never passes the key |
clock.toLocal(remoteMs) |
reproject a host-stamped epoch: number | null (null when the entry has no offset yet) |
state() |
EntryStatus | { kind: "not-a-member" } — a total fold over entries, never nullable |
entry(key) is a partial application of the key: a per-key SurfaceClient<ES>,
cached by key, so two views of one entry share one upstream subscription.
useEntry re-keys on a key change — the old key’s subscriptions dispose, the
new key’s populate synchronously — and canonicalizes its accessor’s key by its
encoded string, so a same-key re-decode reads as a no-op, never a re-key.
const entries = app.entries.use(); // the ONE membership authority (the chips)
const hosts = entries.keys(); // each chip's key
const status = (host: string) => app.entry(host).state().kind; // warming/connected/failed
const active = app.useEntry(activeHost); // re-keys on switch; old subs dispose
const load = active.cells.load.use();
const processes = active.collections.processes.use();
entry.rpc is typed unknown on a generic map — a ContractRouterClient
over an abstract entry spec would overflow TS’s union budget (TS2590). The
consumer casts it once to its own surface’s procedure shape; the key-injecting
link still folds { mapKey } into every call, so the caller never passes the key.
// `entry.rpc` is typed `unknown` on a generic map — a `ContractRouterClient` over
// an abstract entry spec would overflow TS's union budget. The consumer casts it
// ONCE to its own surface's procedure shape.
type KillRpc = {
surface: { proc: { kill: (i: { pid: number }) => Promise<{ ok: boolean }> } };
};
const kill = (
active: Entry<typeof entry.spec>,
pid: number,
): Promise<{ ok: boolean }> =>
(active.rpc as KillRpc).surface.proc.kill({ pid });
scopedByEntry — per-key state retained by membership
The second export of /client. It is the retained-owner dual of useEntry:
where useEntry disposes the old key’s world on every switch (right for cheap
wire subscriptions), scopedByEntry builds a per-key reactive owner for the
client’s own state and keeps it across switches — the focused tile, the camera,
per-host view posture — so switching hosts restores the world you left. Built over
@solid-primitives/keyed’s
keyArray (the ecosystem’s retained-per-key-root primitive), keyed by membership.
scopedByEntry(client, active, build) → { active(): T | undefined, get(key): T | undefined }
active: Accessor<K | null>is app policy —nullmeans nothing selected (drishti’s fleet grid; kolu never). It is not the map’s business which key matters.build(key, ctx)runs once per key on its first activation; its return value is that key’s owned world.ctx.isActiveis anAccessor<boolean>— the active-only discipline (WebGL release/re-acquire, center-on-active) lives inside the owner, not as a bridge between two independently-timed lifecycles.- Lifetime is
entriesmembership, not the wire. An owner is built lazily (a never-visited background host costs nothing), retained across every switch-away, and disposed the instant its key leavesentries. A removed-then-re-added host is a fresh member — lazy again, never resurrected. active()returns the active key’s world, orundefinedfor both anullactive and an active key that is not a current member (a removal race; a dev-modeconsole.warnnames the vanished key).get(key)is a background peek at any key’s world (W5 attention rollups) that never creates an owner —undefinedwhen the key was never activated or is not a member.- Throws if called outside a reactive owner — it holds a
keyArrayof per-key roots that must be disposed with the app.
Keys are folded to their canonical wire string through client.codec (below), not
compared by ===: two logically-equal keys from independent decodes need not be
reference-equal.
// Per-host CLIENT state whose lifetime is `entries` MEMBERSHIP — the retained
// dual of `useEntry`'s dispose-on-switch. An owner is built LAZILY on a key's
// first activation, RETAINED across every switch-away, and DISPOSED the instant
// its key leaves `entries` (a removed-then-re-added host is a FRESH member).
const scopes = scopedByEntry(app, activeHost, (host, ctx) => ({
tiles: new Map<number, string>(), // this host's OWN state — plain, per-host
focusedPid: createSignal<number | null>(null),
isFocused: ctx.isActive, // active-only discipline lives INSIDE the owner
label: host, // the key is in scope for whatever the owner builds
}));
scopes.active(); // the ACTIVE host's world: `T | undefined` (null / vanished)
scopes.get("web-01"); // a background peek at ANY key — never CREATES an owner
EntryStatus<Cause> — the projected per-entry status
type EntryStatus<Cause extends string = string> =
| { kind: "warming" }
| { kind: "connected"; clockOffset: number } // the serving process's own-clock offset
| { kind: "failed"; reason: string; cause: Cause };
- Absence from
entriesis “not a member”. There is noabsentarm — a collection already expresses absence by not carrying the key. Client reads stay total via an explicit{ kind: "not-a-member" }value; the wire never carries it. - The projection contract. Status is a projection of the resolved session’s state, never a second writer:
copying/connecting→warming;connected→connected; adisconnectedcarrying a standing domain cause, or a terminalfailed, →failed; a plain transientdisconnected(no specific cause) →warming.warmingmeans in motion (self-heals — an unreachable box retrying at backoff is warming, not failed);failedmeans it needs intervention. Causeis a type parameter. It defaults tostringso existing consumers keep compiling; a domain instantiatesEntryStatus<PadiEntryFailedCause>at its own map so.causenarrows there. The package carries the discriminant, never the enumeration.- The
failedarm is a loose object (cause: z.string()), so domain extras (a typedrunning/expectedversion pair on a contract-skew cause) ride through untouched. reasonis a human string, never parsed for control flow;kindis the outer discriminant;causeis a sibling field on the samefailedarm, not a secondkind.EntryState<Cause>=EntryStatus<Cause> | { kind: "not-a-member" }lives in the solid-free contract module, so a node consumer re-exports it type-only.
MapRegistry<K> — the server seam
interface MapRegistry<K> {
members(): K[];
subscribe(onChange: () => void): () => void; // fires only AFTER members()/has() reflect the change
has(key: K): boolean;
resolve(key: K): EntrySession | EntryFault; // a kind-tagged sum, provably disjoint
}
- The registry is the one writer of membership;
entriesis that truth published; status is derived from each resolved session’s state. - A call carries its key in every frame: an unknown key is a typed rejection (unary) or an immediate typed end (stream). A key that leaves membership mid-stream ends its subs with a typed
{ reason: "removed" }before the session is destroyed — so no socket-error frame follows a typed end. resolvereturnsEntrySession({ link, state }) orEntryFault({ failed });members()andhas()answer from one consistent snapshot.
The fold envelope
Every procedure folds as { mapKey, input } — one wire shape for any input.
- Field constants:
MAP_KEY_FIELD = "mapKey",INPUT_FIELD = "input". An entry input that itself carries amapKeyfield cannot collide with the folded key — it rides nested underinput. - Void-input rule. A void-input member carries no
inputfield at all —{ mapKey }, not{ mapKey, input: undefined }. Relying on JSON dropping anundefinedvalue and on the validator accepting the missing key is fragile (zod tightenedz.object({ input: z.void() })to reject a missing key); omitting the field makes “void = no input key” the one representation on both encode and validate. - Codec functions
fold(mapKey, input)/unfoldInput(wire)/unfoldKeyField(wire)reference these constants, so the envelope shape lives in exactly one place. Misroute-by-collision is unconstructible, not merely unlikely.
KeyCodec<K>
interface KeyCodec<K> { encode(key: K): string; decode(wire: string): K; }
Bridges Key to the canonical wire string every channel name, dedup key, and
membership entry is keyed on. For a plain-string key it is the identity pair;
kolu’s HostKey passes its own encode/decode. decode is paired with
keySchema.parse and need not validate on its own; encode is a bare cheap call
on the hottest paths (per-key cache lookup, membership fold, per-tick republish).
The server re-derives and re-validates real K from the wire string via
codec.decode then keySchema.parse.
The connected SurfaceMapClient re-exposes this codec as its codec member —
the one key-identity authority on the client. scopedByEntry and any consumer
keying its own per-entry structure off membership fold a key through it rather than
trusting === reference identity, which the client cannot guarantee across
independent decodes of the same logical key.
// `codec` — the ONE key-identity authority: the canonical wire string every
// channel name, dedup key, and membership entry is keyed on. `scopedByEntry`
// folds each key through it rather than trusting `===` reference identity.
const wire: string = app.codec.encode("web-01"); // K → wire string
const key: string = app.codec.decode(wire); // wire string → K
Typed sub ends and the clock seam
entry(key).rpcis the entry surface’s procedure client; the key-injecting link folds{ mapKey }into every call. TypedSurfaceClient<ES>["rpc"](unknownat the generic map — a consumer casts it once) to sidestep the “union too complex” a generic expansion trips. An absent-key call is a typed rejection (MAP_KEY_UNKNOWN), the one-shot twin of a sub’s typed stream-end.entry(key).clock.toLocal(remoteMs)reprojects a host-stamped timestamp into the local clock via the entry’s measured offset:remoteMs − offset. It returnsnull(never a silent identity) when the entry has no offset yet (warming/failed/not-a-member); thenumber | nulltype forces the caller to render a pending “—” rather than fall back to the raw remote value. It reads only.kind, never.cause, and folds the membership collection so it re-answers as the entry connects.
For why the wire is shaped this way, see Entry contracts; to serve a map, see How to serve a map.