authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-02 20:08:32-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 20:08:32-07:00
log66419a1d1b80110c9702edae641bddcc2888fc98
tree67fc102e6d20b942cfd3ec043d9b64d50f4b6ac5
parent85f2479ffe5c9d8ffa91f1bfae5234df4061292f
signature Signed by PGP key B5690EEEBB952194

fix(dev): share dev servers on the loopback Vite actually binds (#9324)

Co-authored-by: Julius Marminge <julius@mac.lan> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

2 files changed, 31 insertions(+), 2 deletions(-)

scripts/lib/dev-share.test.ts+20-1
......@@ -27,11 +27,16 @@ const encode = (value: string) => Stream.make(new TextEncoder().encode(value));
2727 * test set the outcome of the `off` (pre-clear) and `serve` calls separately —
2828 * they are the same subcommand and are told apart by the trailing `off`.
2929 */
30const spawnerLayer = (input: { readonly off?: CallResult; readonly serve?: CallResult }) =>
30const spawnerLayer = (input: {
31 readonly off?: CallResult;
32 readonly serve?: CallResult;
33 readonly calls?: Array<ReadonlyArray<string>>;
34}) =>
3135 Layer.succeed(
3236 ChildProcessSpawner.ChildProcessSpawner,
3337 ChildProcessSpawner.make((command) => {
3438 const args = "args" in command ? (command.args as ReadonlyArray<string>) : [];
39 input.calls?.push(args);
3540 const result: CallResult = args.includes("status")
3641 ? { exitCode: 0 }
3742 : args.includes("off")
......@@ -102,6 +107,20 @@ describe("shareDevServer", () => {
102107 }),
103108 );
104109
110 // Vite binds `localhost`, which modern Node resolves to `::1` first, so a
111 // 127.0.0.1 target would proxy to a loopback nothing listens on.
112 it.effect("proxies to the localhost name Vite binds, not 127.0.0.1", () =>
113 Effect.gen(function* () {
114 const calls: Array<ReadonlyArray<string>> = [];
115 yield* shareDevServer({ webPort: 5788 }).pipe(
116 Effect.provide(spawnerLayer({ off: { exitCode: 0 }, calls })),
117 );
118
119 const serveCall = calls.find((args) => args.includes("--bg"));
120 assert.deepEqual(serveCall, ["serve", "--bg", "--https=5788", "http://localhost:5788"]);
121 }),
122 );
123
105124 // The stale-mapping clear runs before serve, so a failure here leaves the
106125 // port serving nothing. Saying only "serve failed" would let an operator
107126 // assume their previous mapping survived.
scripts/lib/dev-share.ts+11-1
......@@ -195,7 +195,17 @@ export const shareDevServer = Effect.fn("devShare.shareDevServer")(function* (in
195195 });
196196 }
197197
198 yield* ensureTailscaleServe({ localPort: input.webPort, servePort: input.webPort }).pipe(
198 // Proxy to the hostname Vite binds rather than the package default of
199 // 127.0.0.1. Vite listens on `localhost`, which Node 17+ resolves to `::1`
200 // first, so it only binds the IPv6 loopback and a 127.0.0.1 target has
201 // nothing behind it (tailscale answers 502). Passing `localhost` lets the
202 // tailscale proxy resolve it the same way Node did. Not a literal `[::1]`:
203 // tailscale rejects that form.
204 yield* ensureTailscaleServe({
205 localPort: input.webPort,
206 servePort: input.webPort,
207 localHost: "localhost",
208 }).pipe(
199209 Effect.mapError((error) => {
200210 const explanation = explainCommandFailure(error);
201211 return new DevServeFailedError({