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.` + : "" + ); + }); +}