Skip to content

fix(preview): resolve dev-server ports against the environment - #115

Merged
patroza merged 1 commit into
fork/changesfrom
fix/preview-remote-port-reachability
Jul 27, 2026
Merged

fix(preview): resolve dev-server ports against the environment#115
patroza merged 1 commit into
fork/changesfrom
fix/preview-remote-port-reachability

Conversation

@patroza

@patroza patroza commented Jul 27, 2026

Copy link
Copy Markdown
Owner

The bug

Opening a dev server from a remote client shows ERR_CONNECTION_REFUSED.

apps/web/src/browser/browserTargetResolver.ts 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, because dev servers bind loopback. Vite binds localhost by 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_navigate reported it as an app failure rather than an unreachable port.

This is upstream, not fork-local. browserTargetResolver.ts, scripts/lib/dev-share.ts, and packages/tailscale/src/tailscale.ts are byte-identical to upstream/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.resolvePort RPC answers with a URL it has verified, in order:

  1. Same-machine client → plain localhost:PORT, nothing published.
  2. An existing tailscale serve route for the port → reused, at whatever port and scheme it was published under.
  3. Port already answering on the environment's own address (WSL, LAN, a tunnel) → that address, nothing published.
  4. Otherwise → a tailnet-only HTTPS route (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 PreviewPortUnreachableError carrying 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_open all 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 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 — reproduced on this branch before the fix. Both the new resolver and scripts/lib/dev-share.ts now target localhost and let tailscale pick the family.

Verification

Focused tests (357 passing across the touched scope), local typecheck for every touched package, and vp check clean.

Verified against real tailscale on a live dev stack, not just mocks:

case result
127.0.0.1-only backend port, remote client published, verified, returned https://…:15665, withdrawn on shutdown
--share-published web port (::1-bound) reused existing mapping, createdExposure: false, HTTP 200
port with nothing listening not-listening + remedy, no mapping created
same-machine client http://localhost:15665, tailscale never invoked
after all runs zero leaked serve mappings

vp run dev --share re-verified end to end: 502 before the fix, 200 after, and the shared origin loads in a controlled browser.

Notes for review

  • Publishing is automatic and treated as consented by opening the port; routes are tailnet-only and never Funnel. docs/user/remote-access.md documents the behavior, the requirements (tailscale set --operator), and how to opt out by binding a routable address or publishing ports by hand.
  • The direct-reachability probe is skipped when a serve mapping occupies the same port number for a different local port — otherwise the probe would find that other app answering and hand back a URL to the wrong thing.
  • .agents/skills/test-t3-app/SKILL.md now tells agents to hand the preview localhost:<port> and never hand-write the environment hostname with a dev port appended.

🤖 Generated with Claude Code

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
patroza force-pushed the fix/preview-remote-port-reachability branch from 4171df5 to bd3059d Compare July 27, 2026 13:13
@patroza
patroza merged commit e1ce287 into fork/changes Jul 27, 2026
10 checks passed
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant