Skip to content

The Devtools Window

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 │
│ 🖥 │ │ │
└────┴──────────────────────────────────┴───────────────────────────┘

Install the devtools and the Vite plugin:

Terminal window
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:

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:

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:

connectChannel(runtime, channel, { wireTap: devtools.wireTap }); // per connection
connectRealm(gameRealm, { devtools: devtools.realm("game") }); // per realm
<devtools.LauncherButton /> // once, anywhere

That is the whole setup. There is no devtools.html to commit, no window entry module, no second rollupOptions.input.

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:

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:

devtools.html → devtools.ts
import { mountDevtoolsWindow } from "@nice-code/devtools/window";
mountDevtoolsWindow(); // reads the topology id from its own URL

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 <select>) and the rail, the tool, and the sidebar all scope to it.

The center is a flexible canvas, not a single slot. Two directions open it up, and they are deliberately exclusive — you get many tools, one client or many clients, one tool, never a grid of both:

  • Split — click a second rail tool to open it beside the current one (Actions ‖ Realms, say), for one client — no modifier needed; clicking it again closes it. Drag the divider to resize; each combination remembers its own column widths. Press 19 to jump back to a single tool.
  • Lanes — click the + on a second topology node (or add one from the + lane… switcher) to show both clients as side-by-side lanes sharing one tool — two tabs’ realm views next to each other, or a frontend beside its backend. The rail then switches the tool for every lane at once, and each lane carries a header with its own live in/out rate. The lanes float as distinct cards on a darker board so they never blend together, and each laned client wears a bright accent colour — on its node outline, its header chip, its lane’s top edge, and its own in-page connected chip (see below) — so you can tell a lane, and the tab it belongs to, apart at a glance.

