fix(build): stop the client bundle from reaching server-only modules, and make the guard find them (#13436)

Fixes the production build break from `node:fs` reaching client bundles (`oauth.ts → cursorAgentCliVersion.ts` through the codebuddy-cn registry) and widens the client-bundle guard so it finds any Node builtin, not just the one that broke.

Maintainer rework before merge (kept the idea, no default behavior change):
- The guard was 11× slower (3.8s → ~40s) because resolved edges were not cached; with resolved edges and per-file verdicts cached it runs in ~4.6s.
- Bare builtins that Next's client build polyfills (`path`, `os`, `crypto`, `buffer`, … from Next's own `resolve.fallback` list) are allowed consistently; `node:` imports are always flagged; a drift test fails if Next stops polyfilling an allowlisted name.

Validated first on the combined board of all 38 PRs of this batch (10 merged as-is, 28 after the maintainer rework) on top of release/v3.8.51 c0f92ec: typecheck:core, check:open-sse-typecheck and check:dashboard-typecheck clean; ESLint clean on every changed file; file-size (rebaselined for the combined growth), complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync, migration-numbering and i18n new-key gates green; 735 focused node:test cases with the only batch-caused failure (a flag-count assertion) fixed. Then re-validated alone on the fresh release tip right before this merge: ESLint on the changed files, typecheck:core, check:open-sse-typecheck, the file-size/complexity/changelog gates and this PR's own tests.

Thanks @maxmad64bis!
This commit is contained in:
Dizzle
2026-09-15 17:52:41 +02:00
committed by GitHub
parent cb420db64b
commit 1d17c239d1
8 changed files with 194 additions and 73 deletions

View File

