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
This commit is contained in:
snowdamiz 2026-03-21 18:07:10 -04:00
parent c1a35dd1b3
commit 80750b7d40
2 changed files with 95 additions and 2 deletions

View file

@ -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");
});

View file

@ -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<GSDWorkspaceInd
export function resolveBridgeRuntimeConfig(env: NodeJS.ProcessEnv = getBridgeDeps().env ?? process.env, projectCwdOverride?: string): BridgeRuntimeConfig {
const projectCwd = projectCwdOverride || env.GSD_WEB_PROJECT_CWD || process.cwd();
const projectSessionsDir = env.GSD_WEB_PROJECT_SESSIONS_DIR || getProjectSessionsDir(projectCwd);
const packageRoot = env.GSD_WEB_PACKAGE_ROOT || DEFAULT_PACKAGE_ROOT;
const packageRoot = env.GSD_WEB_PACKAGE_ROOT || getDefaultPackageRoot();
return { projectCwd, projectSessionsDir, packageRoot };
}