kolu
Docs

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

  1. Get the starting point and install its dependencies.

    cd packages/surface/example/fleet-top/part-1
    pnpm install

    It 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.