diff --git a/cli/scripts/build-cli.js b/cli/scripts/build-cli.js index a1b28c5f..3af3120f 100644 --- a/cli/scripts/build-cli.js +++ b/cli/scripts/build-cli.js @@ -195,6 +195,10 @@ function ensureModuleInBundle(pkg) { console.log(`✅ Bundled ${pkg}`); } ensureModuleInBundle("sql.js"); +// `open` is external (see serverExternalPackages in next.config.mjs), so it must exist in +// the bundle's node_modules or every importer throws MODULE_NOT_FOUND at runtime. Output +// tracing normally copies it; this is the same belt-and-braces guard used for sql.js. +ensureModuleInBundle("open"); const betterDir = path.join(cliAppDir, "node_modules", "better-sqlite3"); if (fs.existsSync(betterDir)) { fs.rmSync(betterDir, { recursive: true, force: true }); diff --git a/next.config.mjs b/next.config.mjs index d017c157..ecd385ca 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -13,7 +13,14 @@ const proxyClientMaxBodySize = process.env.NINEROUTER_PROXY_CLIENT_MAX_BODY_SIZE const nextConfig = { distDir: process.env.NEXT_DIST_DIR || ".next", output: "standalone", - serverExternalPackages: ["better-sqlite3", "sql.js", "node:sqlite", "bun:sqlite"], + // `open` must stay external. It derives its own directory from `import.meta.url`, and + // webpack replaces that with the absolute path of the BUILD machine as a string literal. + // A release built on macOS therefore ships `file:///Users/.../open/index.js`, which + // `fileURLToPath` rejects on Windows ("File URL path must be absolute" — no drive + // letter). That throw happens at module scope, so every consumer of `open` dies on + // import — including xAI/Grok token refresh, which loads the OAuth service that imports + // it. Keeping it external preserves the real `import.meta.url` at runtime. + serverExternalPackages: ["better-sqlite3", "sql.js", "node:sqlite", "bun:sqlite", "open"], turbopack: { root: tracingRoot }, diff --git a/tests/unit/open-package-external.test.js b/tests/unit/open-package-external.test.js new file mode 100644 index 00000000..de007914 --- /dev/null +++ b/tests/unit/open-package-external.test.js @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +// Regression guard for the Windows xAI/Grok token refresh failure. +// +// `open` computes its own directory from `import.meta.url`. When it is bundled, webpack +// replaces that with the BUILD machine's absolute path as a string literal, so a release +// built on macOS ships `fileURLToPath("file:///Users/.../open/index.js")`. On Windows that +// throws ERR_INVALID_FILE_URL_PATH ("File URL path must be absolute" — no drive letter) at +// module scope, which kills every importer. `refreshXaiToken` imports the xAI OAuth +// service, which imports `open`, so no Grok refresh could ever reach auth.x.ai on Windows. +// +// Keeping the package external preserves the real `import.meta.url` at runtime. +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + +describe("open must not be bundled into the server build", () => { + const config = readFileSync(path.join(repoRoot, "next.config.mjs"), "utf8"); + + it("is declared in serverExternalPackages", () => { + const match = config.match(/serverExternalPackages:\s*\[([^\]]*)\]/); + expect(match, "serverExternalPackages not found in next.config.mjs").toBeTruthy(); + const packages = match[1].split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")); + expect(packages).toContain("open"); + }); + + it("stays a runtime dependency so the standalone output can resolve it", async () => { + const pkg = JSON.parse(readFileSync(path.join(repoRoot, "package.json"), "utf8")); + expect(pkg.dependencies?.open).toBeTruthy(); + }); + + it("resolves its own directory from import.meta.url, which is why it must stay external", async () => { + const entry = path.join(repoRoot, "node_modules", "open", "index.js"); + const source = readFileSync(entry, "utf8"); + expect(source).toMatch(/import\.meta\.url/); + }); +});