Skip to content

The Devtools Window

Open the nice-code devtools in their own browser window. One window shows every connected client, a live map of frontends and backends, wire traffic, and an event log you can click through.

nice-code’s devtools live in their own browser window. Docked panels fight your app for screen space and vanish on reload, so there are none. Your app produces a devtools stream, and a separate page consumes it.

That split buys a lot. The window survives app reloads. It shows several clients at once: two tabs, a private window, a phone on your LAN, even your backend. And because the backend is one more client, the window can draw the topology, a map of 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, which watch actions, state, realms and wire traffic. It starts the bridge that streams them, and hands back the three pieces your code plugs in:

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

There is no devtools.html to commit, no window entry module and no second rollupOptions.input.

The producer is your app. Each module (actions, state, realms, traffic) hands one host a scope: its slice of devtools data, behind an interface the host treats the same for every module. The host streams them all. createNiceDevtools assembles it for you. Use the lower-level API below 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 only runs in development, and it stays idle until a window attaches. With nothing listening, the bridge subscribes to no scope and projects nothing. A realm’s projected view is a structured clone per patch tick, and this design refuses to pay that cost with no reader.

The consumer is a second page on your app’s origin. Sharing the origin is what lets its BroadcastChannel pair with your bridge. The page 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: a clientId, a live label derived from its runtime coordinate, its env, and its kind (frontend / backend). The window lists each producer separately. A command such as clear or pause is addressed to one client, so clearing one tab’s action log leaves the other alone.

Producers announce themselves on a heartbeat. One that goes silent greys out, then drops.

Pick a client from the topology band (or the fallback <select>) and the rail, the tool, and the sidebar all switch to it.

The center can show more than one thing at a time, in two deliberately exclusive directions. 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 key is needed, and clicking it again closes it. Drag the divider to resize; each combination remembers its own column widths. Press 1–9 to jump back to a single tool.
  • Client columns — click the + on a second topology node (or add one from the + column… switcher) to show both clients as side-by-side columns sharing one tool. Think two tabs’ realm views, or a frontend beside its backend. The rail then switches the tool for every column at once, and each column carries a header with its own live in/out rate. The columns float as distinct cards on a darker board so they never blend together. Each client in a column also wears a bright accent colour on its node outline, its header chip, its column’s top edge and its own in-page connected chip (see below), so you can tell a column, and the tab it belongs to, apart at a glance.

With client columns open, the meta sidebar adds things up. traffic · N clients sums the wire across the columns, and the event log merges every column’s feed with a client tag on each row. A sync selection toggle (on by default) mirrors your selection across columns: click an action in one and every column selects its own latest matching call. Turn it off to compare different entries.

  • Topology band — nodes and edges, and where you pick what to look at. Click a card to focus that client; click its + to add it as a column (− 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 client columns, a click makes it the tool every column shows. Press 1–9 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 but hidden, so your selection and scroll position survive switching.
  • Meta sidebar — collapsible with 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, it is scoped to the selection:
    • traffic · all wire (top): the wire tap, which measures true post-encryption bytes. It breaks them down per lane and per client. A lane is a category of frame (action, realm, handshake, keepalive, http), shown in the table’s lane column. Per client means which connected client or which backend those bytes belong to (see Observing Backends). It defaults 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, such as an action settling, a realm write confirming, a link redialing, or 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 you never have to reconcile “the action panel’s traffic” with “the sidebar’s traffic”.

Nodes come from the identity envelope already on every frame, so no extra protocol is needed. Edges are derived rather than 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, for example 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 every backend advertises its coordEnvId. createNiceServerDevtools and createNiceDurableObjectDevtools derive it from the runtime you pass, so there is nothing to set. Only the lower-level hosts (createServerDevtoolsHost, createWireDevtoolsHost) need coordEnvId: runtime.coordinate.envId passed by hand.

A peer this window is not hearing from becomes a dashed placeholder node. It is real and carrying traffic, since the flow beside its wire is measured at the client. It is only not being heard. The badge tells the two reasons apart. “not reporting” means it was never heard from: no devtools host attached, or a per-instance Durable Object nobody dialed. “went quiet” means it was reporting and stopped. Often that is only 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.

Animated flow strips run beside each wire. Green marching toward the backend is the client’s egress, and blue returning is its ingress. Both are 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, it shows ingress frames/bytes and egress messages (the Durable Object billing unit) vs frames, with the coalesce ratio: frames per message, so ×3.00 means batching packed three frames into each billed message. Per action domain, it shows 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. Nothing competes with your app for screen space, and no hidden panel keeps re-rendering a realm tree for nobody. Your app mounts one control, <devtools.LauncherButton />, and streams its cores through the bridge.

That control 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. Clicking the chip still focuses the window, because a status readout that swallowed the click would strand anyone whose devtools window is buried.

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

useDevtoolsWindowAttached() reports whether a window is listening. It reads the bridge’s attach state directly (host.isAttached() / host.onAttachedChange(…)) rather than guessing from a button click. 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 that column hue (or null) if you want to tint a control 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 and no second one opens. The window is already multi-client, so a second one would only split your view. When a standalone nice-devtools CLI is running on the same machine, the same click lands in its workspace instead. See Vite-to-CLI Handoff.

If the browser blocks the popup, the launcher shows an inline alert with a Retry button. Retrying from that fresh click keeps the user gesture the browser requires, and a blocked attempt registers nothing anywhere.

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. That 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. There is nothing to run in a second terminal and no URL to configure. And because a client only dials once it knows a relay exists, there is no console noise in the many dev sessions that never open devtools at all.

Two dev servers (two apps) share the same relay. Separate relays 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, use 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 removes duplicates, 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, and 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= keeps unrelated apps in separate rooms 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 enabled to “not a production build”. The browser reads Vite’s import.meta.env.DEV first, then NODE_ENV; the server reads NODE_ENV. In a production build the handles are inert and no stream is produced. Only an explicit enabled: true overrides that, which is what the staging flavor does.
  • On a Durable Object there is no NODE_ENV to infer from, so createNiceDurableObjectDevtools takes a required stage, and throws without one. stage: "production", or a missing 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 unless you opt in. The bridge sits behind isDevtoolsEnabled(), and createServerDevtoolsHost is an inert stub when NODE_ENV === "production". Both are defaults for enabled; only an explicit enabled: true turns a built bundle on. A feature that isn’t running can’t be attacked.
  • Same-origin by default. BroadcastChannel cannot cross an origin, so the default configuration exposes nothing to the network.
  • The local relay is unauthenticated and ephemeral, for local development 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). Anything a network can reach is the session relay’s job instead: token-authenticated, short-lived, deployable. See Remote Devtools Sessions.
  • A port answering is not a relay answering. Discovery checks a service marker on /health before it connects. An unrelated dev server sitting on 5199 produces a loud warning, not 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 by default, since the app already has them. Anything deployed must not send them: a staging producer sets streamContents: false, which limits action, state and realm alike to shape, not values. See data minimization.