In lanes mode the meta sidebar aggregates: traffic · N clients sums the wire across the lanes, and the event log merges every lane’s feed with a client tag on each row. A sync selection toggle (on by default) mirrors your selection across lanes — click an action in one and every lane selects its own latest matching call; turn it off to compare different entries.

  • Topology band — nodes and edges, and the selection surface. Click a card to focus that client; click its + to add it as a lane ( to remove). See below.
  • Icon rail — ⚡ Actions · 🧩 State · 🌐 Realms · 🖥 Server. With one client, a click toggles the tool in/out of a side-by-side split; with several lanes, a click makes it the tool every lane shows. Press 19 to focus a single tool. The Server tool appears only for a client that advertises server:* scopes, so a frontend never shows an empty tab. Inactive tools stay mounted-hidden, so selection and scroll survive switching.
  • Meta sidebar — collapsible (its chevron, a double-click on the resize handle, or [). Collapsed, it shrinks to a slim rail with a live rate ticker and an activity dot; expanded, scoped to the selection:
    • traffic · all wire (top): the wire tap — true post-encryption bytes, per lane (action, realm, handshake, keepalive, http) and per client (which connected client, or which backend, those bytes belong to — see Observing Backends), defaulting to MAX (everything since the client connected) with the graph as a bounded 30-minute lens on it.
    • event log (bottom): a terse, grouped feed of what caused that traffic — an action settling, a realm write confirming, a link redialing, keepalive × 12 collapsed into one row. Each entry is a jump-link: click it and the window switches to the causing tool and selects the entry inside it.

The panels’ own Traffic tabs are dropped in window mode — the sidebar owns traffic, so there is no split-brain between “the action panel’s traffic” and “the sidebar’s traffic”.

Nodes come from the identity envelope already on every frame; no extra protocol. Edges are derived, not declared: each realm a frontend holds reports the peer it dialed (its backend’s coordinate envId), and that joins to the backend advertising the same coordEnvId.

Joining on the realm id alone would be ambiguous — two backends can serve one realm (a durable board on a Durable Object beside an ephemeral one on a plain server), and the traffic would be drawn on the wrong wire. So a backend should always pass coordEnvId: runtime.coordinate.envId to its devtools host.

A peer this window is not hearing from becomes a dashed placeholder node. It is real and it is carrying traffic — the flow beside its wire is measured at the client — it simply is not being heard. The badge distinguishes the two reasons: “not reporting” (never heard from: no devtools host attached, or a per-instance Durable Object nobody dialed) from “went quiet” (it was reporting and stopped — often just this window sitting in the background, which resolves the moment you bring it to the front). Click the node and it explains itself; see Observing Backends.

Flow lanes run beside each wire: green marching toward the backend is the client’s egress, blue returning is its ingress, scaled logarithmically so a keepalive trickle and a cursor storm both read honestly. The trunk between them lights up neutral white when either end is selected, so selection never mimics or hides the flow.

Clicking a backend node switches to its Server view: per realm engine, ingress frames/bytes and egress messages (the Durable Object billing unit) vs frames, with the coalesce ratio; per action domain, what it recently ran.

For a backend to appear at all it must be a producer: a long-lived server dials the relay below, while a Durable Object is dialed into by the window — see Observing Backends.

There is no in-page dock. Panels render in the window and nowhere else, so nothing competes with your app for screen space, and no hidden panel keeps re-rendering a realm tree behind the window for nobody. Your app mounts one affordance — <devtools.LauncherButton /> — and streams its cores through the bridge.

That affordance has two faces. Detached, it is a devtools button. Once a window is attached there is nothing left to launch, so it becomes a connected chip showing this client’s label — the same label the window’s client switcher and topology band show for it. With three tabs open that match is the only thing that tells you which row in the window is the tab you are looking at. The chip still focuses the window on click, because a status readout that swallowed the click would strand anyone whose devtools window is buried.

When you compare several clients as lanes, the window sends each one the accent colour of its lane, and the connected chip wears that colour — border, dot, and label — so the chip in the corner of a tab matches its lane in the window. It falls back to its default green whenever the window shows this client alone.

useDevtoolsWindowAttached() reports whether a window is listening, read straight off the bridge’s attach state (host.isAttached() / host.onAttachedChange(…)) rather than guessed from whether someone clicked a button. Use it to skip work that only a window would consume. useDevtoolsClientIdentity() gives you the clientId and live label behind the chip, and useDevtoolsClientAccent() gives you the lane hue (or null) if you want to tint an affordance of your own.

One window per app. The launcher routes through a single open-or-focus call. Before opening, it pings the same-origin control channel; if a window (opened from any tab) answers, it is brought forward instead of a second one being spawned. The window is already multi-client, so a second one would only split your view.

BroadcastChannel reaches other tabs of the same origin in the same browser profile. A private window, a second profile, a different browser, or a phone on your LAN is a different storage partition and cannot be reached that way — which is also exactly why those environments show up as distinct, interesting clients.

Reaching them needs a relay, and the niceDevtools() plugin you already added runs one.

The first devtools click starts it, and every local client of that app discovers it and joins the window that is already open. Nothing to run in a second terminal, no URL to configure, and — because a client only dials once it knows a relay exists — no console noise in the many dev sessions that never open devtools at all.

Two dev servers (two apps) converge on the same relay rather than each hosting their own, which would re-partition clients by whichever port their app is served from. The relay lives inside whichever dev server started it and dies with it; survivors ask their own dev server to stand a new one up.

It binds 127.0.0.1 by default. For a phone on your LAN, niceDevtools({ host: "0.0.0.0" }).

Run the relay yourself and name it explicitly. An explicit relayUrl always wins: the client dials it directly and asks nobody. This is the path for React Native, a non-Vite dev server, an https dev server (a page on https: may not open a ws: socket), or a relay on another machine.

Terminal window
bunx @nice-code/devtools-relay # port 5199
src/devtools.ts
export const devtools = createNiceDevtools({
topologyId: "my-app",
relayUrl: "ws://localhost:5199",
// No plugin to serve the window, so host the page yourself and say where it is.
windowUrl: "/devtools.html",
// …
});
import { createFanoutTransport, createWebSocketTransport } from "@nice-code/devtools-core";
const transport = createFanoutTransport([
createBroadcastChannelTransport("my-app"),
createWebSocketTransport("ws://localhost:5199?key=my-app"),
]);

The fanout dedups, so a same-profile peer reachable both ways still arrives once.

A long-lived backend joins the same room in one call. It is not a Vite client, so it cannot discover anything — but it does not need to: it dials the same well-known port, retrying quietly until a relay is there.

import { createNiceServerDevtools } from "@nice-code/devtools/server";
createNiceServerDevtools({
topologyId: DEVTOOLS_TOPOLOGY, // the room — the same id the frontend passes
runtime, // label, clientId and coordEnvId all derived from it
realms: { match: engine },
// relayPort / relayUrl only when the relay isn't the local one on 5199
});

Pass an array of ids for one backend serving several apps; it joins every room. See Observing Backends.

?key= rooms unrelated apps on one relay. A Durable Object never dials out — see Observing Backends.

Devtools are a development tool, built to refuse production and fail closed — shipping a build with devtools wired costs nothing:

  • The browser handle and the server host default to enabled only when NODE_ENV !== "production"; in a production build the handles are inert and no stream is produced.
  • On a Durable Object there is no NODE_ENV to infer from, so createNiceDurableObjectDevtools takes a required stagestage: "production" (or no stage, or no token) returns an inert handle with no protocol registered and nothing observable. A devtools endpoint that failed open would be a data leak, not a devtools endpoint.
  • The token lives on the dev server, never in the app bundle. The devtools window (which holds the token) is served by the niceDevtools() Vite plugin from dev-server config — a token in a VITE_-prefixed variable would be compiled into every client. Never do that.
  • Nothing streams in a production build. The bridge sits behind isDevtoolsEnabled(); createServerDevtoolsHost is an inert stub when NODE_ENV === "production". A feature that isn’t running can’t be attacked.
  • Same-origin by default. BroadcastChannel cannot cross an origin, so the default configuration has no network surface at all.
  • The relay is unauthenticated and ephemeral — local/dev only. It keeps no history and logs no content. The Vite plugin only exists on the dev server and binds the relay to 127.0.0.1 unless you ask otherwise; a production build neither discovers nor dials a relay. If you name one explicitly instead, gate it behind an env var (VITE_DEVTOOLS_RELAY_URL / DEVTOOLS_RELAY_URL).
  • A port answering is not a relay answering. Discovery checks a service marker on /health before it connects, so an unrelated dev server squatting 5199 produces a loud warning rather than devtools that silently never connect.
  • Sizes, not contents, on the wire lane. The traffic layer records byte counts and frame kinds, never payload bytes. State and realm contents do cross the same-origin channel — the app already has them — so treat a relay pointed at anything but your own machine accordingly.