Skip to content

Remote Devtools Sessions

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

The relay is a Cloudflare Worker + a per-session Durable Object room. Copy the reference wiring from @nice-code/devtools-relay/cloudflare (example/cloudflare/):

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,
}),
};
Terminal window
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.

An operator mints a session against the admin secret. It returns the credentials to hand out — and they are short-lived (default 30 min, capped by the relay):

Terminal window
curl -X POST https://<relay-host>/sessions \
-H "authorization: Bearer $RELAY_ADMIN_SECRET" \
-d '{"ttlMs": 1800000, "label": "tank staging"}'
# → { "sessionId": "…", "token": "…", "expiresAt": …, "joinPath": "/session/…" }

DELETE /sessions/<id> (same admin auth) ends a session immediately and evicts its room. A session also dies on its TTL.

The relay-dial code lives behind @nice-code/devtools/remoteimporting 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:

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 { enableRemoteDevtoolsSession } = await import("@nice-code/devtools/remote");
enableRemoteDevtoolsSession({
relayUrl: import.meta.env.VITE_NICE_DEVTOOLS_RELAY, // wss://…
sessionId: import.meta.env.VITE_NICE_DEVTOOLS_SESSION,
sessionToken: import.meta.env.VITE_NICE_DEVTOOLS_TOKEN,
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.

createServerDevtoolsHost (or createNiceServerDevtools) dials the same session instead of the local relay:

createServerDevtoolsHost({
name: "tank-api",
sinks: ["relay"],
session: {
relayUrl: "wss://<relay-host>",
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; it appears in the same window switcher beside the session’s producers.

Open a devtools window locally and use the header ⧉ backend → Join session tab: paste the relay URL, session id, and token. Every producer in the session appears in the switcher, each badged by env and kind, and a prominent ⚠ REMOTE · STAGING banner marks the whole window as observing deployed code. Off-machine relay URLs must be wss://.

5. Data minimization — metrics by default

Section titled “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.

ScopeStreams by default (remote)With streamContents: true
Wire trafficbyte counts, frame kinds per lane(unchanged — never values)
Actionsid, domain, status, timings, input/output shape + hashesfull input/output/error
Statestore structure + sizes, change patches with shape valuesfull store snapshots
Realmtree structure + sizes, versionsfull 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.

  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.