diff --git a/changelog.d/fixes/13704-build-codebuddy-cn-registry-client-bundle.md b/changelog.d/fixes/13704-build-codebuddy-cn-registry-client-bundle.md new file mode 100644 index 00000000000..3c5dfb489b7 --- /dev/null +++ b/changelog.d/fixes/13704-build-codebuddy-cn-registry-client-bundle.md @@ -0,0 +1 @@ +- **fix(build):** `next build --turbopack` also failed with `the chunking context does not support external modules (request: node:fs)` on every dashboard page after #13264 made the browser-reachable provider registry entry `codebuddy-cn` import `src/lib/oauth/constants/oauth.ts` (which pulls in `open-sse/utils/cursorAgentCliVersion.ts` → `node:fs`) — `CODEBUDDY_CN_USER_AGENT` now lives in a dependency-free `open-sse/config/providers/registry/codebuddy-cn/userAgent.ts`, re-exported from the OAuth constants so all three consumers still share one string ([#13704](https://github.com/diegosouzapw/OmniRoute/pull/13704)) — thanks @seanford diff --git a/changelog.d/fixes/13704-build-combo-control-center-client-bundle.md b/changelog.d/fixes/13704-build-combo-control-center-client-bundle.md new file mode 100644 index 00000000000..beb35b0d73b --- /dev/null +++ b/changelog.d/fixes/13704-build-combo-control-center-client-bundle.md @@ -0,0 +1 @@ +- **fix(build):** the production build (`next build --turbopack`, and therefore every Docker image build) failed with 168 `Module not found: Can't resolve 'child_process'` errors after #13283 made the `"use client"` combo control center import `open-sse/services/model.ts`, whose lazy DB imports pulled the whole server graph into the browser bundle — `resolveProviderAlias()` now lives in a pure `open-sse/services/providerAlias.ts` (re-exported from `model.ts`) and the control center imports that ([#13704](https://github.com/diegosouzapw/OmniRoute/pull/13704)) — thanks @seanford diff --git a/open-sse/config/providers/registry/codebuddy-cn/index.ts b/open-sse/config/providers/registry/codebuddy-cn/index.ts index ae951828de0..3b49330f9ce 100644 --- a/open-sse/config/providers/registry/codebuddy-cn/index.ts +++ b/open-sse/config/providers/registry/codebuddy-cn/index.ts @@ -1,4 +1,4 @@ -import { CODEBUDDY_CN_USER_AGENT } from "@/lib/oauth/constants/oauth"; +import { CODEBUDDY_CN_USER_AGENT } from "./userAgent.ts"; import type { RegistryEntry } from "../../shared.ts"; /** diff --git a/open-sse/config/providers/registry/codebuddy-cn/userAgent.ts b/open-sse/config/providers/registry/codebuddy-cn/userAgent.ts new file mode 100644 index 00000000000..62b341dbfaf --- /dev/null +++ b/open-sse/config/providers/registry/codebuddy-cn/userAgent.ts @@ -0,0 +1,18 @@ +/** + * CODEBUDDY_CN_USER_AGENT is the single source of truth for the CLI/CodeBuddy + * version string. It MUST stay identical across OAuth + * (src/lib/oauth/constants/oauth.ts), chat completions (./index.ts) and + * usage/quota (open-sse/services/usage/codebuddy-cn.ts) — a mismatched version + * string across a single account's auth vs. chat calls is exactly the kind of + * internally-inconsistent client fingerprint Tencent's WAF flags as anomalous + * (#12702). + * + * It lives in its own dependency-free module because the provider registry is + * reachable from the browser bundle (dashboard model pickers import + * `open-sse/config/providerModels.ts`), while `src/lib/oauth/constants/oauth.ts` + * pulls in `open-sse/utils/cursorAgentCliVersion.ts` and therefore `node:fs`. + * Importing the OAuth constants module from the registry (#13264) made + * `next build --turbopack` fail with "the chunking context does not support + * external modules (request: node:fs)" on every dashboard page. + */ +export const CODEBUDDY_CN_USER_AGENT = "CLI/2.108.1 CodeBuddy/2.108.1"; diff --git a/open-sse/services/model.ts b/open-sse/services/model.ts index ec0080ef838..eac2a4f5c7f 100644 --- a/open-sse/services/model.ts +++ b/open-sse/services/model.ts @@ -1,4 +1,7 @@ import { PROVIDER_ID_TO_ALIAS, PROVIDER_MODELS } from "../config/providerModels.ts"; +import { ALIAS_TO_PROVIDER_ID, resolveProviderAlias } from "./providerAlias.ts"; + +export { ALIAS_TO_PROVIDER_ID, resolveProviderAlias }; import { resolveWildcardAlias } from "./wildcardRouter.ts"; import { getRegisteredProviderEffortBaseModelId } from "../utils/registeredEffortVariants.ts"; @@ -27,37 +30,9 @@ 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 = {}; -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/") 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/") -// resolves provider = "amazon-q" instead of falling through to the identity fallback. -ALIAS_TO_PROVIDER_ID["aq"] = "amazon-q"; +// ALIAS_TO_PROVIDER_ID and resolveProviderAlias() live in ./providerAlias.ts so +// browser-bundled callers can use them without pulling in this module's lazy DB +// imports; both are re-exported below for existing server-side importers. // Provider-scoped legacy model aliases. Used to normalize provider/model inputs // and keep backward compatibility when upstream IDs change. @@ -180,31 +155,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(); - 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`. * diff --git a/open-sse/services/providerAlias.ts b/open-sse/services/providerAlias.ts new file mode 100644 index 00000000000..9361004bf46 --- /dev/null +++ b/open-sse/services/providerAlias.ts @@ -0,0 +1,74 @@ +/** + * Provider alias → canonical provider id resolution. + * + * Split out of `./model.ts` so that browser-bundled code can use it. `model.ts` + * lazily imports the DB layer (`@/lib/db/readCache`, `@/lib/db/models`, …) for + * catalog lookups; Turbopack follows those `await import()` edges, so any + * `"use client"` component that reaches `model.ts` — even for a pure helper — + * drags the whole server graph (down to the Playwright-backed executors) into + * the client bundle and the production build fails with a wall of + * "Module not found: Can't resolve 'child_process'" errors (#13283 introduced + * exactly that edge via `src/lib/combos/controlCenter.ts`). + * + * This module depends only on the static provider registry and is safe to + * import from client components. `model.ts` re-exports everything here, so + * existing server-side importers are unchanged. + */ +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 = {}; +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/") 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/") +// 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(); + 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; +} diff --git a/src/lib/combos/controlCenter.ts b/src/lib/combos/controlCenter.ts index 92a87b2738a..3c16a03e8ab 100644 --- a/src/lib/combos/controlCenter.ts +++ b/src/lib/combos/controlCenter.ts @@ -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; diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index 8094bb58cd0..547d75cbc0c 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -20,6 +20,7 @@ import { } 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 { CODEBUDDY_CN_USER_AGENT } from "@omniroute/open-sse/config/providers/registry/codebuddy-cn/userAgent.ts"; import { buildGitLabOAuthEndpoints, GITLAB_DUO_DEFAULT_BASE_URL } from "../gitlab"; /** @@ -108,12 +109,10 @@ export const QODER_CONFIG = { // No client_id/secret — the upstream CLI ships none. // // CODEBUDDY_CN_USER_AGENT is the single source of truth for the CLI/CodeBuddy version -// string. It MUST stay identical across OAuth (this file), chat completions -// (open-sse/config/providers/registry/codebuddy-cn/index.ts) and usage/quota -// (open-sse/services/usage/codebuddy-cn.ts) — a mismatched version string across a -// single account's auth vs. chat calls is exactly the kind of internally-inconsistent -// client fingerprint Tencent's WAF flags as anomalous (#12702). -export const CODEBUDDY_CN_USER_AGENT = "CLI/2.108.1 CodeBuddy/2.108.1"; +// string shared by OAuth (this file), chat completions and usage/quota (#12702). It +// is defined in the (browser-safe) provider registry and re-exported here so this +// module's node:fs dependencies never reach the client bundle. +export { CODEBUDDY_CN_USER_AGENT }; export const CODEBUDDY_CN_CONFIG = { baseUrl: "https://copilot.tencent.com", diff --git a/tests/unit/client-bundle-no-server-only-10692.test.ts b/tests/unit/client-bundle-no-server-only-10692.test.ts index e29ae9d2faf..2f243be6119 100644 --- a/tests/unit/client-bundle-no-server-only-10692.test.ts +++ b/tests/unit/client-bundle-no-server-only-10692.test.ts @@ -23,9 +23,19 @@ import { fileURLToPath } from "node:url"; * - **`import type` is not an edge.** TypeScript erases it before the bundler sees it. A scan * that counts type imports reports 26 phantom leaks against 2 real ones here — a guard that * cries wolf gets switched off. - * - **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. + * - **Dynamic `import()` IS followed.** It does not break a bundle edge (that was tried for + * #10692 and failed): the bundler still has to build the lazy chunk for the browser, so a + * Node builtin behind it fails the build exactly like a static one. #13283 proved it — a + * `"use client"` page reached `open-sse/services/model.ts`, whose `await import("@/lib/db/…")` + * dragged the DB layer and the Playwright executors into the client graph and the Docker build + * died with 168 `Module not found: Can't resolve 'child_process'`. + * + * Server-only modules are recognised two ways: the explicit `SERVER_ONLY` list below, and — + * generically — any first-party module that imports a Node builtin the browser bundle cannot + * polyfill (`NON_POLYFILLABLE_BUILTINS`). The second rule is what catches the next occurrence + * of this pattern in PR CI instead of in the next real Docker build (#13264 was + * `codebuddy-cn/index.ts → src/lib/oauth/constants/oauth.ts → cursorAgentCliVersion.ts → + * node:fs`, and nothing on the hand-written list covered it). */ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); @@ -39,6 +49,33 @@ const SERVER_ONLY = new Set([ "open-sse/utils/tlsClient.ts", ]); +/** + * Node builtins that no browser bundle can polyfill. `path`, `crypto`, `buffer`, `util`, + * `stream`, `os` and friends get browser shims and are deliberately NOT listed; a module that + * imports one of these, by bare name or with the `node:` prefix, is server-only. + */ +const NON_POLYFILLABLE_BUILTINS = new Set([ + "fs", + "fs/promises", + "net", + "tls", + "child_process", + "async_hooks", + "worker_threads", + "cluster", + "dgram", + "dns", + "http2", + "readline", + "repl", + "v8", + "vm", +]); + +function isNonPolyfillableBuiltin(specifier: string): boolean { + return NON_POLYFILLABLE_BUILTINS.has(specifier.replace(/^node:/, "")); +} + /** * 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 @@ -97,10 +134,16 @@ function isTypeOnlyClause(clause: string): boolean { return bindings.length > 0 && bindings.every((binding) => /^type\s/.test(binding)); } -/** Value-carrying static specifiers only. */ +/** + * Value-carrying specifiers: static imports/re-exports, side-effect imports, and dynamic + * `import("…")` with a literal specifier (see the header for why the last one counts). + */ function staticSpecifiers(source: string): string[] { const withoutDynamic = source.replace(/\bimport\s*\(/g, "__dynamic_import__("); const out: string[] = []; + for (const match of source.matchAll(/\bimport\s*\(\s*["']([^"']+)["']\s*\)/g)) { + out.push(match[1]); + } for (const pattern of [ /(?:^|\n)\s*import\s+([^;'"]*)from\s*["']([^"']+)["']/g, /(?:^|\n)\s*export\s+([^;'"]*)from\s*["']([^"']+)["']/g, @@ -118,20 +161,35 @@ function staticSpecifiers(source: string): string[] { } const specifierCache = new Map(); +const builtinCache = new Map(); function edgesOf(file: string): string[] { const cached = specifierCache.get(file); if (cached) return cached; const absolute = path.join(REPO_ROOT, file); let edges: string[] = []; + let builtin: string | null = null; if (fs.existsSync(absolute)) { - edges = staticSpecifiers(fs.readFileSync(absolute, "utf8")) + const specifiers = staticSpecifiers(fs.readFileSync(absolute, "utf8")); + builtin = specifiers.find(isNonPolyfillableBuiltin) ?? null; + edges = specifiers .map((specifier) => resolveSpecifier(file, specifier)) .filter((resolved): resolved is string => resolved !== null); } specifierCache.set(file, edges); + builtinCache.set(file, builtin); return edges; } +/** The non-polyfillable builtin `file` imports directly, if any. */ +function builtinOf(file: string): string | null { + if (!builtinCache.has(file)) edgesOf(file); + return builtinCache.get(file) ?? null; +} + +function isServerOnly(file: string): boolean { + return SERVER_ONLY.has(file) || builtinOf(file) !== null; +} + /** BFS over static imports; returns the first path reaching a server-only module. */ function findServerOnlyPath(entry: string): string[] | null { const seen = new Set([entry]); @@ -140,7 +198,10 @@ function findServerOnlyPath(entry: string): string[] | null { 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)) { + const builtin = builtinOf(resolved); + return [...trail, builtin ? `${resolved} (imports ${builtin})` : resolved]; + } seen.add(resolved); queue.push([...trail, resolved]); } @@ -163,7 +224,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) + ) ); } @@ -175,11 +238,30 @@ test("no client entry point statically reaches server-only code", () => { .map((entry) => ({ entry, trail: findServerOnlyPath(entry) })) .filter((row): row is { entry: string; trail: string[] } => row.trail !== null); + // One bad edge is usually reachable from hundreds of entry points; report each distinct + // offending edge (the importer → server-only module pair) once, with one example chain and + // a count. + const distinct = new Map(); + for (const { trail } of offenders) { + const key = trail.slice(-2).join("→"); + const seen = distinct.get(key); + if (seen) seen.entries += 1; + else distinct.set(key, { trail, entries: 1 }); + } + assert.deepEqual( offenders.map((o) => o.entry), [], "A client bundle would have to include server-only modules:\n" + - offenders.map((o) => ` ${o.trail.join("\n → ")}`).join("\n\n") + + [...distinct.values()] + .map( + ({ trail, entries }) => + ` ${trail.join("\n → ")}` + + (entries > 1 + ? `\n (and ${entries - 1} more client entry points reach the same chain)` + : "") + ) + .join("\n\n") + "\nBreak the chain — or, when the binding is only a type, mark it `import type` so it " + "carries no runtime edge." );