mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-21 22:52: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
|
* async API (`/v2/generate/async`). `models` is a live getter so
|
||||||
* imageRegistry stays under the file-size cap and zero-worker names are
|
* imageRegistry stays under the file-size cap and zero-worker names are
|
||||||
* never advertised.
|
* 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 = {
|
export const AI_HORDE_IMAGE_PROVIDER = {
|
||||||
id: "aihorde",
|
id: "aihorde",
|
||||||
@@ -16,11 +22,7 @@ export const AI_HORDE_IMAGE_PROVIDER = {
|
|||||||
authHeader: "apikey",
|
authHeader: "apikey",
|
||||||
format: "aihorde",
|
format: "aihorde",
|
||||||
get models() {
|
get models() {
|
||||||
return getCachedAiHordeImageCatalogEntries().map((entry) => ({
|
return getDynamicImageModels("aihorde");
|
||||||
id: entry.id.startsWith("aihorde/") ? entry.id.slice("aihorde/".length) : entry.id,
|
|
||||||
name: entry.name,
|
|
||||||
inputModalities: entry.inputModalities,
|
|
||||||
}));
|
|
||||||
},
|
},
|
||||||
supportedSizes: ["512x512", "768x768", "1024x1024", "1024x768", "768x1024"],
|
supportedSizes: ["512x512", "768x768", "1024x1024", "1024x768", "768x1024"],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { safeOutboundFetch } from "@/shared/network/safeOutboundFetch";
|
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_API_BASE = "https://aihorde.net/api";
|
||||||
export const AI_HORDE_ANONYMOUS_KEY = "0000000000";
|
export const AI_HORDE_ANONYMOUS_KEY = "0000000000";
|
||||||
@@ -174,7 +175,9 @@ export class HordeImageCatalog {
|
|||||||
await this.refresh(options);
|
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 {
|
try {
|
||||||
const url = `${AI_HORDE_API_BASE}/v2/status/models?type=image`;
|
const url = `${AI_HORDE_API_BASE}/v2/status/models?type=image`;
|
||||||
const response = await this.fetchImpl(url, {
|
const response = await this.fetchImpl(url, {
|
||||||
@@ -218,3 +221,15 @@ export function getCachedAiHordeImageCatalogEntries(): Array<{
|
|||||||
description: `${model.count} worker${model.count === 1 ? "" : "s"} online`,
|
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