@nice-code/devtools-relay
v0.102.0
Published
Downloads
2,286
Readme
@nice-code/devtools-relay
Most operators should manage deployed sessions through
@nice-code/devtools-cli, which stores the mint response, reports
the relay's effective TTL, and prints paste-ready producer URLs. This package remains the reusable
relay host/core.
Docs: nicecode.io — guides, integrations, and the full API surface. Working with an AI assistant? Point it at nicecode.io/llms-devtools.txt (the devtools suite) or nicecode.io/llms.txt (the whole stack) — the complete, current docs flattened into plain text.
A tiny, reusable WebSocket relay that lets the nice-* devtools span browser
storage partitions — a private window, a different browser profile, a different
browser, even a different device — which same-origin BroadcastChannel cannot
cross (each partition is isolated by design, which is also why those environments
connect as a distinct client).
App clients and the devtools window each dial the relay; it rooms them by an app name and fans every frame out to the room's other members. It is purely ephemeral — no history, no content logging.
It has two modes: local rooms (?key=, unauthenticated, same-machine trust —
everything below until noted) and deployable, token-authenticated sessions
for inspecting live staging deployments (@nice-code/devtools-relay/cloudflare —
see "Deploy it" below).
Runs on Bun (
Bun.serve) or Node (node:http+ws), picked at runtime bystartDevtoolsRelayAuto.
Let Vite run it for you
Usually you should not run this by hand at all. Add
@nice-code/devtools-vite
to your Vite config and the relay starts on the first devtools click, with every
local client discovering it — no port to configure, no second terminal:
import { niceDevtools } from "@nice-code/devtools-vite";
export default defineConfig({ plugins: [react(), niceDevtools()] });Or run it yourself
bunx @nice-code/devtools-relay # port 5199, binds 0.0.0.0
bunx @nice-code/devtools-relay --port 6000 --host 127.0.0.1Environment fallbacks: DEVTOOLS_RELAY_PORT, DEVTOOLS_RELAY_HOST.
Then point your app's relay URL at it — relayUrl on the devtools config, or
VITE_DEVTOOLS_RELAY_URL in the demos. An explicit URL bypasses discovery
entirely. The devtools transport connects with ?key=<app-name> so unrelated
apps on one relay never cross streams.
GET /health answers { "service": "nice-devtools-relay", "protocol": 1, "rooms": N }.
The service marker is what lets a prober tell a relay apart from anything else
that happens to hold the port — liveness is not identity.
Embed it
import { startDevtoolsRelayAuto } from "@nice-code/devtools-relay";
const relay = await startDevtoolsRelayAuto({ port: 5199 });
// …
relay.stop();startDevtoolsRelay (Bun) and startDevtoolsRelayNode (Node) are exported
directly when you know which host you want. Both reject with an EADDRINUSE
error — recognisable via isAddressInUseError — when the port is taken, which a
caller racing several dev servers onto one well-known port should treat as
"someone else's relay won" rather than a failure. probeRelay(url) answers
"relay" | "other" | "down".
Deploy it — authenticated sessions on Cloudflare
Local ?key= rooms are same-machine trust. For anything a network can reach —
inspecting a deployed staging frontend/backend from a devtools window — the
relay has a second, heavily-gated mode: sessions, served from a Cloudflare
Worker + one Durable Object room per session via
@nice-code/devtools-relay/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, // absent ⇒ the whole relay is inert (fail-closed)
}),
};An operator mints a session (POST /sessions, admin-Bearer-gated, constant-time
compare) and hands its short-lived token to the staging producers and the
observing window. The room enforces admission — env: "production" is refused
unconditionally, then token, capacity, and TTL (an alarm evicts the room at
expiry). Nothing content-bearing is ever persisted: storage holds only the
admission record, so a killed session yields no history.
A ready-to-deploy example lives in example/cloudflare/ (worker + wrangler
config). Full guide:
nicecode.io/devtools/remote-sessions.
Extend it
The room/fan-out logic lives in DevtoolsRelayHub, which is transport-agnostic
(it forwards opaque frames and knows nothing about WebSockets or the devtools
protocol). Wrap it in whatever carrier you need — the Bun server here, or a Node
ws server. (The Cloudflare host above deliberately does not use it: a
hibernatable Durable Object cannot keep a room in memory, so its fan-out reads
ctx.getWebSockets() instead.)
import { DevtoolsRelayHub } from "@nice-code/devtools-relay";
const hub = new DevtoolsRelayHub();
hub.join(roomKey, conn); // conn: { send(data: string): void }
hub.relay(conn, frame); // → every other member of conn's room
hub.leave(conn);Security
The local mode (?key= rooms) is an unauthenticated, ephemeral relay meant
for local/dev use. The CLI binds 0.0.0.0 by default so other devices on your
LAN can reach it — pass --host 127.0.0.1 to keep it same-machine only. (A
relay started for you by the Vite plugin binds loopback by default instead, on
the grounds that a server nobody consciously started should not be reachable
from the network.) Do not expose it to the public internet.
Anything deployed belongs to session mode (above): admin-gated minting,
token-authenticated joins, per-room capacity, frame rate limits, short TTLs, and
an unconditional refusal of production-declared participants. Neither mode
holds anything at rest.
