From 22b89a273b1ad273ad72cedae31ebc1d30ceabc6 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Tue, 18 Aug 2026 19:23:46 -0300 Subject: [PATCH] fix(config): keep the SQLite driver out of the client bundle (#10692) (#10695) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sse): import localDb through its real .ts extension (#10674) `open-sse/services/combo.ts` imported "../../src/lib/localDb.js" — a .js suffix on a module that only exists as .ts. Turbopack resolved it by accident until the dependency-tree change in #10647; after that the instrumentation hook died at boot with MODULE_NOT_FOUND, breaking `npm run dev` and the production build (60 consecutive red `Build App` runs on release/v3.8.50). Fixes the same latent pattern in src/lib/usage/usageLedger.ts, which survived only because it is an `import type` and is erased before resolution. Adds a guard rejecting relative .js specifiers across open-sse/ and src/. Package specifiers are untouched: publishing ESM as .js is legitimate there (e.g. @modelcontextprotocol/sdk), and only first-party relative imports are first-party TypeScript. Closes #10674 * fix(config): keep the SQLite driver out of the client bundle (#10692) The `aihorde` entry in IMAGE_PROVIDERS imported its live-catalog service directly. IMAGE_PROVIDERS is reachable from "use client" dashboard pages — they read its KEYS to derive which providers support which media kind — so that import dragged aihordeImageCatalog → safeOutboundFetch → proxyFetch → featureFlags → db/core → sqljsAdapter into the browser graph. The build then tried to bundle fs/net/tls for the browser and failed with 28 Module not found errors, leaving `Build App` red for 60 consecutive runs and no artifact buildable from the branch. A dynamic import() does not fix this: the bundler still has to make the module browser-loadable. The dependency is inverted instead — the registry entry knows only a pure registration module, and the server-only service registers itself on import, which every server path needing live models already does. With nothing registered the getter yields [], exactly what the live catalog returned before its first poll. Validated by a full `npm run build:release`: 0 Module not found, artifact produced. Closes #10692 --------- Co-authored-by: Xiangzhe --- open-sse/config/dynamicImageModelSources.ts | 49 +++++++ .../providers/registry/aihorde/imageModels.ts | 14 +- open-sse/services/aihordeImageCatalog.ts | 17 ++- ...client-bundle-no-server-only-10692.test.ts | 120 ++++++++++++++++++ 4 files changed, 193 insertions(+), 7 deletions(-) create mode 100644 open-sse/config/dynamicImageModelSources.ts create mode 100644 tests/unit/client-bundle-no-server-only-10692.test.ts diff --git a/open-sse/config/dynamicImageModelSources.ts b/open-sse/config/dynamicImageModelSources.ts new file mode 100644 index 0000000000..4372186190 --- /dev/null +++ b/open-sse/config/dynamicImageModelSources.ts @@ -0,0 +1,49 @@ +/** + * Registry indirection for image providers whose model list is discovered at runtime (#10692). + * + * `IMAGE_PROVIDERS` is reachable from `"use client"` dashboard pages — they read its KEYS to + * decide which providers support which media kind (see `mediaServiceKinds.ts`). A provider entry + * that imports its live-catalog service directly therefore drags that service, and everything it + * imports, into the browser graph. For AI Horde that meant + * `aihordeImageCatalog → safeOutboundFetch → proxyFetch → featureFlags → db/core → sqljsAdapter`, + * so the build tried to bundle `fs`/`net`/`tls` for the browser and failed. + * + * A dynamic `import()` does not help: the bundler still has to make the module browser-loadable. + * The dependency has to be inverted instead — the registry entry knows only this pure module, and + * the server-only service registers itself when it is imported (which every server path that needs + * live models already does). + * + * With no source registered the getter yields `[]`, which is exactly what the live catalog + * returned before it had polled — so the client keeps seeing the provider without its models, + * unchanged. + */ + +export interface DynamicImageModelEntry { + id: string; + name: string; + inputModalities: string[]; +} + +type DynamicImageModelSource = () => DynamicImageModelEntry[]; + +const sources = new Map(); + +/** Called by a server-only catalog service at import time. Last registration wins. */ +export function registerDynamicImageModelSource( + providerId: string, + source: DynamicImageModelSource +): void { + sources.set(providerId, source); +} + +/** Models discovered for `providerId`, or `[]` when no server-side source is loaded. */ +export function getDynamicImageModels(providerId: string): DynamicImageModelEntry[] { + const source = sources.get(providerId); + if (!source) return []; + return source(); +} + +/** Test seam — drops every registration. */ +export function resetDynamicImageModelSources(): void { + sources.clear(); +} diff --git a/open-sse/config/providers/registry/aihorde/imageModels.ts b/open-sse/config/providers/registry/aihorde/imageModels.ts index be04ce18ae..7b65be0277 100644 --- a/open-sse/config/providers/registry/aihorde/imageModels.ts +++ b/open-sse/config/providers/registry/aihorde/imageModels.ts @@ -5,8 +5,14 @@ * async API (`/v2/generate/async`). `models` is a live getter so * imageRegistry stays under the file-size cap and zero-worker names are * never advertised. + * + * The live models arrive through `dynamicImageModelSources` rather than a direct + * import of the catalog service: this entry is reachable from `"use client"` pages + * via IMAGE_PROVIDERS, and importing the service here pulled the SQLite driver into + * the browser bundle (#10692). `aihordeImageCatalog` registers itself on import, so + * every server path that already loads it behaves exactly as before. */ -import { getCachedAiHordeImageCatalogEntries } from "../../../../services/aihordeImageCatalog.ts"; +import { getDynamicImageModels } from "../../../dynamicImageModelSources.ts"; export const AI_HORDE_IMAGE_PROVIDER = { id: "aihorde", @@ -16,11 +22,7 @@ export const AI_HORDE_IMAGE_PROVIDER = { authHeader: "apikey", format: "aihorde", get models() { - return getCachedAiHordeImageCatalogEntries().map((entry) => ({ - id: entry.id.startsWith("aihorde/") ? entry.id.slice("aihorde/".length) : entry.id, - name: entry.name, - inputModalities: entry.inputModalities, - })); + return getDynamicImageModels("aihorde"); }, supportedSizes: ["512x512", "768x768", "1024x1024", "1024x768", "768x1024"], }; diff --git a/open-sse/services/aihordeImageCatalog.ts b/open-sse/services/aihordeImageCatalog.ts index a0cffa02a2..ea7e5082dc 100644 --- a/open-sse/services/aihordeImageCatalog.ts +++ b/open-sse/services/aihordeImageCatalog.ts @@ -8,6 +8,7 @@ */ import { safeOutboundFetch } from "@/shared/network/safeOutboundFetch"; +import { registerDynamicImageModelSource } from "../config/dynamicImageModelSources.ts"; export const AI_HORDE_API_BASE = "https://aihorde.net/api"; export const AI_HORDE_ANONYMOUS_KEY = "0000000000"; @@ -174,7 +175,9 @@ export class HordeImageCatalog { await this.refresh(options); } - private async refreshOnce(options: { timeoutMs?: number; signal?: AbortSignal } = {}): Promise { + private async refreshOnce( + options: { timeoutMs?: number; signal?: AbortSignal } = {} + ): Promise { try { const url = `${AI_HORDE_API_BASE}/v2/status/models?type=image`; const response = await this.fetchImpl(url, { @@ -218,3 +221,15 @@ export function getCachedAiHordeImageCatalogEntries(): Array<{ description: `${model.count} worker${model.count === 1 ? "" : "s"} online`, })); } + +// Self-registration (#10692): the IMAGE_PROVIDERS entry for `aihorde` reads its models +// through `dynamicImageModelSources` instead of importing this server-only module, so the +// browser graph stays free of the SQLite driver. Importing this file — which every server +// path needing live models already does — restores the live list. +registerDynamicImageModelSource("aihorde", () => + getCachedAiHordeImageCatalogEntries().map((entry) => ({ + id: entry.id.startsWith("aihorde/") ? entry.id.slice("aihorde/".length) : entry.id, + name: entry.name, + inputModalities: entry.inputModalities, + })) +); diff --git a/tests/unit/client-bundle-no-server-only-10692.test.ts b/tests/unit/client-bundle-no-server-only-10692.test.ts new file mode 100644 index 0000000000..558ae7bda4 --- /dev/null +++ b/tests/unit/client-bundle-no-server-only-10692.test.ts @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * #10692: `src/app/(dashboard)/dashboard/providers/page.tsx` is a `"use client"` page. + * Through `serviceKindIndex → mediaServiceKinds → imageRegistry → aihorde/imageModels → + * aihordeImageCatalog → safeOutboundFetch → proxyFetch → featureFlags → db/core` it reached + * the SQLite driver, so the production build tried to bundle `fs`/`net`/`tls` for the browser + * and failed with 28 `Module not found` errors (`Build App` red for 60 consecutive runs). + * + * `serviceKindIndex.ts` already documents the invariant this guard enforces: + * "Client-safe: `mediaServiceKinds` only pulls in the pure-data media registries + * (no server-only deps)." + * + * That was a comment, so nothing stopped #10542 from breaking it. This walks the real + * static-import graph instead — the same edges the bundler follows. Dynamic `import()` is + * deliberately NOT followed: deferring a server-only module behind one is exactly how the + * leak is fixed, and the bundler splits it into a chunk the browser never loads. + */ +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + +/** Entry points that end up in a client bundle and must stay free of server-only code. */ +const CLIENT_SAFE_ENTRIES = [ + "src/lib/providers/serviceKindIndex.ts", + "open-sse/config/mediaServiceKinds.ts", +]; + +/** Modules that pull in Node builtins (fs/net/tls) and must never be statically reachable. */ +const SERVER_ONLY = [ + "src/lib/db/core.ts", + "src/lib/db/adapters/driverFactory.ts", + "src/lib/db/adapters/sqljsAdapter.ts", + "open-sse/utils/proxyFetch.ts", +]; + +const EXTENSIONS = [".ts", ".tsx", ".mts", ".js"]; + +/** Resolve an import specifier to a repo-relative file, or null when it leaves the repo. */ +function resolveSpecifier(fromFile: string, specifier: string): string | null { + let base: string; + if (specifier.startsWith(".")) { + base = path.resolve(path.dirname(path.join(REPO_ROOT, fromFile)), specifier); + } 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("@/")) { + base = path.join(REPO_ROOT, "src", specifier.slice(2)); + } else { + return null; // npm package — not our graph + } + + const candidates = [ + base, + ...EXTENSIONS.map((ext) => base + ext), + ...EXTENSIONS.map((ext) => path.join(base, `index${ext}`)), + ]; + // A `.js` specifier on a first-party module means the sibling `.ts` (see #10674). + if (base.endsWith(".js")) candidates.push(base.replace(/\.js$/, ".ts")); + + for (const candidate of candidates) { + if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { + return path.relative(REPO_ROOT, candidate); + } + } + return null; +} + +/** Static import/export specifiers only — `import(...)` expressions are intentionally skipped. */ +function staticSpecifiers(source: string): string[] { + const withoutDynamic = source.replace(/\bimport\s*\(/g, "__dynamic_import__("); + const out: string[] = []; + const patterns = [ + /(?:^|\n)\s*import\s+[^;'"]*from\s*["']([^"']+)["']/g, + /(?:^|\n)\s*import\s*["']([^"']+)["']/g, + /(?:^|\n)\s*export\s+[^;'"]*from\s*["']([^"']+)["']/g, + ]; + for (const pattern of patterns) { + for (const match of withoutDynamic.matchAll(pattern)) out.push(match[1]); + } + return out; +} + +/** BFS over static imports; returns the first path reaching a server-only module. */ +function findServerOnlyPath(entry: string): string[] | null { + const seen = new Set([entry]); + const queue: Array = [[entry]]; + while (queue.length > 0) { + const trail = queue.shift()!; + const current = trail[trail.length - 1]; + const absolute = path.join(REPO_ROOT, current); + if (!fs.existsSync(absolute)) continue; + + for (const specifier of staticSpecifiers(fs.readFileSync(absolute, "utf8"))) { + const resolved = resolveSpecifier(current, specifier); + if (!resolved || seen.has(resolved)) continue; + const next = [...trail, resolved]; + if (SERVER_ONLY.includes(resolved)) return next; + seen.add(resolved); + queue.push(next); + } + } + return null; +} + +for (const entry of CLIENT_SAFE_ENTRIES) { + test(`${entry} does not statically reach server-only code`, () => { + const trail = findServerOnlyPath(entry); + assert.equal( + trail, + null, + trail + ? `A client bundle would have to include a server-only module. Static import chain:\n ${trail.join("\n → ")}\n` + + `Break the chain (a dynamic import at the boundary is enough) rather than widening this guard.` + : "" + ); + }); +}