@@ -0,0 +1 @@
- **fix(build):** the production build no longer breaks when a client component reaches a server-only module, and the client-bundle guard now discovers server-only modules instead of matching a fixed list ([#13436](https://github.com/diegosouzapw/OmniRoute/pull/13436)) — thanks @maxmad64bis

View File

@@ -1,7 +1,10 @@
import { PROVIDER_ID_TO_ALIAS, PROVIDER_MODELS } from "../config/providerModels.ts";
import { ALIAS_TO_PROVIDER_ID, resolveProviderAlias } from "./providerAlias.ts";
import { resolveWildcardAlias } from "./wildcardRouter.ts";
import { getRegisteredProviderEffortBaseModelId } from "../utils/registeredEffortVariants.ts";
export { resolveProviderAlias };
type ProviderModelAliasMap = Record<string, Record<string, string>>;
type ModelAliasValue = string | { provider?: string; model?: string };
type ModelAliasMap = Record<string, ModelAliasValue>;
@@ -27,38 +30,6 @@ export function stripContextWindowSuffix(
return modelStr.replace(CONTEXT_WINDOW_SUFFIX_RE, "").trimEnd();
}
// Derive alias→provider mapping from the single source of truth (PROVIDER_ID_TO_ALIAS)
// This prevents the two maps from drifting out of sync
const ALIAS_TO_PROVIDER_ID: Record<string, string> = {};
for (const [id, alias] of Object.entries(PROVIDER_ID_TO_ALIAS)) {
if (ALIAS_TO_PROVIDER_ID[alias]) {
console.log(
`[MODEL] Warning: alias "${alias}" maps to both "${ALIAS_TO_PROVIDER_ID[alias]}" and "${id}". Using "${id}".`
);
}
ALIAS_TO_PROVIDER_ID[alias] = id;
}
// Manual alias overrides — maps slug-style prefixes to canonical provider IDs.
// These live outside the registry because they represent multiple providers
// or backward-compatible slug changes, not a single provider's display name.
// opencode/ → opencode-zen (the main free/open tier; opencode-go is a separate paid tier)
ALIAS_TO_PROVIDER_ID["opencode"] = "opencode-zen";
// xiaomi/ is the user-visible prefix for MiMo models; register it so
// parseModel("xiaomi/mimo-v2-flash") resolves provider = "xiaomi-mimo" instead
// of falling through to the identity fallback ("xiaomi").
ALIAS_TO_PROVIDER_ID["xiaomi"] = "xiaomi-mimo";
// llamacpp/ is the user-visible alias for the llama-cpp self-hosted provider.
// The canonical ID is "llama-cpp" (with a hyphen), but the catalog and user-facing
// prefix is "llamacpp". Register it so parseModel("llamacpp/<model>") resolves
// provider = "llama-cpp" instead of the identity fallback ("llamacpp").
ALIAS_TO_PROVIDER_ID["llamacpp"] = "llama-cpp";
// agy/ is the short alias for antigravity provider.
ALIAS_TO_PROVIDER_ID["agy"] = "antigravity";
// aq/ is the user-visible prefix for the Amazon Q (AWS Builder ID) provider.
// The canonical provider ID is "amazon-q". Register it so parseModel("aq/<model>")
// resolves provider = "amazon-q" instead of falling through to the identity fallback.
ALIAS_TO_PROVIDER_ID["aq"] = "amazon-q";
// Provider-scoped legacy model aliases. Used to normalize provider/model inputs
// and keep backward compatibility when upstream IDs change.
const PROVIDER_MODEL_ALIASES: ProviderModelAliasMap = {
@@ -180,31 +151,6 @@ interface ProviderConnectionLike {
is_active?: unknown;
}
/**
* Resolve provider alias to provider ID
*/
export function resolveProviderAlias(aliasOrId: string | null | undefined): string | null {
if (typeof aliasOrId !== "string") return null;
// Follow the alias chain transitively so intermediate alias-only hops resolve
// to the final target, but STOP as soon as a hop lands on a registered
// provider id (#2901): "oc" must resolve to the no-auth "opencode" provider,
// NOT continue through the manual "opencode" → "opencode-zen" slug override —
// that override is for user-typed `opencode/` prefixes only. Without this
// boundary the no-auth provider becomes unreachable by any prefix.
// Guarded against infinite loops with both a depth limit and a seen-set.
let current = aliasOrId;
const seen = new Set<string>();
for (let i = 0; i < 10; i++) {
const next = ALIAS_TO_PROVIDER_ID[current];
if (!next || next === current) return current;
if (next in PROVIDER_ID_TO_ALIAS) return next;
if (seen.has(next)) return next;
seen.add(next);
current = next;
}
return current;
}
/**
* #474 — Resolve a bare model name to the selected connection's `defaultModel`.
*

View File

@@ -0,0 +1,58 @@
import { PROVIDER_ID_TO_ALIAS } from "../config/providerModels.ts";
// Derive alias→provider mapping from the single source of truth (PROVIDER_ID_TO_ALIAS)
// This prevents the two maps from drifting out of sync
export const ALIAS_TO_PROVIDER_ID: Record<string, string> = {};
for (const [id, alias] of Object.entries(PROVIDER_ID_TO_ALIAS)) {
if (ALIAS_TO_PROVIDER_ID[alias]) {
console.log(
`[MODEL] Warning: alias "${alias}" maps to both "${ALIAS_TO_PROVIDER_ID[alias]}" and "${id}". Using "${id}".`
);
}
ALIAS_TO_PROVIDER_ID[alias] = id;
}
// Manual alias overrides — maps slug-style prefixes to canonical provider IDs.
// These live outside the registry because they represent multiple providers
// or backward-compatible slug changes, not a single provider's display name.
// opencode/ → opencode-zen (the main free/open tier; opencode-go is a separate paid tier)
ALIAS_TO_PROVIDER_ID["opencode"] = "opencode-zen";
// xiaomi/ is the user-visible prefix for MiMo models; register it so
// parseModel("xiaomi/mimo-v2-flash") resolves provider = "xiaomi-mimo" instead
// of falling through to the identity fallback ("xiaomi").
ALIAS_TO_PROVIDER_ID["xiaomi"] = "xiaomi-mimo";
// llamacpp/ is the user-visible alias for the llama-cpp self-hosted provider.
// The canonical ID is "llama-cpp" (with a hyphen), but the catalog and user-facing
// prefix is "llamacpp". Register it so parseModel("llamacpp/<model>") resolves
// provider = "llama-cpp" instead of the identity fallback ("llamacpp").
ALIAS_TO_PROVIDER_ID["llamacpp"] = "llama-cpp";
// agy/ is the short alias for antigravity provider.
ALIAS_TO_PROVIDER_ID["agy"] = "antigravity";
// aq/ is the user-visible prefix for the Amazon Q (AWS Builder ID) provider.
// The canonical provider ID is "amazon-q". Register it so parseModel("aq/<model>")
// resolves provider = "amazon-q" instead of falling through to the identity fallback.
ALIAS_TO_PROVIDER_ID["aq"] = "amazon-q";
/**
* Resolve provider alias to provider ID
*/
export function resolveProviderAlias(aliasOrId: string | null | undefined): string | null {
if (typeof aliasOrId !== "string") return null;
// Follow the alias chain transitively so intermediate alias-only hops resolve
// to the final target, but STOP as soon as a hop lands on a registered
// provider id (#2901): "oc" must resolve to the no-auth "opencode" provider,
// NOT continue through the manual "opencode" → "opencode-zen" slug override —
// that override is for user-typed `opencode/` prefixes only. Without this
// boundary the no-auth provider becomes unreachable by any prefix.
// Guarded against infinite loops with both a depth limit and a seen-set.
let current = aliasOrId;
const seen = new Set<string>();
for (let i = 0; i < 10; i++) {
const next = ALIAS_TO_PROVIDER_ID[current];
if (!next || next === current) return current;
if (next in PROVIDER_ID_TO_ALIAS) return next;
if (seen.has(next)) return next;
seen.add(next);
current = next;
}
return current;
}

View File

@@ -19,12 +19,9 @@ import {
} from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { CURSOR_AGENT_CLI_VERSION } from "./cursorAgentCliVersionPin.ts";
/**
* Pinned Agent CLI build id used when no local install is found (typical
* headless OmniRoute). Bump when refreshing Cursor CLI impersonation.
*/
export const CURSOR_AGENT_CLI_VERSION = "2026.07.08-0c04a8a";
export { CURSOR_AGENT_CLI_VERSION };
const VERSION_ID_RE = /^\d{4}\.\d{2}\.\d{2}-[0-9a-f]+$/;
const CACHE_TTL_MS = 60 * 60 * 1000;

View File

@@ -0,0 +1,7 @@
// Import-free pin so client bundles can read the version without pulling node-only detection code.
/**
* Pinned Agent CLI build id used when no local install is found (typical
* headless OmniRoute). Bump when refreshing Cursor CLI impersonation.
*/
export const CURSOR_AGENT_CLI_VERSION = "2026.07.08-0c04a8a";

View File

@@ -1,6 +1,6 @@
import { normalizeComboModels, type ComboStep } from "./steps";
import { resolveComboTargetModelStr } from "../../../open-sse/services/combo/opencodeTargetAlias.ts";
import { resolveProviderAlias } from "../../../open-sse/services/model.ts";
import { resolveProviderAlias } from "../../../open-sse/services/providerAlias.ts";
type JsonRecord = Record<string, unknown>;

View File

@@ -20,7 +20,7 @@ import {
GROK_BUILD_TOKEN_URL,
} from "@omniroute/open-sse/config/grokBuild.ts";
import { resolvePublicCred } from "@omniroute/open-sse/utils/publicCreds.ts";
import { CURSOR_AGENT_CLI_VERSION } from "@omniroute/open-sse/utils/cursorAgentCliVersion.ts";
import { CURSOR_AGENT_CLI_VERSION } from "@omniroute/open-sse/utils/cursorAgentCliVersionPin.ts";
import { buildGitLabOAuthEndpoints, GITLAB_DUO_DEFAULT_BASE_URL } from "../gitlab";
/**
@@ -375,7 +375,7 @@ export const KIRO_CONFIG = {
// Cursor stores credentials in SQLite database: state.vscdb
// Keys: cursorAuth/accessToken, cursorAuth/refreshToken, storage.serviceMachineId
// Deep-control PKCE + refresh aligned with OpenCodex (lidge-jun/opencodex src/oauth/cursor.ts).
// clientVersion pin lives in open-sse/utils/cursorAgentCliVersion.ts — single source of truth.
// clientVersion pin lives in open-sse/utils/cursorAgentCliVersionPin.ts — single source of truth.
export const CURSOR_CONFIG = {
// API endpoints
apiEndpoint: "https://api2.cursor.sh",

View File

@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { test } from "node:test";
import fs from "node:fs";
import path from "node:path";
import { builtinModules } from "node:module";
import { fileURLToPath } from "node:url";
/**
@@ -26,6 +27,20 @@ import { fileURLToPath } from "node:url";
* - **Dynamic `import()` is not followed.** It does not actually break a bundle edge (that was
* tried for #10692 and failed), but it does move the module into a chunk the browser only
* fetches on demand, which is a legitimate boundary for a lazily-used server path.
*
* A reached module counts as server-only when it statically imports a Node builtin the
* production bundler cannot resolve for the browser. The pinned list below (the original
* #10692 chain) stays explicit so it keeps failing loudly even if the discovery logic
* changes; everything else is found by walking the graph and checking each visited file
* for a builtin import.
*
* Builtins Next ships a browser polyfill for are tolerated when imported by their BARE name
* (`path`, `os`, `crypto`, `buffer`, …): Next's client build maps exactly those names to
* `next/dist/compiled/*` shims (`resolve.fallback` for the client compiler in
* `node_modules/next/dist/build/webpack-config.js`), so flagging them would cry wolf the
* same way counting `import type` did. The `node:` scheme is never tolerated — the client
* build has no fallback for it (`UnhandledSchemeError` on `node:fs` / `node:os` / `node:path`
* is what broke the build this guard was widened for).
*/
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
@@ -39,6 +54,45 @@ const SERVER_ONLY = new Set([
"open-sse/utils/tlsClient.ts",
]);
/**
* Bare builtin names Next's client build polyfills (the client `resolve.fallback` map in
* `next/dist/build/webpack-config.js`). Kept in sync by the drift test at the bottom.
*/
const NEXT_CLIENT_POLYFILLED_BUILTINS = new Set([
"assert",
"buffer",
"constants",
"crypto",
"domain",
"events",
"http",
"https",
"os",
"path",
"process",
"punycode",
"querystring",
"stream",
"string_decoder",
"sys",
"timers",
"tty",
"util",
"vm",
"zlib",
]);
const NODE_BUILTINS = new Set(
builtinModules.map((name) => name.replace(/^node:/, "")).filter((bare) => !bare.startsWith("_"))
);
/** True when `specifier` names a Node builtin the browser bundle cannot resolve. */
function isBrowserForbiddenBuiltin(specifier: string): boolean {
if (specifier.startsWith("node:")) return true; // no client fallback for the scheme
const root = specifier.split("/")[0]; // `fs/promises` → `fs`
return NODE_BUILTINS.has(root) && !NEXT_CLIENT_POLYFILLED_BUILTINS.has(root);
}
/**
* Non-`"use client"` entry points that still end up in a client bundle because client
* components import them. Kept explicit so the original #10692 chain stays pinned even if the
@@ -60,6 +114,9 @@ function resolveSpecifier(fromFile: string, specifier: string): string | null {
} else if (specifier.startsWith("@omniroute/open-sse")) {
const rest = specifier.slice("@omniroute/open-sse".length).replace(/^\//, "");
base = path.join(REPO_ROOT, "open-sse", rest);
} else if (specifier.startsWith("@omniroute/browser-pool")) {
const rest = specifier.slice("@omniroute/browser-pool".length).replace(/^\//, "");
base = path.join(REPO_ROOT, "packages/browser-pool/src", rest);
} else if (specifier.startsWith("@/")) {
base = path.join(REPO_ROOT, "src", specifier.slice(2));
} else {
@@ -118,29 +175,53 @@ function staticSpecifiers(source: string): string[] {
}
const specifierCache = new Map<string, string[]>();
function edgesOf(file: string): string[] {
function specifiersOf(file: string): string[] {
const cached = specifierCache.get(file);
if (cached) return cached;
const absolute = path.join(REPO_ROOT, file);
let edges: string[] = [];
let specs: string[] = [];
if (fs.existsSync(absolute)) {
edges = staticSpecifiers(fs.readFileSync(absolute, "utf8"))
.map((specifier) => resolveSpecifier(file, specifier))
.filter((resolved): resolved is string => resolved !== null);
specs = staticSpecifiers(fs.readFileSync(absolute, "utf8"));
}
specifierCache.set(file, edges);
specifierCache.set(file, specs);
return specs;
}
// Resolved edges and verdicts are cached per file: the BFS runs once per client entry and
// re-visits the same shared modules thousands of times, so re-resolving specifiers
// (fs.existsSync/statSync per candidate) on every visit made the guard ~6x slower.
const edgeCache = new Map<string, string[]>();
function edgesOf(file: string): string[] {
const cached = edgeCache.get(file);
if (cached) return cached;
const edges = specifiersOf(file)
.map((specifier) => resolveSpecifier(file, specifier))
.filter((resolved): resolved is string => resolved !== null);
edgeCache.set(file, edges);
return edges;
}
const serverOnlyVerdictCache = new Map<string, boolean>();
/** True when the file is pinned server-only or itself imports a browser-forbidden builtin. */
function isServerOnly(file: string): boolean {
const cached = serverOnlyVerdictCache.get(file);
if (cached !== undefined) return cached;
const verdict = SERVER_ONLY.has(file) || specifiersOf(file).some(isBrowserForbiddenBuiltin);
serverOnlyVerdictCache.set(file, verdict);
return verdict;
}
/** BFS over static imports; returns the first path reaching a server-only module. */
function findServerOnlyPath(entry: string): string[] | null {
const seen = new Set<string>([entry]);
if (isServerOnly(entry)) return [entry];
const queue: Array<string[]> = [[entry]];
while (queue.length > 0) {
const trail = queue.shift()!;
for (const resolved of edgesOf(trail[trail.length - 1])) {
if (seen.has(resolved)) continue;
if (SERVER_ONLY.has(resolved)) return [...trail, resolved];
if (isServerOnly(resolved)) {
return [...trail, resolved];
}
seen.add(resolved);
queue.push([...trail, resolved]);
}
@@ -163,7 +244,9 @@ function walk(dir: string, acc: string[] = []): string[] {
function clientEntryPoints(): string[] {
return walk(path.join(REPO_ROOT, "src")).filter((file) =>
/^\s*["']use client["']/m.test(fs.readFileSync(path.join(REPO_ROOT, file), "utf8").slice(0, 200))
/^\s*["']use client["']/m.test(
fs.readFileSync(path.join(REPO_ROOT, file), "utf8").slice(0, 200)
)
);
}
@@ -184,3 +267,32 @@ test("no client entry point statically reaches server-only code", () => {
"carries no runtime edge."
);
});
test("builtin classification: node: scheme always forbidden, bare polyfilled names tolerated", () => {
for (const specifier of ["node:fs", "node:path", "node:os", "node:crypto", "fs", "fs/promises"]) {
assert.equal(isBrowserForbiddenBuiltin(specifier), true, specifier);
}
for (const specifier of ["child_process", "net", "tls", "module", "worker_threads"]) {
assert.equal(isBrowserForbiddenBuiltin(specifier), true, specifier);
}
for (const specifier of ["path", "os", "crypto", "buffer", "events", "util", "stream"]) {
assert.equal(isBrowserForbiddenBuiltin(specifier), false, specifier);
}
for (const specifier of ["react", "@/lib/db/core", "./local", "zod"]) {
assert.equal(isBrowserForbiddenBuiltin(specifier), false, specifier);
}
});
test("the polyfilled-builtin allowlist matches Next's client resolve.fallback", () => {
const webpackConfig = fs.readFileSync(
path.join(REPO_ROOT, "node_modules/next/dist/build/webpack-config.js"),
"utf8"
);
for (const name of NEXT_CLIENT_POLYFILLED_BUILTINS) {
assert.match(
webpackConfig,
new RegExp(`\\b${name}: require\\.resolve\\(`),
`Next no longer polyfills "${name}" for the client — drop it from the allowlist`
);
}
});