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.
1. Deploy the relay
Section titled “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/):
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, }),};wrangler secret put RELAY_ADMIN_SECRET # the operator's mint credentialwrangler deployNothing 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
Section titled “2. Mint a session”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):
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.
3. Wire the producers (staging only)
Section titled “3. Wire the producers (staging only)”A deployed frontend
Section titled “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:
// 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.
A long-lived backend
Section titled “A long-lived backend”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.
4. Join from a window
Section titled “4. Join from a window”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.
| 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.
The gates, restated
Section titled “The gates, restated”- Build-time — the remote dial lives only behind
@nice-code/devtools/remote; a production bundle never imports it. Verified in CI bycheck-devtools-remote-strip. - Deploy-time — session credentials live only in a staging deploy config; production configs carry none.
- 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.