singularity-forge/src/web/cli-entry.ts
Tom Boucher 2e04253c0b fix: resolve Node v24 web boot failure — ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING (#1864)
Node v24 forbids --experimental-strip-types for files under node_modules/.
When GSD is globally installed, all src/ files live under node_modules/gsd-pi/,
causing every subprocess worker to crash with ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING.

Bug 1: Extract resolveTypeStrippingFlag() into src/web/ts-subprocess-flags.ts.
When the package root is under node_modules/ and Node >= 22.7, the function
returns --experimental-transform-types (which handles node_modules paths).
All 15 service files and cli-entry.ts now call this function instead of
hardcoding --experimental-strip-types.

Bug 2: waitForBootReady() now tracks consecutive 5xx responses and aborts
after 3 in a row, including the response body in the error message.
Connection-level errors (transient during cold start) reset the counter.

Bug 3: The /api/boot route handler now wraps collectBootPayload() in
try/catch and returns { error: message } with status 500, matching the
error response pattern used by other API routes.

Fixes #1849

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:24:07 -06:00

76 lines
2.3 KiB
TypeScript

import { existsSync } from "node:fs";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { resolveTypeStrippingFlag } from "./ts-subprocess-flags.ts";
export interface GsdCliEntry {
command: string;
args: string[];
cwd: string;
}
export interface ResolveGsdCliEntryOptions {
packageRoot: string;
cwd: string;
execPath?: string;
hostKind?: string;
mode?: "interactive" | "rpc";
sessionDir?: string;
messages?: string[];
existsSync?: (path: string) => boolean;
}
function buildExtraArgs(options: ResolveGsdCliEntryOptions): string[] {
if (options.mode !== "rpc") return [];
if (!options.sessionDir) {
throw new Error("RPC CLI entry requires sessionDir");
}
return ["--mode", "rpc", "--continue", "--session-dir", options.sessionDir];
}
export function resolveGsdCliEntry(options: ResolveGsdCliEntryOptions): GsdCliEntry {
const checkExists = options.existsSync ?? existsSync;
const execPath = options.execPath ?? process.execPath;
const extraArgs = buildExtraArgs(options);
const messageArgs = options.mode === "interactive" ? options.messages ?? [] : [];
const sourceEntry = join(options.packageRoot, "src", "loader.ts");
const resolveTsLoader = join(options.packageRoot, "src", "resources", "extensions", "gsd", "tests", "resolve-ts.mjs");
const builtEntry = join(options.packageRoot, "dist", "loader.js");
const sourceCliEntry =
checkExists(sourceEntry) && checkExists(resolveTsLoader)
? {
command: execPath,
args: [
"--import",
pathToFileURL(resolveTsLoader).href,
resolveTypeStrippingFlag(options.packageRoot),
sourceEntry,
...extraArgs,
...messageArgs,
],
cwd: options.cwd,
} satisfies GsdCliEntry
: null;
const builtCliEntry = checkExists(builtEntry)
? {
command: execPath,
args: [builtEntry, ...extraArgs, ...messageArgs],
cwd: options.cwd,
} satisfies GsdCliEntry
: null;
if (options.hostKind === "packaged-standalone") {
if (builtCliEntry) return builtCliEntry;
if (sourceCliEntry) return sourceCliEntry;
} else {
if (sourceCliEntry) return sourceCliEntry;
if (builtCliEntry) return builtCliEntry;
}
throw new Error(`GSD CLI entry not found; checked=${sourceEntry},${builtEntry}`);
}