Skip to content

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.

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.

  1. 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 cliDevtools snapshot: 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 or present: false field clears it, so a vanished CLI stops attracting handoffs.
  2. 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.
  3. 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-topology with the topology id. The announce can create a discovered topology workspace and start the CLI’s already-enabled relay; it never removes anything and never mutates a setting.
  4. 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.
  5. 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.

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

  • 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>)):

ReasonMeaning
cli-goneNo live, compatible CLI answered — not running, or its record pointed nowhere.
non-loopback-clientThe 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-modeThe CLI is running in LAN mode, which serves neither discovery nor announce.
announcements-disabledThe CLI declines dev-server announces (--no-dev-server-announcements or the dashboard setting).
local-relay-disabledThe CLI’s local relay is explicitly disabled; the announce refuses rather than silently re-enabling it.
local-relay-unavailableThe CLI accepted but could not bring its relay up (port conflict, bind failure).
protocol-mismatchThe discovered CLI speaks an incompatible discovery protocol version — update one side.
endpoint-mismatchThe app’s relay endpoint and the CLI’s don’t match (see relay matching).

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 with announcements-disabled. --dev-server-announcements turns it back on.
  • The app: an explicit windowUrl in createNiceDevtools is a deliberate window-host choice and is never handed off.