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”The preferred operator path is the Standalone Devtools CLI, which remembers the mint response, reports TTL clamping, and prints the full producer URL:
bunx @nice-code/devtools-cli \ --relay staging=https://<relay-host> \ --producer-url staging=https://staging.example.com/ \ --ttl 8hThe raw API remains available. It returns the credentials to hand out plus the exact effective TTL:
curl -X POST https://<relay-host>/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/<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 { 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
Section titled “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=<id>&nice-devtools-token=<token>&nice-devtools-expires=<epochMs>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.
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”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
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.
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
Section titled “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:
// doc-check: skip — the options shape only; the full call is in §3createNiceDevtools({ 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:
// doc-check: skip — the options shape only; the full call is in §3createServerDevtoolsHost({ 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
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. - 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.