# nice-code — full documentation Transport-agnostic typed actions, serializable errors, and reactive state for TypeScript. This file concatenates every page of https://nicecode.io so you can feed the library's entire API surface into a local AI coding assistant for accurate context. Using only one package? Point your agent at the per-module file instead: - /llms-action.txt — @nice-code/action - /llms-realm.txt — @nice-code/realm - /llms-error.txt — @nice-code/error - /llms-state.txt — @nice-code/state - /llms-util.txt — @nice-code/util - /llms-common-errors.txt — @nice-code/common-errors Generated from src/content/docs — do not edit by hand. --- # Validation & Hono Source: /common-errors/overview Description: Shared Standard Schema validation errors and drop-in Hono middleware. `@nice-code/common-errors` gives you a ready-made error domain for [Standard Schema](https://github.com/standard-schema/standard-schema) validation failures, plus Hono middleware that throws it for you. ```bash bun add @nice-code/common-errors ``` Peer deps: `valibot` (or any Standard Schema library), `hono` (for the `/hono` subpath). ## The validation error domain `err_validation` is a [`@nice-code/error`](/nice-error/domains/) domain. Import it to match or inspect validation errors by domain. It exposes one id — `EValidator.standard_schema` — with context `{ issues }`. ```ts import { err_validation, EValidator } from "@nice-code/common-errors"; if (err_validation.isExact(caught)) { const hydrated = err_validation.hydrate(caught); const { issues } = hydrated.getContext(EValidator.standard_schema); // issues: readonly StandardSchemaV1.Issue[] } ``` ## Hono middleware Import from the `/hono` subpath. ### `niceSValidator(target, schema)` A drop-in replacement for `@hono/standard-validator`'s `sValidator`. When validation fails it throws a `NiceError` instead of returning a 400 — so all your error handling goes through one place. ```ts import { niceSValidator } from "@nice-code/common-errors/hono"; import * as v from "valibot"; const CreateUserSchema = v.object({ name: v.string(), email: v.pipe(v.string(), v.email()), }); app.post("/users", niceSValidator("json", CreateUserSchema), async (c) => { const body = c.req.valid("json"); // fully typed // ... }); ``` Convert the thrown error to a response in Hono's `onError`: ```ts import { castNiceError } from "@nice-code/error"; import type { ContentfulStatusCode } from "hono/utils/http-status"; app.onError((err, c) => { const niceError = castNiceError(err); // `httpStatusCode` is a plain `number`; Hono's `c.json` only accepts its restricted // status-code union, so the cast bridges the two. The value is a real HTTP code at runtime. return c.json(niceError.toJsonObject(), niceError.httpStatusCode as ContentfulStatusCode); }); ``` If something that *isn't* a `NiceError` crashes the request — a dropped DB connection, a thrown string, a bug deep in a dependency — you don't need a separate 500 branch. `castNiceError` wraps any non-`NiceError` value in a generic `NiceError` flagged `isUnhandled: true`, with a default `httpStatusCode` of `500`. So the single `onError` above already returns a well-formed JSON error with a sensible status for *every* failure path; the `isUnhandled` flag is what lets you tell a declared, expected error apart from an unexpected crash (e.g. to log it differently or hide its message in production). ### `niceCatchSValidation()` Catches the raw responses `@hono/standard-validator` sends back (its default `{ success: false, error: [...] }` shape) and turns them into `NiceError` JSON responses — useful when you can't swap `sValidator` out yourself. ```ts import { niceCatchSValidation } from "@nice-code/common-errors/hono"; app.use(niceCatchSValidation()); // Existing sValidator usage is now intercepted automatically app.post("/data", sValidator("json", MySchema), handler); ``` ## Full example ```ts import { Hono } from "hono"; import type { ContentfulStatusCode } from "hono/utils/http-status"; import { niceSValidator } from "@nice-code/common-errors/hono"; import { castNiceError } from "@nice-code/error"; import * as v from "valibot"; const app = new Hono(); app.onError((err, c) => { const niceError = castNiceError(err); return c.json(niceError.toJsonObject(), niceError.httpStatusCode as ContentfulStatusCode); }); const BodySchema = v.object({ message: v.string() }); app.post("/echo", niceSValidator("json", BodySchema), async (c) => { const { message } = c.req.valid("json"); return c.json({ echo: message }); }); ``` --- # Observing Backends Source: /devtools/backends Description: Attach the devtools window to a server — including a Cloudflare Durable Object — and read per-client wire traffic: which client costs what, in true billable bytes. A frontend's devtools tell you what your app sent. They cannot tell you what your **server** paid for it. This page is about the other end of the wire: getting a backend into the devtools window, and reading its traffic *per connected client*, because that is the unit your bill is denominated in. ## Two lenses, and why they disagree A backend reports the same traffic twice, measured at different layers. Both are correct. They are labelled in the UI so the difference never reads as a bug. | Lens | Where it measures | What it counts | | --- | --- | --- | | **wire · post-encryption bytes** (Traffic sidebar) | the carrier | Every frame on every lane (`handshake`, `keepalive`, `action`, `realm`, `http`), sized *after* encryption — what the platform transmits and bills. | | **engine · decoded payload** (Server view, per realm) | inside the realm engine | The realm lane only, sized *before* the crypto envelope — plus egress in **messages** (the per-message billing unit) vs frames, and the coalesce ratio batching won you. | The wire lens is always larger: it carries the envelope the engine never sees. If you are optimizing *cost*, read egress messages from the engine lens and egress bytes from the wire lens. ## Per-client attribution Every tapped frame carries a `linkId` — the coordinate of whoever is on the other end. On a server that is the connected client; on a client it is the backend it dialed. So the same **clients** table appears on both sides of a wire, answering the same question from each end: ``` CLIENTS · LAST 5M client in in bytes out out bytes envId[web_app]…insId[i1] 412 21.1 KB 418 44.9 KB envId[web_app]…insId[k9] 91 4.8 KB 94 9.7 KB (handshaking) 8 3.2 KB 8 2.6 KB total 511 29.1 KB 520 57.2 KB ``` Two reserved rows keep the arithmetic honest, so the table always sums to the headline totals: - **`(handshaking)`** — frames tapped before a peer identity existed. A server cannot know *who* is connecting until the handshake binds them, so those bytes are real but unattributable. (A client stamps its handshake, because it knows its peer before it sends the first byte.) - **`(other links)`** — links past `maxLinks` (default 200), folded together. A public server must not grow one counter per socket it has ever seen. Nothing here captures payload contents. Sizes and counts only, by design. ## Identity, derived Every producer stamps an identity envelope on its frames. All of it comes off the runtime coordinate you already hand over, and none of it is worth restating: | Envelope field | Where it comes from | Why | | --- | --- | --- | | `coordEnvId` | `runtime.coordinate.envId` | The identity a client names as its `peer`. The topology joins each frontend's realm wire to the backend advertising it — a realm-id join alone is ambiguous when two backends host the same realm. | | `clientId` | **frontend**: `insId` · **server**: `envId::perId` · **DO**: `do_` | Frontends key by *instance* (two tabs are two clients). Backends key by *identity*, so a restarted dev server **replaces** its entry rather than leaving a ghost beside a fresh one. A DO's persistent id survives hibernation wakes. | | `label` | `envId`, plus an elided `perId` | The switcher and the topology band read like the coordinates do. Override with `name`. | | `stage` → `env` | you, explicitly | Deployment stage is *where it runs*; `envId` is *which runtime it is*. They change for different reasons, so neither can be derived from the other. | That asymmetry between `insId` and `perId` is not a devtools invention — it is the distinction `RuntimeCoordinate` already draws, reused rather than re-stated. ## Which backends can serve several topologies | Producer | How it reaches a window | Topologies | | --- | --- | --- | | Web app | `BroadcastChannel` + a relay room | **Exactly one.** A frontend *is* one app. | | Long-lived server | dials **out**, one relay room per socket | **Many** — pass `topologyId: [...]`. | | Durable Object | the window dials **in** | **Many, with zero configuration.** It has no `topologyId`; whoever dials in observes it. | ## Attaching a backend ### A long-lived server (Bun, Node, …) It dials the relay itself and appears in the switcher as its own client. There is nothing to configure: the relay lives on a well-known port, and a backend simply looks there. ```ts title="backend/devtools.ts" import { createNiceServerDevtools } from "@nice-code/devtools/server"; import { DEVTOOLS_TOPOLOGY } from "my-app-shared"; export const devtools = createNiceServerDevtools({ topologyId: DEVTOOLS_TOPOLOGY, // the relay room — the same id your frontend passes runtime, // identity: label, clientId and coordEnvId all derived realms: { match: engine }, // one scope per realm engine domains: [act_root], // the ROOT action domains to observe }); serveChannel(runtime, channel, { storage, carriers, wireTap: devtools.wireTap }); ``` That is the whole setup: one call, the exact mirror of the frontend's `createNiceDevtools()`, and the same three seams behind it. :::note[What you no longer pass] `coordEnvId` — the identity a client names as its `peer`, which the topology joins each realm's wire against — **is** `runtime.coordinate.envId`. So is the label, and so is the `clientId`. Handing the runtime over once is enough; see [Identity, derived](#identity-derived) below. ::: The `relay` sink defaults to `ws://localhost:5199` — where [`niceDevtools()`](/devtools/window/#crossing-browser-partitions) starts a relay, and where `bunx @nice-code/devtools-relay` listens. A backend usually boots *before* anyone opens devtools, so its first dials are refused; it keeps retrying quietly on a backoff and joins the moment a relay appears, with no restart and no line in your log. Pass `relayPort` when the relay is on another port, or `relayUrl` when it is on another machine. A **deployed** backend must always name its own relay — `localhost` means nothing there — though the whole thing is inert under `NODE_ENV === "production"` regardless. `createServerDevtoolsHost` from `@nice-code/devtools-core` is the seam underneath, if you need to compose scopes the two records don't cover. ### One backend, several apps A service shared by a web app and an admin console belongs to **both** devtools topologies. Name them all: ```ts createNiceServerDevtools({ topologyId: ["web-app", "admin-app"], runtime, /* … */ }); ``` It joins both relay rooms and appears in both windows. The two apps' *frontends* stay invisible to each other — a backend is a room member, not a router — and a `clear` issued from one window is addressed to one client, so it cannot disturb the other. Costs one WebSocket per topology, to the same local relay. ### A Cloudflare Durable Object A DO is different, and the difference is not cosmetic. **Its entire cost model is hibernation.** A dial-out relay sink would hold an outbound socket open, pinning the DO awake — billing you for the privilege of measuring its idle cost, and reporting numbers from a DO that never sleeps. So the direction is inverted: **the window dials in** — a plain wire connection to the DO's **ordinary WebSocket endpoint** (the same one its clients use), where devtools rides the wire mux as its own token-gated protocol. No separate dev route, no socket tagging: the DO's forwards stay exactly as they were, and a hibernation wake needs no re-adoption (the protocol re-admits a surviving socket lazily, on its next frame). ```ts title="MatchDO.ts" import { createNiceDurableObjectDevtools } from "@nice-code/devtools/server"; import { serveDurableObject } from "@nice-code/action/platform/cloudflare"; constructor(ctx: DurableObjectState) { super(ctx, env); const devtools = createNiceDurableObjectDevtools(ctx, { runtime, // identity: label, clientId and coordEnvId all derived stage: "development", // required — see below token: env.DEVTOOLS_TOKEN, // unset ⇒ inert. No token, no protocol, no bridge, no cost. realms: { match: () => this.engine }, // a getter: resolved when a window attaches persistCounters: true, // optional: lifetime totals survive an eviction }); // Wires the wire tap and registers the devtools protocol beside the app's own. this.server = serveDurableObject(ctx, channel, { runtime, devtools }); } ``` A realm-only DO does the same on the wire host — `serveWireDurableObject(ctx, { identity, protocols, devtools })`, with `runtime: { coordinate: identity }` (the coordinate source is structural; no `ActionRuntime` needed). Note there is **no `topologyId`**, and no `token ? … : undefined` conditional. An absent token means *inert*, which is still fail-closed: an unset token can only ever close the door. And because the window dials *in*, any number of apps' windows may observe one shared DO with no configuration at all. `realms` takes a getter because a DO routinely wires its devtools before its engines exist. It is resolved on window attach, long after the constructor returned, so either construction order is correct. Point the window at the DO's WebSocket endpoint — from the **dev server**, which is the only place that can hold the token without publishing it: ```ts title="vite.config.ts" import { niceDevtools } from "@nice-code/devtools-vite"; export default defineConfig(({ mode }) => { // Empty prefix, so an un-prefixed name is visible here. A `VITE_`-prefixed token would be inlined // into the app's bundle, and a devtools endpoint that fails open is a data leak, not a dev tool. const env = loadEnv(mode, import.meta.dirname, ""); return { plugins: [ react(), niceDevtools({ backends: [{ url: "ws://localhost:8787/match/ws", token: env.DEVTOOLS_DO_TOKEN }], }), ], }; }); ``` The plugin injects it into the **devtools window page it serves**, and nowhere else. An app tab never receives it, and a production build never sees it — the plugin is `apply: "serve"`. A backend with no token is skipped rather than dialed. Without one the DO registers no devtools protocol at all, so a dial could never be admitted; staying away leaves it an honest "not reporting" node in the topology. The window's dial is owned by the wire client — reconnection is its keep-alive ladder, and every link-up (first attach, redial, or a re-admission after the DO wakes) triggers an immediate `hello`, so a rewoken backend re-attaches at once instead of waiting out the heartbeat. ### The hibernation caveat, stated plainly While a devtools window is attached, the bridge holds a heartbeat timer and the window's own heartbeat re-wakes an evicted DO. **Attached window ≈ DO awake.** That is inherent to observing a thing that only exists when observed, and it is why this is a dev tool. Detached, it costs exactly nothing: no timers, no sockets, no projection work. A started-but-unattached bridge arms **zero** intervals — a single pending timer would prevent hibernation forever. ### Counters across an eviction A DO's in-memory counters die with it. Two honest options: - **Default:** they restart, and the MAX view says so — `⟳ counters reset (host woke)`. Nothing pretends to be a lifetime total. - **`persistCounters: true`:** a compact rollup (lifetime totals + per-client totals, not the 1-second ring) is flushed when the last observer detaches and restored on wake. The rolling windows and the graph still restart — only the all-time numbers continue. Off by default: it is a storage write on a path that otherwise costs nothing, and traffic between the last detach and an eviction is not captured. ## Safety A server scope can be observed over a socket, so the defaults assume it will be: - **`stage` is required, and `"production"` disables everything.** workerd has no `NODE_ENV` to infer from, and a devtools endpoint that fails *open* is not a devtools endpoint. (An absent `token` is *also* fail-closed: it can only ever close the route, never open it — which is why the wrapper treats it as "devtools off" rather than a crash.) - **The token is compared in constant time**, and a missing token is a construction error, not a warning. - **Payload contents never leave the process by default.** `actionDomainScope` streams action id, domain, status, timings and error *shape* — not `input`, `output`, or error payloads. Pass `logPayloads: true` deliberately, in dev, when you need the values. This is a **localhost / `wrangler dev`** feature. Exposing it to a deployed environment needs short-lived authenticated sessions and a build-time strip, neither of which exists yet. ## Reading the topology Backends and clients are joined on the **peer** each client dials, not on a shared realm id — two backends may serve the same realm (a durable board on a DO beside an ephemeral one on a plain server), and guessing draws the traffic on the wrong wire. A backend this window is not hearing from appears as a **dashed placeholder node**. It is not a ghost: the flow animating beside its wire is measured at the client, so those bytes are real. Click it and it tells you which of two things is true — the badge says so too: - **`backend · not reporting`** — never heard from. Either no devtools host is attached to it, or it is a **per-instance** Durable Object (one per game, match, room) whose endpoint this window was never told to dial, because only the instances named in `backends` are dialed. Attach a host, or dial the instance, and it becomes a selectable backend with its own Server view. - **`backend · went quiet`** — it *was* reporting and stopped. Either the backend went down, or the devtools window sat in the background long enough for its bridge to detach. The second case heals itself: bring the window to the front and the backend returns within a second. That second state is worth understanding, because it is normal and it is not a bug. A producer's bridge stops projecting after 45s without a `hello` from any window — that detachment is exactly what lets a Durable Object hibernate instead of billing you to watch itself idle. A window whose page is hidden has its timers throttled by the browser to roughly once a minute, so its heartbeat cannot hold the bridge open, and after 30s of silence the window forgets the producer. Neither side is wrong. Recovery is an **event**, not a timeout: the window re-`hello`s the instant its page becomes visible again, and every bridge re-attaches and replays over the socket it never closed. Flow lanes run *beside* each wire — green marching toward the backend is the client's egress, blue coming back is its ingress — with width, opacity and speed scaling logarithmically with the rate, so a keepalive trickle and a cursor storm both read honestly. --- # The Devtools Window Source: /devtools/window Description: Pop the nice-code devtools out of your app into their own window — one window, many clients, a live topology of frontends and backends, wire traffic, and an event log that navigates. Docked panels fight your app for screen space and vanish on reload, so nice-code has none. Instead, your app **produces** a devtools stream, and a separate page **consumes** it. That one change buys a lot. The window survives app reloads. It mirrors **several clients at once** — two tabs, a private window, a phone on your LAN, and your *backend* — instead of one page's panels. And because the backend is just another client, the window can draw the **topology**: which frontends are talking to which servers, over which realms. ``` ┌─ topology ────────────────────────────────────────────────────────┐ │ CLIENTS SERVERS │ │ ● web_app · a3f9·b1 ──────── realm ─────────▶ 🖥 match-api │ │ ◐ web_app · c2e1·d4 ──────── realm ─────────▶ demo_board · 3 │ └───────────────────────────────────────────────────────────────────┘ ┌────┬──────────────────────────────────┬───────────────────────────┐ │ ⚡ │ │ traffic · all wire │ │ 🧩 │ the selected tool │───────────────────────────│ │ 🌐 │ │ event log │ │ 🖥 │ │ │ └────┴──────────────────────────────────┴───────────────────────────┘ ``` ## Setup, in one call Install the devtools and the Vite plugin: ```sh bun add @nice-code/devtools bun add -d @nice-code/devtools-vite ``` Add the plugin to your Vite config. It serves the devtools window page and runs the relay: ```ts title="vite.config.ts" import { niceDevtools } from "@nice-code/devtools-vite"; export default defineConfig({ plugins: [react(), niceDevtools()] }); ``` Then wire your app's devtools in one place: ```ts title="src/devtools.ts" import { createNiceDevtools } from "@nice-code/devtools"; export const devtools = createNiceDevtools({ topologyId: "my-app", // names the devtools topology this app belongs to runtime: getRuntime, // an ActionRuntime, or a getter for one domains: [act_root], // the ROOT action domains to observe stores: { UserStore }, // the stores the State panel lists }); ``` That call builds the four cores — actions, state, realms, wire traffic — starts the bridge that streams them, and hands back the three seams your code plugs into: ```tsx connectChannel(runtime, channel, { wireTap: devtools.wireTap }); // per connection connectRealm(gameRealm, { devtools: devtools.realm("game") }); // per realm // once, anywhere ``` That is the whole setup. There is no `devtools.html` to commit, no window entry module, no second `rollupOptions.input`. :::note[What `createNiceDevtools` does in production] Nothing. `enabled` is `false`, no core is constructed, `wireTap` and `realm()` return `undefined`, and the launcher renders `null`. Passing `undefined` is what makes `connectRealm` and `connectChannel` skip the observation work entirely rather than measure frames into a sink. ::: ## The two halves **The producer** is your app. One host composes each module's neutral scope and streams them. `createNiceDevtools` assembles it for you; reach for the seam underneath only to contribute a scope the four cores don't cover: ```ts import { createClientDevtoolsHost, createBroadcastChannelTransport, trafficSampleScope } from "@nice-code/devtools-core"; import { actionBridgeScope } from "@nice-code/action/devtools/browser"; import { realmBridgeScope } from "@nice-code/realm/devtools/browser"; import { stateBridgeScope } from "@nice-code/state/devtools/browser"; createClientDevtoolsHost({ transport: createBroadcastChannelTransport("my-app"), clientId: runtime.coordinate.insId ?? "my-app", // a fresh id per page-load distinguishes tabs getLabel: () => runtime.coordinate.stringId, // read live: `perId` fills in after the handshake }) .contribute(actionBridgeScope(actionDevtools)) .contribute(stateBridgeScope(stateDevtools)) .contribute(realmBridgeScope(realmDevtools).snapshot) .contributeSample(trafficSampleScope("traffic", trafficDevtools)) .start(); ``` It is dev-gated, and **idle until a window attaches**: with nothing listening, the bridge subscribes to no scope and projects nothing. That matters more than it sounds — a realm's projected view is a structured clone per patch tick, and paying for it with no reader is exactly the cost this design refuses. **The consumer** is a second page on your app's origin — same origin is what lets its `BroadcastChannel` pair with your bridge — which never boots your app runtime. It builds the same transport, tracks every producer it hears, and renders the unchanged panels. The `niceDevtools()` plugin serves it at `/__nice_devtools/window`, and the launcher opens it with your topology id in the query string. That is how one plugin serves any number of apps, and why the page needs no per-app build step. If you are not using Vite, host the page yourself. It is one line, and the app points at it with `windowUrl`: ```ts title="devtools.html → devtools.ts" import { mountDevtoolsWindow } from "@nice-code/devtools/window"; mountDevtoolsWindow(); // reads the topology id from its own URL ``` ## Many clients, one window Every message a producer sends carries an identity envelope: `clientId`, a live `label` derived from its runtime coordinate, its `env`, and its `kind` (`frontend` / `backend`). The window lists each producer separately, and a command (`clear`, `pause`) is **addressed** to one client — so clearing one tab's action log leaves the other alone. Producers announce themselves on a heartbeat. A silent one greys out, then drops. Pick a client from the topology band (or the fallback `