diff --git a/.agents/skills/test-t3-app/SKILL.md b/.agents/skills/test-t3-app/SKILL.md index f26270cd06d..7bc0d86498a 100644 --- a/.agents/skills/test-t3-app/SKILL.md +++ b/.agents/skills/test-t3-app/SKILL.md @@ -13,13 +13,17 @@ Use this skill for the web client. For iOS Simulator, Android Emulator, or physi 2. Choose a base directory that belongs only to the current worktree or test: - Use the repository's ignored `.t3` directory for reusable worktree-local state. - Use `mktemp -d /tmp/t3code-test.XXXXXX` for disposable state and retain the printed absolute path. -3. Start the full web stack with `vp run dev`. In a linked worktree it defaults to that worktree's gitignored `.t3`; pass `--home-dir ` only when the test needs a different isolated directory. +3. Start the full web stack with `vp run dev`. Add `--share` when the user needs to open it from another tailnet device. In a linked worktree it defaults to that worktree's gitignored `.t3`; pass `--home-dir ` only when the test needs a different isolated directory. 4. Keep the terminal session alive and read the selected server port, web port, base directory, and pairing URL from its output. Treat a base directory as disposable only when it was created or deliberately selected for the current test. Never delete or directly seed the shared `~/.t3` directory. Prefer starting with a new temporary base directory over clearing state of uncertain ownership. The worktree-local default deliberately outranks an ambient `T3CODE_HOME`; do not pass the shared home through to a worktree dev server. +Ports are derived from the worktree path but can shift when occupied. Always read the actual values from the `[dev-runner]` line. + +Shared browser dev is single-origin: Vite proxies the backend paths, so never set `VITE_HTTP_URL` or `VITE_WS_URL` for `dev`/`dev:web`. + 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. ## Preserve the environment while iterating diff --git a/AGENTS.md b/AGENTS.md index 7b67ebb7bd5..b6881156b84 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,9 @@ ## Dev Servers - In a linked git worktree, dev state defaults to that worktree's gitignored `.t3`. This deliberately outranks an ambient `T3CODE_HOME`, which could otherwise select the installed app's live `~/.t3/userdata` database. An explicit `--home-dir` still wins. +- Start the web stack with `vp run dev`. Add `--share` when someone needs to open it from another device on the tailnet. +- Browser dev is single-origin: Vite proxies `/api`, `/ws`, `/oauth`, and `/.well-known` to the backend. Do not set `VITE_HTTP_URL` or `VITE_WS_URL` for `dev`/`dev:web`. +- Worktree paths supply stable preferred port offsets. Read the actual server and web ports from the `[dev-runner]` line because occupied ports can still shift them. ## Package Roles diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 335e0685197..8432f49695a 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -8,9 +8,13 @@ import * as ServerConfig from "../config.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as PairingGrantStore from "./PairingGrantStore.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; +import { resolveSessionCookieName } from "./utils.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; +/** Pinned so dev-mode cookie tests can assert the port-scoped name. */ +const TEST_SERVER_PORT = 13_773; + const makeServerConfigLayer = (overrides?: Partial) => Layer.effect( ServerConfig.ServerConfig, @@ -19,6 +23,12 @@ const makeServerConfigLayer = (overrides?: Partial while every request still sent t3_session_13773, + // and the tests would fail for a reason unrelated to what they assert. + port: TEST_SERVER_PORT, } satisfies ServerConfig.ServerConfig["Service"]; }), ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-server-test-" }))); @@ -35,7 +45,11 @@ const makeCookieRequest = ( ): Parameters[0] => ({ cookies: { - t3_session: sessionToken, + // Derived, not hardcoded: the name is port-scoped so concurrent servers + // on one hostname don't share a cookie. Mode and devUrl mirror + // ServerConfig.layerTest, so this resolves to whatever the server reads. + [resolveSessionCookieName({ mode: "web", port: TEST_SERVER_PORT, devUrl: undefined })]: + sessionToken, }, headers: {}, }) as unknown as Parameters< diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index dd53a83ca95..eb056342140 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -438,6 +438,7 @@ export class EnvironmentAuth extends Context.Service< readonly scopes?: ReadonlyArray; readonly subject?: string; readonly proofKeyThumbprint?: string; + readonly purpose?: "startup"; }) => Effect.Effect; readonly issuePairingCredential: ( input?: AuthCreatePairingCredentialInput, @@ -746,11 +747,13 @@ export const make = Effect.gen(function* () { readonly scopes: ReadonlyArray; readonly subject: string; readonly label?: string; + readonly purpose?: "startup"; }) => createPairingLink({ scopes: input.scopes, subject: input.subject, ...(input.label ? { label: input.label } : {}), + ...(input.purpose ? { purpose: input.purpose } : {}), }).pipe( Effect.map( (issued) => @@ -774,6 +777,7 @@ export const make = Effect.gen(function* () { ...(input?.ttl ? { ttl: input.ttl } : {}), ...(input?.label ? { label: input.label } : {}), ...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}), + ...(input?.purpose ? { purpose: input.purpose } : {}), }); return { id: issued.id, @@ -872,6 +876,7 @@ export const make = Effect.gen(function* () { issuePairingCredentialForSubject({ scopes: AuthAdministrativeScopes, subject: INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT, + purpose: "startup", }).pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential")); const listClientSessions: EnvironmentAuth["Service"]["listClientSessions"] = (currentSessionId) => diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts index 95269fb6c37..0e21ef19c90 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts @@ -34,6 +34,9 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { expect(descriptor.policy).toBe("desktop-managed-local"); expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap"]); + // Packaged desktop has no devUrl, but still needs the port scope: it + // scans upward from 3773 for a free port and binds 127.0.0.1, so a second + // instance shares this one's hostname on a different port. expect(descriptor.sessionCookieName).toBe("t3_session_3773"); }).pipe( Effect.provide( @@ -45,6 +48,22 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { ), ); + it.effect("keeps desktop cookies port-scoped on the port a second instance lands on", () => + Effect.gen(function* () { + const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; + const descriptor = yield* policy.getDescriptor(); + + expect(descriptor.sessionCookieName).toBe("t3_session_3774"); + }).pipe( + Effect.provide( + makeEnvironmentAuthPolicyLayer({ + mode: "desktop", + port: 3774, + }), + ), + ), + ); + it.effect("uses remote-reachable policy for desktop mode when bound beyond loopback", () => Effect.gen(function* () { const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; @@ -75,6 +94,24 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { makeEnvironmentAuthPolicyLayer({ mode: "web", host: "127.0.0.1", + port: 13773, + }), + ), + ), + ); + + it.effect("scopes web session cookies by port only in development", () => + Effect.gen(function* () { + const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; + const descriptor = yield* policy.getDescriptor(); + + expect(descriptor.sessionCookieName).toBe("t3_session_13773"); + }).pipe( + Effect.provide( + makeEnvironmentAuthPolicyLayer({ + mode: "web", + port: 13773, + devUrl: new URL("http://127.0.0.1:5733"), }), ), ), diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.ts b/apps/server/src/auth/EnvironmentAuthPolicy.ts index 7ffef0ff0a5..28e41576769 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.ts @@ -41,6 +41,7 @@ export const make = Effect.gen(function* () { sessionCookieName: resolveSessionCookieName({ mode: config.mode, port: config.port, + devUrl: config.devUrl, }), }; diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index 588d5e3775f..057a257ba66 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -202,6 +202,11 @@ export class PairingGrantStore extends Context.Service< readonly subject?: string; readonly label?: string; readonly proofKeyThumbprint?: string; + /** + * "startup" marks the credential the server mints for itself at boot, + * which gets the long dev TTL when a dev URL is configured. + */ + readonly purpose?: "startup"; }) => Effect.Effect; readonly listActive: () => Effect.Effect< ReadonlyArray, @@ -243,6 +248,15 @@ const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5); // window can still recover by re-bootstrapping rather than locking // the user out of the backend. const DESKTOP_BOOTSTRAP_TTL_HOURS = Duration.hours(24); +// A dev server's startup token is read off a log by whoever (or whatever) is +// driving the session, often minutes later — after a `node --watch` restart, a +// detour into another task, or a hand-off to the person actually doing the +// testing. Five minutes turns that into a restart-the-server loop for no +// security benefit: the token only unlocks a local dev backend, and its holder +// could read the log anyway. Same reasoning (and duration) as the desktop +// bootstrap grant above. Only applies when a dev URL is configured; user-issued +// pairing links and real servers keep the 5-minute default. +const DEV_STARTUP_TTL_HOURS = Duration.hours(24); const PAIRING_TOKEN_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; const PAIRING_TOKEN_LENGTH = 12; const PAIRING_TOKEN_REJECTION_LIMIT = @@ -371,7 +385,10 @@ export const make = Effect.gen(function* () { ), ); const credential = yield* generatePairingToken; - const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES; + const isDevStartupToken = config.devUrl !== undefined && input?.purpose === "startup"; + const ttl = + input?.ttl ?? + (isDevStartupToken ? DEV_STARTUP_TTL_HOURS : DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES); const now = yield* DateTime.now; const expiresAt = DateTime.add(now, { milliseconds: Duration.toMillis(ttl) }); const issued: IssuedBootstrapCredential = { diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index 12ecb7dba4d..efa811302dc 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -470,6 +470,7 @@ export const make = Effect.gen(function* () { const cookieName = resolveSessionCookieName({ mode: serverConfig.mode, port: serverConfig.port, + devUrl: serverConfig.devUrl, }); const emitUpsert = (clientSession: AuthClientSession) => diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts index 39f04988ac5..81ef9bffc49 100644 --- a/apps/server/src/auth/utils.ts +++ b/apps/server/src/auth/utils.ts @@ -10,15 +10,29 @@ import * as Result from "effect/Result"; const SESSION_COOKIE_NAME = "t3_session"; +/** + * Cookies are scoped by host but *not* by port, so any two servers that can be + * live on one hostname at once need separate names — otherwise the second + * clobbers the first's session and both sides see "Invalid session token + * signature" until someone clears cookies by hand. + * + * Two populations qualify, for the same reason but from different causes: + * + * - **Dev servers** (`devUrl` set), which run several at a time across worktrees. + * - **Desktop**, which scans upward from 3773 for a free port and binds + * 127.0.0.1, so a second instance lands on a different port and the same host. + * + * Hosted deployments keep the stable production name: their public port can + * change between releases, and scoping it would log every user out. + */ export function resolveSessionCookieName(input: { readonly mode: "web" | "desktop"; readonly port: number; + readonly devUrl: URL | undefined; }): string { - if (input.mode !== "desktop") { - return SESSION_COOKIE_NAME; - } - - return `${SESSION_COOKIE_NAME}_${input.port}`; + return input.devUrl === undefined && input.mode !== "desktop" + ? SESSION_COOKIE_NAME + : `${SESSION_COOKIE_NAME}_${input.port}`; } export function base64UrlEncode(input: string | Uint8Array): string { diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 91006a9bece..fcb662b9b78 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -84,6 +84,7 @@ const makeCliTestServerConfig = (baseDir: string) => ...derivedPaths, staticDir: undefined, devUrl: undefined, + devAllowedOrigins: [], noBrowser: true, startupPresentation: "browser", desktopBootstrapToken: undefined, diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index f6c2a63e192..39c00053fef 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -48,6 +48,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { otlpMetricsUrl: undefined, otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", + devAllowedOrigins: [], } as const; const openBootstrapFd = Effect.fn(function* (payload: DesktopBackendBootstrapValue) { @@ -95,6 +96,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { T3CODE_HOST: "0.0.0.0", T3CODE_HOME: baseDir, VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", + T3CODE_DEV_ALLOWED_ORIGINS: + "https://host.example.ts.net, https://phone.example.ts.net ", T3CODE_NO_BROWSER: "true", T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false", T3CODE_LOG_WS_EVENTS: "true", @@ -117,6 +120,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { host: "0.0.0.0", staticDir: undefined, devUrl: new URL("http://127.0.0.1:5173"), + devAllowedOrigins: ["https://host.example.ts.net", "https://phone.example.ts.net"], noBrowser: true, startupPresentation: "browser", desktopBootstrapToken: undefined, diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 5a4cde0a6fd..e3f231cca29 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -106,6 +106,15 @@ const EnvServerConfig = Config.all({ host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)), t3Home: Config.string("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)), devUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)), + devAllowedOrigins: Config.string("T3CODE_DEV_ALLOWED_ORIGINS").pipe( + Config.withDefault(""), + Config.map((value) => + value + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0), + ), + ), noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe( Config.option, Config.map(Option.getOrUndefined), @@ -363,6 +372,7 @@ export const resolveServerConfig = ( host, staticDir, devUrl, + devAllowedOrigins: env.devAllowedOrigins, noBrowser, startupPresentation, desktopBootstrapToken, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 3b081c95c34..5a16e144ee4 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -72,6 +72,7 @@ export class ServerConfig extends Context.Service< readonly baseDir: string; readonly staticDir: string | undefined; readonly devUrl: URL | undefined; + readonly devAllowedOrigins: ReadonlyArray; readonly noBrowser: boolean; readonly startupPresentation: StartupPresentation; readonly desktopBootstrapToken: string | undefined; @@ -187,6 +188,7 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( desktopBootstrapToken: undefined, staticDir: undefined, devUrl, + devAllowedOrigins: [], noBrowser: false, startupPresentation: "browser", }); diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 61892d53d63..e9dbc4a5956 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -43,6 +43,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { desktopBootstrapToken: undefined, staticDir: undefined, devUrl: undefined, + devAllowedOrigins: [], noBrowser: false, startupPresentation: "browser", } satisfies ServerConfig.ServerConfig["Service"]; diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index b9bb40f372d..77eef955d55 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -4,6 +4,7 @@ import { AuthOrchestrationReadScope, EnvironmentHttpApi, } from "@t3tools/contracts"; +import { isDevProxiedPath } from "@t3tools/shared/devProxy"; import { decodeOtlpTraceRecords } from "@t3tools/shared/observability"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; @@ -48,9 +49,17 @@ export const browserApiCorsLayer = Layer.unwrap( const devOrigin = config.devUrl?.origin; // Dev uses credentialed requests from Vite or the Electron custom origin, so both must be // explicit. Packaged desktop omits credentials and uses Effect's default wildcard origin. + // + // T3CODE_DEV_ALLOWED_ORIGINS covers dev servers reached from a second + // origin — a tailnet name, a LAN IP, a phone. Browser dev normally proxies + // through Vite and is same-origin (no preflight at all), so this is a + // safety net for the desktop renderer and any direct-to-backend caller. return HttpRouter.cors({ ...(devOrigin - ? { allowedOrigins: [devOrigin, ...DESKTOP_RENDERER_ORIGINS], credentials: true } + ? { + allowedOrigins: [devOrigin, ...DESKTOP_RENDERER_ORIGINS, ...config.devAllowedOrigins], + credentials: true, + } : {}), allowedMethods: browserApiCorsAllowedMethods, allowedHeaders: browserApiCorsAllowedHeaders, @@ -216,6 +225,10 @@ export const staticAndDevRouteLayer = HttpRouter.add( } const config = yield* ServerConfig.ServerConfig; + if (config.devUrl && isDevProxiedPath(url.value.pathname)) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + if (config.devUrl && isLoopbackHostname(url.value.hostname)) { return HttpServerResponse.redirect(resolveDevRedirectUrl(config.devUrl, url.value), { status: 302, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 871b79eca90..f06c27a066e 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -380,6 +380,7 @@ const buildAppUnderTest = (options?: { ...derivedPaths, staticDir: undefined, devUrl, + devAllowedOrigins: [], noBrowser: true, startupPresentation: "browser", desktopBootstrapToken: defaultDesktopBootstrapToken, @@ -1328,6 +1329,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { "bearer-access-token", "dpop-access-token", ]); + // Desktop, so port-scoped: instances scan for a free port and share + // 127.0.0.1, and cookies are not scoped by port. assert.isTrue(body.auth.sessionCookieName.startsWith("t3_session_")); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -3246,6 +3249,34 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("allows configured development origins through ServerConfig", () => + Effect.gen(function* () { + const tailnetOrigin = "https://host.example.ts.net"; + yield* buildAppUnderTest({ + config: { + devUrl: new URL(crossOriginClientOrigin), + devAllowedOrigins: [tailnetOrigin], + }, + }); + + const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); + const response = yield* fetchEffect(sessionUrl, { + method: "OPTIONS", + headers: { + origin: tailnetOrigin, + "access-control-request-method": "GET", + "access-control-request-headers": "content-type", + }, + }); + + assert.equal(response.status, 204); + assertBrowserApiCorsPreflightHeaders(response.headers, { + origin: tailnetOrigin, + credentials: true, + }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + for (const desktopOrigin of ["t3code://app", "t3code-dev://app"]) { it.effect(`allows credentialed preflights from ${desktopOrigin} in development`, () => Effect.gen(function* () { diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 6e5b532b58a..e1d590ecdf2 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -7,14 +7,27 @@ import "vite-plus/test/config"; import { defineConfig } from "vite-plus"; import pkg from "./package.json" with { type: "json" }; +import { DEV_PROXIED_PATH_PREFIXES } from "@t3tools/shared/devProxy"; + import { loadRepoEnv } from "../../scripts/lib/public-config"; const repoEnv = loadRepoEnv(); Object.assign(process.env, repoEnv); +// Single-origin dev is signalled positively, because it cannot be inferred +// from the absence of VITE_HTTP_URL/VITE_WS_URL: the runner deletes those keys +// but `loadRepoEnv` merges `.env`/`.env.local` *underneath* the process env, so +// a developer with either URL in their `.env` gets it back here. Baking it then +// pins the client to localhost and breaks every non-localhost origin — the +// exact failure single-origin mode exists to prevent, and an invisible one +// since the page still loads. +const isSingleOriginDev = process.env.T3CODE_SINGLE_ORIGIN_DEV === "1"; + const port = Number(process.env.PORT ?? 5733); -const host = process.env.HOST?.trim() || "localhost"; -const configuredWsUrl = process.env.VITE_WS_URL?.trim(); +const explicitHost = process.env.HOST?.trim(); +const host = explicitHost || "localhost"; +const configuredWsUrl = isSingleOriginDev ? undefined : process.env.VITE_WS_URL?.trim(); +const configuredHttpUrl = isSingleOriginDev ? undefined : process.env.VITE_HTTP_URL?.trim(); const configuredRelayUrl = repoEnv.VITE_T3CODE_RELAY_URL?.trim() || ""; const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || ""; const configuredClerkJwtTemplate = repoEnv.VITE_CLERK_JWT_TEMPLATE?.trim() || ""; @@ -65,7 +78,20 @@ const unitTestProject = { }, } satisfies TestProjectInlineConfiguration; -function resolveDevProxyTarget(wsUrl: string | undefined): string | undefined { +function resolveDevProxyTarget( + backendPort: string | undefined, + wsUrl: string | undefined, +): string | undefined { + // Browser dev is single-origin: the backend port is proxied through this + // server so the app works from any origin (localhost, tailnet, LAN, phone). + // T3CODE_PORT is set by scripts/dev-runner.ts for every non-desktop mode. + const port = Number(backendPort?.trim()); + if (Number.isInteger(port) && port > 0) { + return `http://localhost:${port}/`; + } + + // dev:desktop still points the renderer straight at the backend, so fall + // back to deriving the target from the explicit websocket URL. if (!wsUrl) { return undefined; } @@ -86,7 +112,17 @@ function resolveDevProxyTarget(wsUrl: string | undefined): string | undefined { } } -const devProxyTarget = resolveDevProxyTarget(configuredWsUrl); +const devProxyTarget = resolveDevProxyTarget(process.env.T3CODE_PORT, configuredWsUrl); + +// Vite rejects requests whose Host header isn't localhost, which blocks sharing +// a dev server over Tailscale/LAN. Tailnet names are safe to allow wholesale: +// the DNS is controlled by tailscale, so they can't be rebound by an attacker. +// Anything else (ngrok, a LAN IP alias) goes through the env var. +const configuredAllowedHosts = (process.env.T3CODE_DEV_ALLOWED_HOSTS ?? "") + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +const allowedHosts = [".ts.net", ...configuredAllowedHosts]; export default defineConfig(() => { return { @@ -119,6 +155,10 @@ export default defineConfig(() => { define: { // In dev mode, tell the web app where the WebSocket server lives "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""), + // Pinned explicitly rather than left to Vite's automatic VITE_ exposure: + // under single-origin dev this must stay empty even when a `.env` + // supplies it, so the client falls back to window.location.origin. + "import.meta.env.VITE_HTTP_URL": JSON.stringify(configuredHttpUrl ?? ""), "import.meta.env.VITE_T3CODE_RELAY_URL": JSON.stringify(configuredRelayUrl), "import.meta.env.VITE_CLERK_PUBLISHABLE_KEY": JSON.stringify(configuredClerkPublishableKey), "import.meta.env.VITE_CLERK_JWT_TEMPLATE": JSON.stringify(configuredClerkJwtTemplate), @@ -145,32 +185,41 @@ export default defineConfig(() => { host, port, strictPort: true, + allowedHosts, ...(devProxyTarget ? { - proxy: { - "/.well-known": { - target: devProxyTarget, - changeOrigin: true, - }, - "/api": { - target: devProxyTarget, - changeOrigin: true, - }, - "/attachments": { - target: devProxyTarget, - changeOrigin: true, - }, + // One entry per shared prefix; the server's dev catch-all 404s the + // same list, so the two sides cannot drift. `/ws` is the app's own + // socket — Vite's HMR socket is matched separately and exactly + // (path "/" plus a vite-hmr subprotocol), so the two upgrade + // handlers don't collide. + proxy: Object.fromEntries( + DEV_PROXIED_PATH_PREFIXES.map((prefix) => [ + prefix, + { + target: devProxyTarget, + changeOrigin: true, + ...(prefix === "/ws" ? { ws: true } : {}), + }, + ]), + ), + } + : {}), + // Electron's BrowserWindow needs the HMR socket pinned to an explicit + // host to connect reliably; dev:desktop is the only mode that sets HOST. + // Everywhere else, leaving this unset lets the client derive it from the + // page origin, which is what makes HMR work over Tailscale/LAN instead of + // failing an attempt against the wrong machine's localhost first. + // (Vite 8 logs connection state via console.debug — enable "Verbose".) + ...(explicitHost + ? { + hmr: { + protocol: "ws", + host: explicitHost, + clientPort: port, }, } : {}), - hmr: { - // Explicit config so Vite's HMR WebSocket connects reliably - // inside Electron's BrowserWindow. Vite 8 uses console.debug for - // connection logs — enable "Verbose" in DevTools to see them. - protocol: "ws", - host, - clientPort: port, - }, }, build: { outDir: "dist", diff --git a/docs/reference/scripts.md b/docs/reference/scripts.md index b985c9c9e3d..dac49a8825c 100644 --- a/docs/reference/scripts.md +++ b/docs/reference/scripts.md @@ -1,6 +1,7 @@ # Scripts - `vp run dev` — Starts contracts, server, and web in watch mode. +- `vp run dev --share` — Also publishes the web port over HTTPS on this machine's tailnet. The startup pairing URL is built against the shared origin, and the mapping is removed on exit. - `vp run dev:server` — Starts just the WebSocket server. The server process runs on Bun (`@effect/platform-bun` + `BunPtyAdapter`), but task running uses `vp run`. - `vp run dev:web` — Starts just the Vite dev server for the web app. - Dev commands run from a linked **git worktree** default to that worktree's gitignored `.t3`, even when `T3CODE_HOME` is set, storing state in `/.t3/userdata`. Pass `--home-dir ` to choose another isolated directory explicitly. Submodules are not worktrees and keep the normal precedence. @@ -24,6 +25,7 @@ - Default build is unsigned/not notarized for local sharing. - The DMG build uses `assets/prod/black-macos-1024.png` as the production app icon source. - Desktop production windows load the bundled UI from `t3code://app/index.html` (not a `127.0.0.1` document URL). + - Desktop packaging includes `apps/server/dist` (the `t3` backend) and starts it on loopback with an auth token for WebSocket/API traffic. - Your tester can still open it on macOS by right-clicking the app and choosing **Open** on first launch. - To keep staging files for debugging package contents, run: `vp run dist:desktop:dmg -- --keep-stage` @@ -37,6 +39,12 @@ - Azure authentication env vars are also required (for example service principal with secret): `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`. +## Browser development + +`dev` and `dev:web` leave `VITE_HTTP_URL` and `VITE_WS_URL` unset so the browser resolves the backend from `window.location.origin`. Vite proxies `/api`, `/ws`, `/oauth`, and `/.well-known` to the server, allowing the same bundle to work from localhost or a tailnet hostname. + +Worktrees derive a preferred port offset from their path. The runner shifts both ports together when either is occupied, so treat the `[dev-runner]` output as authoritative. + ## Running multiple dev instances Set `T3CODE_DEV_INSTANCE` to any value to deterministically shift all dev ports together. diff --git a/package.json b/package.json index 3c0a8cc6b77..e2492914d6d 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "scripts": { "prepare": "effect-tsgo patch && vp config --no-agent", "dev": "node scripts/dev-runner.ts dev", + "dev:share": "node scripts/dev-runner.ts dev --share", "dev:server": "node scripts/dev-runner.ts dev:server", "dev:web": "node scripts/dev-runner.ts dev:web", "dev:marketing": "vp run --filter @t3tools/marketing dev", diff --git a/packages/shared/package.json b/packages/shared/package.json index b83ecc53624..8a45591fd36 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -198,6 +198,10 @@ "./devHome": { "types": "./src/devHome.ts", "import": "./src/devHome.ts" + }, + "./devProxy": { + "types": "./src/devProxy.ts", + "import": "./src/devProxy.ts" } }, "scripts": { diff --git a/packages/shared/src/devProxy.ts b/packages/shared/src/devProxy.ts new file mode 100644 index 00000000000..13336cb48a1 --- /dev/null +++ b/packages/shared/src/devProxy.ts @@ -0,0 +1,17 @@ +/** + * Backend paths the web dev server proxies in single-origin browser dev. + * + * Two consumers must agree on this list: the Vite proxy map + * (apps/web/vite.config.ts) that forwards these to the backend, and the + * server's dev catch-all (apps/server/src/http.ts) that 404s them instead of + * redirecting back to Vite. Drift is silent and nasty in both directions — a + * prefix only Vite knows gets answered with index.html; a prefix only the + * server knows redirect-loops through the proxy. + */ +export const DEV_PROXIED_PATH_PREFIXES = ["/api", "/oauth", "/.well-known", "/ws"] as const; + +export function isDevProxiedPath(pathname: string): boolean { + return DEV_PROXIED_PATH_PREFIXES.some( + (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`), + ); +} diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index 853bb1f81c2..24c22454d9d 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -25,6 +25,44 @@ import { } from "./tailscale.ts"; const encoder = new TextEncoder(); + +/** + * Asserts nothing reachable from `error` contains `secret`. Recurses through + * nested objects, arrays, and `cause` chains rather than checking only + * top-level strings: a leak one level down (say, a wrapped cause carrying raw + * stderr) is just as visible in a log, and a shallow check would pass it. + * + * Walks values instead of serializing so it holds for fields added later, and + * tracks visited objects so a cyclic cause chain terminates. + */ +function assertCarriesNoSecret(error: object, secret: string): void { + const seen = new WeakSet(); + + const walk = (value: unknown, path: string): void => { + if (typeof value === "string") { + assert.notInclude(value, secret, `${path} leaked stderr`); + return; + } + if (typeof value !== "object" || value === null || seen.has(value)) { + return; + } + seen.add(value); + + if (Array.isArray(value)) { + value.forEach((entry, index) => walk(entry, `${path}[${String(index)}]`)); + return; + } + // `message` and `cause` are getters on Error subclasses, so they are not + // own enumerable properties and Object.entries alone would skip them. + walk((value as { message?: unknown }).message, `${path}.message`); + walk((value as { cause?: unknown }).cause, `${path}.cause`); + for (const [key, nested] of Object.entries(value)) { + walk(nested, `${path}.${key}`); + } + }; + + walk(error, "error"); +} 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"]}}`; @@ -194,6 +232,26 @@ describe("tailscale", () => { assert.notProperty(error, "stderr"); assert.notInclude(error.message, "tskey-auth-secret-token-value"); assert.equal(error.message, "tailscale status exited with code 7."); + assert.equal(error.stderrDiagnostic, "not-logged-in"); + assertCarriesNoSecret(error, "tskey-auth-secret-token-value"); + }); + }); + + it.effect("classifies unrecognized stderr without quoting it", () => { + const layer = mockSpawnerLayer(() => ({ + code: 3, + stderr: "something novel went wrong for node fluffy-badger tskey-auth-secret-token-value", + })); + + return Effect.gen(function* () { + const error = yield* readTailscaleStatus.pipe(Effect.flip, Effect.provide(layer)); + + assert.instanceOf(error, TailscaleCommandExitError); + // Unmatched stderr degrades to "unknown" rather than passing text + // through — that fallback is what keeps novel output from leaking. + assert.equal(error.stderrDiagnostic, "unknown"); + assertCarriesNoSecret(error, "tskey-auth-secret-token-value"); + assertCarriesNoSecret(error, "fluffy-badger"); }); }); @@ -253,6 +311,10 @@ describe("tailscale", () => { assert.notProperty(error, "command"); assert.notProperty(error, "stderr"); assert.notInclude(error.message, "tskey-auth-secret-token-value"); + // The diagnostic classifies the failure without quoting stderr, so the + // key cannot reach a log through it either. + assert.equal(error.stderrDiagnostic, "permission-denied"); + assertCarriesNoSecret(error, "tskey-auth-secret-token-value"); }); }); diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index 27761490af0..7260a9de11b 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -23,6 +23,37 @@ const TailscaleCommandContext = { argumentCount: Schema.Number, }; +/** + * Failure kinds we can name without quoting the CLI. Anything unrecognized + * becomes "unknown" rather than falling back to raw text — stderr can contain + * auth keys (`tskey-…`) and node names, and these labels are logged. + */ +export const TailscaleStderrDiagnostic = Schema.Literals([ + "no-existing-handler", + "not-logged-in", + "permission-denied", + "unknown", +]); +export type TailscaleStderrDiagnostic = typeof TailscaleStderrDiagnostic.Type; + +// Matched against stderr, most specific first. Patterns are deliberately short +// and anchored on tailscale's own wording. +const STDERR_DIAGNOSTIC_PATTERNS: ReadonlyArray< + readonly [RegExp, Exclude] +> = [ + [/handler does not exist/i, "no-existing-handler"], + [/not logged in|logged out|needs? login/i, "not-logged-in"], + [/permission denied|access denied|must be root|operation not permitted/i, "permission-denied"], +]; + +/** Classifies stderr into a safe label, dropping the text itself. */ +export const stderrDiagnosticOf = (stderr: string): TailscaleStderrDiagnostic | undefined => { + if (stderr.trim().length === 0) { + return undefined; + } + return STDERR_DIAGNOSTIC_PATTERNS.find(([pattern]) => pattern.test(stderr))?.[1] ?? "unknown"; +}; + export class TailscaleCommandSpawnError extends Schema.TaggedErrorClass()( "TailscaleCommandSpawnError", { @@ -54,6 +85,12 @@ export class TailscaleCommandExitError extends Schema.TaggedErrorClass { }); assert.equal(env.T3CODE_PORT, "13773"); + assert.equal(env.PORT, "5733"); + }), + ); + + // Browser dev is single-origin: Vite proxies the backend, and the client + // resolves it from window.location.origin. Baking a localhost URL here is + // what breaks sharing a dev server to another device. + for (const mode of ["dev", "dev:web"] as const) { + it.effect(`leaves the client backend URLs unset in ${mode} mode`, () => + Effect.gen(function* () { + const env = yield* createDevRunnerEnv({ + mode, + baseEnv: { + VITE_HTTP_URL: "http://localhost:1234", + VITE_WS_URL: "ws://localhost:1234", + }, + serverOffset: 0, + webOffset: 0, + t3Home: undefined, + browser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: undefined, + devUrl: undefined, + }); + + assert.equal(env.VITE_HTTP_URL, undefined); + assert.equal(env.VITE_WS_URL, undefined); + assert.equal(env.T3CODE_PORT, "13773"); + // Deleting the keys is not sufficient — vite.config.ts merges + // `.env`/`.env.local` underneath this env and would revive them, so + // the intent has to be stated positively. + assert.equal(env.T3CODE_SINGLE_ORIGIN_DEV, "1"); + }), + ); + } + + // Desktop pins the renderer at loopback deliberately; an ambient marker + // must not make Vite discard those URLs. + it.effect("clears the single-origin marker in dev:desktop mode", () => + Effect.gen(function* () { + const env = yield* createDevRunnerEnv({ + mode: "dev:desktop", + baseEnv: { T3CODE_SINGLE_ORIGIN_DEV: "1" }, + serverOffset: 0, + webOffset: 0, + t3Home: undefined, + browser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: undefined, + devUrl: undefined, + }); + + assert.equal(env.T3CODE_SINGLE_ORIGIN_DEV, undefined); + assert.equal(env.VITE_HTTP_URL, "http://127.0.0.1:13773"); + }), + ); + + it.effect("clears the single-origin marker in dev:server mode", () => + Effect.gen(function* () { + const env = yield* createDevRunnerEnv({ + mode: "dev:server", + baseEnv: { T3CODE_SINGLE_ORIGIN_DEV: "1" }, + serverOffset: 0, + webOffset: 0, + t3Home: undefined, + browser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: undefined, + devUrl: undefined, + }); + + assert.equal(env.T3CODE_SINGLE_ORIGIN_DEV, undefined); assert.equal(env.VITE_HTTP_URL, "http://localhost:13773"); - assert.equal(env.VITE_WS_URL, "ws://localhost:13773"); + }), + ); + + // HOST is Vite's bind address and gates the HMR pin in vite.config.ts. An + // inherited one would survive into browser dev and point HMR at the wrong + // interface — invisible over a shared origin, since the page still loads. + for (const mode of ["dev", "dev:web"] as const) { + it.effect(`drops an inherited HOST in ${mode} mode`, () => + Effect.gen(function* () { + const env = yield* createDevRunnerEnv({ + mode, + baseEnv: { HOST: "0.0.0.0" }, + serverOffset: 0, + webOffset: 0, + t3Home: undefined, + browser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: undefined, + devUrl: undefined, + }); + + assert.equal(env.HOST, undefined); + }), + ); + } + + // --host configures the *backend* (T3CODE_HOST). It must not become Vite's + // bind address by way of an inherited HOST that happens to agree with it. + it.effect("drops an inherited HOST even when --host is given", () => + Effect.gen(function* () { + const env = yield* createDevRunnerEnv({ + mode: "dev", + baseEnv: { HOST: "0.0.0.0" }, + serverOffset: 0, + webOffset: 0, + t3Home: undefined, + browser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: "0.0.0.0", + port: undefined, + devUrl: undefined, + }); + + assert.equal(env.HOST, undefined); + assert.equal(env.T3CODE_HOST, "0.0.0.0"); + }), + ); + + // Desktop sets HOST itself, so the clearing must not reach it. + it.effect("still pins HOST for dev:desktop", () => + Effect.gen(function* () { + const env = yield* createDevRunnerEnv({ + mode: "dev:desktop", + baseEnv: { HOST: "0.0.0.0" }, + serverOffset: 0, + webOffset: 0, + t3Home: undefined, + browser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: undefined, + devUrl: undefined, + }); + + assert.equal(env.HOST, "127.0.0.1"); + }), + ); + + it.effect("keeps explicit backend URLs for the desktop renderer", () => + Effect.gen(function* () { + const env = yield* createDevRunnerEnv({ + mode: "dev:desktop", + baseEnv: {}, + serverOffset: 0, + webOffset: 0, + t3Home: undefined, + browser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: undefined, + devUrl: undefined, + }); + + assert.equal(env.VITE_HTTP_URL, "http://127.0.0.1:13773"); + assert.equal(env.VITE_WS_URL, "ws://127.0.0.1:13773"); }), ); }); @@ -444,6 +613,59 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { ); }); + describe("devPortProbeHosts", () => { + it.effect("probes loopback only when no bind host is configured", () => + Effect.sync(() => { + assert.deepStrictEqual(devPortProbeHosts(undefined), ["127.0.0.1", "::1"]); + assert.deepStrictEqual(devPortProbeHosts(" "), ["127.0.0.1", "::1"]); + }), + ); + + // A port free on loopback can be taken on the interface the server will + // actually bind, so --host/T3CODE_HOST has to be probed as well. + it.effect("adds a non-loopback bind host to the probe list", () => + Effect.sync(() => { + assert.deepStrictEqual(devPortProbeHosts("0.0.0.0"), ["127.0.0.1", "::1", "0.0.0.0"]); + assert.deepStrictEqual(devPortProbeHosts("192.168.1.10"), [ + "127.0.0.1", + "::1", + "192.168.1.10", + ]); + }), + ); + + it.effect("does not probe loopback twice when it is the configured host", () => + Effect.sync(() => { + assert.deepStrictEqual(devPortProbeHosts("127.0.0.1"), ["127.0.0.1", "::1"]); + }), + ); + + // Only the backend honours --host/T3CODE_HOST. Vite reads HOST (set for + // desktop only), so judging the web port against the backend's interface + // would reject ports for a server that never binds there. + it.effect("passes the port role so only the server port sees the bind host", () => + Effect.gen(function* () { + const probed: Array<{ port: number; role: string | undefined }> = []; + + yield* resolveModePortOffsets({ + mode: "dev", + startOffset: 0, + hasExplicitServerPort: false, + hasExplicitDevUrl: false, + checkPortAvailability: (port, role) => { + probed.push({ port, role }); + return Effect.succeed(true); + }, + }); + + assert.deepStrictEqual(probed, [ + { port: 13_773, role: "server" }, + { port: 5733, role: "web" }, + ]); + }), + ); + }); + describe("resolveModePortOffsets", () => { it.effect("uses a shared fallback offset for dev mode", () => Effect.gen(function* () { @@ -577,6 +799,170 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }); }); + // `tailscale serve` config outlives the process, so a dry run that shared + // would replace and then tear down whatever mapping the port already had. + // Base-dir precedence (--home-dir > worktree .t3 > ambient T3CODE_HOME) + // lives in runDevRunnerWithInput; the env builder must not consult the + // ambient variable on its own, or it would silently outrank the worktree + // default and land dev state on the user's real database. + it.effect("ignores an ambient T3CODE_HOME when no home is resolved", () => + Effect.gen(function* () { + const env = yield* createDevRunnerEnv({ + mode: "dev", + baseEnv: { T3CODE_HOME: "/home/user/.t3" }, + serverOffset: 0, + webOffset: 0, + t3Home: undefined, + browser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: undefined, + devUrl: undefined, + }); + + assert.equal(env.T3CODE_HOME, undefined); + }), + ); + + // Sharing dev:desktop would publish a URL whose renderer dials the + // visitor's own loopback, and would clobber the VITE_DEV_SERVER_URL that + // Electron loads from. It must decline, not half-work. + it.effect("declines to share for dev:desktop and still starts the stack", () => { + let spawnCount = 0; + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => { + spawnCount += 1; + return Effect.succeed(mockProcess(0)); + }), + ); + + return Effect.gen(function* () { + yield* runDevRunnerWithInput({ + ...devServerInput, + mode: "dev:desktop", + port: undefined, + share: true, + }).pipe( + Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), + Effect.provideService(HostProcessPlatform, "linux"), + ); + + assert.equal(spawnCount, 1); + }); + }); + + // Single-origin browser dev proxies the backend at localhost, so a backend + // bound only to a specific interface breaks every proxied request in a way + // that looks like a broken server. Reject the combination up front. + it.effect("rejects a specific non-loopback --host for browser dev modes", () => { + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.succeed(mockProcess(0))), + ); + + return Effect.gen(function* () { + const error = yield* runDevRunnerWithInput({ + ...devServerInput, + mode: "dev", + port: undefined, + host: "192.168.1.10", + }).pipe( + Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.flip, + ); + + if (error._tag !== "DevRunnerHostNotProxiableError") { + assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.mode, "dev"); + assert.equal(error.host, "192.168.1.10"); + assert.include(error.message, "0.0.0.0"); + assert.include(error.message, "--share"); + }); + }); + + // Wildcards keep loopback answering, so the proxy target stays valid and + // the combination must keep working — it is the documented way to serve a + // LAN interface and the browser proxy at once. + it.effect("still spawns the stack for a wildcard --host in dev mode", () => { + let spawnCount = 0; + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => { + spawnCount += 1; + return Effect.succeed(mockProcess(0)); + }), + ); + + return Effect.gen(function* () { + yield* runDevRunnerWithInput({ + ...devServerInput, + mode: "dev", + port: undefined, + host: "0.0.0.0", + }).pipe( + Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), + Effect.provideService(HostProcessPlatform, "linux"), + ); + + assert.equal(spawnCount, 1); + }); + }); + + // dev:server does not proxy — the client talks to the backend directly — + // so a specific interface bind stays legitimate there. + it.effect("keeps a specific --host working for dev:server", () => { + let spawnCount = 0; + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => { + spawnCount += 1; + return Effect.succeed(mockProcess(0)); + }), + ); + + return Effect.gen(function* () { + yield* runDevRunnerWithInput({ + ...devServerInput, + host: "192.168.1.10", + }).pipe( + Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), + Effect.provideService(HostProcessPlatform, "linux"), + ); + + assert.equal(spawnCount, 1); + }); + }); + + it.effect("spawns nothing when --dry-run is combined with --share", () => { + let spawnCount = 0; + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => { + spawnCount += 1; + return Effect.succeed(mockProcess(0)); + }), + ); + + return Effect.gen(function* () { + yield* runDevRunnerWithInput({ + ...devServerInput, + mode: "dev", + port: undefined, + dryRun: true, + share: true, + }).pipe( + Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), + Effect.provideService(HostProcessPlatform, "linux"), + ); + + assert.equal(spawnCount, 0); + }); + }); + it.effect("reports non-zero exits without manufacturing a cause", () => { const spawnerLayer = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 2875cbe88d3..976951dc543 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -5,7 +5,7 @@ import * as NodeOS from "node:os"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NetService from "@t3tools/shared/Net"; -import { resolveWorktreeT3Home } from "@t3tools/shared/devHome"; +import { resolveGitWorktreePath, resolveWorktreeT3Home } from "@t3tools/shared/devHome"; import { HostProcessEnvironment, HostProcessWorkingDirectory } from "@t3tools/shared/hostProcess"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Config from "effect/Config"; @@ -19,6 +19,7 @@ import * as Schema from "effect/Schema"; import { Argument, Command, Flag } from "effect/unstable/cli"; import { ChildProcess } from "effect/unstable/process"; +import { type DevShareError, shareDevServer, unshareDevServer } from "./lib/dev-share.ts"; import { loadRepoEnv } from "./lib/public-config.ts"; Object.assign(process.env, loadRepoEnv()); @@ -28,7 +29,32 @@ const BASE_WEB_PORT = 5733; const MAX_HASH_OFFSET = 3000; const MAX_PORT = 65535; const DESKTOP_DEV_LOOPBACK_HOST = "127.0.0.1"; -const DEV_PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::1", "::"] as const; +// Dev servers bind loopback, so loopback is the only interface whose +// availability decides whether we can use a port. Probing wildcards too made +// the runner walk away from a perfectly free port whenever something else held +// the same number on another interface — `tailscale serve` does exactly that, +// which silently moved the ports out from under a URL that had just been shared. +const DEV_PORT_PROBE_HOSTS = ["127.0.0.1", "::1"] as const; + +/** + * Bind hosts on which a backend still answers `http://localhost:`, which + * is where single-origin browser dev proxies to. Loopback and the wildcards + * qualify; a specific interface (e.g. a LAN IP) does not — the OS binds only + * that address and the proxy target goes dark. + */ +export function isProxiableBindHost(host: string): boolean { + const normalized = host.trim(); + return ( + normalized === "" || + normalized === "localhost" || + normalized === "127.0.0.1" || + normalized === "::1" || + normalized === "[::1]" || + normalized === "0.0.0.0" || + normalized === "::" || + normalized === "[::]" + ); +} export const DEFAULT_T3_HOME = Effect.map(Effect.service(Path.Path), (path) => path.join(NodeOS.homedir(), ".t3"), @@ -49,7 +75,15 @@ const MODE_ARGS = { } as const satisfies Record>; type DevMode = keyof typeof MODE_ARGS; -type PortAvailabilityCheck = (port: number) => Effect.Effect; +/** + * `role` matters because only the backend honours `--host`/`T3CODE_HOST`; the + * web port is always loopback. Passed explicitly rather than inferred from the + * port number, which stops distinguishing them under a large port offset. + */ +type PortAvailabilityCheck = ( + port: number, + role?: "server" | "web", +) => Effect.Effect; const DEV_RUNNER_MODES = Object.keys(MODE_ARGS) as Array; @@ -129,8 +163,21 @@ export class DevRunnerProcessExitError extends Schema.TaggedErrorClass()( + "DevRunnerHostNotProxiableError", + { + mode: Schema.Literals(["dev", "dev:web"]), + host: Schema.String, + }, +) { + override get message(): string { + return `--host ${this.host} cannot be combined with ${this.mode}: single-origin browser dev proxies the backend at localhost, and a backend bound only to ${this.host} leaves localhost unanswered, so every proxied request fails. Use a wildcard (0.0.0.0 or ::) to serve that interface and loopback together, or --share for remote access.`; + } +} + export const DevRunnerError = Schema.Union([ DevRunnerConfigurationError, + DevRunnerHostNotProxiableError, DevRunnerInvalidPortOffsetError, DevRunnerPortExhaustedError, DevRunnerProcessError, @@ -167,6 +214,7 @@ const OffsetConfig = Config.all({ export function resolveOffset(config: { readonly portOffset: number | undefined; readonly devInstance: string | undefined; + readonly worktreePath?: string | undefined; }): Effect.Effect< { readonly offset: number; readonly source: string }, DevRunnerInvalidPortOffsetError @@ -188,19 +236,30 @@ export function resolveOffset(config: { } const seed = config.devInstance?.trim(); - if (!seed) { - return Effect.succeed({ offset: 0, source: "default ports" }); + if (seed) { + if (/^\d+$/.test(seed)) { + return Effect.succeed({ + offset: Number(seed), + source: `numeric T3CODE_DEV_INSTANCE=${seed}`, + }); + } + + const offset = ((Hash.string(seed) >>> 0) % MAX_HASH_OFFSET) + 1; + return Effect.succeed({ offset, source: `hashed T3CODE_DEV_INSTANCE=${seed}` }); } - if (/^\d+$/.test(seed)) { - return Effect.succeed({ - offset: Number(seed), - source: `numeric T3CODE_DEV_INSTANCE=${seed}`, - }); + // Worktrees get ports derived from their path so each one is stable across + // restarts and distinct from its siblings. Without this every worktree starts + // at offset 0 and scan-collides onto whatever happens to be free that minute, + // so ports move under you between runs — which breaks any URL you already + // shared. The main checkout keeps the documented 5733/13773. + const worktreePath = config.worktreePath?.trim(); + if (worktreePath) { + const offset = ((Hash.string(worktreePath) >>> 0) % MAX_HASH_OFFSET) + 1; + return Effect.succeed({ offset, source: `worktree ${worktreePath}` }); } - const offset = ((Hash.string(seed) >>> 0) % MAX_HASH_OFFSET) + 1; - return Effect.succeed({ offset, source: `hashed T3CODE_DEV_INSTANCE=${seed}` }); + return Effect.succeed({ offset: 0, source: "default ports" }); } function resolveBaseDir(baseDir: string | undefined): Effect.Effect { @@ -246,8 +305,8 @@ export function createDevRunnerEnv({ return Effect.gen(function* () { const serverPort = port ?? BASE_SERVER_PORT + serverOffset; const webPort = BASE_WEB_PORT + webOffset; - // Precedence is resolved by the caller. An unset t3Home here genuinely - // means "use the default" rather than inheriting an ambient value. + // Precedence (--home-dir > worktree .t3 > ambient T3CODE_HOME) is resolved + // by the caller; an unset t3Home here genuinely means "use the default". const configuredBaseDir = t3Home?.trim() || undefined; const resolvedBaseDir = yield* resolveBaseDir(configuredBaseDir); const isDesktopMode = mode === "dev:desktop"; @@ -268,12 +327,41 @@ export function createDevRunnerEnv({ if (!isDesktopMode) { output.T3CODE_PORT = String(serverPort); - output.VITE_HTTP_URL = `http://localhost:${serverPort}`; - output.VITE_WS_URL = `ws://localhost:${serverPort}`; + // HOST is Vite's own bind address, and the desktop branch below is the + // only place we set it. An inherited one (an exported HOST, a container, + // a `HOST=0.0.0.0 npm start` habit) would otherwise reach Vite and pin + // its HMR socket to that address — see the `explicitHost` gate in + // apps/web/vite.config.ts. Over a shared origin that is invisible: the + // page loads and only HMR quietly dials the wrong machine. + delete output.HOST; + if (mode === "dev" || mode === "dev:web") { + // Browser dev is single-origin: everything (including /ws) is proxied + // through Vite, so the client must resolve its backend from + // window.location.origin rather than a baked-in localhost URL. See + // resolveConfiguredPrimaryTarget in apps/web/src/environments/primary/target.ts + // — it only defers to the origin when both of these are absent. Baking + // localhost here is what breaks any non-localhost origin (tailnet, LAN, + // phone): the remote browser dials its own machine. + delete output.VITE_HTTP_URL; + delete output.VITE_WS_URL; + // Deleting is not enough on its own: vite.config.ts calls loadRepoEnv, + // which merges `.env`/`.env.local` *under* this env, so a developer + // with either URL in their `.env` would get it back and silently lose + // single-origin mode. This states the intent positively so Vite can + // ignore those values rather than infer from their absence. + output.T3CODE_SINGLE_ORIGIN_DEV = "1"; + } else { + output.VITE_HTTP_URL = `http://localhost:${serverPort}`; + output.VITE_WS_URL = `ws://localhost:${serverPort}`; + delete output.T3CODE_SINGLE_ORIGIN_DEV; + } } else { output.T3CODE_PORT = String(serverPort); output.VITE_HTTP_URL = `http://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`; output.VITE_WS_URL = `ws://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`; + // Desktop pins the renderer to loopback on purpose; an ambient marker + // must not make Vite drop those URLs. + delete output.T3CODE_SINGLE_ORIGIN_DEV; delete output.T3CODE_MODE; delete output.T3CODE_NO_BROWSER; delete output.T3CODE_HOST; @@ -344,13 +432,41 @@ export function checkPortAvailabilityOnHosts( }); } -const defaultCheckPortAvailability: PortAvailabilityCheck = (port) => - Effect.gen(function* () { - const net = yield* NetService.NetService; - return yield* checkPortAvailabilityOnHosts(port, DEV_PORT_PROBE_HOSTS, (candidatePort, host) => - net.canListenOnHost(candidatePort, host), - ); - }); +/** + * Hosts to probe for a dev server bound to `configuredHost`. + * + * Loopback is always checked because the web server and the desktop renderer + * target reach it there. When `--host`/`T3CODE_HOST` moves the backend onto + * another interface, that interface decides whether the bind actually + * succeeds — probing only loopback would hand back a port that is free here + * and taken there, and the server would fail to start. + * + * `configuredHost` applies to the *backend* only. Vite takes its bind address + * from `HOST`, which the runner sets for desktop alone, so the web port stays + * on loopback and must not be judged against the backend's interface — + * a port free on loopback but busy on that interface would otherwise be + * rejected for a server that was never going to bind there. + */ +export function devPortProbeHosts(configuredHost: string | undefined): ReadonlyArray { + const host = configuredHost?.trim(); + if (!host || DEV_PORT_PROBE_HOSTS.includes(host as (typeof DEV_PORT_PROBE_HOSTS)[number])) { + return DEV_PORT_PROBE_HOSTS; + } + return [...DEV_PORT_PROBE_HOSTS, host]; +} + +const makeDefaultCheckPortAvailability = + (configuredHost: string | undefined): PortAvailabilityCheck => + (port, role) => + Effect.gen(function* () { + const net = yield* NetService.NetService; + const hosts = role === "web" ? DEV_PORT_PROBE_HOSTS : devPortProbeHosts(configuredHost); + return yield* checkPortAvailabilityOnHosts(port, hosts, (candidatePort, host) => + net.canListenOnHost(candidatePort, host), + ); + }); + +const defaultCheckPortAvailability = makeDefaultCheckPortAvailability(undefined); interface FindFirstAvailableOffsetInput { readonly startOffset: number; @@ -384,10 +500,10 @@ export function findFirstAvailableOffset({ const checks: Array> = []; if (requireServerPort) { - checks.push(checkPort(serverPort)); + checks.push(checkPort(serverPort, "server")); } if (requireWebPort) { - checks.push(checkPort(webPort)); + checks.push(checkPort(webPort, "web")); } if (checks.length === 0) { @@ -483,6 +599,7 @@ interface DevRunnerCliInput { readonly port: number | undefined; readonly devUrl: URL | undefined; readonly dryRun: boolean; + readonly share: boolean; readonly runArgs: ReadonlyArray; } @@ -498,19 +615,41 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { ), ); - const { offset, source } = yield* resolveOffset({ portOffset, devInstance }); + // Single-origin browser dev proxies the backend at localhost. A wildcard + // bind still answers there; a specific non-loopback interface does not, + // which breaks every proxied request in a way that reads as "server is + // broken" rather than "flag combination is unsupported". Reject it up + // front instead. (dev:server and dev:desktop don't proxy — untouched.) + if ( + (input.mode === "dev" || input.mode === "dev:web") && + input.host !== undefined && + !isProxiableBindHost(input.host) + ) { + return yield* new DevRunnerHostNotProxiableError({ mode: input.mode, host: input.host }); + } + + const worktreePath = yield* resolveGitWorktreePath(yield* HostProcessWorkingDirectory); + + const { offset, source } = yield* resolveOffset({ + portOffset, + devInstance, + worktreePath, + }); const { serverOffset, webOffset } = yield* resolveModePortOffsets({ mode: input.mode, startOffset: offset, hasExplicitServerPort: input.port !== undefined, hasExplicitDevUrl: input.devUrl !== undefined, + // A non-loopback bind host decides whether the backend can actually take + // the port, so it has to be probed alongside loopback. + checkPortAvailability: makeDefaultCheckPortAvailability(input.host), }); const hostEnvironment = yield* HostProcessEnvironment; - // A worktree defaults to its own gitignored `.t3`. This deliberately - // outranks ambient T3CODE_HOME, which otherwise selects the installed - // app's live userdata database. An explicit --home-dir still wins. + // A dev server started inside a worktree defaults to that worktree's own + // (gitignored) `.t3` — see @t3tools/shared/devHome for why this must + // outrank an ambient T3CODE_HOME. `--home-dir` still wins. const worktreeHome = yield* resolveWorktreeT3Home(yield* HostProcessWorkingDirectory); // Trim before choosing: `--home-dir ""` is not a selection, and treating it // as one would skip the worktree default and land on the shared home — @@ -543,10 +682,90 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${String(env.T3CODE_PORT)} webPort=${String(env.PORT)} baseDir=${baseDir}`, ); + // Before the share block: --dry-run only resolves and prints. Sharing would + // replace, then tear down, whatever mapping the port already had — a + // surprising side effect from a command documented as inert. if (input.dryRun) { return; } + const sharedWebPort = BASE_WEB_PORT + webOffset; + if (input.share) { + if (input.mode === "dev:server") { + yield* Effect.logInfo("[dev-runner] --share has no effect for dev:server (no web server)."); + } else if (input.mode === "dev:desktop") { + // Desktop is not single-origin: the renderer gets VITE_HTTP_URL and + // VITE_WS_URL baked to loopback, so a tailnet visitor would load the UI + // and then watch it dial its own 127.0.0.1 for the backend. Worse, + // sharing would overwrite VITE_DEV_SERVER_URL, which is the origin + // Electron itself loads the renderer from. Refuse rather than hand out + // a URL that is broken in a way the user cannot see. + yield* Effect.logWarning( + "[dev-runner] --share is not supported for dev:desktop (the renderer is pinned to loopback). Use `dev`, which runs the whole browser stack.", + ); + } else { + // acquireRelease, not share-then-addFinalizer: the mapping outlives this + // process (and reboots), so the cleanup has to be registered atomically + // with creating it. An interrupt landing in between would otherwise + // leave a mapping pointing at a port nothing is listening on. + // + // Deliberately no ownership tracking beyond that: if a second runner + // takes this port during a fast restart, the first's exit can briefly + // tear down the new mapping — visible (the URL stops working) and fixed + // by re-running --share. A lease protocol closing that window existed + // and was removed as more machinery than a dev convenience warrants. + // + // A tailnet that isn't up shouldn't stop the dev server from starting — + // warn, and carry on serving locally. + const shared = yield* Effect.acquireRelease( + shareDevServer({ webPort: sharedWebPort }), + () => + // Serve config outlives this process, so a cleanup that did not + // take leaves a tailnet URL pointing at a port nothing serves. + unshareDevServer(sharedWebPort).pipe( + Effect.flatMap((result) => + result.cleared + ? Effect.void + : Effect.logWarning( + `[dev-runner] could not remove the tailnet mapping for port ${String(sharedWebPort)}${ + result.explanation ? `: ${result.explanation}` : "" + }. Remove it with \`tailscale serve --https=${String(sharedWebPort)} off\`.`, + ), + ), + ), + ).pipe( + Effect.tapError((error: DevShareError) => + Effect.logWarning( + `[dev-runner] could not share on the tailnet: ${error.message}${ + error.hint ? ` — ${error.hint}` : "" + }`, + ), + ), + Effect.option, + Effect.map(Option.getOrUndefined), + ); + + if (shared) { + // The app is reached from the tailnet origin. Vite already allows + // *.ts.net hosts; the backend needs the origin for credentialed + // requests that bypass the proxy (desktop renderer, direct calls). + env.T3CODE_DEV_ALLOWED_ORIGINS = [ + env.T3CODE_DEV_ALLOWED_ORIGINS, + new URL(shared.url).origin, + ] + .filter((entry) => entry && entry.length > 0) + .join(","); + // The server builds its pairing URL from this, so the URL printed at + // startup is already the shareable one — no rewriting by hand. An + // explicit --dev-url still wins. + if (input.devUrl === undefined) { + env.VITE_DEV_SERVER_URL = shared.url; + } + yield* Effect.logInfo(`[dev-runner] shared on tailnet: ${shared.url}`); + } + } + } + const spawnCommand = yield* resolveSpawnCommand( "vp", [...MODE_ARGS[input.mode], ...input.runArgs], @@ -606,7 +825,7 @@ const devRunnerCli = Command.make("dev-runner", { ), t3Home: Flag.string("home-dir").pipe( Flag.withDescription( - "Explicit T3 Code data directory; runtime state is stored under userdata. Inside a git worktree this defaults to that worktree's own .t3.", + "Explicit T3 Code data directory; runtime state is stored under userdata (equivalent to T3CODE_HOME). Inside a git worktree this defaults to that worktree's own .t3 so dev state stays off the shared home.", ), Flag.optional, Flag.map(Option.getOrUndefined), @@ -646,6 +865,12 @@ const devRunnerCli = Command.make("dev-runner", { Flag.withDescription("Resolve mode/ports/env and print, but do not spawn Vite+."), Flag.withDefault(false), ), + share: Flag.boolean("share").pipe( + Flag.withDescription( + "Publish the web dev server on this machine's tailnet over HTTPS (via `tailscale serve`) and print the pairing URL for it. Removed again on exit.", + ), + Flag.withDefault(false), + ), runArgs: Argument.string("run-arg").pipe( Argument.withDescription("Additional Vite+ run args (pass after `--`)."), Argument.variadic(), diff --git a/scripts/lib/dev-share.test.ts b/scripts/lib/dev-share.test.ts new file mode 100644 index 00000000000..b2dfba3585e --- /dev/null +++ b/scripts/lib/dev-share.test.ts @@ -0,0 +1,178 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + type DevShareError, + DevServeFailedError, + shareDevServer, + unshareDevServer, +} from "./dev-share.ts"; + +const TAILNET_STATUS = JSON.stringify({ Self: { DNSName: "host.example.ts.net." } }); +const NO_HANDLER_STDERR = "error: failed to remove web serve: handler does not exist"; + +interface CallResult { + readonly exitCode: number; + readonly stderr?: string; +} + +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`. + */ +const spawnerLayer = (input: { readonly off?: CallResult; readonly serve?: CallResult }) => + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const args = "args" in command ? (command.args as ReadonlyArray) : []; + const result: CallResult = args.includes("status") + ? { exitCode: 0 } + : args.includes("off") + ? (input.off ?? { exitCode: 0 }) + : (input.serve ?? { exitCode: 0 }); + + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: args.includes("status") ? encode(TAILNET_STATUS) : Stream.empty, + stderr: result.stderr ? encode(result.stderr) : Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }), + ); + +describe("unshareDevServer", () => { + it.effect("treats a removed mapping as cleared", () => + Effect.gen(function* () { + const result = yield* unshareDevServer(5788).pipe( + Effect.provide(spawnerLayer({ off: { exitCode: 0 } })), + ); + assert.isTrue(result.cleared); + }), + ); + + // `tailscale serve … off` exits 1 when the port had no mapping, which is the + // normal first-share case — the port is clear, so this must not be an error. + it.effect("treats a missing handler as cleared", () => + Effect.gen(function* () { + const result = yield* unshareDevServer(5788).pipe( + Effect.provide(spawnerLayer({ off: { exitCode: 1, stderr: NO_HANDLER_STDERR } })), + ); + assert.isTrue(result.cleared); + }), + ); + + it.effect("reports a genuine removal failure as not cleared", () => + Effect.gen(function* () { + const result = yield* unshareDevServer(5788).pipe( + Effect.provide(spawnerLayer({ off: { exitCode: 1, stderr: "permission denied" } })), + ); + assert.isFalse(result.cleared); + assert.include(result.explanation, "permission denied"); + // Structured, so a wrapping error can keep the real chain. + assert.equal(result.cause?._tag, "TailscaleCommandExitError"); + }), + ); +}); + +describe("shareDevServer", () => { + it.effect("returns the tailnet URL for the same port", () => + Effect.gen(function* () { + const shared = yield* shareDevServer({ webPort: 5788 }).pipe( + Effect.provide(spawnerLayer({ off: { exitCode: 1, stderr: NO_HANDLER_STDERR } })), + ); + + assert.equal(shared.host, "host.example.ts.net"); + assert.equal(shared.url, "https://host.example.ts.net: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. + it.effect("reports that the prior mapping was cleared when serve fails", () => + Effect.gen(function* () { + const error: DevShareError = yield* shareDevServer({ webPort: 5788 }).pipe( + Effect.provide( + spawnerLayer({ + off: { exitCode: 0 }, + serve: { exitCode: 1, stderr: "port already in use" }, + }), + ), + Effect.flip, + ); + + assert.instanceOf(error, DevServeFailedError); + assert.equal(error.stage, "serve"); + assert.equal(error.webPort, 5788); + // The underlying failure is preserved rather than flattened to a string. + assert.equal( + (error.cause as { _tag?: string } | undefined)?._tag, + "TailscaleCommandExitError", + ); + // Unclassifiable stderr is never quoted (it can carry auth keys), so the + // message points at the command instead of echoing the CLI. + assert.notInclude(error.message, "port already in use"); + assert.include(error.message, "run the command by hand"); + assert.include(error.message, "no longer served"); + assert.include(error.message, "5788"); + }), + ); + + // A recognized failure gets our own wording for it — enough to act on + // without passing CLI text through. + it.effect("explains a recognized serve failure without quoting stderr", () => + Effect.gen(function* () { + const error: DevShareError = yield* shareDevServer({ webPort: 5788 }).pipe( + Effect.provide( + spawnerLayer({ + off: { exitCode: 0 }, + serve: { + exitCode: 1, + stderr: "permission denied for tskey-auth-secret-token-value", + }, + }), + ), + Effect.flip, + ); + + assert.instanceOf(error, DevServeFailedError); + assert.equal(error.stage, "serve"); + assert.include(error.message, "permission denied"); + assert.include(error.message, "elevated privileges"); + assert.notInclude(error.message, "tskey-auth-secret-token-value"); + }), + ); + + // Serving over routes we could not remove yields a URL that loads but whose + // /ws and /api quietly point at a dead backend. + it.effect("refuses to serve when the existing mapping could not be cleared", () => + Effect.gen(function* () { + const error: DevShareError = yield* shareDevServer({ webPort: 5788 }).pipe( + Effect.provide(spawnerLayer({ off: { exitCode: 1, stderr: "permission denied" } })), + Effect.flip, + ); + + assert.instanceOf(error, DevServeFailedError); + // A distinct stage: the prior mapping survived, so nothing was replaced. + assert.equal(error.stage, "clear-existing"); + assert.include(error.message, "could not clear the existing mapping"); + assert.include(error.message, "permission denied"); + }), + ); +}); diff --git a/scripts/lib/dev-share.ts b/scripts/lib/dev-share.ts new file mode 100644 index 00000000000..0f843b3ba91 --- /dev/null +++ b/scripts/lib/dev-share.ts @@ -0,0 +1,217 @@ +/** + * Shares a running dev server on the local tailnet via `tailscale serve`, so it + * can be opened from a phone, another laptop, or by whoever is reviewing the + * work. + * + * Thin wrapper over `@t3tools/tailscale` (the same client the server's own + * `--tailscale-serve` uses). What it adds is dev-share semantics: replacing a + * stale mapping left by a killed run, and refusing to serve over routes it + * could not remove. + * + * Because browser dev is single-origin (Vite proxies the backend — see + * `resolveDevProxyTarget` in apps/web/vite.config.ts), one proxy rule covering + * the web port is enough; the backend needs no mapping of its own. + */ + +import { + buildTailscaleHttpsBaseUrl, + disableTailscaleServe, + ensureTailscaleServe, + readTailscaleStatus, + type TailscaleCommandError, + type TailscaleStderrDiagnostic, +} from "@t3tools/tailscale"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import type { ChildProcessSpawner } from "effect/unstable/process"; + +/** + * Human-readable gloss for each diagnostic. Deliberately our own words rather + * than the CLI's: tailscale prints auth keys and node names into stderr, and + * this string is logged. + */ +const DIAGNOSTIC_EXPLANATIONS: Record = { + "no-existing-handler": "no mapping existed for that port", + "not-logged-in": "this machine is not logged into a tailnet — run `tailscale up`", + "permission-denied": "permission denied — `tailscale serve` may need elevated privileges", + unknown: undefined, +}; + +/** + * Our own wording for why a tailscale command failed, derived from the + * classified diagnostic. Never the CLI's text — see `stderrDiagnosticOf`. + */ +const explainCommandFailure = (error: TailscaleCommandError): string | undefined => + error._tag === "TailscaleCommandExitError" && error.stderrDiagnostic !== undefined + ? (DIAGNOSTIC_EXPLANATIONS[error.stderrDiagnostic] ?? "run the command by hand to see why") + : undefined; + +/** + * Three distinct failures, three classes: each has its own caller-visible + * message and its own remedy, and `shareDevServer` chooses between them + * structurally. A single error with a `reason` discriminator would encode that + * distinction twice and put a lookup table in the `message` getter. + * + * Each wraps a real underlying failure and so keeps it as `cause`; the message + * is derived only from the structural fields, never from `cause.message`. + */ +export class TailscaleUnavailableError extends Schema.TaggedErrorClass()( + "TailscaleUnavailableError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "could not talk to tailscale"; + } + + get hint(): string { + return "Is Tailscale installed and tailscaled running? Try `tailscale status` — or drop --share and open the printed localhost URL."; + } +} + +/** No underlying failure: the status read succeeded and simply had no name. */ +export class TailnetNameMissingError extends Schema.TaggedErrorClass()( + "TailnetNameMissingError", + {}, +) { + override get message(): string { + return "this machine has no tailnet DNS name"; + } + + get hint(): string { + return "Run `tailscale up` and make sure MagicDNS is enabled."; + } +} + +/** + * `stage` is a genuine multi-value discriminator: both stages share the same + * semantics (a `tailscale serve` invocation failed for this port) and differ + * only in which one, which the message states plainly. + */ +export class DevServeFailedError extends Schema.TaggedErrorClass()( + "DevServeFailedError", + { + stage: Schema.Literals(["clear-existing", "serve"]), + webPort: Schema.Number, + explanation: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + const port = String(this.webPort); + const base = + this.stage === "clear-existing" + ? `could not clear the existing mapping for port ${port}. Run \`tailscale serve --https=${port} off\` and retry` + : `could not serve port ${port} on the tailnet (it is no longer served; any previous mapping for it was cleared before this attempt)`; + return this.explanation ? `${base}: ${this.explanation}` : base; + } + + get hint(): undefined { + return undefined; + } +} + +export const DevShareError = Schema.Union([ + TailscaleUnavailableError, + TailnetNameMissingError, + DevServeFailedError, +]); +export type DevShareError = typeof DevShareError.Type; +export const isDevShareError = Schema.is(DevShareError); + +/** + * Removes any mapping for `webPort`, reporting whether the port is now clear. + * + * Runs uninterruptibly: this is called from a finalizer on the way out of an + * interrupted program, and cancelling the cleanup subprocess would leave + * exactly the stale mapping it exists to remove. + */ +export const unshareDevServer = ( + webPort: number, +): Effect.Effect< + { + readonly cleared: boolean; + readonly explanation?: string | undefined; + // Kept structured so a caller wrapping this can preserve the real error + // chain rather than a flattened string. + readonly cause?: TailscaleCommandError | undefined; + }, + never, + ChildProcessSpawner.ChildProcessSpawner +> => + disableTailscaleServe({ servePort: webPort }).pipe( + Effect.as({ cleared: true } as const), + Effect.catch((error: TailscaleCommandError) => + Effect.succeed( + // "Nothing was mapped" leaves the port clear either way. + error._tag === "TailscaleCommandExitError" && + error.stderrDiagnostic === "no-existing-handler" + ? ({ cleared: true } as const) + : ({ + cleared: false, + ...(explainCommandFailure(error) !== undefined + ? { explanation: explainCommandFailure(error) } + : {}), + cause: error, + } as const), + ), + ), + Effect.uninterruptible, + ); + +export interface DevShareResult { + readonly url: string; + readonly host: string; +} + +/** + * Publishes `webPort` on the tailnet at the same port number and returns the + * resulting HTTPS URL. Idempotent: re-running replaces any existing mapping. + */ +export const shareDevServer = Effect.fn("devShare.shareDevServer")(function* (input: { + readonly webPort: number; +}) { + const status = yield* readTailscaleStatus.pipe( + Effect.mapError((error) => new TailscaleUnavailableError({ cause: error })), + ); + if (status.magicDnsName === null) { + return yield* new TailnetNameMissingError(); + } + + // Clear any mapping left behind by a run that was killed before its finalizer + // could fire. Serve config survives both the process and a reboot, and a + // stale entry may carry path routes we no longer want — older versions mapped + // /ws, /api and friends to a separate backend port, and serving "/" alone + // would leave those pointing at a port nothing is listening on. + const cleared = yield* unshareDevServer(input.webPort); + if (!cleared.cleared) { + // Serving over routes we failed to remove would hand out a URL that is + // broken in a way the user cannot see: the page loads while /ws and /api + // silently resolve to a dead backend. Better to refuse and say why. + return yield* new DevServeFailedError({ + stage: "clear-existing", + webPort: input.webPort, + ...(cleared.explanation !== undefined ? { explanation: cleared.explanation } : {}), + ...(cleared.cause !== undefined ? { cause: cleared.cause } : {}), + }); + } + + yield* ensureTailscaleServe({ localPort: input.webPort, servePort: input.webPort }).pipe( + Effect.mapError((error) => { + const explanation = explainCommandFailure(error); + return new DevServeFailedError({ + stage: "serve", + webPort: input.webPort, + ...(explanation !== undefined ? { explanation } : {}), + cause: error, + }); + }), + ); + + return { + url: buildTailscaleHttpsBaseUrl({ + magicDnsName: status.magicDnsName, + servePort: input.webPort, + }), + host: status.magicDnsName, + } satisfies DevShareResult; +}); diff --git a/scripts/package.json b/scripts/package.json index 629163d688e..457a8f0d3a3 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -10,6 +10,7 @@ "@effect/platform-node": "catalog:", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", + "@t3tools/tailscale": "workspace:*", "effect": "catalog:", "pngjs": "7.0.0", "yaml": "catalog:"