# 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 - /llms-wire.txt — @nice-code/wire - /llms-process.txt — @nice-code/process - /llms-commander.txt — @nice-code/commander - /llms-devtools.txt — @nice-code/devtools 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** (staging/dev) backend doesn't use the local relay at all — it passes `session` instead, dialing an authenticated short-lived room on a deployed session relay; see [Remote Devtools Sessions](/devtools/remote-sessions/#a-long-lived-backend). 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 page is the **localhost / `wrangler dev`** case. The same dial-in mechanism reaches a *deployed* staging/dev backend too — over `wss://`, with the token in your deploy config — see [Inspecting Live Deployments](/devtools/remote-backends/). It stays inert under `stage: "production"` regardless. ## 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. --- # Vite-to-CLI Handoff Source: /devtools/cli-handoff Description: How a devtools click in a Vite-served app lands in a running nice-devtools CLI workspace — same-machine discovery, the announce handshake, relay matching, every stable fallback reason, and which side owns each opt-out. Run a Vite app with `niceDevtools()` and a standalone [`nice-devtools` CLI](/devtools/cli/) at the same time and you would normally get two parallel devtools: the app's own popped-out window, and the CLI's workspace for the same topology. Instead, the dev server **hands the click off**: it discovers the running CLI, registers the app's topology there, and the devtools button opens the CLI's workspace directly. One devtools surface, whichever way you started it. Nothing is required to turn this on. It works whenever both processes are running on the same machine, and every failure falls back to the app-local window — a devtools click always opens *something*, and never more than one window. ## How discovery works The CLI writes one **instance record** per process into `instances/.json` under the per-user nice-devtools data directory: - Windows: `%LOCALAPPDATA%\nice-devtools\instances\` - macOS: `~/Library/Application Support/nice-devtools/instances/` - Linux: `$XDG_STATE_HOME/nice-devtools/instances/`, then `~/.local/state/nice-devtools/instances/` Records are **ephemeral**: written only once the dashboard is actually listening (so a record never advertises a port the process failed to acquire), removed on clean shutdown, and scavenged by readers only when a probe fails *and* the recorded PID is dead — age alone never proves death, because PIDs are reused and machines sleep. The Vite dev server reads those records and **probe-verifies** each one against the CLI's credential-free loopback route `GET /api/instance`. Liveness is not identity: the probe must match the service marker (`nice-devtools-cli`), the discovery protocol version, and the canonical dashboard URL before an instance counts, so an unrelated server squatting the port is never mistaken for a CLI. With no records at all, the default dashboard port range (`5200` plus a small following range) is probed directly and the lowest compatible live port wins. When **several** CLI instances are live, the one whose local relay endpoint matches the app's wins, then the most recently started. At click time the dev server re-verifies the exact instance the launcher saw; if that instance died in the gap, one fresh selection is made rather than failing on a stale id. :::caution[Both processes must resolve the same data directory] Discovery is file-based. If either side sets `NICE_DEVTOOLS_DATA_DIR`, both must see the same value — a CLI writing records into one directory while the dev server reads another looks exactly like "no CLI running", and the click quietly stays on the app-local window. ::: ## The click, step by step 1. The app's devtools bridge polls the dev server's status route as part of ordinary relay discovery. For a **same-machine** browser that names its topology, the answer carries a `cliDevtools` snapshot: instance id, dashboard URL, whether announces are accepted, and the workspace link if one already exists. The launcher keeps the latest snapshot per topology; an absent or `present: false` field clears it, so a vanished CLI stops attracting handoffs. 2. On click, the launcher opens a **named placeholder window synchronously** — while the click's transient activation is unquestionably live — and only then asks the dev server to perform the handoff (`POST /__nice_devtools/handoff`). A slow or dead CLI can never trip the popup blocker or strand the click. 3. The dev server re-probes (the snapshot may be up to a few seconds stale), then makes an **add-only announce** to the CLI: `POST /api/announce-topology` with the topology id. The announce can create a `discovered` topology workspace and start the CLI's already-enabled relay; it never removes anything and never mutates a setting. 4. The CLI answers with a relative workspace href. The dev server joins it onto the **probe-verified loopback base** — the CLI cannot steer the launcher to an arbitrary URL — and the placeholder navigates there. 5. On *any* failure, the same placeholder becomes the ordinary app-local devtools window. Exactly one window per click, whatever happens. The browser itself never talks to the CLI: it cannot read instance records, and the CLI's cross-site guard refuses foreign-origin requests. Everything runs server-to-server between the dev server and the CLI, and the browser only ever receives a verified loopback URL. ## Relay matching A handoff is only correct when the app's producers and the CLI's workspace listen on the **same relay**. Both sides' endpoints are normalized (loopback aliases collapse to `127.0.0.1`, default ports made explicit) and compared as full endpoints, never bare ports. The app's side is its explicit `relayUrl` if it set one, otherwise the relay its own dev server advertises (default `ws://127.0.0.1:5199`); the CLI's side is the local relay it hosts or shares. A mismatch refuses the handoff — an app-local window that hears its producers beats a CLI workspace that never would. After a successful handoff the app's bridge **re-checks** relay status (a plain GET) so its carrier attaches to the relay the CLI just ensured — it deliberately does not *ensure* a relay of its own, which would race the dev server into becoming a second relay owner. ### The same-origin relay proxy The dev server also serves `/__nice_devtools/relay`, a same-origin WebSocket path that pipes to the loopback relay. A page's own origin is reachable however the page was opened — `localhost`, the machine's LAN IP, a phone — so the relay's *bind address* stops mattering to clients: a loopback-bound relay hosted by the CLI is still reachable from a LAN-opened page through its own dev server. Older devtools builds that predate the proxy would direct-dial and fail, so they are truthfully told no relay is running, with the remedy printed in the dev server's log. ## One window, not several - **Live-opener reuse.** The launcher window name is derived from the CLI instance and topology, so a repeat click finds the live workspace window and focuses it — never reloads it mid-investigation. - **An existing app-origin window keeps winning.** If a devtools window was open *before* the CLI started, clicks keep focusing it until it closes. A click never jumps origins mid-session; convergence happens on the next fresh one. - **Newest-window takeover.** A CLI workspace page claims a same-origin presence key per *workspace*; when a newer window claims the same key, older siblings of that workspace close themselves. The fresh window is the one the browser put in front — a buried window cannot self-raise — so it is always the survivor. Two different workspaces never displace each other. ## When the click stays on the app-local window Every refusal has a **stable reason**, logged once per reason in the dev server console (`devtools click stays on the app-local window ()`): | Reason | Meaning | | --- | --- | | `cli-gone` | No live, compatible CLI answered — not running, or its record pointed nowhere. | | `non-loopback-client` | The browser is on **another device**. A `127.0.0.1` dashboard URL would make it dial itself, so remote-device browsers always keep the Vite-served window. Judged by the TCP peer address, not the `Host` header — your own browser at the dev server's LAN URL still hands off. | | `lan-mode` | The CLI is running in LAN mode, which serves neither discovery nor announce. | | `announcements-disabled` | The CLI declines dev-server announces (`--no-dev-server-announcements` or the dashboard setting). | | `local-relay-disabled` | The CLI's local relay is explicitly disabled; the announce refuses rather than silently re-enabling it. | | `local-relay-unavailable` | The CLI accepted but could not bring its relay up (port conflict, bind failure). | | `protocol-mismatch` | The discovered CLI speaks an incompatible discovery protocol version — update one side. | | `endpoint-mismatch` | The app's relay endpoint and the CLI's don't match (see [relay matching](#relay-matching)). | ## Who owns each opt-out Three parties can decline a handoff, each on its own side: - **The dev server**: `niceDevtools({ handoffToCli: false })` — neither the status snapshot nor the handoff route is served, and the launcher behaves exactly as if no CLI existed. - **The CLI**: `--no-dev-server-announcements` (or the dashboard's *Accept local dev-server handoffs* setting) — discovery still answers, but every announce is refused with `announcements-disabled`. `--dev-server-announcements` turns it back on. - **The app**: an explicit `windowUrl` in `createNiceDevtools` is a deliberate window-host choice and is never handed off. --- # Standalone Devtools CLI Source: /devtools/cli Description: Open complete nice-code devtools without an app/Vite host, manage relay sessions, print producer URLs, and remember development credentials in one OS-level data store. `@nice-code/devtools-cli` is the preferred operator front door when an app's Vite server is not the right place to host the window. It ships the complete pre-built browser application and works under Node 22+ or Bun. (Running it *beside* a Vite app is fine too — the dev server discovers it and hands devtools clicks to its workspace; see [Vite-to-CLI Handoff](/devtools/cli-handoff/).) ```powershell $env:RELAY_ADMIN_SECRET = "" bunx @nice-code/devtools-cli ` --topology pixel-plaza ` --relay staging=https://staging.devtools-relay.example.com ` --producer-url staging=https://staging.example.com/ ``` The CLI opens `http://127.0.0.1:5200`. Each selected topology, deployed session, or direct backend is its own labelled workspace, so commands and clients cannot cross unrelated provenance boundaries. If the default port is occupied, a no-argument/default-port launch tries a small following range; an explicit `--port` fails unless `--fallback-port` is passed. ## The dashboard: two tasks Arguments are an initial configuration overlay, not a requirement for using the tool. Keep the dashboard at `/` open as the **Workspaces & settings** control plane, or return to it from the **⌂ Workspaces & settings** link in any workspace window's header — dashboard and windows are one product, and navigation runs both ways. The dashboard is organized around the two things you actually come to do: - **Local development** — observe apps running on this machine through the CLI's local relay. Add a topology workspace by its exact id; the card then reports the relay and its producers live (see [live status](#live-status-and-what-it-can-prove) below). Workspaces a Vite dev server registered through [handoff](/devtools/cli-handoff/) appear here labelled `discovered`. - **Remote observation** — watch a *deployed* app through an authenticated relay session, or dial a backend directly. Sessions live inside their relay profile's card. An argument-free first launch intentionally begins with no workspace and asks what you would like to observe, with both paths one click away. Later launches restore only the workspaces selected previously. Every add/edit/remove is a guided dialog — no browser prompts — and applies live, isolated by workspace provenance. Removing a workspace only stops observing it; **forget** removes its local credential; **close and forget** also closes a deployed relay session. ### Connecting a deployed relay: mint or import The guided flow asks for the relay URL first and **probes it** before anything is saved: the CLI fetches `/health` server-side and checks that the answer identifies a nice-devtools relay. A URL that answers but is *not* a relay — pasting your app's URL here is the classic mistake — is refused on the spot with the probe's finding, instead of surfacing later as a confusing error when minting. (A reachability failure can be overridden with *Continue anyway*; a wrong identity should be corrected.) The profile name is optional and defaults to the relay's hostname. From a verified relay you either **mint** a new session (needs the relay's admin secret; the label is optional and generated from the profile and date) or **import** one someone else minted (session id + token — no admin secret involved). Minting ends with the producer URL step and a revealable join URL to share; importing goes straight to the workspace. ## Live status, and what it can prove Each local topology card carries two live lines, and both are careful to only claim what the CLI can actually verify: **The relay line** distinguishes five states: running and **hosted by this CLI** (ownership the CLI can prove — it holds the server); running but **hosted by another process (shared)** — a Vite plugin's relay, typically; the port **held by something that is not a devtools relay** (a service marker check, not a port check — see [troubleshooting](#troubleshooting)); not running; or disabled in settings. **The producer line** counts who is actually in the topology's relay room. Participants declare a diagnostic role when they join — an app bridge joins as a *producer*, a devtools window as a *window* — and the counts read accordingly: "2 producers connected, 1 window". These roles are self-declared and are used for diagnostics only, never for authentication; a participant that declares none is counted honestly as an *unlabelled participant*, and a room with only unlabelled participants says so rather than guessing ("roles unknown — producers cannot be counted exactly"). That honesty is what makes the **topology-mismatch detection** trustworthy: when producers are connected on some *other* room key while your workspace listens on yours, the card says exactly that — `Producers are connected on "tank-shooter"; this workspace listens on "tank-shoter".` — which is a proven mismatch. Unlabelled participants on another key are reported only as a *possible* mismatch. And against an **older or external relay** that reports no room diagnostics at all, the card keeps the plain waiting hint, explicitly labelled as such, rather than inventing counts. A workspace card also shows a **window open** chip while its workspace page is open somewhere. This is a server-side lease the page heartbeats, released when the page closes (with lease expiry as the fallback), so the chip is trustworthy across tabs, browsers, and crashed windows — it clears within seconds of the window actually going away. The Settings section maps every CLI option to an in-app control and labels its source. Explicit CLI arguments win at startup, followed by environment fallbacks, saved UI preferences, then defaults. A UI change takes effect immediately where possible. Dashboard host/port, fallback-port, and auto-open settings are saved for the next launch and remain visibly marked `restart required`; local-relay host/port changes reconnect producers and require confirmation. `--json`, `--show-credentials`, `--yes`, and help/version use native actions rather than meaningless global toggles: copy/export JSON, reveal/reprint, destructive confirmation, and Help/About. ## Topology selection The CLI cannot infer an app's topology. Pass the exact `topologyId` used by `createNiceDevtools`: ```sh bunx @nice-code/devtools-cli --topology pixel-plaza ``` With fresh state and no workspace option, the CLI selects `default`, which observes only producers configured with `topologyId: "default"`; it does not discover arbitrary topology names. An empty connected window usually means the producer uses another topology. The dashboard shows the selected id and keeps that distinction visible. Repeat `--topology` for isolated windows. The CLI starts the existing local relay on port 5199. A non-default `--relay-port` must match the producer's `niceDevtools({ port })` configuration. ## Deployed relay sessions Name every relay explicitly so profiles and credentials cannot change meaning when order changes: ```sh nice-devtools session create \ --relay staging=https://relay.example.com/prefix \ --secret-env staging=STAGING_RELAY_ADMIN_SECRET \ --ttl 8h --label "tank staging" ``` The CLI reports the relay's effective lifetime, including a clamp. New relays return the exact grant; against an older compatible relay the remaining duration is labelled approximate. `session list` means sessions known to this nice-devtools installation—not a relay-wide active list. Use `session forget` to remove only the local credential. Use `session close` or the dashboard's **close and forget** to end the relay room and remove it locally. Closing requires an admin secret; when none is available the dashboard truthfully offers only local forgetting. The session workspace reports connecting, connected, retrying, expired, and proven terminal-close states. A failure before the socket first opens is labelled as ambiguous because browsers cannot distinguish bad credentials, capacity, origin policy, and relay unavailability. `--producer-url staging=https://staging.example.com/` prints one full URL that preserves the app's existing query and hash route. See [Remote Devtools Sessions](/devtools/remote-sessions/) for producer wiring and production gates. ## Plaintext data and removal For development convenience, remembered session tokens, backend tokens, and opted-in admin secrets are stored plaintext in `state.json`: - Windows: `%LOCALAPPDATA%\nice-devtools` - macOS: `~/Library/Application Support/nice-devtools` - Linux: `$XDG_STATE_HOME/nice-devtools`, then `~/.local/state/nice-devtools` Set `NICE_DEVTOOLS_DATA_DIR` for a portable/test location. `nice-devtools data path` prints the exact file. Admin/backend values are copied from an env/flag only with `--remember-credentials`; a hidden prompt asks explicitly. On a loopback dashboard, a credential can also be entered through a one-way field. It is cleared after submission and is never prefilled, returned, cached, or stored in browser storage. An environment-variable name can be saved without its value; copying the resolved value to plaintext requires a separate confirmation. Credential entry and all credential-bearing mutations remain absent in LAN mode. A remembered admin secret has broader, longer-lived authority than a session token: it can mint and close sessions until rotated or forgotten locally. The CLI prints where each newly persisted credential was written and the command that removes it. The dashboard can forget a session, forget credentials for one target, or clear all local data. CLI equivalents are `session forget`, `credentials forget`, and `data clear`. Forgetting local state does not close a live relay session unless **close and forget** is selected. ## JSON automation `--json` writes JSON only to stdout and diagnostics to stderr. Non-TTY mode never prompts. `session create --json` includes the new token because automation needs it; `session list` and `session show --json` redact credentials unless `--show-credentials` is explicit. ## LAN mode Credential-bearing workspaces remain loopback-only. A local topology may be exposed deliberately: ```sh nice-devtools --topology pixel --host 0.0.0.0 --relay-host 0.0.0.0 ``` The local relay is unauthenticated: anyone on that network can observe application state/actions. The CLI warns at startup and removes all credential/data controls from the LAN dashboard. A producer page opened from another device needs `niceDevtools({ host: "0.0.0.0", port: 5199 })` too. A LAN dashboard also omits relay room names and the local-relay status line entirely — those are a loopback-only disclosure — and a LAN-mode CLI serves no dev-server discovery or handoff. ## One product, one language The dashboard and the [devtools window](/devtools/window/) are deliberately built from the same shared design system — one token set, one type ramp (sans for prose, monospace for ids, URLs, and snippets), and the same accessible primitives: native `` modals that take focus and close on `Escape`, labelled form fields, and status lines whose tone is carried in words ("Error:", "✓", "Note:") rather than color alone. Arbitrary names — topology ids, labels, URLs — always render as text, never as markup. If a dashboard surface and a window surface read as two different products, that is a bug worth reporting. ## Troubleshooting **"Relay URL … did not answer as a nice-devtools relay."** The guided flow probes `/health` before saving. This message almost always means the URL is your *app's* URL, not the relay's — answering is not the same as being a relay. Enter the relay deployment's URL (the one your producer config calls `relayUrl`). *Continue anyway* exists for a relay that is temporarily unreachable, not for a wrong identity. **"Port … is held by something that is not a devtools relay."** The relay status line checks the service marker, not just the port. Another dev server (or anything else) is squatting the relay port; stop it or move the relay port in Settings. Similarly for the dashboard itself: a default launch tries a small following port range, but an explicit `--port` **fails** when the port is taken unless `--fallback-port` says falling forward is acceptable — a script that pinned a port should not silently end up on another one. **Producers connected, but on a different topology id.** The card's mismatch line quotes both ids — the fix is making the app's `topologyId` and the workspace id exactly equal (they are case-sensitive, and a one-letter typo is the common case). If the card only reports *possible* mismatch, the connected participants declared no role, so the CLI won't claim more than it knows. **"Waiting for producers … (This relay does not report room diagnostics.)"** The workspace is attached to an older or externally started relay that predates room introspection. Everything still works; the card just cannot count producers. Restart the relay from this CLI (or update the process hosting it) to get live counts. **The browser blocked the devtools window.** The in-app launcher shows an inline alert with a **Retry** button — allow popups for the site and retry from that fresh click; nothing was registered or half-opened in the blocked attempt. **A devtools click opened the app-local window instead of the CLI workspace.** The dev server logs a stable reason once per cause — `cli-gone`, `endpoint-mismatch`, `announcements-disabled`, and friends. The full table and what each one means is in [Vite-to-CLI Handoff](/devtools/cli-handoff/#when-the-click-stays-on-the-app-local-window). --- # Inspecting Live Deployments Source: /devtools/remote-backends Description: Dial a deployed backend — a Durable Object or a long-lived server — from a devtools window on your own machine, over an encrypted, token-gated link that a production build refuses by construction. [Observing Backends](/devtools/backends/) gets a backend into the window on `localhost` and `wrangler dev`. This page is the deployed case: a **staging or dev deployment** you want to look at from a window running on your own machine — a misbehaving Durable Object, a server whose traffic you want to read against real network conditions. The mechanism is the same one the local case already uses — the window **dials into** the backend's ordinary WebSocket endpoint, where devtools rides the wire mux as its own token-gated protocol. Only two things change for a deployment: the URL is remote (so it must be `wss://`), and the token lives in your deploy config instead of a `.env` file. :::danger[Never production] This is for **staging / dev deployments with synthetic or explicitly-consented data**. A Durable Object constructed with `stage: "production"` serves no devtools admission *even with a token set* — the host is inert by construction. Do not point devtools at real production user data. ::: ## Enable it on the backend Nothing new to write — the [`createNiceDurableObjectDevtools`](/devtools/backends/#a-cloudflare-durable-object) / [`createNiceServerDevtools`](/devtools/backends/#a-long-lived-server-bun-node-) call you already have is the whole surface. What changes is deploy configuration: ```ts title="MatchDO.ts" const devtools = createNiceDurableObjectDevtools(ctx, { runtime, stage: env.DEVTOOLS_ENV ?? "development", // declare it: "staging", not the dev default token: env.DEVTOOLS_TOKEN, // a wrangler secret — unset ⇒ inert realms: { match: () => this.engine }, }); ``` On the deployment, set the two values out of band so neither lands in source: ```sh # a strong random secret — the window must present exactly this to be admitted wrangler secret put DEVTOOLS_TOKEN # declare the stage explicitly; do NOT lean on the "development" default on a deploy wrangler deploy --var DEVTOOLS_ENV:staging ``` **No `DEVTOOLS_TOKEN` on the deployment ⇒ no devtools protocol is registered ⇒ no route, no bridge, no cost.** Enabling inspection is opt-in per deployment, and it is off until you set that secret. ## Dial it from a window on your machine Open a devtools window locally (any app's launcher, or a bare `mountDevtoolsWindow()` page) and use the **⧉ backend** control in the header: paste the backend's WebSocket URL and its token. ``` wss://api.demo.example.com/match/ws?matchId=42 ``` - The URL is the **same endpoint the game clients dial** — no separate dev route. Include whatever query params address the instance (`?matchId=…`), exactly as a client would. - Off-machine URLs **must be `wss://`**. A plain `ws://` to a non-local host is refused in the control with a visible reason: the devtools protocol rides the link plain (the token is the gate, not encryption), so TLS is what protects it in transit. - The token never enters any app bundle — it lives only in the window page you typed it into. Recently-dialed URLs are remembered in the window's own storage for one-click reconnect; **tokens are not persisted**. A dialed backend appears in the switcher as an ordinary backend client, wearing a **REMOTE** badge, with its live Server view and per-client Traffic table. Reconnection is the wire client's keep-alive ladder; a rewoken Durable Object re-attaches on its next link-up without waiting out the heartbeat. If you set the endpoint at build time instead of dialing by hand, the [`niceDevtools()` Vite plugin's `backends`](/devtools/backends/#a-cloudflare-durable-object) option takes the same `{ url, token }` — point `url` at the `wss://` deployment. ## The fail-closed matrix Every way this can be misconfigured resolves to *off*, never *open*: | Situation | Result | | --- | --- | | No `DEVTOOLS_TOKEN` set on the deployment | Devtools protocol never registered — dials land in the unknown-prefix drop. | | `stage: "production"` (token set or not) | Host is inert by construction — admission is never served. | | Wrong token from the window | `auth_denied`; the window warns; nothing is streamed. | | Plain `ws://` to a remote host | Refused in the connect control before any socket opens. | ## What streams, and what does not The remote link inherits the same data-minimization defaults as any server scope: - **Traffic is sizes and counts, never payload bytes** — the wire lane records frame kinds and byte counts only. - **Action payloads do not leave the process by default.** `actionDomainScope` streams action id, domain, status, timings and error *shape*; `input`/`output` values stay put unless you pass `logPayloads: true` deliberately. - **The backend realm scope is metrics-only.** `realmEngineScope` streams connection counts, ingress/egress frames/bytes/messages, the coalesce ratio and a per-connection table (named by each connection's avatar) — never the values in the realm tree. Realm *contents* only stream from a **frontend** producer, and there they are gated behind `streamContents` — see [Remote Devtools Sessions](/devtools/remote-sessions/#5-data-minimization--metrics-by-default). ## Hibernation, on a deployment The [hibernation caveat](/devtools/backends/#the-hibernation-caveat-stated-plainly) is unchanged and now literal: while your window is attached, the Durable Object is kept awake (the heartbeat re-wakes an evicted DO). On a metered deployment that is real cost for as long as you watch. Detached, it costs nothing — the bridge arms zero timers, and the DO hibernates cleanly. Close the window when you are done. ## Inspecting deployed *frontends* Dialing in covers backends. Getting a deployed **frontend's** browser clients into a window — where the window cannot dial *into* a browser — needs a relay the producers dial *out* to, with authenticated short-lived sessions. That is the session relay — a distinct, more heavily-gated surface than backend dial-in — covered in [Remote Devtools Sessions](/devtools/remote-sessions/). --- # Remote Devtools Sessions Source: /devtools/remote-sessions Description: Stream a deployed staging frontend and its backends to a devtools window through an authenticated, short-lived relay session — metrics by default, contents only on deliberate opt-in, and never production by construction. [Inspecting Live Deployments](/devtools/remote-backends/) covers dialing *into* a deployed backend. This page is the other direction: getting a deployed **frontend's** browser clients — which a window cannot dial into — plus its dial-out backends into one window, through a **relay session**. A session is a short-lived, token-authenticated room on a relay you deploy. Producers (a staging frontend, a long-lived server) dial *out* to it; a devtools window joins it and sees them all. It is the most heavily-gated surface in the devtools, because it points them at deployed code. :::danger[Never production, by construction] Three independent gates keep this off production, and each is provable on its own: the relay-dial code is **absent from a production bundle** (build-time), the session credentials live **only in a staging deploy config** (deploy-time), and the relay **refuses any `env: "production"` participant** (runtime). Use it for staging/dev with synthetic or explicitly-consented data. ::: ## 1. Deploy the relay The relay is a Cloudflare Worker + a per-session Durable Object room. Copy the reference wiring from `@nice-code/devtools-relay/cloudflare` (`example/cloudflare/`): ```ts title="worker.ts" import { DurableObject } from "cloudflare:workers"; import { handleDevtoolsRelayRequest, serveDevtoolsRelayRoom } from "@nice-code/devtools-relay/cloudflare"; export class DevtoolsRelayRoomDO extends DurableObject { private room = serveDevtoolsRelayRoom(this.ctx); fetch(r: Request) { return this.room.fetch(r); } webSocketMessage(ws: WebSocket, m: string | ArrayBuffer) { return this.room.webSocketMessage(ws, m); } webSocketClose(ws: WebSocket) { return this.room.webSocketClose(ws); } alarm() { return this.room.alarm(); } } export default { fetch: (request: Request, env: Env) => handleDevtoolsRelayRequest(request, { rooms: env.DEVTOOLS_RELAY_ROOM, adminSecret: env.RELAY_ADMIN_SECRET, }), }; ``` ```sh wrangler secret put RELAY_ADMIN_SECRET # the operator's mint credential wrangler deploy ``` **Nothing is held at rest.** The relay is a pure ephemeral fan-out: it persists only a session's admission record (token + expiry) so a room survives a hibernation wake, and never a single devtools frame. A compromised relay yields no history. With no `RELAY_ADMIN_SECRET` set, minting is refused and the whole relay is inert. ## 2. Mint a session The preferred operator path is the [Standalone Devtools CLI](/devtools/cli/), which remembers the mint response, reports TTL clamping, and prints the full producer URL: ```sh bunx @nice-code/devtools-cli \ --relay staging=https:// \ --producer-url staging=https://staging.example.com/ \ --ttl 8h ``` The raw API remains available. It returns the credentials to hand out plus the exact effective TTL: ```sh curl -X POST https:///sessions \ -H "authorization: Bearer $RELAY_ADMIN_SECRET" \ -H "content-type: application/json" \ -d '{"ttlMs": 1800000, "label": "tank staging"}' # → { "sessionId": "…", "token": "…", "expiresAt": …, "effectiveTtlMs": …, # "ttlCapped": false, "joinPath": "/session/…" } ``` `DELETE /sessions/` (same admin auth) ends a session immediately and evicts its room. A session also dies on its TTL. ## 3. Wire the producers (staging only) ### A deployed frontend The relay-dial code lives behind `@nice-code/devtools/remote` — **importing it is the strip boundary**, so a production bundle that never imports it contains none of it. Enable it from a staging build, behind a statically-false flag so the bundler tree-shakes the whole branch out of production: ```ts title="src/devtools.ts" // One flag drives everything. In a staging build it is set; in production it is absent, so every // branch below folds to dead code and the dynamic import is dropped from the bundle. const remoteSession = Boolean(import.meta.env.VITE_NICE_DEVTOOLS_REMOTE); export const devtools = createNiceDevtools({ topologyId: "tank-shooter", runtime: getWebRuntime, domains: [act_root_tank], ...(remoteSession ? { enabled: true, // see the note below — a BUILT bundle must opt its bridge in env: "staging", // the env badge — never "production" streamContents: false, // metrics/shape only (see §5) } : {}), }); if (import.meta.env.VITE_NICE_DEVTOOLS_REMOTE) { const { enableRemoteDevtoolsSessionFromUrl } = await import("@nice-code/devtools/remote"); enableRemoteDevtoolsSessionFromUrl({ relayUrl: import.meta.env.VITE_NICE_DEVTOOLS_RELAY, // wss://… — a build-time constant env: "staging", }); } ``` The `VITE_NICE_DEVTOOLS_*` values live in the **staging deploy config only**. A production build sets none of them, so the `if` branch is dead code and the dynamic import is dropped. ### The session comes from the URL, not the build `enableRemoteDevtoolsSessionFromUrl` reads the credentials from the page's **location hash**: ``` https://staging.example.com/#nice-devtools-session=&nice-devtools-token=&nice-devtools-expires= ``` **Do not bake a session into a bundle.** A session lives ~30 minutes by default; a deploy lives until the next one. Credentials compiled into a build are therefore stale almost immediately, and joining a new session would mean shipping a new build — so the deploy-time path costs you a release cycle per debugging session and puts a secret in an artifact. The hash is the right carrier for two more reasons: browsers never send it to the server, so the token stays out of access logs and `Referer`; and the helper clears every recognised devtools param from the address bar the moment it has been read, so it does not linger in history or in a URL you paste to someone else. The optional expiry is advisory cleanup metadata, not admission authority. The accepted id/token is stored in that tab's `sessionStorage`. A refresh or sleep/wake reconnects without a second paste; closing the tab ends browser persistence. A known expiry (with clock-skew grace), explicit detach, or terminal relay close clears the record and stops retrying. An older two-parameter URL remains supported. Calling the disposer returned by `enableRemoteDevtoolsSessionFromUrl` detaches and forgets the tab. `forgetRemoteDevtoolsSession({ relayUrl, env })` is also exported for an explicit “forget” control. If you have the pair some other way, `enableRemoteDevtoolsSession({ relayUrl, sessionId, sessionToken, env })` takes it directly. :::caution[Credentials are runtime; the command gate is not] A remote window is read-only against a producer unless that producer opted in with `allowRemoteCommands` (see §4). That opt-in is **build-time on purpose** — it describes the deployment, so no URL can turn it on. Credentials are per-session and belong in the URL; posture is per-deployment and belongs in the build. ::: :::note[`enabled: true` is load-bearing for a deployed staging build] A staging flavor is a real `vite build`, so its `import.meta.env.DEV` is statically `false` — and the default devtools gate would construct nothing, leaving the session with no bridge to stream through. `enabled: true` opts the bridge in for exactly this build. Drive it from the same flag as the remote entry (as above), so a production build — which sets no flag — stays on the default gate. ::: ### A long-lived backend `createServerDevtoolsHost` (or `createNiceServerDevtools`) dials the same session instead of the local relay: ```ts createServerDevtoolsHost({ name: "tank-api", sinks: ["relay"], session: { relayUrl: "wss://", sessionId: process.env.DEVTOOLS_SESSION!, sessionToken: process.env.DEVTOOLS_TOKEN!, env: "staging", }, }).contribute(realmEngineScope(engine, "match")).start(); ``` A **Durable Object** stays dial-in — it cannot hold an outbound socket without pinning itself awake. Observe it with [Inspecting Live Deployments](/devtools/remote-backends/); it appears in the same window switcher beside the session's producers. ## 4. Join from a window The preferred flow is `bunx @nice-code/devtools-cli --relay …`, which opens one isolated session workspace. The existing header **⧉ backend → Join session** control remains available for ad-hoc use: paste the relay URL, session id, and token. Every producer appears in the switcher, each badged by env and kind, and a prominent **⚠ REMOTE · STAGING** banner marks the window. Off-machine relay URLs must be `wss://`. ## 5. Data minimization — metrics by default A deployed producer streams **shape, not contents** — the staging wiring in §3 sets `streamContents: false`, and with it the action, state and realm panels stream structure, sizes, timings and metadata — every leaf value replaced by its type (`number`, `string(12)`, `{"[…]": 3}`) — so no value leaves the deployment. Wire-traffic metrics (byte counts, frame kinds) never carried values in the first place. This is proven at the frame level in CI: a capture test drives the whole producer pipeline and asserts sentinel values appear in **no frame of any kind** while shape-gated. | Scope | Streams by default (remote) | With `streamContents: true` | | --- | --- | --- | | Wire traffic | byte counts, frame kinds per lane | (unchanged — never values) | | Actions | id, domain, status, timings, `input`/`output` **shape** + hashes | full `input`/`output`/`error` | | State | store structure + sizes, change patches with **shape** values | full store snapshots | | Realm | tree structure + sizes, versions | full view state | Turn contents on only for a session you have deliberately consented to, on synthetic or non-sensitive data. The relay carries whatever the producer sends — the minimization is the producer's choice, made once at build time. A producer that *is* streaming contents says so, and the window shows a **◉ CONTENTS** badge naming them. It is there so the state is visible rather than inferred: without it, an empty-looking panel is ambiguous between "this realm is empty" and "its values were redacted before they reached me", and a build flag set weeks ago is a poor thing to have to remember. The badge appears only for remote producers — a local app streaming its own values to its own window is the ordinary case. ## 6. Remote windows are read-only A session window **observes**. It does not drive. Devtools commands — editing state, reverting a value, clearing a log — are refused by default when they arrive over a session carrier, and the window greys out those controls rather than offering buttons that will fail. The reason is what a session token *is*. Minting one produces a link made to be handed to someone: it gets pasted into chat, sits in terminal scrollback, and stays valid until it expires — long after the conversation that prompted it. Letting a teammate watch your staging deployment is the intended trade. Letting anyone who ever received that link rewrite its state is a different thing to have handed out, and it should be a decision someone made on purpose: The producer decides, on the same call that wires its devtools — a deployed frontend: ```ts // doc-check: skip — the options shape only; the full call is in §3 createNiceDevtools({ topologyId: "tank-shooter", enabled: Boolean(import.meta.env.VITE_NICE_DEVTOOLS_REMOTE) || undefined, env: "staging", streamContents: false, allowRemoteCommands: true, // default false — remote windows observe only }); ``` …or a long-lived backend, on the same host that names the session: ```ts // doc-check: skip — the options shape only; the full call is in §3 createServerDevtoolsHost({ name: "tank-api", sinks: ["relay"], session: { relayUrl, sessionId, sessionToken, env: "staging" }, allowRemoteCommands: true, // default false }); ``` Turn it on for a staging deployment you own and are actively driving, and prefer turning it back off when you are done. Three properties worth knowing: - **The producer decides, not the window.** Enforcement is at the producer, where the frame's true carrier is known. A window's greyed-out controls are it being polite about what it expects; they are not the mechanism. - **Provenance comes from the carrier, never the frame.** A producer judges a command by the carrier it physically arrived on. A relayed frame cannot describe itself as local — nothing in the frame is consulted. - **Refusals are loud.** A refused command comes back as an explicit rejection the window surfaces. A silently-dropped command would be worse than a refused one: you would believe the deployment changed when it did not, and read everything after through that belief. **Locally, nothing changes.** A window on your own machine — same-origin, or the local relay, or dialed straight into a `wrangler dev` DO — is not remote, and keeps full command rights regardless of this setting. The gate is about session relays specifically. ## The gates, restated 1. **Build-time** — the remote dial lives only behind `@nice-code/devtools/remote`; a production bundle never imports it. Verified in CI by `check-devtools-remote-strip`. 2. **Deploy-time** — session credentials live only in a staging deploy config; production configs carry none. 3. **Runtime** — the relay refuses any `env: "production"` producer or consumer, and a deployed frontend built without the flag never dials. WSS is required off-machine on both sides. 4. **Command-time** — a producer refuses commands arriving over a session carrier unless it opted in with `allowRemoteCommands`. Observation and mutation are separate grants; a session token buys the first. --- # 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 one sanctioned exception is a **staging flavor** — a deployed build that streams to an authenticated relay session; it opts its bridge in explicitly with `enabled: true` behind a build flag. See [Remote Devtools Sessions](/devtools/remote-sessions/). ::: ## 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 `