@kolu/surface reference
The core package. You declare a surface with defineSurface, serve it with
implementSurface, and consume it with surfaceClient. Everything below is
imported from a @kolu/surface/* subpath.
Defining a surface
defineSurface(spec) declares the whole reactive surface of an app at one site
and returns a Surface<S>. The contract is a compile-time literal built from
the spec — the framework does not reflect it at runtime, because the typed
client needs the literal at compile time.
export const surface = defineSurface({
cells: { load: { schema: Load, default: ZERO } },
collections: { processes: { keySchema: Pid, schema: Proc } },
streams: { nodeLog: { inputSchema: NodeId, outputSchema: LogFrame } },
events: { autosave: { inputSchema: DocId, outputSchema: SavedAt } },
procedures: { proc: { kill: { input: KillArgs, output: Killed } } },
});
A surface has five member kinds. The first four are the reactive primitives;
procedures is the imperative escape hatch.
| Member | Question it answers | Cardinality | Server sends | Client-mutable | Current value |
|---|---|---|---|---|---|
cells |
“What’s the current X?” | one singleton | snapshot, then deltas | yes | yes |
collections |
“current X for each key?” | many, keyed | per-key snapshot, then deltas | yes | yes (per key) |
streams |
“live output for input I?” | one per input | snapshot, then deltas | no | yes |
events |
“has X happened?” | occurrences | occurrences, no snapshot | no | no (handler-based) |
procedures |
“do this now” | per call | a return value | — | — |
Cells, collections, and streams are state — a current value to render. An event is an occurrence — a handler fires per yield and there is no current value. Anything genuinely outside these shapes (bidirectional binary streams, commands) stays as raw oRPC. For why these four and not one, see Why surfaces.
The Surface<S> value carries surface.contract (the typed oRPC router that
replaces a hand-written oc.router), surface.descriptors, and surface.spec.
Read member types back with SurfaceTypes<typeof surface.spec>.
Every surface also carries two framework-reserved procedures,
surface.system.live and surface.system.identity, both injected by
defineSurface and auto-answered by the server — no app declares or implements
them (see Liveness).
To key several standalone surfaces under one namespace over one transport, use
composeSurfaceContracts({ <key>: surface }).
Serving a surface
implementSurface(surface, deps) wires every handler and returns a router
fragment plus a ctx for typed mutations. Persistence and pub/sub are supplied
as dependencies.
const fragment = implementSurface(surface, {
channel: inMemoryChannelByName(),
cells: { load: { store: inMemoryStore(ZERO) } },
collections: { processes: { readAll, upsert, remove } },
streams: { nodeLog: { source } },
procedures: {
proc: {
kill: async ({ input, ctx }) => {
ctx.collections.processes.remove(input.pid);
return { ok: true };
},
},
},
});
Dependency shapes, per member:
cells: { <k>: { store, patch?, connect? } }—storeis aCellStore<T>.collections: { <k>: { readAll, upsert, remove } }— persistence only; the surface wraps publish.streams: { <k>: { source } }— or a declarative poll form{ read, install, isEqual }. Supplying both is a type error.procedures: { <ns>: { <verb>: async ({ input, ctx, signal }) => … } }— mutate throughctx.cells.X.set(…),ctx.collections.X.upsert(k, v),ctx.events.X.publish(input, payload)so the apply-and-publish chain stays single.
Stores and channels:
| Adapter | Import | Purpose |
|---|---|---|
inMemoryStore<T>(initial) |
@kolu/surface/server |
in-memory cell store |
confStore<T>(conf, key) |
@kolu/surface/server |
conf-backed persistent cell |
inMemoryChannel<T>() |
@kolu/surface/server |
single-process broadcast |
inMemoryChannelByName() |
@kolu/surface/server |
the channel factory form |
publisherChannel<T>(publisher, name) |
@kolu/surface/server |
@orpc/experimental-publisher adapter |
implementSurfaces(surfaces, framework, deps) serves N surfaces over one
transport, keyed the same as the map.
Consuming in SolidJS
surfaceClient(surface, link) returns a client with a bound accessor per
member. It replaces the source / mutate / keyToInput refs you would
otherwise thread at every hook.
const app = surfaceClient(surface, link);
const load = app.cells.load.use({ authority: "server" }); // Accessor<Load>
const procs = app.collections.processes.use(); // .byKey(id) / .keys()
const log = app.streams.nodeLog.use(() => nodeId, { onError }); // .pending() / .error()
app.events.autosave.use(() => docId, handler, { onError }); // returns nothing
await app.rpc.surface.proc.kill({ pid });
| Member | Hook | Returns |
|---|---|---|
| cell | .cells.X.use({ authority, initial, applyPatch, onError, coalesceMs }) |
an accessor; mutate with .set(v) / .patch(p) |
| collection | .collections.X.use({ keys?, onError? }) |
.byKey(id)?.(), .keys(); mutate with .upsert(k,v) / .delete(k) |
| stream | .streams.X.use(inputFn, { onError? }) |
an accessor plus .pending() and .error() |
| event | .events.X.use(inputFn, handler, { onError?, signal? }) |
nothing (no current value) |
| procedure | .rpc.surface.<ns>.<verb>(input) |
the typed result |
A cell’s authority is "server" (default — every push reconciles) or
"local" (the local store wins after the first server yield). An inputFn
that returns null pauses a stream or event. surfaceClients(link, map) splits
one combined transport into a sibling client per surface.
Links
A link maps a way of reaching the served contract to a typed client. Pick one per transport; see How to choose a link.
| Link | Import | Reaches |
|---|---|---|
websocketLink(ws) |
@kolu/surface/links/websocket |
a server over a WebSocket (the browser path) |
stdioLink({ read, write }) |
@kolu/surface/links/stdio |
a subprocess or ssh stdio pair |
unixSocketLink({ socketPath }) |
@kolu/surface/links/unix-socket |
a daemon on the same machine (async; it dials) |
directLink(router) |
@kolu/surface/links/direct |
the in-process handlers, no wire (the identity element) |
Matching serve-side entry points: serveOverStdio, serveOverUnixSocket, and
oRPC’s RPCHandler (.upgrade(ws)) for browsers.
Liveness and health
Liveness is on by construction. surface.system.live (@kolu/surface/liveness)
is a contract-agnostic round-trip a client watchdog calls to tell a live link
from a silently half-open one — no app nominates its own probe. probeSurfaceLive(client)
is the one-liner that calls it; the socket and ssh seams default their watchdog
to it.
client.health() returns a fact, not a verdict:
{ live: boolean, subs: [{ name, pending, error }] }. live is the full
conjunction of transport-live and every readiness predicate; a subscription
error stays in subs and is never folded into live. This is what lets the
UI stay honest — see Reactive honesty.
Cross-cutting invariants
- Snapshot-then-deltas. The first frame of every stream is a fresh full snapshot; every server helper enforces it, because the retry plugin re-invokes the source on each reconnect and a delta-first frame would silently lose state.
patchis a cross-runtime contract. A cell’spatch(current, p)runs on both the server and the client. Treat any change to it as a wire-format change and redeploy both sides together.- A raw stream joins health through
client.rawStream/client.enrollso it can’t escapehealth().unenrolledStreamCall(@kolu/surface/client) carries the reconnect context for non-descriptor shapes. - The 2-file invariant. Adding a member is one spec entry plus one wiring entry. If the count creeps up, something is wrong.