fix(config): keep the SQLite driver out of the client bundle (#10692) (#10695)

* 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 <bakryun0718@proton.me>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-18 19:23:46 -03:00
committed by GitHub
parent 37c81ce1d7
commit 22b89a273b
4 changed files with 193 additions and 7 deletions

View File

@@ -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<string, DynamicImageModelSource>();
/** 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();
}

View File

@@ -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"],
};

View File

@@ -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<void> {
private async refreshOnce(
options: { timeoutMs?: number; signal?: AbortSignal } = {}
): Promise<void> {
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,
}))
);

View File

@@ -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<string>([entry]);
const queue: Array<string[]> = [[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.`
: ""
);
});
}