diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index de861cc6645..59cab8507d3 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts"; +import { + isAllowedBrowserApiCorsOrigin, + isLoopbackHostname, + resolveDevRedirectUrl, +} from "./http.ts"; describe("http dev routing", () => { it("treats localhost and loopback addresses as local", () => { @@ -25,3 +29,26 @@ describe("http dev routing", () => { ); }); }); + +describe("browser API CORS origin allowlist", () => { + it("allows loopback and RFC6761 .localhost dev origins", () => { + expect(isAllowedBrowserApiCorsOrigin("http://localhost:5733")).toBe(true); + expect(isAllowedBrowserApiCorsOrigin("http://127.0.0.1:5733")).toBe(true); + expect(isAllowedBrowserApiCorsOrigin("http://vite.localhost:5733")).toBe(true); + }); + + it("allows Tailscale CGNAT and common private LAN ranges", () => { + expect(isAllowedBrowserApiCorsOrigin("http://100.91.197.39:5733")).toBe(true); + expect(isAllowedBrowserApiCorsOrigin("http://192.168.1.10:5733")).toBe(true); + expect(isAllowedBrowserApiCorsOrigin("http://10.0.0.5:5733")).toBe(true); + expect(isAllowedBrowserApiCorsOrigin("http://172.20.0.2:5733")).toBe(true); + }); + + it("rejects missing, invalid, or public origins", () => { + expect(isAllowedBrowserApiCorsOrigin(undefined)).toBe(false); + expect(isAllowedBrowserApiCorsOrigin("")).toBe(false); + expect(isAllowedBrowserApiCorsOrigin("not-a-url")).toBe(false); + expect(isAllowedBrowserApiCorsOrigin("https://example.com")).toBe(false); + expect(isAllowedBrowserApiCorsOrigin("http://185.199.108.153:5733")).toBe(false); + }); +}); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 88cc5adae92..752cbaff86e 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -5,6 +5,7 @@ import { HttpBody, HttpClient, HttpClientResponse, + HttpMiddleware, HttpRouter, HttpServerResponse, HttpServerRequest, @@ -30,12 +31,6 @@ const FALLBACK_PROJECT_FAVICON_SVG = ` Number(part)); + if (octets.length !== 4 || octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) { + return false; + } + + const a = octets[0]; + const b = octets[1]; + if (a === undefined || b === undefined) { + return false; + } + if (a === 10) { + return true; + } + if (a === 172 && b >= 16 && b <= 31) { + return true; + } + if (a === 192 && b === 168) { + return true; + } + // Tailscale CGNAT (100.64.0.0/10) + if (a === 100 && b >= 64 && b <= 127) { + return true; + } + + return false; +} + +export const browserApiCorsLayer = HttpRouter.middleware( + HttpMiddleware.cors({ + allowedMethods: ["GET", "POST", "OPTIONS"], + allowedHeaders: ["authorization", "b3", "traceparent", "content-type"], + maxAge: 600, + credentials: true, + allowedOrigins: isAllowedBrowserApiCorsOrigin, + }), + { global: true }, +); + export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string { const redirectUrl = new URL(devUrl.toString()); redirectUrl.pathname = requestUrl.pathname; diff --git a/apps/web/src/environments/primary/target.test.ts b/apps/web/src/environments/primary/target.test.ts new file mode 100644 index 00000000000..ed3d29d670f --- /dev/null +++ b/apps/web/src/environments/primary/target.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { readPrimaryEnvironmentTarget } from "./target"; + +describe("readPrimaryEnvironmentTarget", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + it("rewrites loopback configured URLs to the page hostname for LAN-style access", () => { + vi.stubEnv("VITE_HTTP_URL", "http://localhost:13773"); + vi.stubEnv("VITE_WS_URL", "ws://localhost:13773"); + vi.stubGlobal("window", { + location: new URL("http://100.64.0.2:5733/"), + desktopBridge: undefined, + }); + + const target = readPrimaryEnvironmentTarget(); + expect(target).toEqual({ + source: "configured", + target: { + httpBaseUrl: "http://100.64.0.2:13773/", + wsBaseUrl: "ws://100.64.0.2:13773/", + }, + }); + }); + + it("does not rewrite when the page is served from loopback", () => { + vi.stubEnv("VITE_HTTP_URL", "http://localhost:13773"); + vi.stubEnv("VITE_WS_URL", "ws://localhost:13773"); + vi.stubGlobal("window", { + location: new URL("http://127.0.0.1:5733/"), + desktopBridge: undefined, + }); + + const target = readPrimaryEnvironmentTarget(); + expect(target).toEqual({ + source: "configured", + target: { + httpBaseUrl: "http://localhost:13773/", + wsBaseUrl: "ws://localhost:13773/", + }, + }); + }); +}); diff --git a/apps/web/src/environments/primary/target.ts b/apps/web/src/environments/primary/target.ts index 04b7d903d4b..72e01da24ba 100644 --- a/apps/web/src/environments/primary/target.ts +++ b/apps/web/src/environments/primary/target.ts @@ -90,6 +90,41 @@ function resolveConfiguredPrimaryTarget(): PrimaryEnvironmentTarget | null { }; } +/** + * When the UI is opened from a non-loopback hostname (LAN / Tailscale) but the + * build still points the API at loopback, rewrite to the page hostname so the + * browser does not try to reach its own localhost. + */ +function rewriteConfiguredTargetLoopbackHostForPageHostname( + target: PrimaryEnvironmentTarget, +): PrimaryEnvironmentTarget { + if (target.source !== "configured") { + return target; + } + + const pageHostname = normalizeHostname(new URL(window.location.href).hostname); + if (isLoopbackHostname(pageHostname)) { + return target; + } + + const rewriteIfLoopback = (rawBaseUrl: string): string => { + const url = new URL(normalizeBaseUrl(rawBaseUrl)); + if (!isLoopbackHostname(normalizeHostname(url.hostname))) { + return rawBaseUrl; + } + url.hostname = pageHostname; + return url.toString(); + }; + + return { + ...target, + target: { + httpBaseUrl: rewriteIfLoopback(target.target.httpBaseUrl), + wsBaseUrl: rewriteIfLoopback(target.target.wsBaseUrl), + }, + }; +} + function resolveWindowOriginPrimaryTarget(): PrimaryEnvironmentTarget { const httpBaseUrl = normalizeBaseUrl(window.location.origin); const url = new URL(httpBaseUrl); @@ -150,9 +185,15 @@ export function resolvePrimaryEnvironmentHttpUrl( } export function readPrimaryEnvironmentTarget(): PrimaryEnvironmentTarget | null { - return ( - resolveDesktopPrimaryTarget() ?? - resolveConfiguredPrimaryTarget() ?? - resolveWindowOriginPrimaryTarget() - ); + const desktopTarget = resolveDesktopPrimaryTarget(); + if (desktopTarget) { + return desktopTarget; + } + + const configuredTarget = resolveConfiguredPrimaryTarget(); + if (configuredTarget) { + return rewriteConfiguredTargetLoopbackHostForPageHostname(configuredTarget); + } + + return resolveWindowOriginPrimaryTarget(); } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 01b50766952..75063488217 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -7,6 +7,9 @@ import pkg from "./package.json" with { type: "json" }; const port = Number(process.env.PORT ?? 5733); const host = process.env.HOST?.trim() || "localhost"; +const wildcardBindHosts = new Set(["0.0.0.0", "::"]); +const viteListenHost = wildcardBindHosts.has(host) ? true : host; +const hmrUsesExplicitHost = !wildcardBindHosts.has(host); const configuredHttpUrl = process.env.VITE_HTTP_URL?.trim(); const configuredWsUrl = process.env.VITE_WS_URL?.trim(); const sourcemapEnv = process.env.T3CODE_WEB_SOURCEMAP?.trim().toLowerCase(); @@ -68,7 +71,7 @@ export default defineConfig({ tsconfigPaths: true, }, server: { - host, + host: viteListenHost, port, strictPort: true, ...(devProxyTarget @@ -94,7 +97,9 @@ export default defineConfig({ // inside Electron's BrowserWindow. Vite 8 uses console.debug for // connection logs — enable "Verbose" in DevTools to see them. protocol: "ws", - host, + // When listening on all interfaces, omit `host` so the client uses the + // page hostname (LAN / Tailscale) instead of ws://0.0.0.0 which breaks HMR. + ...(hmrUsesExplicitHost ? { host } : {}), }, }, build: { diff --git a/bun.lock b/bun.lock index a8dc482f464..33620d02890 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,9 @@ "workspaces": { "": { "name": "@t3tools/monorepo", + "dependencies": { + "@t3tools/monorepo": ".", + }, "devDependencies": { "@types/node": "catalog:", "oxfmt": "^0.40.0", @@ -735,6 +738,8 @@ "@t3tools/marketing": ["@t3tools/marketing@workspace:apps/marketing"], + "@t3tools/monorepo": ["@t3tools/monorepo@root:", {}], + "@t3tools/scripts": ["@t3tools/scripts@workspace:scripts"], "@t3tools/shared": ["@t3tools/shared@workspace:packages/shared"], diff --git a/package.json b/package.json index 93a75d5b7e8..b69c7f184e9 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,9 @@ "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo", "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs" }, + "dependencies": { + "@t3tools/monorepo": "." + }, "devDependencies": { "@types/node": "catalog:", "oxfmt": "^0.40.0", diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index ce4865ecede..e53fbc0a4c9 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -92,6 +92,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { assert.equal(env.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD, "0"); assert.equal(env.T3CODE_LOG_WS_EVENTS, "1"); assert.equal(env.T3CODE_HOST, "0.0.0.0"); + assert.equal(env.HOST, "0.0.0.0"); assert.equal(env.VITE_DEV_SERVER_URL, "http://localhost:7331/"); }), ); @@ -141,6 +142,28 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }), ); + it.effect("preserves T3CODE_NO_BROWSER from base env when noBrowser override is unset", () => + Effect.gen(function* () { + const env = yield* createDevRunnerEnv({ + mode: "dev", + baseEnv: { + T3CODE_NO_BROWSER: "true", + }, + serverOffset: 0, + webOffset: 0, + t3Home: undefined, + noBrowser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: undefined, + devUrl: undefined, + }); + + assert.equal(env.T3CODE_NO_BROWSER, "true"); + }), + ); + it.effect("uses custom t3Home when provided", () => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 1621b60da73..10b6677180e 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -173,12 +173,15 @@ export function createDevRunnerEnv({ if (!isDesktopMode && host !== undefined) { output.T3CODE_HOST = host; + // Vite reads `HOST` for dev-server bind address (see apps/web/vite.config.ts). + output.HOST = host; } + // When `noBrowser` is set (CLI flag or dev-runner config), normalize for children. + // When it is unset, keep whatever came from `baseEnv` (e.g. `T3CODE_NO_BROWSER=true bun dev`). + // Do not delete here: with `extendEnv: false`, deleting would strip the user's env before turbo/t3. if (!isDesktopMode && noBrowser !== undefined) { output.T3CODE_NO_BROWSER = noBrowser ? "1" : "0"; - } else if (!isDesktopMode) { - delete output.T3CODE_NO_BROWSER; } if (autoBootstrapProjectFromCwd !== undefined) { @@ -491,7 +494,9 @@ const devRunnerCli = Command.make("dev-runner", { Flag.withFallbackConfig(optionalBooleanConfig("T3CODE_LOG_WS_EVENTS")), ), host: Flag.string("host").pipe( - Flag.withDescription("Server host/interface override (forwards to T3CODE_HOST)."), + Flag.withDescription( + "Bind host for the API server and Vite (forwards to T3CODE_HOST and HOST). Use 0.0.0.0 to listen on all interfaces (LAN, Tailscale, etc.).", + ), Flag.withFallbackConfig(optionalStringConfig("T3CODE_HOST")), ), port: Flag.integer("port").pipe( diff --git a/turbo.json b/turbo.json index e6ebeee912f..02f51033b55 100644 --- a/turbo.json +++ b/turbo.json @@ -3,6 +3,8 @@ "globalEnv": [ "HOST", "PORT", + "T3CODE_HOST", + "VITE_HTTP_URL", "VITE_WS_URL", "VITE_DEV_SERVER_URL", "T3CODE_LOG_WS_EVENTS",