From c06cc0845353c3ac38cfb4def5d673779b47ad69 Mon Sep 17 00:00:00 2001 From: Tomauskasz Date: Wed, 5 Aug 2026 10:38:06 +0700 Subject: [PATCH] fix(oauth): keep `open` external so xAI/Grok token refresh works on Windows `open` derives its own directory from import.meta.url at module scope. Webpack replaces that with the build machine's absolute path as a string literal, so a release built on macOS ships a file:///Users/... URL that fileURLToPath rejects on Windows (no drive letter), throwing on import. refreshXaiToken dynamic-imports the xAI OAuth service, which imports open eagerly, so every Grok token refresh silently failed and was swallowed by a catch that only logs a warning. Add open to serverExternalPackages so it keeps its real import.meta.url at runtime, and bundle it into the CLI package via ensureModuleInBundle (same guard already used for sql.js) since externalizing it means webpack no longer traces/copies it automatically. --- cli/scripts/build-cli.js | 4 +++ next.config.mjs | 9 +++++- tests/unit/open-package-external.test.js | 38 ++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 tests/unit/open-package-external.test.js 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/); + }); +});