Skip to content

Commit 6f3c32e

Browse files
patrozagithub-actions[bot]
authored andcommitted
fix(preview): resolve dev-server ports against the environment (#115)
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.
1 parent 4ff87e3 commit 6f3c32e

20 files changed

Lines changed: 1407 additions & 280 deletions

File tree

.agents/skills/test-t3-app/SKILL.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,23 @@ Shared browser dev is single-origin: Vite proxies the backend paths, so never se
2626

2727
The dev runner disables browser auto-open by default. Do not pass `--browser` during automated testing: an automatically opened page can consume the one-time bootstrap token before the controlled browser uses it.
2828

29+
### Previewing the dev server from a remote client
30+
31+
When T3 itself is being driven from another machine — the app connected to this environment over a
32+
tailnet or LAN address rather than `localhost` — a dev server bound to loopback is not reachable at
33+
that address just because the hostname is. Opening it does **not** require `--share`: the
34+
environment resolves the port on demand, reusing an existing `tailscale serve` route, using the
35+
environment's own address when the port already answers there, and otherwise publishing a
36+
tailnet-only HTTPS route for the port and withdrawing it when the dev server exits.
37+
38+
Give the preview a `localhost:<port>` URL and let it resolve. Never hand-write the environment's
39+
hostname with the dev port appended — that is the shape that fails, because nothing promises the
40+
dev port is published under the same number or scheme.
41+
42+
If the port cannot be made reachable, the preview reports why and what to do (dev server not
43+
running, tailscale not logged in, no permission to manage routes, tailnet port already taken).
44+
Treat that message as the result; do not retry the same URL.
45+
2946
### Verify a shared environment before human handoff
3047

3148
When another person will use the printed pairing URL, first open the shared origin without the pairing path or fragment in the controlled browser and confirm the T3 Code app loads. This browser navigation is required even when curl succeeds because browsers block some otherwise reachable ports before making a network request.
Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
import { assert, describe, it } from "@effect/vitest";
2+
import { PreviewPortUnreachableError } from "@t3tools/contracts";
3+
import * as Net from "@t3tools/shared/Net";
4+
import * as Effect from "effect/Effect";
5+
import * as Fiber from "effect/Fiber";
6+
import * as Layer from "effect/Layer";
7+
import * as Ref from "effect/Ref";
8+
import * as Sink from "effect/Sink";
9+
import * as Stream from "effect/Stream";
10+
import * as TestClock from "effect/testing/TestClock";
11+
import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http";
12+
import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
13+
14+
import { PreviewPortExposure, layer as portExposureLayer } from "./PortExposure.ts";
15+
16+
const encoder = new TextEncoder();
17+
18+
const CLIENT_ON_TAILNET = "https://smart.tail.ts.net/";
19+
const CLIENT_ON_LOOPBACK = "http://localhost:5732/";
20+
21+
interface SpawnCall {
22+
readonly args: ReadonlyArray<string>;
23+
}
24+
25+
const serveStatusWith = (mappings: ReadonlyArray<{ servePort: number; localPort: number }>) =>
26+
JSON.stringify({
27+
Web: Object.fromEntries(
28+
mappings.map(({ servePort, localPort }) => [
29+
`smart.tail.ts.net:${servePort}`,
30+
{ Handlers: { "/": { Proxy: `http://127.0.0.1:${localPort}` } } },
31+
]),
32+
),
33+
});
34+
35+
/**
36+
* Records every tailscale invocation and answers `serve status` from a mutable
37+
* script, so a test can assert that publishing a port is what makes it appear.
38+
*/
39+
const spawnerHarness = (input: {
40+
readonly serveStatus: () => string;
41+
readonly onServe?: (args: ReadonlyArray<string>) => { stderr?: string; code?: number };
42+
}) =>
43+
Effect.gen(function* () {
44+
const calls = yield* Ref.make<ReadonlyArray<SpawnCall>>([]);
45+
const layer = Layer.succeed(
46+
ChildProcessSpawner.ChildProcessSpawner,
47+
ChildProcessSpawner.make((command) => {
48+
const spawned = command as unknown as { readonly args: ReadonlyArray<string> };
49+
const args = spawned.args;
50+
const isStatusRead = args[0] === "serve" && args[1] === "status";
51+
const result = isStatusRead
52+
? { stdout: input.serveStatus(), code: 0 }
53+
: { stdout: "", ...(input.onServe?.(args) ?? { code: 0 }) };
54+
return Ref.update(calls, (previous) => [...previous, { args }]).pipe(
55+
Effect.as(
56+
ChildProcessSpawner.makeHandle({
57+
pid: ChildProcessSpawner.ProcessId(1),
58+
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code ?? 0)),
59+
isRunning: Effect.succeed(false),
60+
kill: () => Effect.void,
61+
unref: Effect.succeed(Effect.void),
62+
stdin: Sink.drain,
63+
stdout: Stream.make(encoder.encode(result.stdout ?? "")),
64+
stderr: Stream.make(encoder.encode(result.stderr ?? "")),
65+
all: Stream.empty,
66+
getInputFd: () => Sink.drain,
67+
getOutputFd: () => Stream.empty,
68+
}),
69+
),
70+
);
71+
}),
72+
);
73+
return { calls, layer };
74+
});
75+
76+
const netLayer = (listeningPorts: ReadonlyArray<number>) =>
77+
Layer.succeed(Net.NetService, {
78+
canListenOnHost: () => Effect.succeed(true),
79+
isPortAvailableOnLoopback: (port: number) => Effect.succeed(!listeningPorts.includes(port)),
80+
reserveLoopbackPort: () => Effect.succeed(0),
81+
findAvailablePort: (preferred: number) => Effect.succeed(preferred),
82+
} as Net.NetServiceShape);
83+
84+
/**
85+
* Answers a probe only for origins the test says are actually serving. Modelled
86+
* on reachability rather than a fixed status code, because the resolver's whole
87+
* job is to tell a route that answers from one that does not.
88+
*/
89+
const httpLayer = (isReachable: (url: string) => boolean) =>
90+
Layer.succeed(
91+
HttpClient.HttpClient,
92+
HttpClient.make(
93+
(
94+
request,
95+
): Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError> =>
96+
isReachable(request.url)
97+
? Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 200 })))
98+
: Effect.fail(
99+
new HttpClientError.HttpClientError({
100+
reason: new HttpClientError.TransportError({
101+
request,
102+
description: "connection refused",
103+
}),
104+
}),
105+
),
106+
),
107+
);
108+
109+
const runResolve = (input: {
110+
readonly port: number;
111+
readonly clientBaseUrl: string;
112+
readonly serveStatus: () => string;
113+
readonly listeningPorts: ReadonlyArray<number>;
114+
readonly onServe?: (args: ReadonlyArray<string>) => { stderr?: string; code?: number };
115+
/** Origins that answer beyond whatever the current serve status publishes. */
116+
readonly alsoReachable?: ReadonlyArray<string>;
117+
/** Set when a published mapping still must not answer. */
118+
readonly neverReachable?: boolean;
119+
}) =>
120+
Effect.gen(function* () {
121+
const harness = yield* spawnerHarness({
122+
serveStatus: input.serveStatus,
123+
...(input.onServe ? { onServe: input.onServe } : {}),
124+
});
125+
const reachable = (url: string) => {
126+
if (input.neverReachable) return false;
127+
if (input.alsoReachable?.some((origin) => url.startsWith(origin))) return true;
128+
const published = JSON.parse(input.serveStatus()) as {
129+
Web?: Record<string, unknown>;
130+
};
131+
return Object.keys(published.Web ?? {}).some((hostKey) =>
132+
url.startsWith(`https://${hostKey}`),
133+
);
134+
};
135+
const resolving = yield* Effect.forkChild(
136+
Effect.gen(function* () {
137+
const exposure = yield* PreviewPortExposure;
138+
return yield* exposure
139+
.resolve({ port: input.port, clientBaseUrl: input.clientBaseUrl })
140+
.pipe(Effect.result);
141+
}).pipe(
142+
Effect.provide(
143+
portExposureLayer.pipe(
144+
Layer.provide(harness.layer),
145+
Layer.provide(netLayer(input.listeningPorts)),
146+
Layer.provide(httpLayer(reachable)),
147+
),
148+
),
149+
),
150+
);
151+
// The reachability probe retries on a schedule until a deadline, so an
152+
// unreachable port only resolves once virtual time passes that deadline.
153+
yield* TestClock.adjust("10 seconds");
154+
const result = yield* Fiber.join(resolving);
155+
return { result, calls: yield* Ref.get(harness.calls) };
156+
});
157+
158+
describe("PreviewPortExposure", () => {
159+
it.effect("keeps a same-machine client on loopback and publishes nothing", () =>
160+
Effect.gen(function* () {
161+
const { result, calls } = yield* runResolve({
162+
port: 5733,
163+
clientBaseUrl: CLIENT_ON_LOOPBACK,
164+
serveStatus: () => "{}",
165+
listeningPorts: [5733],
166+
});
167+
168+
assert.deepEqual(result._tag === "Success" ? result.success : null, {
169+
origin: "http://localhost:5733",
170+
strategy: "loopback",
171+
createdExposure: false,
172+
});
173+
// Nothing was asked of tailscale: a local client never needs the tailnet,
174+
// and publishing here would share a dev server nobody asked to share.
175+
assert.deepEqual(calls, []);
176+
}),
177+
);
178+
179+
it.effect("reuses an existing mapping instead of assuming port parity", () =>
180+
Effect.gen(function* () {
181+
const { result, calls } = yield* runResolve({
182+
port: 5733,
183+
clientBaseUrl: CLIENT_ON_TAILNET,
184+
// Published on a different tailnet port, over https — exactly the shape
185+
// the old client-side guess (same port, same scheme) got wrong.
186+
serveStatus: () => serveStatusWith([{ servePort: 45733, localPort: 5733 }]),
187+
listeningPorts: [5733],
188+
});
189+
190+
assert.deepEqual(result._tag === "Success" ? result.success : null, {
191+
origin: "https://smart.tail.ts.net:45733",
192+
strategy: "tailnet-serve",
193+
createdExposure: false,
194+
});
195+
assert.isUndefined(calls.find((call) => call.args.includes("--bg")));
196+
}),
197+
);
198+
199+
it.effect("uses the environment's own address when the port already answers there", () =>
200+
Effect.gen(function* () {
201+
const { result, calls } = yield* runResolve({
202+
port: 5173,
203+
// A WSL / LAN environment, where a dev server bound to a wildcard
204+
// address is genuinely reachable at the host the client already uses.
205+
clientBaseUrl: "http://172.25.85.75:3773/",
206+
serveStatus: () => "{}",
207+
listeningPorts: [5173],
208+
alsoReachable: ["http://172.25.85.75:5173"],
209+
});
210+
211+
assert.deepEqual(result._tag === "Success" ? result.success : null, {
212+
origin: "http://172.25.85.75:5173",
213+
strategy: "direct-private-network",
214+
createdExposure: false,
215+
});
216+
// Nothing published: a route that already works needs no second one.
217+
assert.isUndefined(calls.find((call) => call.args.includes("--bg")));
218+
}),
219+
);
220+
221+
it.effect("publishes a loopback-only port on demand", () =>
222+
Effect.gen(function* () {
223+
let published = false;
224+
const { result, calls } = yield* runResolve({
225+
port: 6545,
226+
clientBaseUrl: CLIENT_ON_TAILNET,
227+
serveStatus: () =>
228+
published ? serveStatusWith([{ servePort: 6545, localPort: 6545 }]) : "{}",
229+
listeningPorts: [6545],
230+
onServe: () => {
231+
published = true;
232+
return { code: 0 };
233+
},
234+
});
235+
236+
assert.deepEqual(result._tag === "Success" ? result.success : null, {
237+
origin: "https://smart.tail.ts.net:6545",
238+
strategy: "tailnet-serve",
239+
createdExposure: true,
240+
});
241+
// Targets `localhost`, not `127.0.0.1`: Vite's default bind is `::1` only,
242+
// and an IPv4-pinned mapping proxies to nothing and answers 502.
243+
assert.deepEqual(calls.find((call) => call.args.includes("--bg"))?.args, [
244+
"serve",
245+
"--bg",
246+
"--https=6545",
247+
"http://localhost:6545",
248+
]);
249+
}),
250+
);
251+
252+
it.effect("fails with a remedy when the dev server is not running", () =>
253+
Effect.gen(function* () {
254+
const { result } = yield* runResolve({
255+
port: 6545,
256+
clientBaseUrl: CLIENT_ON_TAILNET,
257+
serveStatus: () => "{}",
258+
listeningPorts: [],
259+
});
260+
261+
const error = result._tag === "Failure" ? result.failure : null;
262+
assert.instanceOf(error, PreviewPortUnreachableError);
263+
assert.equal(error?.reason, "not-listening");
264+
assert.include(error?.message ?? "", "Start the dev server first");
265+
}),
266+
);
267+
268+
it.effect("refuses to take over a tailnet port that routes elsewhere", () =>
269+
Effect.gen(function* () {
270+
const { result, calls } = yield* runResolve({
271+
port: 6545,
272+
clientBaseUrl: CLIENT_ON_TAILNET,
273+
serveStatus: () =>
274+
serveStatusWith([
275+
{ servePort: 6545, localPort: 9999 },
276+
{ servePort: 46545, localPort: 9998 },
277+
]),
278+
listeningPorts: [6545],
279+
});
280+
281+
const error = result._tag === "Failure" ? result.failure : null;
282+
assert.equal(error?.reason, "serve-port-conflict");
283+
// The pre-existing mapping is left exactly as it was found.
284+
assert.isUndefined(calls.find((call) => call.args.includes("--bg")));
285+
}),
286+
);
287+
288+
it.effect("surfaces a permission failure as an actionable reason", () =>
289+
Effect.gen(function* () {
290+
const { result } = yield* runResolve({
291+
port: 6545,
292+
clientBaseUrl: CLIENT_ON_TAILNET,
293+
serveStatus: () => "{}",
294+
listeningPorts: [6545],
295+
onServe: () => ({ code: 1, stderr: "access denied: must be root" }),
296+
});
297+
298+
const error = result._tag === "Failure" ? result.failure : null;
299+
assert.equal(error?.reason, "tailscale-permission-denied");
300+
// The classified label travels, never the raw stderr.
301+
assert.notInclude(error?.message ?? "", "must be root");
302+
}),
303+
);
304+
305+
it.effect("withdraws a mapping it published but could not reach", () =>
306+
Effect.gen(function* () {
307+
let published = false;
308+
const { result, calls } = yield* runResolve({
309+
port: 6545,
310+
clientBaseUrl: CLIENT_ON_TAILNET,
311+
serveStatus: () =>
312+
published ? serveStatusWith([{ servePort: 6545, localPort: 6545 }]) : "{}",
313+
listeningPorts: [6545],
314+
onServe: () => {
315+
published = true;
316+
return { code: 0 };
317+
},
318+
neverReachable: true,
319+
});
320+
321+
const error = result._tag === "Failure" ? result.failure : null;
322+
assert.equal(error?.reason, "not-reachable");
323+
// Publishing a port and then leaving it behind would keep a dead route on
324+
// the tailnet, so the failure path has to undo its own mapping.
325+
assert.deepEqual(calls.at(-1)?.args, ["serve", "--https=6545", "off"]);
326+
}),
327+
);
328+
});

0 commit comments

Comments
 (0)