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 covers the other direction. A window cannot dial into a browser, so a deployed frontend’s browser clients reach it through a relay session, and its dial-out backends join the same session.

A session is a short-lived, token-authenticated room on a relay you deploy. Producers, such as a staging frontend or a long-lived server, dial out to it. A devtools window joins it and sees them all. It is the most heavily gated part of the devtools, because it points them at deployed code.

The relay is a Cloudflare Worker plus one Durable Object room per session. 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 only fans frames out as they arrive. 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.

The recommended operator path is the Standalone Devtools CLI. It remembers the mint response, reports TTL clamping, and prints the full producer URL:

Terminal window
bunx @nice-code/devtools-cli \
--relay staging=https://<relay-host> \
--producer-url staging=https://staging.example.com/ \
--ttl 8h

The raw API is still available. It returns the credentials to hand out, plus the exact effective TTL:

Terminal window
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 ends when its TTL runs out.

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:

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 { 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, while a deploy lives until the next one. Credentials compiled into a build go stale almost immediately, and joining a new session would mean shipping a new build. 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. The helper also 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.

Pass session to createNiceServerDevtools, and it dials the session instead of the local relay. Everything else stays as it is locally: the label, clientId and coordEnvId still come from runtime. While a session is set, topologyId, relayUrl and relayPort go unused, because the session is the room.

Build the session only when the deploy provides one, so the same file works on your machine. If the deployed process runs with NODE_ENV=production, also pass enabled: true, or the devtools are inert:

backend/devtools.ts
import { createNiceServerDevtools } from "@nice-code/devtools/server";
import { DEVTOOLS_TOPOLOGY } from "my-app-shared";
const { DEVTOOLS_SESSION, DEVTOOLS_TOKEN } = process.env;
export const devtools = createNiceServerDevtools({
topologyId: DEVTOOLS_TOPOLOGY,
runtime,
realms: { match: engine },
domains: [act_root],
sinks: ["relay"],
session:
DEVTOOLS_SESSION != null && DEVTOOLS_TOKEN != null
? {
relayUrl: "wss://<relay-host>",
sessionId: DEVTOOLS_SESSION,
sessionToken: DEVTOOLS_TOKEN,
env: "staging",
}
: undefined,
});

The backend’s env is the session’s env, unless you pass stage, which wins. A backend whose env resolves to "production" never dials.

The lower-level createServerDevtoolsHost takes the same session option, if you compose the host by hand.

A Durable Object stays dial-in, because it cannot hold an outbound socket without keeping itself awake. Observe it with Inspecting Live Deployments; it appears in the same window switcher beside the session’s producers.

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. With that set, the action, state and realm panels stream structure, sizes, timings and metadata, with every leaf value replaced by its type (number, string(12), {"[…]": 3}). No value leaves the deployment. Wire-traffic metrics (byte counts, frame kinds) never carried values in the first place. CI proves this at the frame level: 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.

A producer that is streaming contents says so, and the window shows a ◉ CONTENTS badge naming them. It makes the state visible rather than inferred. Without it, an empty-looking panel could mean “this realm is empty” or “its values were redacted before they reached me”, and a build flag set weeks ago is easy to forget. The badge appears only for remote producers, because a local app streaming its own values to its own window is the ordinary case.

A session window observes. It does not drive. Devtools commands, such as editing state, reverting a value or clearing a log, are refused by default when they arrive over a session carrier. The window greys out those controls instead of 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. For a deployed frontend:

// doc-check: skip — the options shape only; the full call is in §3
createNiceDevtools({
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, in the same call that names the session:

// doc-check: skip — the options shape only; the full call is in §3
createNiceServerDevtools({
topologyId: "tank-shooter",
runtime,
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 things are worth knowing:

  • The producer decides, not the window. Enforcement is at the producer, where the frame’s true carrier is known. The window’s greyed-out controls only reflect 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, which the window shows you. 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 is not remote, whether it is same-origin, on the local relay, or dialed straight into a wrangler dev DO. It keeps full command rights regardless of this setting. The gate is about session relays specifically.

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