From e5770e5197e6869e9c74f6baa5cbc188f26a2ff5 Mon Sep 17 00:00:00 2001 From: Frank Behrens Date: Sat, 22 Aug 2026 16:38:27 +0200 Subject: [PATCH] fix(dev): --share no longer publishes a 502 link on IPv6-first hosts `vp run dev --share` pointed `tailscale serve` at 127.0.0.1, but the Vite dev server binds `localhost`, which resolves to ::1 first on modern macOS. The tailnet URL therefore answered 502 while localhost worked. Pass localHost: "localhost" for the dev share mapping so tailscaled resolves the proxy target exactly like the dev server did; the mapping and the listener agree by construction. The server's own --tailscale-serve and pair flows already pass explicit hosts and are untouched. Worked on by ox-alpha (opencode). --- scripts/lib/dev-share.test.ts | 22 ++++++++++++++++++++-- scripts/lib/dev-share.ts | 11 ++++++++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/scripts/lib/dev-share.test.ts b/scripts/lib/dev-share.test.ts index b2dfba3585eb..bfecc525e7ac 100644 --- a/scripts/lib/dev-share.test.ts +++ b/scripts/lib/dev-share.test.ts @@ -26,8 +26,13 @@ const encode = (value: string) => Stream.make(new TextEncoder().encode(value)); * Answers `tailscale status --json` with a valid tailnet name, and lets each * test set the outcome of the `off` (pre-clear) and `serve` calls separately — * they are the same subcommand and are told apart by the trailing `off`. + * `onServeArgs` observes each non-status, non-off invocation. */ -const spawnerLayer = (input: { readonly off?: CallResult; readonly serve?: CallResult }) => +const spawnerLayer = (input: { + readonly off?: CallResult; + readonly serve?: CallResult; + readonly onServeArgs?: (args: ReadonlyArray) => void; +}) => Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => { @@ -36,7 +41,7 @@ const spawnerLayer = (input: { readonly off?: CallResult; readonly serve?: CallR ? { exitCode: 0 } : args.includes("off") ? (input.off ?? { exitCode: 0 }) - : (input.serve ?? { exitCode: 0 }); + : (input.onServeArgs?.(args), input.serve ?? { exitCode: 0 }); return Effect.succeed( ChildProcessSpawner.makeHandle({ @@ -102,6 +107,19 @@ describe("shareDevServer", () => { }), ); + it.effect("points the serve mapping at the host Vite actually bound", () => + Effect.gen(function* () { + const serveArgs: ReadonlyArray[] = []; + yield* shareDevServer({ webPort: 5788 }).pipe( + Effect.provide(spawnerLayer({ onServeArgs: (args) => serveArgs.push([...args]) })), + ); + + // Vite binds `localhost`, which resolves IPv6-first on some OSes; a + // hardcoded 127.0.0.1 target would answer 502 there. + assert.deepEqual(serveArgs, [["serve", "--bg", "--https=5788", "http://localhost:5788"]]); + }), + ); + // The stale-mapping clear runs before serve, so a failure here leaves the // port serving nothing. Saying only "serve failed" would let an operator // assume their previous mapping survived. diff --git a/scripts/lib/dev-share.ts b/scripts/lib/dev-share.ts index 0f843b3ba91e..f9e781ee6249 100644 --- a/scripts/lib/dev-share.ts +++ b/scripts/lib/dev-share.ts @@ -195,7 +195,16 @@ export const shareDevServer = Effect.fn("devShare.shareDevServer")(function* (in }); } - yield* ensureTailscaleServe({ localPort: input.webPort, servePort: input.webPort }).pipe( + // "localhost", not "127.0.0.1": Vite binds `host: "localhost"` (see + // apps/web/vite.config.ts), which resolves to whichever loopback family this + // OS prefers — ::1 on modern macOS. Naming the same host here lets + // tailscaled resolve it exactly like the dev server did, so the mapping and + // the listener always agree. + yield* ensureTailscaleServe({ + localPort: input.webPort, + servePort: input.webPort, + localHost: "localhost", + }).pipe( Effect.mapError((error) => { const explanation = explainCommandFailure(error); return new DevServeFailedError({