Vite-to-CLI Handoff
How a devtools click in a Vite-served app lands in a running nice-devtools CLI workspace — same-machine discovery, the announce handshake, relay matching, every stable fallback reason, and which side owns each opt-out.
Run a Vite app with niceDevtools() and a standalone nice-devtools CLI at the
same time and you would normally get two parallel devtools: the app’s own popped-out window, and the
CLI’s workspace for the same topology. Instead, the dev server hands the click off: it discovers
the running CLI, registers the app’s topology there, and the devtools button opens the CLI’s
workspace directly. One devtools surface, whichever way you started it.
Nothing is required to turn this on. It works whenever both processes are running on the same machine, and every failure falls back to the app-local window — a devtools click always opens something, and never more than one window.
How discovery works
Section titled “How discovery works”The CLI writes one instance record per process into instances/<instance-id>.json under the
per-user nice-devtools data directory:
- Windows:
%LOCALAPPDATA%\nice-devtools\instances\ - macOS:
~/Library/Application Support/nice-devtools/instances/ - Linux:
$XDG_STATE_HOME/nice-devtools/instances/, then~/.local/state/nice-devtools/instances/
Records are ephemeral: written only once the dashboard is actually listening (so a record never advertises a port the process failed to acquire), removed on clean shutdown, and scavenged by readers only when a probe fails and the recorded PID is dead — age alone never proves death, because PIDs are reused and machines sleep.
The Vite dev server reads those records and probe-verifies each one against the CLI’s
credential-free loopback route GET /api/instance. Liveness is not identity: the probe must match
the service marker (nice-devtools-cli), the discovery protocol version, and the canonical
dashboard URL before an instance counts, so an unrelated server squatting the port is never mistaken
for a CLI. With no records at all, the default dashboard port range (5200 plus a small following
range) is probed directly and the lowest compatible live port wins.
When several CLI instances are live, the one whose local relay endpoint matches the app’s wins, then the most recently started. At click time the dev server re-verifies the exact instance the launcher saw; if that instance died in the gap, one fresh selection is made rather than failing on a stale id.
The click, step by step
Section titled “The click, step by step”- The app’s devtools bridge polls the dev server’s status route as part of ordinary relay
discovery. For a same-machine browser that names its topology, the answer carries a
cliDevtoolssnapshot: instance id, dashboard URL, whether announces are accepted, and the workspace link if one already exists. The launcher keeps the latest snapshot per topology; an absent orpresent: falsefield clears it, so a vanished CLI stops attracting handoffs. - On click, the launcher opens a named placeholder window synchronously — while the click’s
transient activation is unquestionably live — and only then asks the dev server to perform the
handoff (
POST /__nice_devtools/handoff). A slow or dead CLI can never trip the popup blocker or strand the click. - The dev server re-probes (the snapshot may be up to a few seconds stale), then makes an
add-only announce to the CLI:
POST /api/announce-topologywith the topology id. The announce can create adiscoveredtopology workspace and start the CLI’s already-enabled relay; it never removes anything and never mutates a setting. - The CLI answers with a relative workspace href. The dev server joins it onto the probe-verified loopback base — the CLI cannot steer the launcher to an arbitrary URL — and the placeholder navigates there.
- On any failure, the same placeholder becomes the ordinary app-local devtools window. Exactly one window per click, whatever happens.
The browser itself never talks to the CLI: it cannot read instance records, and the CLI’s cross-site guard refuses foreign-origin requests. Everything runs server-to-server between the dev server and the CLI, and the browser only ever receives a verified loopback URL.
Relay matching
Section titled “Relay matching”A handoff is only correct when the app’s producers and the CLI’s workspace listen on the same
relay. Both sides’ endpoints are normalized (loopback aliases collapse to 127.0.0.1, default
ports made explicit) and compared as full endpoints, never bare ports. The app’s side is its
explicit relayUrl if it set one, otherwise the relay its own dev server advertises (default
ws://127.0.0.1:5199); the CLI’s side is the local relay it hosts or shares. A mismatch refuses the
handoff — an app-local window that hears its producers beats a CLI workspace that never would.
After a successful handoff the app’s bridge re-checks relay status (a plain GET) so its carrier attaches to the relay the CLI just ensured — it deliberately does not ensure a relay of its own, which would race the dev server into becoming a second relay owner.
The same-origin relay proxy
Section titled “The same-origin relay proxy”The dev server also serves /__nice_devtools/relay, a same-origin WebSocket path that pipes to the
loopback relay. A page’s own origin is reachable however the page was opened — localhost, the
machine’s LAN IP, a phone — so the relay’s bind address stops mattering to clients: a
loopback-bound relay hosted by the CLI is still reachable from a LAN-opened page through its own dev
server. Older devtools builds that predate the proxy would direct-dial and fail, so they are
truthfully told no relay is running, with the remedy printed in the dev server’s log.
One window, not several
Section titled “One window, not several”- Live-opener reuse. The launcher window name is derived from the CLI instance and topology, so a repeat click finds the live workspace window and focuses it — never reloads it mid-investigation.
- An existing app-origin window keeps winning. If a devtools window was open before the CLI started, clicks keep focusing it until it closes. A click never jumps origins mid-session; convergence happens on the next fresh one.
- Newest-window takeover. A CLI workspace page claims a same-origin presence key per workspace; when a newer window claims the same key, older siblings of that workspace close themselves. The fresh window is the one the browser put in front — a buried window cannot self-raise — so it is always the survivor. Two different workspaces never displace each other.
When the click stays on the app-local window
Section titled “When the click stays on the app-local window”Every refusal has a stable reason, logged once per reason in the dev server console
(devtools click stays on the app-local window (<reason>)):
| Reason | Meaning |
|---|---|
cli-gone | No live, compatible CLI answered — not running, or its record pointed nowhere. |
non-loopback-client | The browser is on another device. A 127.0.0.1 dashboard URL would make it dial itself, so remote-device browsers always keep the Vite-served window. Judged by the TCP peer address, not the Host header — your own browser at the dev server’s LAN URL still hands off. |
lan-mode | The CLI is running in LAN mode, which serves neither discovery nor announce. |
announcements-disabled | The CLI declines dev-server announces (--no-dev-server-announcements or the dashboard setting). |
local-relay-disabled | The CLI’s local relay is explicitly disabled; the announce refuses rather than silently re-enabling it. |
local-relay-unavailable | The CLI accepted but could not bring its relay up (port conflict, bind failure). |
protocol-mismatch | The discovered CLI speaks an incompatible discovery protocol version — update one side. |
endpoint-mismatch | The app’s relay endpoint and the CLI’s don’t match (see relay matching). |
Who owns each opt-out
Section titled “Who owns each opt-out”Three parties can decline a handoff, each on its own side:
- The dev server:
niceDevtools({ handoffToCli: false })— neither the status snapshot nor the handoff route is served, and the launcher behaves exactly as if no CLI existed. - The CLI:
--no-dev-server-announcements(or the dashboard’s Accept local dev-server handoffs setting) — discovery still answers, but every announce is refused withannouncements-disabled.--dev-server-announcementsturns it back on. - The app: an explicit
windowUrlincreateNiceDevtoolsis a deliberate window-host choice and is never handed off.