From 80750b7d4037a1cdf447f3a75604461199c51d76 Mon Sep 17 00:00:00 2001 From: snowdamiz Date: Sat, 21 Mar 2026 18:07:10 -0400 Subject: [PATCH] fix(web): lazily compute default package root to avoid Windows standalone crash The standalone Next.js bundle bakes import.meta.url at build time with the Linux CI runner's absolute path. On Windows, fileURLToPath() rejects the Unix file:// URL at module load time, crashing all API routes with ERR_INVALID_FILE_URL_PATH before GSD_WEB_PACKAGE_ROOT can be checked. Replace the eager top-level const with a lazy getter that: 1. Defers evaluation until GSD_WEB_PACKAGE_ROOT is actually absent 2. Catches the cross-platform fileURLToPath failure gracefully 3. Falls back to process.cwd() when the baked-in URL is invalid 4. Caches the result so the computation only runs once Add regression tests verifying: - GSD_WEB_PACKAGE_ROOT is used when set - Lazy fallback returns a valid absolute path without throwing - Memoization is stable across calls - Module loads without crash (the original failure mode) Closes gsd-build/gsd-2#1881 --- src/tests/web-bridge-package-root.test.ts | 70 +++++++++++++++++++++++ src/web/bridge-service.ts | 27 ++++++++- 2 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 src/tests/web-bridge-package-root.test.ts diff --git a/src/tests/web-bridge-package-root.test.ts b/src/tests/web-bridge-package-root.test.ts new file mode 100644 index 000000000..f919ce873 --- /dev/null +++ b/src/tests/web-bridge-package-root.test.ts @@ -0,0 +1,70 @@ +/** + * Regression tests for the default package root fallback in bridge-service. + * + * Issue: gsd-build/gsd-2#1881 + * The standalone Next.js bundle bakes import.meta.url at build time with the + * CI runner's absolute path. On Windows, fileURLToPath() rejects the Unix + * file:// URL at module load time, 500-ing all API routes. + * + * The fix makes the fallback lazy and catch-guarded so the module loads safely + * on any OS regardless of what import.meta.url resolved to at build time. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { resolve } from "node:path"; + +const bridge = await import("../web/bridge-service.ts"); + +test("resolveBridgeRuntimeConfig uses GSD_WEB_PACKAGE_ROOT when set", () => { + const env = { + GSD_WEB_PACKAGE_ROOT: "/custom/package/root", + GSD_WEB_PROJECT_CWD: "/some/project", + } as unknown as NodeJS.ProcessEnv; + + const config = bridge.resolveBridgeRuntimeConfig(env); + assert.equal(config.packageRoot, "/custom/package/root"); +}); + +test("resolveBridgeRuntimeConfig falls back to lazy default when GSD_WEB_PACKAGE_ROOT is absent", () => { + // Reset the memoized value so we exercise the lazy computation path. + bridge.resetDefaultPackageRootForTests(); + + const env = { + GSD_WEB_PROJECT_CWD: "/some/project", + } as unknown as NodeJS.ProcessEnv; + + // Should not throw — the lazy getter catches cross-platform failures. + const config = bridge.resolveBridgeRuntimeConfig(env); + assert.equal(typeof config.packageRoot, "string"); + assert.ok(config.packageRoot.length > 0, "packageRoot must be a non-empty string"); +}); + +test("lazy default package root is an absolute path", () => { + bridge.resetDefaultPackageRootForTests(); + + const env = { + GSD_WEB_PROJECT_CWD: "/some/project", + } as unknown as NodeJS.ProcessEnv; + + const config = bridge.resolveBridgeRuntimeConfig(env); + // resolve() returns the same path if already absolute. + assert.equal(config.packageRoot, resolve(config.packageRoot)); +}); + +test("lazy default package root is memoized across calls", () => { + bridge.resetDefaultPackageRootForTests(); + + const env = {} as unknown as NodeJS.ProcessEnv; + + const first = bridge.resolveBridgeRuntimeConfig(env).packageRoot; + const second = bridge.resolveBridgeRuntimeConfig(env).packageRoot; + assert.equal(first, second, "memoized value should be stable across calls"); +}); + +test("module loads without throwing (regression: eager fileURLToPath crash)", () => { + // The fact that we can import bridge-service at the top of this file without + // an unhandled exception is itself the primary regression gate. This test + // makes that contract explicit. + assert.ok(typeof bridge.resolveBridgeRuntimeConfig === "function"); +}); diff --git a/src/web/bridge-service.ts b/src/web/bridge-service.ts index 32ed1048b..fc942bf71 100644 --- a/src/web/bridge-service.ts +++ b/src/web/bridge-service.ts @@ -39,7 +39,30 @@ import { } from "./auto-dashboard-service.ts"; import { resolveGsdCliEntry } from "./cli-entry.ts"; -const DEFAULT_PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +// Lazily computed fallback — import.meta.url is baked in at build time by +// webpack, so when the standalone bundle built on Linux CI runs on Windows the +// literal file:// URL contains a Unix path that fileURLToPath() rejects. +// Deferring the computation means it only fires when GSD_WEB_PACKAGE_ROOT is +// absent, and if it does fire we handle the cross-platform failure gracefully. +let _defaultPackageRoot: string | undefined; +function getDefaultPackageRoot(): string { + if (_defaultPackageRoot !== undefined) return _defaultPackageRoot; + try { + _defaultPackageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + } catch { + // Standalone bundle running on a different OS than the builder — the + // baked-in import.meta.url is not a valid local file URL. Fall back to + // cwd which is the best available approximation; callers that need the + // real package root should set GSD_WEB_PACKAGE_ROOT. + _defaultPackageRoot = process.cwd(); + } + return _defaultPackageRoot; +} + +/** @internal — test-only: reset the memoized default package root */ +export function resetDefaultPackageRootForTests(): void { + _defaultPackageRoot = undefined; +} const RESPONSE_TIMEOUT_MS = 30_000; const START_TIMEOUT_MS = 150_000; const MAX_STDERR_BUFFER = 8_000; @@ -1047,7 +1070,7 @@ async function fallbackWorkspaceIndex(basePath: string): Promise