Vite-to-CLI Handoff
How a devtools click in a Vite-served app opens 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 finds the
running CLI, registers the app’s topology there, and the devtools button opens the CLI’s workspace
directly. You get one devtools, whichever way you started it.
There is nothing to turn on. It works whenever both processes are running on the same machine. Every failure falls back to the app-local window, so 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. One is written only once the dashboard is actually listening, so a record never advertises a port the process failed to acquire. It is removed on clean shutdown. Readers scavenge a leftover record 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 sitting on 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, it makes one fresh selection and does not fail 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. Only then does it ask 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, because the snapshot may be up to a few seconds stale. It 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, so 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 and default
ports are made explicit. They are then 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, because 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 can always reach its own origin, however it 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 without reloading 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. Once that window is closed, the next click opens the CLI workspace.
- 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, and a buried window cannot raise itself, so it always survives. 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. It is 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, so 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.