fix(preview): resolve dev-server ports against the environment - #115
Merged
Conversation
Opening a dev server from a remote client showed ERR_CONNECTION_REFUSED. The client rewrote `localhost:PORT` to the environment's hostname, kept the port and scheme, and navigated. That address only works if the port is independently published there, which it usually is not — dev servers bind loopback. The failure surfaced as a browser error page, indistinguishable from a broken app, so agents reported it as one. Reachability is now resolved by the environment, which is the only side that knows what is listening and what the tailnet routes. A new `preview.resolvePort` RPC answers with a URL it has verified: - a same-machine client keeps plain localhost; - an existing `tailscale serve` route is reused, at whatever port and scheme it was published under; - a port already answering on the environment's own address (WSL, LAN, a tunnel) uses that address and publishes nothing; - otherwise a tailnet-only HTTPS route is published, probed until it answers, and withdrawn when the dev server exits or the server shuts down. When none of those work it fails with a reason and a next action instead of a URL that cannot load. Navigation, the port list, chat links, and agent `preview_navigate`/`preview_open` all route through it; the old synchronous rewrite is kept for labels only. Two loopback-family bugs fell out of verifying this end to end. Vite's default `--host localhost` binds `::1` only, so `tailscale serve` mappings pinned to `127.0.0.1` proxy to nothing and answer 502 — `vp run dev --share` produced a URL that never worked. Both the new resolver and dev-share now target `localhost` and let tailscale pick the family. Upstream, not fork-local: both halves came from merged upstream PRs (pingdotgg#4011 rewrote the URL, pingdotgg#4556 added sharing) and the seam between them was never covered. Promote after merge.
patroza
force-pushed
the
fix/preview-remote-port-reachability
branch
from
July 27, 2026 13:13
4171df5 to
bd3059d
Compare
patroza
added a commit
that referenced
this pull request
Jul 27, 2026
Opening a dev server from a remote client showed ERR_CONNECTION_REFUSED. The client rewrote `localhost:PORT` to the environment's hostname, kept the port and scheme, and navigated. That address only works if the port is independently published there, which it usually is not — dev servers bind loopback. The failure surfaced as a browser error page, indistinguishable from a broken app, so agents reported it as one. Reachability is now resolved by the environment, which is the only side that knows what is listening and what the tailnet routes. A new `preview.resolvePort` RPC answers with a URL it has verified: - a same-machine client keeps plain localhost; - an existing `tailscale serve` route is reused, at whatever port and scheme it was published under; - a port already answering on the environment's own address (WSL, LAN, a tunnel) uses that address and publishes nothing; - otherwise a tailnet-only HTTPS route is published, probed until it answers, and withdrawn when the dev server exits or the server shuts down. When none of those work it fails with a reason and a next action instead of a URL that cannot load. Navigation, the port list, chat links, and agent `preview_navigate`/`preview_open` all route through it; the old synchronous rewrite is kept for labels only. Two loopback-family bugs fell out of verifying this end to end. Vite's default `--host localhost` binds `::1` only, so `tailscale serve` mappings pinned to `127.0.0.1` proxy to nothing and answer 502 — `vp run dev --share` produced a URL that never worked. Both the new resolver and dev-share now target `localhost` and let tailscale pick the family. Upstream, not fork-local: both halves came from merged upstream PRs (pingdotgg#4011 rewrote the URL, pingdotgg#4556 added sharing) and the seam between them was never covered. Promote after merge.
patroza
added a commit
that referenced
this pull request
Jul 27, 2026
Opening a dev server from a remote client showed ERR_CONNECTION_REFUSED. The client rewrote `localhost:PORT` to the environment's hostname, kept the port and scheme, and navigated. That address only works if the port is independently published there, which it usually is not — dev servers bind loopback. The failure surfaced as a browser error page, indistinguishable from a broken app, so agents reported it as one. Reachability is now resolved by the environment, which is the only side that knows what is listening and what the tailnet routes. A new `preview.resolvePort` RPC answers with a URL it has verified: - a same-machine client keeps plain localhost; - an existing `tailscale serve` route is reused, at whatever port and scheme it was published under; - a port already answering on the environment's own address (WSL, LAN, a tunnel) uses that address and publishes nothing; - otherwise a tailnet-only HTTPS route is published, probed until it answers, and withdrawn when the dev server exits or the server shuts down. When none of those work it fails with a reason and a next action instead of a URL that cannot load. Navigation, the port list, chat links, and agent `preview_navigate`/`preview_open` all route through it; the old synchronous rewrite is kept for labels only. Two loopback-family bugs fell out of verifying this end to end. Vite's default `--host localhost` binds `::1` only, so `tailscale serve` mappings pinned to `127.0.0.1` proxy to nothing and answer 502 — `vp run dev --share` produced a URL that never worked. Both the new resolver and dev-share now target `localhost` and let tailscale pick the family. Upstream, not fork-local: both halves came from merged upstream PRs (pingdotgg#4011 rewrote the URL, pingdotgg#4556 added sharing) and the seam between them was never covered. Promote after merge.
patroza
added a commit
that referenced
this pull request
Jul 28, 2026
Opening a dev server from a remote client showed ERR_CONNECTION_REFUSED. The client rewrote `localhost:PORT` to the environment's hostname, kept the port and scheme, and navigated. That address only works if the port is independently published there, which it usually is not — dev servers bind loopback. The failure surfaced as a browser error page, indistinguishable from a broken app, so agents reported it as one. Reachability is now resolved by the environment, which is the only side that knows what is listening and what the tailnet routes. A new `preview.resolvePort` RPC answers with a URL it has verified: - a same-machine client keeps plain localhost; - an existing `tailscale serve` route is reused, at whatever port and scheme it was published under; - a port already answering on the environment's own address (WSL, LAN, a tunnel) uses that address and publishes nothing; - otherwise a tailnet-only HTTPS route is published, probed until it answers, and withdrawn when the dev server exits or the server shuts down. When none of those work it fails with a reason and a next action instead of a URL that cannot load. Navigation, the port list, chat links, and agent `preview_navigate`/`preview_open` all route through it; the old synchronous rewrite is kept for labels only. Two loopback-family bugs fell out of verifying this end to end. Vite's default `--host localhost` binds `::1` only, so `tailscale serve` mappings pinned to `127.0.0.1` proxy to nothing and answer 502 — `vp run dev --share` produced a URL that never worked. Both the new resolver and dev-share now target `localhost` and let tailscale pick the family. Upstream, not fork-local: both halves came from merged upstream PRs (pingdotgg#4011 rewrote the URL, pingdotgg#4556 added sharing) and the seam between them was never covered. Promote after merge.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
Opening a dev server from a remote client shows
ERR_CONNECTION_REFUSED.apps/web/src/browser/browserTargetResolver.tsrewrotelocalhost:PORTto the environment's hostname, kept the port and scheme, and navigated. That address only works if the port is independently published there — which it usually is not, because dev servers bind loopback. Vite bindslocalhostby default (apps/web/vite.config.ts), so nothing listens on the tailnet interface at that port.The failure surfaced as a browser error page, indistinguishable from a broken app. Agents driving
preview_navigatereported it as an app failure rather than an unreachable port.This is upstream, not fork-local.
browserTargetResolver.ts,scripts/lib/dev-share.ts, andpackages/tailscale/src/tailscale.tsare byte-identical toupstream/main. Both halves came from merged upstream PRs — #4011 added the rewrite, #4556 added tailnet sharing — and the seam between them was never covered. No upstream issue reports it. Promote after merge.The fix
Reachability is resolved by the environment, the only side that knows what is listening locally and what the tailnet routes. A new
preview.resolvePortRPC answers with a URL it has verified, in order:localhost:PORT, nothing published.tailscale serveroute for the port → reused, at whatever port and scheme it was published under.tailscale serve, never Funnel), probed until it answers, withdrawn when the dev server exits or the server shuts down.When none apply it fails with
PreviewPortUnreachableErrorcarrying a reason and a next action, instead of a URL that cannot load.Navigation, the discovered-port list, chat links, and agent
preview_navigate/preview_openall route through it. The old synchronous rewrite is kept for labels only and documented as such.Two loopback-family bugs found while verifying
Vite's default
--host localhostbinds::1only, sotailscale servemappings pinned to127.0.0.1proxy to nothing and answer 502.vp run dev --shareproduced a URL that never worked — reproduced on this branch before the fix. Both the new resolver andscripts/lib/dev-share.tsnow targetlocalhostand let tailscale pick the family.Verification
Focused tests (357 passing across the touched scope), local typecheck for every touched package, and
vp checkclean.Verified against real tailscale on a live dev stack, not just mocks:
https://…:15665, withdrawn on shutdown--share-published web port (::1-bound)createdExposure: false, HTTP 200not-listening+ remedy, no mapping createdhttp://localhost:15665, tailscale never invokedvp run dev --sharere-verified end to end: 502 before the fix, 200 after, and the shared origin loads in a controlled browser.Notes for review
docs/user/remote-access.mddocuments the behavior, the requirements (tailscale set --operator), and how to opt out by binding a routable address or publishing ports by hand..agents/skills/test-t3-app/SKILL.mdnow tells agents to hand the previewlocalhost:<port>and never hand-write the environment hostname with a dev port appended.🤖 Generated with Claude Code