diff --git a/.agents/skills/test-t3-app/SKILL.md b/.agents/skills/test-t3-app/SKILL.md index 45524f6fcd3..78478290f6a 100644 --- a/.agents/skills/test-t3-app/SKILL.md +++ b/.agents/skills/test-t3-app/SKILL.md @@ -26,6 +26,23 @@ Shared browser dev is single-origin: Vite proxies the backend paths, so never se 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. +### Previewing the dev server from a remote client + +When T3 itself is being driven from another machine — the app connected to this environment over a +tailnet or LAN address rather than `localhost` — a dev server bound to loopback is not reachable at +that address just because the hostname is. Opening it does **not** require `--share`: the +environment resolves the port on demand, reusing an existing `tailscale serve` route, using the +environment's own address when the port already answers there, and otherwise publishing a +tailnet-only HTTPS route for the port and withdrawing it when the dev server exits. + +Give the preview a `localhost:` URL and let it resolve. Never hand-write the environment's +hostname with the dev port appended — that is the shape that fails, because nothing promises the +dev port is published under the same number or scheme. + +If the port cannot be made reachable, the preview reports why and what to do (dev server not +running, tailscale not logged in, no permission to manage routes, tailnet port already taken). +Treat that message as the result; do not retry the same URL. + ### Verify a shared environment before human handoff 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. diff --git a/apps/server/src/preview/PortExposure.test.ts b/apps/server/src/preview/PortExposure.test.ts new file mode 100644 index 00000000000..f46c5204b27 --- /dev/null +++ b/apps/server/src/preview/PortExposure.test.ts @@ -0,0 +1,328 @@ +import { assert, describe, it } from "@effect/vitest"; +import { PreviewPortUnreachableError } from "@t3tools/contracts"; +import * as Net from "@t3tools/shared/Net"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import { PreviewPortExposure, layer as portExposureLayer } from "./PortExposure.ts"; + +const encoder = new TextEncoder(); + +const CLIENT_ON_TAILNET = "https://smart.tail.ts.net/"; +const CLIENT_ON_LOOPBACK = "http://localhost:5732/"; + +interface SpawnCall { + readonly args: ReadonlyArray; +} + +const serveStatusWith = (mappings: ReadonlyArray<{ servePort: number; localPort: number }>) => + JSON.stringify({ + Web: Object.fromEntries( + mappings.map(({ servePort, localPort }) => [ + `smart.tail.ts.net:${servePort}`, + { Handlers: { "/": { Proxy: `http://127.0.0.1:${localPort}` } } }, + ]), + ), + }); + +/** + * Records every tailscale invocation and answers `serve status` from a mutable + * script, so a test can assert that publishing a port is what makes it appear. + */ +const spawnerHarness = (input: { + readonly serveStatus: () => string; + readonly onServe?: (args: ReadonlyArray) => { stderr?: string; code?: number }; +}) => + Effect.gen(function* () { + const calls = yield* Ref.make>([]); + const layer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const spawned = command as unknown as { readonly args: ReadonlyArray }; + const args = spawned.args; + const isStatusRead = args[0] === "serve" && args[1] === "status"; + const result = isStatusRead + ? { stdout: input.serveStatus(), code: 0 } + : { stdout: "", ...(input.onServe?.(args) ?? { code: 0 }) }; + return Ref.update(calls, (previous) => [...previous, { args }]).pipe( + Effect.as( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code ?? 0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(encoder.encode(result.stdout ?? "")), + stderr: Stream.make(encoder.encode(result.stderr ?? "")), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ), + ); + }), + ); + return { calls, layer }; + }); + +const netLayer = (listeningPorts: ReadonlyArray) => + Layer.succeed(Net.NetService, { + canListenOnHost: () => Effect.succeed(true), + isPortAvailableOnLoopback: (port: number) => Effect.succeed(!listeningPorts.includes(port)), + reserveLoopbackPort: () => Effect.succeed(0), + findAvailablePort: (preferred: number) => Effect.succeed(preferred), + } as Net.NetServiceShape); + +/** + * Answers a probe only for origins the test says are actually serving. Modelled + * on reachability rather than a fixed status code, because the resolver's whole + * job is to tell a route that answers from one that does not. + */ +const httpLayer = (isReachable: (url: string) => boolean) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make( + ( + request, + ): Effect.Effect => + isReachable(request.url) + ? Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 200 }))) + : Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + description: "connection refused", + }), + }), + ), + ), + ); + +const runResolve = (input: { + readonly port: number; + readonly clientBaseUrl: string; + readonly serveStatus: () => string; + readonly listeningPorts: ReadonlyArray; + readonly onServe?: (args: ReadonlyArray) => { stderr?: string; code?: number }; + /** Origins that answer beyond whatever the current serve status publishes. */ + readonly alsoReachable?: ReadonlyArray; + /** Set when a published mapping still must not answer. */ + readonly neverReachable?: boolean; +}) => + Effect.gen(function* () { + const harness = yield* spawnerHarness({ + serveStatus: input.serveStatus, + ...(input.onServe ? { onServe: input.onServe } : {}), + }); + const reachable = (url: string) => { + if (input.neverReachable) return false; + if (input.alsoReachable?.some((origin) => url.startsWith(origin))) return true; + const published = JSON.parse(input.serveStatus()) as { + Web?: Record; + }; + return Object.keys(published.Web ?? {}).some((hostKey) => + url.startsWith(`https://${hostKey}`), + ); + }; + const resolving = yield* Effect.forkChild( + Effect.gen(function* () { + const exposure = yield* PreviewPortExposure; + return yield* exposure + .resolve({ port: input.port, clientBaseUrl: input.clientBaseUrl }) + .pipe(Effect.result); + }).pipe( + Effect.provide( + portExposureLayer.pipe( + Layer.provide(harness.layer), + Layer.provide(netLayer(input.listeningPorts)), + Layer.provide(httpLayer(reachable)), + ), + ), + ), + ); + // The reachability probe retries on a schedule until a deadline, so an + // unreachable port only resolves once virtual time passes that deadline. + yield* TestClock.adjust("10 seconds"); + const result = yield* Fiber.join(resolving); + return { result, calls: yield* Ref.get(harness.calls) }; + }); + +describe("PreviewPortExposure", () => { + it.effect("keeps a same-machine client on loopback and publishes nothing", () => + Effect.gen(function* () { + const { result, calls } = yield* runResolve({ + port: 5733, + clientBaseUrl: CLIENT_ON_LOOPBACK, + serveStatus: () => "{}", + listeningPorts: [5733], + }); + + assert.deepEqual(result._tag === "Success" ? result.success : null, { + origin: "http://localhost:5733", + strategy: "loopback", + createdExposure: false, + }); + // Nothing was asked of tailscale: a local client never needs the tailnet, + // and publishing here would share a dev server nobody asked to share. + assert.deepEqual(calls, []); + }), + ); + + it.effect("reuses an existing mapping instead of assuming port parity", () => + Effect.gen(function* () { + const { result, calls } = yield* runResolve({ + port: 5733, + clientBaseUrl: CLIENT_ON_TAILNET, + // Published on a different tailnet port, over https — exactly the shape + // the old client-side guess (same port, same scheme) got wrong. + serveStatus: () => serveStatusWith([{ servePort: 45733, localPort: 5733 }]), + listeningPorts: [5733], + }); + + assert.deepEqual(result._tag === "Success" ? result.success : null, { + origin: "https://smart.tail.ts.net:45733", + strategy: "tailnet-serve", + createdExposure: false, + }); + assert.isUndefined(calls.find((call) => call.args.includes("--bg"))); + }), + ); + + it.effect("uses the environment's own address when the port already answers there", () => + Effect.gen(function* () { + const { result, calls } = yield* runResolve({ + port: 5173, + // A WSL / LAN environment, where a dev server bound to a wildcard + // address is genuinely reachable at the host the client already uses. + clientBaseUrl: "http://172.25.85.75:3773/", + serveStatus: () => "{}", + listeningPorts: [5173], + alsoReachable: ["http://172.25.85.75:5173"], + }); + + assert.deepEqual(result._tag === "Success" ? result.success : null, { + origin: "http://172.25.85.75:5173", + strategy: "direct-private-network", + createdExposure: false, + }); + // Nothing published: a route that already works needs no second one. + assert.isUndefined(calls.find((call) => call.args.includes("--bg"))); + }), + ); + + it.effect("publishes a loopback-only port on demand", () => + Effect.gen(function* () { + let published = false; + const { result, calls } = yield* runResolve({ + port: 6545, + clientBaseUrl: CLIENT_ON_TAILNET, + serveStatus: () => + published ? serveStatusWith([{ servePort: 6545, localPort: 6545 }]) : "{}", + listeningPorts: [6545], + onServe: () => { + published = true; + return { code: 0 }; + }, + }); + + assert.deepEqual(result._tag === "Success" ? result.success : null, { + origin: "https://smart.tail.ts.net:6545", + strategy: "tailnet-serve", + createdExposure: true, + }); + // Targets `localhost`, not `127.0.0.1`: Vite's default bind is `::1` only, + // and an IPv4-pinned mapping proxies to nothing and answers 502. + assert.deepEqual(calls.find((call) => call.args.includes("--bg"))?.args, [ + "serve", + "--bg", + "--https=6545", + "http://localhost:6545", + ]); + }), + ); + + it.effect("fails with a remedy when the dev server is not running", () => + Effect.gen(function* () { + const { result } = yield* runResolve({ + port: 6545, + clientBaseUrl: CLIENT_ON_TAILNET, + serveStatus: () => "{}", + listeningPorts: [], + }); + + const error = result._tag === "Failure" ? result.failure : null; + assert.instanceOf(error, PreviewPortUnreachableError); + assert.equal(error?.reason, "not-listening"); + assert.include(error?.message ?? "", "Start the dev server first"); + }), + ); + + it.effect("refuses to take over a tailnet port that routes elsewhere", () => + Effect.gen(function* () { + const { result, calls } = yield* runResolve({ + port: 6545, + clientBaseUrl: CLIENT_ON_TAILNET, + serveStatus: () => + serveStatusWith([ + { servePort: 6545, localPort: 9999 }, + { servePort: 46545, localPort: 9998 }, + ]), + listeningPorts: [6545], + }); + + const error = result._tag === "Failure" ? result.failure : null; + assert.equal(error?.reason, "serve-port-conflict"); + // The pre-existing mapping is left exactly as it was found. + assert.isUndefined(calls.find((call) => call.args.includes("--bg"))); + }), + ); + + it.effect("surfaces a permission failure as an actionable reason", () => + Effect.gen(function* () { + const { result } = yield* runResolve({ + port: 6545, + clientBaseUrl: CLIENT_ON_TAILNET, + serveStatus: () => "{}", + listeningPorts: [6545], + onServe: () => ({ code: 1, stderr: "access denied: must be root" }), + }); + + const error = result._tag === "Failure" ? result.failure : null; + assert.equal(error?.reason, "tailscale-permission-denied"); + // The classified label travels, never the raw stderr. + assert.notInclude(error?.message ?? "", "must be root"); + }), + ); + + it.effect("withdraws a mapping it published but could not reach", () => + Effect.gen(function* () { + let published = false; + const { result, calls } = yield* runResolve({ + port: 6545, + clientBaseUrl: CLIENT_ON_TAILNET, + serveStatus: () => + published ? serveStatusWith([{ servePort: 6545, localPort: 6545 }]) : "{}", + listeningPorts: [6545], + onServe: () => { + published = true; + return { code: 0 }; + }, + neverReachable: true, + }); + + const error = result._tag === "Failure" ? result.failure : null; + assert.equal(error?.reason, "not-reachable"); + // Publishing a port and then leaving it behind would keep a dead route on + // the tailnet, so the failure path has to undo its own mapping. + assert.deepEqual(calls.at(-1)?.args, ["serve", "--https=6545", "off"]); + }), + ); +}); diff --git a/apps/server/src/preview/PortExposure.ts b/apps/server/src/preview/PortExposure.ts new file mode 100644 index 00000000000..66071a7d384 --- /dev/null +++ b/apps/server/src/preview/PortExposure.ts @@ -0,0 +1,324 @@ +/** + * Resolves a local dev-server port to a URL the *connected client* can open. + * + * The preview surface used to do this on the client by swapping `localhost` for + * the environment's hostname and keeping the port and scheme. That guess is + * wrong whenever the port is not independently published on that hostname — + * which is the normal case, because dev servers bind loopback. The browser then + * showed ERR_CONNECTION_REFUSED, indistinguishable from a broken app. + * + * Only the server can answer this: it is the side that knows what is listening + * locally and what the tailnet actually routes. So it looks up the real + * `tailscale serve` mapping, creates one when the port has none, verifies the + * result answers, and otherwise fails with a reason and a next action instead + * of handing back a URL that cannot work. + */ +import { + PreviewPortUnreachableError, + type PreviewPortResolution, + type PreviewPortResolveRequest, +} from "@t3tools/contracts"; +import * as Net from "@t3tools/shared/Net"; +import { isLoopbackHost } from "@t3tools/shared/preview"; +import { + disableTailscaleServe, + ensureTailscaleServe, + findRootServeMappingForLocalPort, + readTailscaleServeMappings, + type TailscaleServeMapping, +} from "@t3tools/tailscale"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +export class PreviewPortExposure extends Context.Service< + PreviewPortExposure, + { + readonly resolve: ( + request: PreviewPortResolveRequest, + ) => Effect.Effect; + } +>()("t3/preview/PortExposure/PreviewPortExposure") {} + +/** + * A tailnet mapping only carries a dev server correctly when it is offered at + * the site root, so the serve port is the only free variable. Preferring the + * local port number keeps parity with `vp run dev --share`, which makes an + * already-shared dev server resolve to the URL the developer was told about. + */ +const SERVE_PORT_FALLBACK_OFFSET = 40_000; + +const PROBE_TIMEOUT = Duration.seconds(2); +// `tailscale serve` returns before the listener is accepting, and a fresh +// MagicDNS cert can take a beat. Retrying until a deadline turns that race into +// a slower success instead of a spurious "unreachable". The deadline bounds the +// whole loop rather than an attempt count, so a slow attempt cannot multiply +// out into a request the caller waits minutes on. +const PROBE_SCHEDULE = Schedule.spaced(Duration.millis(250)); +const PROBE_DEADLINE = Duration.seconds(5); + +/** + * Route through `localhost` rather than `127.0.0.1`. + * + * Vite's default `--host localhost` binds `::1` only, so a mapping pinned to the + * IPv4 loopback proxies to nothing and answers 502 — reachable, and useless. + * The hostname lets tailscale pick whichever family the dev server actually + * bound. + */ +const SERVE_TARGET_HOST = "localhost"; + +const REMEDY_BY_REASON = { + "tailscale-unavailable": + "This machine has no tailnet identity, so a loopback-only dev server cannot be reached from a remote client. Run the client on this machine, or bring up Tailscale here.", + "tailscale-not-logged-in": "Run `tailscale up` on the environment host, then retry.", + "tailscale-permission-denied": + "The server may not manage tailnet routes. Run `sudo tailscale set --operator=$USER` on the environment host, or publish the port yourself with `tailscale serve`.", + "not-listening": "Start the dev server first, then open the port again.", +} as const; + +const conflictRemedy = (port: number, servePort: number): string => + `Tailnet port ${servePort} already routes somewhere else, so port ${port} cannot be published without taking it over. Free it with \`tailscale serve --https=${servePort} off\`, or publish the port yourself on a spare tailnet port.`; + +const exposureRemedy = (port: number): string => + `Publish it manually with \`tailscale serve --bg --https=${port} http://${SERVE_TARGET_HOST}:${port}\`, or restart the dev server with \`vp run dev --share\`.`; + +const unreachableRemedy = (port: number, origin: string): string => + `${origin} was published but did not answer. Confirm the dev server on port ${port} is serving, and that this client is on the same tailnet.`; + +/** Maps a tailscale CLI failure onto a reason the caller can act on. */ +const reasonForTailscaleError = ( + error: unknown, +): "tailscale-not-logged-in" | "tailscale-permission-denied" | "exposure-failed" => { + const diagnostic = + typeof error === "object" && error !== null && "stderrDiagnostic" in error + ? (error as { readonly stderrDiagnostic?: string }).stderrDiagnostic + : undefined; + if (diagnostic === "not-logged-in") return "tailscale-not-logged-in"; + if (diagnostic === "permission-denied") return "tailscale-permission-denied"; + return "exposure-failed"; +}; + +const originOf = (url: string): string => new URL(url).origin; + +export const make = Effect.gen(function* () { + const net = yield* Net.NetService; + const httpClient = yield* HttpClient.HttpClient; + // Captured once so the service's own signature stays free of process + // plumbing: callers ask for a reachable URL, not for a way to run tailscale. + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + /** + * Serve ports this process published, so they can be withdrawn again. A + * mapping outlives the process that made it, so leaving them behind would + * keep publishing a port on the tailnet long after its dev server exited. + */ + const created = yield* Ref.make>(new Map()); + + const withdraw = (localPort: number, servePort: number) => + disableTailscaleServe({ servePort }).pipe( + Effect.tap(() => + Effect.logInfo("Withdrew preview tailnet mapping", { localPort, servePort }), + ), + Effect.catch((cause) => + Effect.logWarning("Failed to withdraw preview tailnet mapping", { + cause, + localPort, + servePort, + }), + ), + Effect.andThen( + Ref.update(created, (entries) => { + const next = new Map(entries); + next.delete(localPort); + return next; + }), + ), + ); + + /** + * Drops mappings whose dev server has since exited. Reconciling here rather + * than on a timer keeps the common path free of a background poller: a stale + * mapping is only observable through this service, and every observation + * passes through here first. + */ + const reconcile = Effect.gen(function* () { + const entries = yield* Ref.get(created); + for (const [localPort, servePort] of entries) { + if (yield* net.isPortAvailableOnLoopback(localPort)) { + yield* withdraw(localPort, servePort); + } + } + }); + + const fail = (port: number, reason: PreviewPortUnreachableError["reason"], remedy: string) => + Effect.fail(new PreviewPortUnreachableError({ port, reason, remedy })); + + // Any HTTP answer proves the route works. A dev server is free to 404 its own + // root (an API-only server does), and that is still reachable. + const probeOnce = (origin: string) => + httpClient + .execute(HttpClientRequest.get(origin)) + .pipe(Effect.timeout(PROBE_TIMEOUT), Effect.scoped, Effect.as(true)); + + /** One attempt — used to test a route that either already exists or does not. */ + const isReachable = (origin: string) => probeOnce(origin).pipe(Effect.orElseSucceed(() => false)); + + /** Resolves once a freshly published origin answers. */ + const becomesReachable = (origin: string) => + probeOnce(origin).pipe( + Effect.retry({ schedule: PROBE_SCHEDULE }), + Effect.timeout(PROBE_DEADLINE), + Effect.orElseSucceed(() => false), + ); + + const pickServePort = (port: number, mappings: readonly TailscaleServeMapping[]) => { + const takenBySomethingElse = (candidate: number) => + mappings.some((mapping) => mapping.servePort === candidate && mapping.localPort !== port); + if (!takenBySomethingElse(port)) return port; + const fallback = port + SERVE_PORT_FALLBACK_OFFSET; + if (fallback < 65_536 && !takenBySomethingElse(fallback)) return fallback; + return null; + }; + + const resolve = (request: PreviewPortResolveRequest) => + Effect.gen(function* () { + const { port } = request; + + // A client on the same machine reaches the port directly; publishing it + // on the tailnet would expose a dev server nobody asked to share. + const clientUrl = yield* Effect.try({ + try: () => new URL(request.clientBaseUrl), + catch: () => null, + }).pipe(Effect.orElseSucceed(() => null)); + if (clientUrl !== null && isLoopbackHost(clientUrl.hostname)) { + return { + origin: `http://localhost:${port}`, + strategy: "loopback", + createdExposure: false, + } satisfies PreviewPortResolution; + } + + yield* reconcile; + + if (yield* net.isPortAvailableOnLoopback(port)) { + return yield* fail(port, "not-listening", REMEDY_BY_REASON["not-listening"]); + } + + // Read the tailnet's routes before probing anything. A host without + // tailscale is not an error yet — the port may still answer directly — + // so a failure here only rules out the tailnet branch. + const mappings = yield* readTailscaleServeMappings.pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to read tailnet serve mappings", { cause, port }).pipe( + Effect.as(null), + ), + ), + ); + + const existing = mappings && findRootServeMappingForLocalPort(mappings, port); + if (existing) { + return { + origin: originOf(existing.url), + strategy: "tailnet-serve", + createdExposure: false, + } satisfies PreviewPortResolution; + } + + // A dev server bound to a wildcard or interface address already answers on + // the environment's own address — WSL, a LAN, an SSH tunnel. Publishing a + // tailnet route for it would be redundant, so this is checked before any + // route is created. Verified rather than assumed: whether a listener is + // loopback-only is exactly what the old client-side guess got wrong. + // + // Skipped when a serve mapping already occupies that number for some other + // local port: the probe would find *that* app answering and hand back a URL + // to the wrong thing. + const shadowedByOtherMapping = + mappings?.some((mapping) => mapping.servePort === port && mapping.localPort !== port) ?? + false; + if (clientUrl !== null && !shadowedByOtherMapping) { + const directOrigin = `${clientUrl.protocol}//${clientUrl.hostname}:${port}`; + if (yield* isReachable(directOrigin)) { + return { + origin: directOrigin, + strategy: "direct-private-network", + createdExposure: false, + } satisfies PreviewPortResolution; + } + } + + if (mappings === null) { + return yield* fail( + port, + "tailscale-unavailable", + REMEDY_BY_REASON["tailscale-unavailable"], + ); + } + + const servePort = pickServePort(port, mappings); + if (servePort === null) { + return yield* fail(port, "serve-port-conflict", conflictRemedy(port, port)); + } + + yield* ensureTailscaleServe({ + localPort: port, + servePort, + localHost: SERVE_TARGET_HOST, + }).pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to publish preview port on the tailnet", { + cause, + port, + servePort, + }).pipe(Effect.andThen(fail(port, reasonForTailscaleError(cause), exposureRemedy(port)))), + ), + ); + yield* Ref.update(created, (entries) => new Map(entries).set(port, servePort)); + + const published = yield* readTailscaleServeMappings.pipe( + Effect.map((refreshed) => findRootServeMappingForLocalPort(refreshed, port)), + Effect.orElseSucceed(() => undefined), + ); + if (!published) { + yield* withdraw(port, servePort); + return yield* fail(port, "exposure-failed", exposureRemedy(port)); + } + + const origin = originOf(published.url); + // Verify before answering: a URL that resolves but does not serve is the + // failure this whole path exists to remove. + if (!(yield* becomesReachable(origin))) { + yield* withdraw(port, servePort); + return yield* fail(port, "not-reachable", unreachableRemedy(port, origin)); + } + + yield* Effect.logInfo("Published preview port on the tailnet", { port, servePort, origin }); + return { + origin, + strategy: "tailnet-serve", + createdExposure: true, + } satisfies PreviewPortResolution; + }); + + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + for (const [localPort, servePort] of yield* Ref.get(created)) { + yield* withdraw(localPort, servePort); + } + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)), + ); + + return PreviewPortExposure.of({ + resolve: (request) => + resolve(request).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + }); +}); + +export const layer = Layer.effect(PreviewPortExposure, make); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index c709745133d..cdbb6676f17 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -99,6 +99,7 @@ import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerSettings from "./serverSettings.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as PreviewManager from "./preview/Manager.ts"; +import * as PortExposure from "./preview/PortExposure.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; @@ -714,6 +715,9 @@ const buildAppUnderTest = (options?: { registerTerminalProcesses: () => Effect.void, unregisterTerminal: () => Effect.void, }), + Layer.mock(PortExposure.PreviewPortExposure)({ + resolve: () => Effect.die("PreviewPortExposure not stubbed in this test"), + }), Layer.mock(AiUsageMonitorModule.AiUsageMonitor)({ current: () => Effect.succeed(AI_USAGE_UNAVAILABLE), subscribe: () => Effect.void, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index bd8c7063d6b..3436a81733b 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -39,6 +39,7 @@ import * as McpHttpServer from "./mcp/McpHttpServer.ts"; import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as PreviewManager from "./preview/Manager.ts"; +import * as PortExposure from "./preview/PortExposure.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as AiUsageMonitor from "./aiUsage/AiUsageMonitor.ts"; import * as ProcessRunner from "./processRunner.ts"; @@ -286,8 +287,17 @@ const TerminalLayerLive = TerminalManager.layer.pipe( Layer.provide(PortScannerLayerLive), ); +// Self-contained: the HTTP client is only used to prove a published port +// answers, and the Net service only to notice a dev server that has exited. +// Neither belongs in the requirements of everything that renders a preview. +const PortExposureLayerLive = PortExposure.layer.pipe( + Layer.provide(FetchHttpClient.layer), + Layer.provide(NetService.layer), +); + const PreviewLayerLive = Layer.empty.pipe( Layer.provideMerge(PreviewManager.layer), + Layer.provideMerge(PortExposureLayerLive), Layer.provideMerge(PortScannerLayerLive), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 440721c3ce3..6feda24315f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -94,6 +94,7 @@ import * as TerminalManager from "./terminal/Manager.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as PreviewManager from "./preview/Manager.ts"; import { issueAssetUrl } from "./assets/AssetAccess.ts"; +import * as PortExposure from "./preview/PortExposure.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as AiUsageMonitorModule from "./aiUsage/AiUsageMonitor.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; @@ -408,6 +409,8 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.previewRefresh, AuthOrchestrationOperateScope], [WS_METHODS.previewClose, AuthOrchestrationOperateScope], [WS_METHODS.previewList, AuthOrchestrationReadScope], + // Operate, not read: resolving may publish the port on the tailnet. + [WS_METHODS.previewResolvePort, AuthOrchestrationOperateScope], [WS_METHODS.previewReportStatus, AuthOrchestrationOperateScope], [WS_METHODS.previewAutomationConnect, AuthOrchestrationOperateScope], [WS_METHODS.previewAutomationRespond, AuthOrchestrationOperateScope], @@ -483,6 +486,7 @@ const makeWsRpcLayer = ( const terminalManager = yield* TerminalManager.TerminalManager; const previewManager = yield* PreviewManager.PreviewManager; const portDiscovery = yield* PortScanner.PortDiscovery; + const portExposure = yield* PortExposure.PreviewPortExposure; const aiUsageMonitor = yield* AiUsageMonitorModule.AiUsageMonitor; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; @@ -2235,6 +2239,10 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.previewList, previewManager.list(input), { "rpc.aggregate": "preview", }), + [WS_METHODS.previewResolvePort]: (input) => + observeRpcEffect(WS_METHODS.previewResolvePort, portExposure.resolve(input), { + "rpc.aggregate": "preview", + }), [WS_METHODS.previewReportStatus]: (input) => observeRpcEffect(WS_METHODS.previewReportStatus, previewManager.reportStatus(input), { "rpc.aggregate": "preview", diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts index b3d563ca9ff..4ab8a96c6c1 100644 --- a/apps/web/src/browser/browserTargetResolver.test.ts +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -1,9 +1,19 @@ -import { EnvironmentId } from "@t3tools/contracts"; +import { EnvironmentId, PreviewPortUnreachableError } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const readPreparedConnection = vi.fn(); +const runAtomCommand = vi.fn(); vi.mock("~/state/session", () => ({ readPreparedConnection })); +vi.mock("@t3tools/client-runtime/state/runtime", async (importOriginal) => ({ + ...(await importOriginal>()), + runAtomCommand, +})); +// The resolver reaches the environment through the app-wide registry; the +// command itself is stubbed above, so the registry only needs to exist. +vi.mock("~/rpc/atomRegistry", () => ({ appAtomRegistry: {} })); +vi.mock("~/state/preview", () => ({ previewEnvironment: { resolvePort: { label: "test" } } })); describe("browser target resolver", () => { beforeEach(() => readPreparedConnection.mockReset()); @@ -194,3 +204,98 @@ describe("browser target resolver", () => { expect(resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), " ")).toBe(" "); }); }); + +describe("navigable url resolution", () => { + beforeEach(() => { + readPreparedConnection.mockReset(); + runAtomCommand.mockReset(); + }); + + const succeedWith = (origin: string) => + runAtomCommand.mockResolvedValue({ + _tag: "Success", + value: { origin, strategy: "tailnet-serve", createdExposure: true }, + }); + + it("asks the environment for a reachable origin instead of reusing the port", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://smart.tail.ts.net/" }); + succeedWith("https://smart.tail.ts.net:46545"); + const { resolveNavigableUrl } = await import("./browserTargetResolver"); + + const url = await resolveNavigableUrl(EnvironmentId.make("environment-1"), { + kind: "url", + url: "http://localhost:6545/dashboard?mode=test#results", + }); + + // The serve port and scheme both differ from the requested ones — the exact + // pair the old host-swap guess could not produce. + expect(url).toBe("https://smart.tail.ts.net:46545/dashboard?mode=test#results"); + expect(runAtomCommand.mock.calls[0]?.[2]).toEqual({ + environmentId: "environment-1", + input: { port: 6545, clientBaseUrl: "https://smart.tail.ts.net/" }, + }); + }); + + it("resolves a link already rewritten to the environment host", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://smart.tail.ts.net/" }); + succeedWith("https://smart.tail.ts.net:46545"); + const { resolveNavigableUrl } = await import("./browserTargetResolver"); + + // Chat renders hrefs against the environment host for readability, so the + // click path receives this spelling rather than the localhost one. + expect( + await resolveNavigableUrl(EnvironmentId.make("environment-1"), { + kind: "url", + url: "http://smart.tail.ts.net:6545/", + }), + ).toBe("https://smart.tail.ts.net:46545/"); + }); + + it("never asks about a port a same-machine client already reaches", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://localhost:3773" }); + const { resolveNavigableUrl } = await import("./browserTargetResolver"); + + expect( + await resolveNavigableUrl(EnvironmentId.make("environment-1"), { + kind: "url", + url: "http://localhost:6545/", + }), + ).toBe("http://localhost:6545/"); + expect(runAtomCommand).not.toHaveBeenCalled(); + }); + + it("leaves an unrelated external URL alone", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://smart.tail.ts.net/" }); + const { resolveNavigableUrl } = await import("./browserTargetResolver"); + + expect( + await resolveNavigableUrl(EnvironmentId.make("environment-1"), { + kind: "url", + url: "https://example.com/docs", + }), + ).toBe("https://example.com/docs"); + expect(runAtomCommand).not.toHaveBeenCalled(); + }); + + it("raises the environment's explanation rather than navigating somewhere broken", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://smart.tail.ts.net/" }); + runAtomCommand.mockResolvedValue({ + _tag: "Failure", + cause: Cause.fail( + new PreviewPortUnreachableError({ + port: 6545, + reason: "not-listening", + remedy: "Start the dev server first, then open the port again.", + }), + ), + }); + const { resolveNavigableUrl } = await import("./browserTargetResolver"); + + await expect( + resolveNavigableUrl(EnvironmentId.make("environment-1"), { + kind: "url", + url: "http://localhost:6545/", + }), + ).rejects.toThrow("Start the dev server first"); + }); +}); diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index 9b201dbdbae..54d360cd91e 100644 --- a/apps/web/src/browser/browserTargetResolver.ts +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -3,8 +3,13 @@ import type { EnvironmentId, PreviewUrlResolution, } from "@t3tools/contracts"; +import { PreviewPortUnreachableError } from "@t3tools/contracts"; +import { runAtomCommand, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; +import { Schema } from "effect"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { previewEnvironment } from "~/state/preview"; import { readPreparedConnection } from "~/state/session"; const normalizeHostname = (host: string): string => host.toLowerCase().replace(/^\[|\]$/g, ""); @@ -89,6 +94,12 @@ const resolveEnvironmentPortTarget = ( }; }; +/** + * Best-effort resolution used for *labels* — the port list, a link rendered in + * chat. It names the environment host so a remote reader can tell which machine + * a port lives on, but it cannot know whether that port is published there, so + * nothing may navigate to its result. Use `resolveNavigableUrl` for that. + */ export function resolveBrowserNavigationTarget( environmentId: EnvironmentId, target: BrowserNavigationTarget, @@ -139,3 +150,71 @@ export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: return rawUrl; } } + +/** + * Resolution for anything that is about to be *opened*. + * + * A port on the environment host is reachable from this client only if + * something publishes it there, and only the environment knows what that is — + * whether a tailnet route already exists, on which port, over which scheme. So + * this asks, rather than rewriting the hostname and hoping the port answers on + * the other side. When the environment cannot make it reachable it says why, + * and that surfaces as a real error instead of a browser error page that reads + * like the app is broken. + */ +export async function resolveNavigableUrl( + environmentId: EnvironmentId, + target: BrowserNavigationTarget, +): Promise { + const label = resolveBrowserNavigationTarget(environmentId, target); + const requested = new URL(label.requestedUrl); + const environmentUrl = readEnvironmentUrl(environmentId); + // Already reachable as written: an external URL, or a local client whose + // loopback is the same loopback the port is on. A URL the label pass already + // pointed at the environment host is not — it names the right machine on a + // port nothing promised to publish there, so it still needs resolving. + if (label.resolutionKind === "direct" && !namesAnEnvironmentPort(requested, environmentUrl)) { + return label.resolvedUrl; + } + + const port = Number(requested.port || (requested.protocol === "https:" ? 443 : 80)); + const result = await runAtomCommand( + appAtomRegistry, + previewEnvironment.resolvePort, + { environmentId, input: { port, clientBaseUrl: environmentUrl.toString() } }, + { label: "resolve preview port", reportFailure: false, reportDefect: false }, + ); + if (result._tag === "Failure") { + throw new Error(previewPortFailureMessage(squashAtomCommandFailure(result), port)); + } + + return new URL( + requested.pathname + requested.search + requested.hash, + result.value.origin, + ).toString(); +} + +/** + * True when a URL points at the environment's own host but a different port — + * a dev server beside the T3 server, not the T3 server itself. Chat links reach + * here already rewritten this way by the label pass, so recognizing the shape + * keeps one resolution path for both spellings of the same port. + */ +const namesAnEnvironmentPort = (candidate: URL, environmentUrl: URL): boolean => + normalizeHostname(candidate.hostname) === normalizeHostname(environmentUrl.hostname) && + !isLocalLoopbackHost(candidate.hostname) && + effectivePort(candidate) !== effectivePort(environmentUrl); + +const effectivePort = (url: URL): number => + Number(url.port || (url.protocol === "https:" ? 443 : 80)); + +const isPortUnreachable = Schema.is(PreviewPortUnreachableError); + +/** + * Keeps the environment's own explanation — it names the reason and the next + * action, which a browser error page cannot. + */ +const previewPortFailureMessage = (error: unknown, port: number): string => + isPortUnreachable(error) + ? error.message + : `Port ${port} could not be resolved to an address this client can reach: ${String(error)}`; diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index b251d44d648..9b9c84a3a1b 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -89,7 +89,7 @@ import { openUrlInPreview, BrowserPreviewUnavailableError, } from "../browser/openFileInPreview"; -import { resolveDiscoveredServerUrl } from "../browser/browserTargetResolver"; +import { resolveDiscoveredServerUrl, resolveNavigableUrl } from "../browser/browserTargetResolver"; import { useAssetUrl } from "../assets/assetUrls"; class CodeHighlightErrorBoundary extends React.Component< @@ -1368,19 +1368,24 @@ function ChatMarkdown({ event.clipboardData.setData("text/html", payload.html); }, []); const openExternalLinkInPreview = useCallback( - (url: string) => { + async (url: string) => { if (!threadRef) { - return Promise.resolve( - AsyncResult.failure( - Cause.fail( - new BrowserPreviewUnavailableError({ - message: "Thread context is unavailable.", - }), - ), + return AsyncResult.failure( + Cause.fail( + new BrowserPreviewUnavailableError({ + message: "Thread context is unavailable.", + }), ), ); } - return openUrlInPreview({ threadRef, url, openPreview }); + // The rendered href names the environment host for readability; the + // address that is actually opened has to be one the environment confirms + // it publishes. + return openUrlInPreview({ + threadRef, + url: await resolveNavigableUrl(threadRef.environmentId, { kind: "url", url }), + openPreview, + }); }, [openPreview, threadRef], ); diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 165fb6136aa..f186b1430a0 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -30,7 +30,7 @@ import { updatePreviewServerSnapshot, } from "~/previewStateStore"; import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; -import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver"; +import { resolveNavigableUrl } from "~/browser/browserTargetResolver"; import { readActiveBrowserRecordingTabIds, startBrowserRecording, @@ -349,10 +349,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) case "open": { const input = request.input as PreviewAutomationOpenInput; const resolvedInputUrl = input.url - ? resolveBrowserNavigationTarget(environmentId, { - kind: "url", - url: input.url, - }).resolvedUrl + ? await resolveNavigableUrl(environmentId, { kind: "url", url: input.url }) : undefined; let activeTabId = resolvePreviewAutomationOpenTab( state, @@ -407,14 +404,14 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) case "navigate": { const ready = await requireReadyTab(); const input = request.input as PreviewAutomationNavigateInput; - const resolution = resolveBrowserNavigationTarget( + const resolvedUrl = await resolveNavigableUrl( environmentId, input.target ?? { kind: "url", url: input.url!, }, ); - await ready.bridge.navigate(ready.tabId, resolution.resolvedUrl); + await ready.bridge.navigate(ready.tabId, resolvedUrl); await waitForNavigationReadiness( threadRef, request.requestId, diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index 2532b8c89ed..c2d6bce816a 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -6,6 +6,12 @@ const mocks = vi.hoisted(() => ({ navigate: vi.fn(async (_tabId: string, _url: string): Promise => undefined), rememberPreviewUrl: vi.fn(), readPreparedConnection: vi.fn(() => ({ httpBaseUrl: "http://172.25.85.75:3773" })), + // Reachability is the resolver's job and is covered by its own tests; here it + // stands in for "whatever the environment says is reachable". + resolveNavigableUrl: vi.fn( + async (_environmentId: string, target: { readonly url?: string }): Promise => + (target.url ?? "").replace("localhost", "172.25.85.75"), + ), submittedUrl: null as ((url: string) => void) | null, emptyStateUrl: null as ((url: string) => void) | null, togglePictureInPicture: null as (() => void) | null, @@ -187,6 +193,10 @@ vi.mock("~/browser/BrowserSurfaceSlot", () => ({ BrowserSurfaceSlot: () => null vi.mock("./useLoadingProgress", () => ({ useLoadingProgress: () => 0 })); vi.mock("./usePreviewSession", () => ({ usePreviewSession: vi.fn() })); +vi.mock("~/browser/browserTargetResolver", () => ({ + resolveNavigableUrl: mocks.resolveNavigableUrl, +})); + import { PreviewView } from "./PreviewView"; describe("PreviewView navigation", () => { @@ -194,6 +204,7 @@ describe("PreviewView navigation", () => { mocks.navigate.mockClear(); mocks.rememberPreviewUrl.mockClear(); mocks.readPreparedConnection.mockClear(); + mocks.resolveNavigableUrl.mockClear(); mocks.submittedUrl = null; mocks.emptyStateUrl = null; mocks.togglePictureInPicture = null; @@ -209,13 +220,16 @@ describe("PreviewView navigation", () => { mocks.showEmptyState = false; }); + // A typed localhost URL means "the dev server on the environment host", so it + // goes through the same reachability resolution as a clicked port. Typing it + // used to navigate verbatim, which cannot work from a remote client. it.each([ [ "https://localhost:8000/dashboard?mode=test#top", - "https://localhost:8000/dashboard?mode=test#top", + "https://172.25.85.75:8000/dashboard?mode=test#top", ], - ["localhost:5173/app", "http://localhost:5173/app"], - ])("preserves a direct localhost URL in a WSL environment", async (submitted, expected) => { + ["localhost:5173/app", "http://172.25.85.75:5173/app"], + ])("resolves a submitted localhost URL against the environment", async (submitted, expected) => { renderToStaticMarkup( { ); }); - it("maps an empty-state localhost server onto the WSL host", async () => { + it("resolves an empty-state localhost server against the environment", async () => { mocks.showEmptyState = true; renderToStaticMarkup( { try { - await navigateToResolvedUrl(normalizePreviewUrl(next)); + await navigateToResolvedUrl( + await resolveNavigableUrl(threadRef.environmentId, { + kind: "url", + url: normalizePreviewUrl(next), + }), + ); } catch { // Server-side `failed` event renders the unreachable view. } }, - [navigateToResolvedUrl], + [navigateToResolvedUrl, threadRef.environmentId], ); const handleOpenServerUrl = useCallback( async (next: string) => { try { - await navigateToResolvedUrl(resolveDiscoveredServerUrl(threadRef.environmentId, next)); + await navigateToResolvedUrl( + await resolveNavigableUrl(threadRef.environmentId, { kind: "url", url: next }), + ); } catch { // Server-side `failed` event renders the unreachable view. } diff --git a/apps/web/src/components/preview/openDiscoveredPort.ts b/apps/web/src/components/preview/openDiscoveredPort.ts index 664c2e33a5c..22623a07c71 100644 --- a/apps/web/src/components/preview/openDiscoveredPort.ts +++ b/apps/web/src/components/preview/openDiscoveredPort.ts @@ -4,7 +4,7 @@ import { type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; -import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; +import { resolveNavigableUrl } from "~/browser/browserTargetResolver"; import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; import { useRightPanelStore } from "~/rightPanelStore"; import { openPreviewSession } from "./openPreviewSession"; @@ -14,7 +14,10 @@ export async function openDiscoveredPort(input: { readonly port: DiscoveredLocalServer; readonly openPreview: OpenPreviewMutation; }): Promise> { - const resolvedUrl = resolveDiscoveredServerUrl(input.threadRef.environmentId, input.port.url); + const resolvedUrl = await resolveNavigableUrl(input.threadRef.environmentId, { + kind: "url", + url: input.port.url, + }); const result = await openPreviewSession({ openPreview: input.openPreview, threadRef: input.threadRef, diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index d6ff00bee1f..21bfeb041c3 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -203,9 +203,42 @@ Typical uses: Use `t3 auth --help` and the nested subcommand help pages for the full reference. +## Previewing Dev Server Ports + +When you connect to a remote environment, the ports its agents open — a Vite dev server, a +preview build, an API — are usually bound to that machine's loopback interface. They are not +reachable at the address you use to reach T3, even though that address names the right machine. + +Opening such a port in the in-app browser asks the environment to resolve it, in this order: + +1. An existing `tailscale serve` route for the port is reused, at whatever tailnet port and scheme + it was published under. +2. If the port already answers on the environment's own address — a dev server bound to a wildcard + address, reached over WSL, a LAN, or a tunnel — that address is used and nothing is published. +3. Otherwise the environment publishes a **tailnet-only** HTTPS route for the port + (`tailscale serve`, never Tailscale Funnel), confirms it answers, and hands back that URL. The + route is withdrawn when the dev server exits or the environment shuts down. + +The environment never guesses: it answers with a URL it has verified, or explains why it cannot — +the dev server is not running, tailscale is not logged in, the server may not manage tailnet routes, +or the tailnet port is already taken by something else. + +Requirements for step 3: tailscale must be logged in on the environment host, and the T3 server +must be allowed to manage serve routes (`sudo tailscale set --operator=$USER`). Without those, +steps 1 and 2 still work, and you can publish a port yourself: + +```sh +tailscale serve --bg --https=5173 http://127.0.0.1:5173 +``` + +Routes published this way are visible to your tailnet only. If you would rather not have ports +published automatically, start the dev server bound to a routable address so step 2 applies, or +publish the specific ports you want by hand. + ## Security Notes - Treat pairing URLs and pairing tokens like passwords. +- Ports published for preview are tailnet-only; they are never exposed through Tailscale Funnel. Anyone on your tailnet can reach a published dev server while it runs. - Prefer binding `--host` to a trusted private address, such as a Tailnet IP, instead of exposing the server broadly. - Anyone with a valid pairing credential can create a session until that credential expires or is revoked. - Hosted pairing links keep the credential in the URL hash so it is not sent to the hosted app server, but it can still be exposed through browser history, screenshots, logs, or copy/paste. diff --git a/packages/client-runtime/src/state/preview.ts b/packages/client-runtime/src/state/preview.ts index f9469ee96a5..77c05867bf2 100644 --- a/packages/client-runtime/src/state/preview.ts +++ b/packages/client-runtime/src/state/preview.ts @@ -80,6 +80,22 @@ export function createPreviewEnvironmentAtoms( scheduler: lifecycleScheduler, concurrency: lifecycleConcurrency, }), + /** + * Asks the environment for a URL this client can actually open for one of + * its local ports. Deduped per port rather than serialized with the tab + * lifecycle: resolving may publish a tailnet route, and two tabs opening + * the same port must not race to publish it twice. + */ + resolvePort: createEnvironmentRpcCommand(runtime, { + label: "environment-data:preview:resolve-port", + tag: WS_METHODS.previewResolvePort, + scheduler: lifecycleScheduler, + concurrency: { + mode: "singleFlight", + key: ({ environmentId, input }: { environmentId: string; input: { port: number } }) => + JSON.stringify([environmentId, input.port]), + }, + }), reportStatus: createEnvironmentRpcCommand(runtime, { label: "environment-data:preview:report-status", tag: WS_METHODS.previewReportStatus, diff --git a/packages/contracts/src/preview.ts b/packages/contracts/src/preview.ts index dfc10e0b9b7..968375c4815 100644 --- a/packages/contracts/src/preview.ts +++ b/packages/contracts/src/preview.ts @@ -275,6 +275,71 @@ export const DiscoveredLocalServerList = Schema.Struct({ }); export type DiscoveredLocalServerList = typeof DiscoveredLocalServerList.Type; +export const PreviewPortResolveRequest = Schema.Struct({ + port: Schema.Int.check(Schema.isGreaterThan(0)).check(Schema.isLessThan(65536)), + /** + * The environment base URL this client is actually connected through. + * + * Reachability is a property of the pair (client, port), not of the port + * alone: a client on loopback wants `localhost`, one on the tailnet needs a + * tailnet route. The client is the only side that knows which it is, so it + * says so rather than letting the server infer it from a request header that + * a proxy may have rewritten. + */ + clientBaseUrl: Url, +}); +export type PreviewPortResolveRequest = typeof PreviewPortResolveRequest.Type; + +export const PreviewPortExposureStrategy = Schema.Literals([ + "loopback", + /** + * The port already answers on the environment's own address — a dev server + * bound to a wildcard or interface address, reached over WSL, a LAN, or an + * existing tunnel. Nothing is published for these. + */ + "direct-private-network", + "tailnet-serve", +]); +export type PreviewPortExposureStrategy = typeof PreviewPortExposureStrategy.Type; + +export const PreviewPortResolution = Schema.Struct({ + /** Origin only — the caller keeps its own path, query, and hash. */ + origin: Url, + strategy: PreviewPortExposureStrategy, + /** True when this call created the tailnet mapping rather than reusing one. */ + createdExposure: Schema.Boolean, +}); +export type PreviewPortResolution = typeof PreviewPortResolution.Type; + +/** + * Why a local port cannot be reached from this client. + * + * Carries a `remedy` because the consumer is frequently an agent driving the + * preview: without a next action it retries the same unreachable URL. The + * reasons are a closed set so the UI can special-case them, and `remedy` never + * quotes raw CLI stderr (tailscale prints auth keys there). + */ +export class PreviewPortUnreachableError extends Schema.TaggedErrorClass()( + "PreviewPortUnreachableError", + { + port: Schema.Int, + reason: Schema.Literals([ + "tailscale-unavailable", + "tailscale-not-logged-in", + "tailscale-permission-denied", + "serve-port-conflict", + "exposure-failed", + "not-listening", + "not-reachable", + ]), + remedy: TrimmedNonEmptyString, + }, +) { + override get message() { + return `Port ${this.port} is not reachable from this client (${this.reason}). ${this.remedy}`; + } +} + export class PreviewSessionLookupError extends Schema.TaggedErrorClass()( "PreviewSessionLookupError", { diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 66775fe6d05..f1d81b8c4d9 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -110,6 +110,9 @@ import { PreviewListResult, PreviewNavigateInput, PreviewOpenInput, + PreviewPortResolution, + PreviewPortResolveRequest, + PreviewPortUnreachableError, PreviewRefreshInput, PreviewReportStatusInput, PreviewResizeInput, @@ -212,6 +215,7 @@ export const WS_METHODS = { previewRefresh: "preview.refresh", previewClose: "preview.close", previewList: "preview.list", + previewResolvePort: "preview.resolvePort", previewReportStatus: "preview.reportStatus", previewAutomationConnect: "previewAutomation.connect", previewAutomationRespond: "previewAutomation.respond", @@ -614,6 +618,12 @@ export const WsPreviewListRpc = Rpc.make(WS_METHODS.previewList, { error: EnvironmentAuthorizationError, }); +export const WsPreviewResolvePortRpc = Rpc.make(WS_METHODS.previewResolvePort, { + payload: PreviewPortResolveRequest, + success: PreviewPortResolution, + error: Schema.Union([PreviewPortUnreachableError, EnvironmentAuthorizationError]), +}); + export const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus, { payload: PreviewReportStatusInput, error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), @@ -819,6 +829,7 @@ export const WsRpcGroup = RpcGroup.make( WsPreviewRefreshRpc, WsPreviewCloseRpc, WsPreviewListRpc, + WsPreviewResolvePortRpc, WsPreviewReportStatusRpc, WsPreviewAutomationConnectRpc, WsPreviewAutomationRespondRpc, diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index 24c22454d9d..f3bde67b43a 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -13,14 +13,18 @@ import { buildTailscaleHttpsBaseUrl, disableTailscaleServe, ensureTailscaleServe, + findRootServeMappingForLocalPort, isTailscaleIpv4Address, parseTailscaleMagicDnsName, + parseTailscaleServeMappings, parseTailscaleStatus, + readTailscaleServeMappings, readTailscaleStatus, TAILSCALE_STATUS_TIMEOUT, TailscaleCommandExitError, TailscaleCommandSpawnError, TailscaleCommandTimeoutError, + TailscaleServeStatusParseError, TailscaleStatusParseError, } from "./tailscale.ts"; @@ -65,6 +69,16 @@ function assertCarriesNoSecret(error: object, secret: string): void { } const tailscaleStatusJson = `{"Self":{"DNSName":"desktop.tail.ts.net.","TailscaleIPs":["100.100.100.100","fd7a:115c:a1e0::1","192.168.1.20"]}}`; const tailscaleStatusWithSingleIpJson = `{"Self":{"DNSName":"desktop.tail.ts.net.","TailscaleIPs":["100.90.1.2"]}}`; +// Shaped after real `tailscale serve status --json`: TCP lists the listening +// ports, Web carries the routes. The serve port deliberately differs from the +// local port it proxies, which is the case the resolver used to get wrong. +const tailscaleServeStatusJson = `{ + "TCP": { "45733": { "HTTPS": true }, "8443": { "HTTPS": true } }, + "Web": { + "desktop.tail.ts.net:45733": { "Handlers": { "/": { "Proxy": "http://127.0.0.1:5733" } } }, + "desktop.tail.ts.net:8443": { "Handlers": { "/docs": { "Proxy": "http://127.0.0.1:6001" } } } + } +}`; function mockHandle(result: { stdout?: string; stderr?: string; code?: number }) { return ChildProcessSpawner.makeHandle({ @@ -155,6 +169,78 @@ describe("tailscale", () => { }), ); + it.effect("flattens serve mappings, keeping the serve port distinct from the local port", () => + Effect.gen(function* () { + const mappings = yield* parseTailscaleServeMappings(tailscaleServeStatusJson); + + assert.deepEqual(mappings, [ + { + magicDnsName: "desktop.tail.ts.net", + servePort: 45733, + path: "/", + localHost: "127.0.0.1", + localPort: 5733, + url: "https://desktop.tail.ts.net:45733/", + }, + { + magicDnsName: "desktop.tail.ts.net", + servePort: 8443, + path: "/docs", + localHost: "127.0.0.1", + localPort: 6001, + url: "https://desktop.tail.ts.net:8443/docs", + }, + ]); + }), + ); + + it.effect("treats a tailnet with no serve mappings as empty rather than failing", () => + Effect.gen(function* () { + assert.deepEqual(yield* parseTailscaleServeMappings("{}"), []); + // TCP forwards and text handlers carry no local HTTP port to route to. + assert.deepEqual( + yield* parseTailscaleServeMappings( + `{"TCP":{"445":{"TCPForward":"127.0.0.1:445"}},"Web":{"desktop.tail.ts.net:443":{"Handlers":{"/":{"Text":"hello"}}}}}`, + ), + [], + ); + }), + ); + + it.effect("preserves serve status decoding failures", () => + Effect.gen(function* () { + const error = yield* parseTailscaleServeMappings("{not-json").pipe(Effect.flip); + + assert.instanceOf(error, TailscaleServeStatusParseError); + assert.equal(error.message, "Failed to decode tailscale serve status JSON."); + }), + ); + + it.effect("matches only root mappings when resolving a local port", () => + Effect.gen(function* () { + const mappings = yield* parseTailscaleServeMappings(tailscaleServeStatusJson); + + assert.equal(findRootServeMappingForLocalPort(mappings, 5733)?.servePort, 45733); + // Served under /docs, so it cannot carry a dev server's absolute asset + // paths — treated as no mapping at all. + assert.isUndefined(findRootServeMappingForLocalPort(mappings, 6001)); + assert.isUndefined(findRootServeMappingForLocalPort(mappings, 9999)); + }), + ); + + it.effect("reads serve mappings through the process spawner service", () => { + const layer = mockSpawnerLayer((command, args) => { + assert.equal(command, "tailscale"); + assert.deepEqual(args, ["serve", "status", "--json"]); + return { stdout: tailscaleServeStatusJson }; + }); + + return Effect.gen(function* () { + const mappings = yield* readTailscaleServeMappings; + assert.equal(mappings.length, 2); + }).pipe(Effect.provide(layer)); + }); + it.effect("builds clean HTTPS base URLs", () => Effect.sync(() => { assert.equal( diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index 7260a9de11b..87b4e62ec98 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -128,6 +128,15 @@ export class TailscaleStatusParseError extends Schema.TaggedErrorClass()( + "TailscaleServeStatusParseError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to decode tailscale serve status JSON."; + } +} + const TailscaleStatusSelf = Schema.Struct({ DNSName: Schema.optional(Schema.Unknown), TailscaleIPs: Schema.optional(Schema.Unknown), @@ -217,59 +226,177 @@ export const parseTailscaleStatus = ( }), ); -export const readTailscaleStatus = Effect.gen(function* () { - const args = ["status", "--json"]; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const hostPlatform = yield* HostProcessPlatform; - const executable = tailscaleCommandForPlatform(hostPlatform); - const commandContext = { - executable, - subcommand: "status" as const, - argumentCount: args.length, - }; - return yield* Effect.gen(function* () { - const child = yield* spawner - .spawn(ChildProcess.make(executable, args)) - .pipe( - Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), +/** + * Runs a tailscale subcommand that answers on stdout, applying the same + * spawn/exit/timeout error mapping as the rest of this module. `serve status` + * and `status` differ only in arguments and how the payload is decoded. + */ +const readTailscaleCommandStdout = (input: { + readonly args: readonly string[]; + readonly subcommand: "status" | "serve"; + readonly timeout: Duration.Duration; +}) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const hostPlatform = yield* HostProcessPlatform; + const executable = tailscaleCommandForPlatform(hostPlatform); + const commandContext = { + executable, + subcommand: input.subcommand, + argumentCount: input.args.length, + }; + return yield* Effect.gen(function* () { + const child = yield* spawner + .spawn(ChildProcess.make(executable, input.args)) + .pipe( + Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), + ); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStdout(child.stdout), + collectStderr(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError((cause) => new TailscaleCommandOutputError({ ...commandContext, cause })), ); - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - collectStdout(child.stdout), - collectStderr(child.stderr), - child.exitCode.pipe(Effect.map(Number)), - ], - { concurrency: "unbounded" }, - ).pipe( - Effect.mapError((cause) => new TailscaleCommandOutputError({ ...commandContext, cause })), + if (exitCode !== 0) { + return yield* new TailscaleCommandExitError({ + ...commandContext, + exitCode, + stdoutLength: stdout.length, + stderrLength: stderr.length, + ...(stderrDiagnosticOf(stderr) !== undefined + ? { stderrDiagnostic: stderrDiagnosticOf(stderr) } + : {}), + }); + } + return stdout; + }).pipe( + Effect.scoped, + Effect.timeout(input.timeout), + Effect.catchTags({ + TimeoutError: (cause) => + Effect.fail( + new TailscaleCommandTimeoutError({ + ...commandContext, + timeoutMs: Duration.toMillis(input.timeout), + cause, + }), + ), + }), ); - if (exitCode !== 0) { - return yield* new TailscaleCommandExitError({ - ...commandContext, - exitCode, - stdoutLength: stdout.length, - stderrLength: stderr.length, - ...(stderrDiagnosticOf(stderr) !== undefined - ? { stderrDiagnostic: stderrDiagnosticOf(stderr) } - : {}), - }); - } - return yield* parseTailscaleStatus(stdout); - }).pipe( - Effect.scoped, - Effect.timeout(TAILSCALE_STATUS_TIMEOUT), - Effect.catchTags({ - TimeoutError: (cause) => - Effect.fail( - new TailscaleCommandTimeoutError({ - ...commandContext, - timeoutMs: Duration.toMillis(TAILSCALE_STATUS_TIMEOUT), - cause, - }), - ), + }); + +export const readTailscaleStatus = readTailscaleCommandStdout({ + args: ["status", "--json"], + subcommand: "status", + timeout: TAILSCALE_STATUS_TIMEOUT, +}).pipe(Effect.flatMap(parseTailscaleStatus)); + +/** + * One `tailscale serve` HTTPS mapping, flattened from the `Web` section of + * `tailscale serve status --json`. + * + * `servePort` is the port the tailnet dials and `localPort` the loopback port + * behind it. They are frequently different — nothing forces serve mappings to + * preserve the port number — which is why callers must read this instead of + * assuming the local port is also reachable on the tailnet. + */ +export interface TailscaleServeMapping { + readonly magicDnsName: string; + readonly servePort: number; + readonly path: string; + readonly localHost: string; + readonly localPort: number; + /** Always https: `tailscale serve` terminates TLS for every Web handler. */ + readonly url: string; +} + +const TailscaleServeHandler = Schema.Struct({ + Proxy: Schema.optional(Schema.Unknown), +}); + +const TailscaleServeWebEntry = Schema.Struct({ + Handlers: Schema.optional(Schema.Record(Schema.String, TailscaleServeHandler)), +}); + +const TailscaleServeStatusJson = Schema.Struct({ + Web: Schema.optional(Schema.Record(Schema.String, TailscaleServeWebEntry)), +}); + +const decodeTailscaleServeStatusJson = Schema.decodeEffect( + Schema.fromJsonString(TailscaleServeStatusJson), +); + +/** Splits a `host:port` serve key, tolerating bracketed IPv6 literals. */ +const parseServeHostKey = (key: string): { host: string; port: number } | null => { + const separator = key.lastIndexOf(":"); + if (separator <= 0) return null; + const host = key.slice(0, separator).replace(/^\[|\]$/gu, ""); + const port = Number.parseInt(key.slice(separator + 1), 10); + return host.length > 0 && Number.isInteger(port) && port > 0 && port < 65536 + ? { host, port } + : null; +}; + +export const parseTailscaleServeMappings = ( + rawServeStatusJson: string, +): Effect.Effect => + decodeTailscaleServeStatusJson(rawServeStatusJson).pipe( + Effect.mapError((cause) => new TailscaleServeStatusParseError({ cause })), + Effect.map((parsed) => { + const mappings: Array = []; + for (const [hostKey, entry] of Object.entries(parsed.Web ?? {})) { + const target = parseServeHostKey(hostKey); + if (!target) continue; + for (const [path, handler] of Object.entries(entry.Handlers ?? {})) { + if (typeof handler.Proxy !== "string") continue; + // A non-proxy handler (static text, a file share) has no local port + // to match against, so it is not a route to a dev server. + let proxy: URL; + try { + proxy = new URL(handler.Proxy); + } catch { + continue; + } + const localPort = Number.parseInt(proxy.port, 10); + if (!Number.isInteger(localPort) || localPort <= 0) continue; + const url = new URL(`https://${hostKey}`); + url.pathname = path; + mappings.push({ + magicDnsName: target.host, + servePort: target.port, + path, + localHost: proxy.hostname.replace(/^\[|\]$/gu, ""), + localPort, + url: url.toString(), + }); + } + } + return mappings; }), ); -}); + +export const readTailscaleServeMappings = readTailscaleCommandStdout({ + args: ["serve", "status", "--json"], + subcommand: "serve", + timeout: TAILSCALE_STATUS_TIMEOUT, +}).pipe(Effect.flatMap(parseTailscaleServeMappings)); + +/** + * The mapping that serves `localPort` at the site root, if one exists. + * + * Root-only: a mapping under a sub-path rewrites neither the dev server's + * absolute asset URLs (`/@vite/client`) nor its HMR websocket, so handing one + * out would load a blank page instead of failing honestly. + */ +export const findRootServeMappingForLocalPort = ( + mappings: readonly TailscaleServeMapping[], + localPort: number, +): TailscaleServeMapping | undefined => + mappings.find((mapping) => mapping.localPort === localPort && mapping.path === "/"); export function buildTailscaleHttpsBaseUrl(input: { readonly magicDnsName: string; diff --git a/scripts/lib/dev-share.ts b/scripts/lib/dev-share.ts index 0f843b3ba91..798f351d452 100644 --- a/scripts/lib/dev-share.ts +++ b/scripts/lib/dev-share.ts @@ -195,7 +195,15 @@ export const shareDevServer = Effect.fn("devShare.shareDevServer")(function* (in }); } - yield* ensureTailscaleServe({ localPort: input.webPort, servePort: input.webPort }).pipe( + // `localhost`, not the default `127.0.0.1`: Vite's default `--host localhost` + // binds `::1` only, so an IPv4-pinned mapping proxies to nothing and every + // shared URL answers 502. The hostname lets tailscale pick the family the web + // server actually bound. + yield* ensureTailscaleServe({ + localPort: input.webPort, + servePort: input.webPort, + localHost: "localhost", + }).pipe( Effect.mapError((error) => { const explanation = explainCommandFailure(error); return new DevServeFailedError({