tutorial
Your first surface.
We are going to build fleet-top — a live top for your own machine. By the
end of this page it will run in your browser: load averages and memory in the
header, a table of processes that updates on its own, and a button that kills a
process. No polling code, no reconnect handling, no hand-written RPC — you
declare the shape of the live state once and read it.
We will get there in two hops. First we consume the surface in the same process, so you see it work with no network at all. Then we change one line and the same code is talking to a WebSocket. That is the whole point of a surface, and you will feel it by the end.
Set up
-
Get the starting point and install its dependencies.
cd packages/surface/example/fleet-top/part-1 pnpm installIt is a bare Vite + Solid project — no framework shell, just enough to serve a page and a WebSocket.
Declare the surface
A surface is a typed, reactive slice of live state, declared once with
defineSurface. Ours has four members: two cells (load and memory — single
values), one collection (the processes, keyed by pid), and one procedure
(kill — the single mutation).
/**
* fleet-top part 1 — the surface, declared once.
*
* A live `top` for one machine. Four primitives carry the whole tool:
*
* - `load` — Cell: the 1/5/15-minute load averages (a singleton).
* - `memory` — Cell: bytes used / total (a singleton).
* - `processes` — Collection keyed by pid: one row per process.
* - `process.kill` — Procedure: the one mutation (send a signal to a pid).
*
* `defineSurface` turns this spec into `surface.contract` (the oRPC router the
* server implements and the client consumes) plus `surface.descriptors` /
* `surface.spec` for reflection. Nothing else in the app re-declares these
* shapes — the inferred domain types at the bottom are the single source of
* truth (`SurfaceTypes` lifts them straight out of the spec).
*/
import { defineSurface, type SurfaceTypes } from "@kolu/surface/define";
import { z } from "zod";
// ── Named schemas — referenced from more than one position ──────────────
const PidSchema = z.number().int().nonnegative();
const ProcessSchema = z.object({
/** Owning user (name on darwin/ps, uid-derived on linux). */
user: z.string(),
/** Percent of one core during the last poll window. */
cpuPct: z.number(),
/** Resident memory as a percent of total. */
memPct: z.number(),
/** The command line (truncated). */
command: z.string(),
});
const LoadSchema = z.object({
/** 1-minute, 5-minute, 15-minute load averages. */
avg: z.tuple([z.number(), z.number(), z.number()]),
/** Logical CPU count — the "100% == this many busy cores" denominator. */
cores: z.number().int().nonnegative(),
});
const MemorySchema = z.object({
/** Bytes in use (total − available). */
used: z.number(),
/** Total physical memory in bytes. */
total: z.number(),
});
export const DEFAULT_LOAD: z.infer<typeof LoadSchema> = {
avg: [0, 0, 0],
cores: 0,
};
export const DEFAULT_MEMORY: z.infer<typeof MemorySchema> = {
used: 0,
total: 0,
};
// ── The surface ─────────────────────────────────────────────────────────
export const surface = defineSurface({
cells: {
load: { schema: LoadSchema, default: DEFAULT_LOAD },
memory: { schema: MemorySchema, default: DEFAULT_MEMORY },
},
collections: {
processes: { keySchema: PidSchema, schema: ProcessSchema },
},
procedures: {
process: {
// Imperative escape hatch: killing a pid is a command, not a keyed
// upsert, so it doesn't fit the collection's mutation verbs.
kill: {
input: z.object({
pid: PidSchema,
signal: z.enum(["TERM", "KILL", "HUP", "INT"]).default("TERM"),
}),
output: z.object({ ok: z.boolean() }),
},
},
},
});
// ── Inferred domain types — single source of truth ──────────────────────
type SF = SurfaceTypes<typeof surface.spec>;
export type Pid = SF["collections"]["processes"]["Key"];
export type Process = SF["collections"]["processes"]["Value"];
export type Load = SF["cells"]["load"]["Value"];
export type Memory = SF["cells"]["memory"]["Value"];
That one declaration is the contract. From it the framework derives the server router and the client hooks — you never hand-write either. (For why there are four member kinds and not one, see Why surfaces; you do not need it to finish here.)
Fill in the data
The surface says what the state is; ordinary code says where it comes from.
src/server/top.ts starts a poll loop that reads the OS every second and writes
the cells and the collection; src/server/proc.ts is the platform-specific bit
that reads /proc on Linux or ps on macOS. Nothing framework-shaped is
happening in there — it is just a timer that calls set and upsert. Open them
if you are curious; you do not have to change a line.
See it work — in one process
Before any network, the surface is consumable in the same process. directLink
takes the served router and hands you the exact client a WebSocket consumer would
get — every call runs the handler directly.
/**
* The first link: `directLink` — in-process, no wire.
*
* Before any socket, the surface is consumable *in the same process*. Feed the
* flattened router straight to `directLink` and you get the EXACT
* `ContractRouterClient<typeof surface.contract>` a WebSocket or ssh consumer
* would hold — byte-identical across a later transport swap. Every call invokes
* the handler directly (microtask-deferred); streams come back as async
* iterables, just as the wire links yield them.
*
* Run with `pnpm run inproc`. This is the honest "hello world" of the stack:
* define → implement → consume, with the transport collapsed to nothing.
*/
import { directLink } from "@kolu/surface/links/direct";
import type { surface } from "./common/surface";
import { createTop } from "./server/top";
/** A cell/collection `get`/`keys` verb yields snapshot-then-deltas as an async
* iterable (the same shape every wire link yields). In-process we only want the
* current value, so take the first frame — the snapshot. */
async function firstFrame<T>(
source: AsyncIterable<T> | Promise<AsyncIterable<T>>,
): Promise<T> {
for await (const frame of await source) return frame;
throw new Error("stream closed before its snapshot frame");
}
async function main(): Promise<void> {
const top = createTop();
top.start();
// `C` is load-bearing and must be passed explicitly — the router arg is typed
// loosely, so an omitted generic silently degrades every call to `any`.
const client = directLink<typeof surface.contract>(top.router);
// Give the first poll a moment to land, then read the cells + collection —
// each `get`/`keys` is a stream whose first frame is the current snapshot.
await new Promise((r) => setTimeout(r, 100));
const load = await firstFrame(client.surface.load.get({}));
const memory = await firstFrame(client.surface.memory.get({}));
const pids = await firstFrame(client.surface.processes.keys({}));
process.stdout.write(
`load ${load.avg.join(" ")} over ${load.cores} cores · ` +
`mem ${(memory.used / 1e9).toFixed(1)}/${(memory.total / 1e9).toFixed(1)} GB · ` +
`${pids.length} processes\n`,
);
top.dispose();
}
main().catch((err) => {
process.stderr.write(`fatal: ${(err as Error).message}\n`);
process.exit(1);
});
Run it:
pnpm run inproc
You will see one line, printed from live readings of your machine:
load 0.42 0.38 0.31 over 8 cores · mem 9.2/16.0 GB · 431 processes
That is a working surface — defined, served, and consumed — with the transport
collapsed to nothing. Notice firstFrame: a cell read is a stream whose first
frame is the current snapshot, so we take that first frame. That is not an
in-process quirk; every link yields the same snapshot-then-deltas shape, which is
what keeps a reconnect honest (see Reactive honesty).
Put it on a WebSocket
Now the same surface over the wire. src/server/main.ts serves the same
createTop() engine — the same router — over a WebSocket for streaming
subscriptions and HTTP for one-shot calls. On the client, the only thing that
changes from the in-process version is the link: createLiveSignal builds the
WebSocket link and wires the half-open heartbeat, then surfaceClient binds the
hooks.
/// <reference types="vite/client" />
/**
* Client surface bundle — `surfaceClient` over a live WebSocket.
*
* `createLiveSignal` builds the oRPC link over `ws` AND wires the half-open
* heartbeat (a websocket can stay `open` while no bytes flow — the
* green-over-a-dead-link lie), bundling both into the watchdog-backed handle
* `surfaceClient` requires. It lives in `@kolu/surface`, so this part needs no
* `@kolu/surface-app` dependency.
*
* The result — `app.cells.load`, `app.cells.memory`, `app.collections.processes`,
* `app.rpc.surface.process.kill` — is the exact shape `directLink` gave us
* in-process (`inproc.ts`); only the transport changed.
*/
import { createLiveSignal, surfaceClient } from "@kolu/surface/solid";
import { WebSocket as PartySocket } from "partysocket";
import { surface } from "../common/surface";
const wsUrl = `${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/rpc/ws`;
export const ws = new PartySocket(wsUrl);
const transport = createLiveSignal<typeof surface.contract>(ws, {});
if (import.meta.hot) {
import.meta.hot.dispose(() => {
transport.dispose();
ws.close();
});
}
export const app = surfaceClient(surface, transport);
The result — app.cells.load, app.collections.processes,
app.rpc.surface.process.kill — is the exact shape directLink gave us
in-process. Only the transport changed.
Render it live
In the Solid component, each member becomes a bound .use() hook. There is no
subscription lifecycle to manage: when a process appears or a load average
changes, only the affected row re-renders.
const load = app.cells.load.use();
const memory = app.cells.memory.use();
const processes = app.collections.processes.use({
onError: (err) => console.error("processes subscription failed", err),
});
// Busiest first — sort the live key set by the current cpu reading.
const rows = createMemo<Pid[]>(() =>
[...processes.keys()].sort(
(a, b) =>
(processes.byKey(b)?.()?.cpuPct ?? 0) -
(processes.byKey(a)?.()?.cpuPct ?? 0),
),
);
const kill = async (pid: Pid): Promise<void> => {
await app.rpc.surface.process.kill({ pid, signal: "TERM" });
};
The kill handler is a plain procedure call. The rest of App.tsx is ordinary
Solid markup — a header and a table — reading load.value(),
processes.byKey(pid)?.(), and so on.
Watch it move
Start the server and the client together:
pnpm run dev
Open http://localhost:5175. You will see your machine’s load and memory in the header and a live process table, busiest first, updating every second on its own. Find a process you own and click kill — the row vanishes as the next snapshot lands. You did not write a refresh; the surface pushed the change.
Next
Right now fleet-top dies when you close the terminal. In Make it a daemon you will turn it into a long-lived process that survives, supervises itself, and can be upgraded live while your browser stays connected.