mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-19 05:32:19 +03:00
* 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:
committed by
GitHub
parent
37c81ce1d7
commit
22b89a273b
49
open-sse/config/dynamicImageModelSources.ts
Normal file
49
open-sse/config/dynamicImageModelSources.ts
Normal 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();
|
||||
}
|
||||
@@ -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"],
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
);
|
||||
|
||||
120
tests/unit/client-bundle-no-server-only-10692.test.ts
Normal file
120
tests/unit/client-bundle-no-server-only-10692.test.ts
Normal 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.`
|
||||
: ""
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user