From 2c175fd3a6ae00e5b1b2903d974c7319967b81a8 Mon Sep 17 00:00:00 2001 From: Joshua Goon Date: Tue, 21 Apr 2026 23:40:04 -0700 Subject: [PATCH 1/7] chore: add workspace root as explicit dependency Bun records the root package as a workspace dependency so the lockfile matches package.json after install. Made-with: Cursor --- bun.lock | 5 +++++ package.json | 3 +++ 2 files changed, 8 insertions(+) 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", From eff1e1b4cb0119fefc94485a51828fcb38398e7f Mon Sep 17 00:00:00 2001 From: Joshua Goon Date: Tue, 21 Apr 2026 23:40:07 -0700 Subject: [PATCH 2/7] chore(turbo): hash T3CODE_HOST and VITE_HTTP_URL for task cache Include these in globalEnv so dev and web tasks invalidate when bind address or HTTP base URL overrides change. Made-with: Cursor --- turbo.json | 2 ++ 1 file changed, 2 insertions(+) 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", From 8299154136bf78569111f7905691e62c1c6f902f Mon Sep 17 00:00:00 2001 From: Joshua Goon Date: Tue, 21 Apr 2026 23:40:09 -0700 Subject: [PATCH 3/7] fix(dev-runner): forward HOST to Vite and keep inherited T3CODE_NO_BROWSER Set HOST alongside T3CODE_HOST so apps/web/vite.config.ts sees the bind address. Stop deleting T3CODE_NO_BROWSER when the CLI omits --no-browser so extendEnv:false runs preserve a parent shell export. Made-with: Cursor --- scripts/dev-runner.test.ts | 23 +++++++++++++++++++++++ scripts/dev-runner.ts | 11 ++++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) 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( From d04750892f7fe607cd5928c900a0e352b71af38f Mon Sep 17 00:00:00 2001 From: Joshua Goon Date: Tue, 21 Apr 2026 23:40:12 -0700 Subject: [PATCH 4/7] feat(server): allowlist credentialed browser API CORS origins Replace wildcard CORS with credentials plus isAllowedBrowserApiCorsOrigin (loopback, .localhost, private CGNAT/LAN ranges) so credentialed fetches from LAN or Tailscale dev URLs succeed safely. Made-with: Cursor --- apps/server/src/http.test.ts | 29 +++++++++++++- apps/server/src/http.ts | 78 +++++++++++++++++++++++++++++++++--- 2 files changed, 100 insertions(+), 7 deletions(-) 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..998bc60afbb 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -30,12 +30,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, b] = octets; + 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.cors({ + allowedMethods: ["GET", "POST", "OPTIONS"], + allowedHeaders: ["authorization", "b3", "traceparent", "content-type"], + maxAge: 600, + credentials: true, + allowedOrigins: isAllowedBrowserApiCorsOrigin as (origin: string) => boolean, +}); + export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string { const redirectUrl = new URL(devUrl.toString()); redirectUrl.pathname = requestUrl.pathname; From e03e05290bc69f06cbbcfae3006eb9c598b81b8e Mon Sep 17 00:00:00 2001 From: Joshua Goon Date: Tue, 21 Apr 2026 23:40:15 -0700 Subject: [PATCH 5/7] fix(web): fix Vite listen host and HMR client when binding 0.0.0.0 Map wildcard bind hosts to true for server.host and omit HMR host so the client uses the page hostname instead of ws://0.0.0.0. Made-with: Cursor --- apps/web/vite.config.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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: { From 1d3f50519ffa99447468a904a339d05601d2d225 Mon Sep 17 00:00:00 2001 From: Joshua Goon Date: Tue, 21 Apr 2026 23:40:18 -0700 Subject: [PATCH 6/7] fix(web): rewrite loopback configured API URLs to the page hostname When env points at localhost but the UI is opened from LAN or Tailscale, rewrite http/ws base URLs to the current page host so requests do not hit the browser machine loopback. Add Vitest coverage for rewrite behavior. Made-with: Cursor --- .../src/environments/primary/target.test.ts | 46 +++++++++++++++++ apps/web/src/environments/primary/target.ts | 51 +++++++++++++++++-- 2 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/environments/primary/target.test.ts 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(); } From ea17fcac6aa79659b6beb498c4c19af2441699d9 Mon Sep 17 00:00:00 2001 From: Joshua Goon Date: Wed, 22 Apr 2026 00:25:46 -0700 Subject: [PATCH 7/7] fix cors issue --- apps/server/src/http.ts | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 998bc60afbb..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, @@ -84,7 +85,11 @@ export function isAllowedBrowserApiCorsOrigin(origin: string | undefined): boole return false; } - const [a, b] = octets; + const a = octets[0]; + const b = octets[1]; + if (a === undefined || b === undefined) { + return false; + } if (a === 10) { return true; } @@ -102,13 +107,16 @@ export function isAllowedBrowserApiCorsOrigin(origin: string | undefined): boole return false; } -export const browserApiCorsLayer = HttpRouter.cors({ - allowedMethods: ["GET", "POST", "OPTIONS"], - allowedHeaders: ["authorization", "b3", "traceparent", "content-type"], - maxAge: 600, - credentials: true, - allowedOrigins: isAllowedBrowserApiCorsOrigin as (origin: string) => boolean, -}); +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());