Files
RSSHub/lib/registry-dev.ts
T
Tony cb0791cb41 perf: dx (#22478)
* perf: use only one WASM alloc for xxhash

* perf(deps): lazy load mercury-parser and use googleapis/youtube instead of heavy googleapis

* perf(dx): lazy load dev registry

speed up npm dev script

* perf: close playwright browser on destroy

* fix: show exception msg in prod

* revert: "perf: use only one WASM alloc for xxhash"

This reverts commit 01ecdd48d0.

* perf: use only one WASM alloc for xxhash

* test: add claim for worker

* fix: missing piece from 8dd9c9a

* feat: add namespace to the chunkfilename

* fix: keep var naming
2026-07-09 02:37:41 +08:00

127 lines
5.0 KiB
TypeScript

import fs from 'node:fs';
import path from 'node:path';
import type { Context, MiddlewareHandler } from 'hono';
import { Hono } from 'hono';
import type { ModulesType, NamespacesType } from '@/registry-helpers';
import { applyModulesToNamespaces, registerApiRoutes, registerRssRoutes } from '@/registry-helpers';
import { directoryImport } from '@/utils/directory-import';
export type DevRegistry = {
middleware: MiddlewareHandler;
ensureAllLoaded: () => Promise<void>;
};
/**
* Dev-only lazy route loading. Hono routers reject new routes once an app has matched a request
* ("matcher is already built"), so each top-level routes directory gets its own self-contained
* sub-app, built on first request and dispatched from the middleware.
*/
export function createDevRegistry({ routesDirectory, namespaces }: { routesDirectory: string; namespaces: NamespacesType }): DevRegistry {
const subApps = new Map<string, Promise<Hono>>();
const outerContexts = new WeakMap<Request, Context>();
// One readdir at startup replaces per-request fs probing (and doubles as the path-safety check);
// a top-level directory added at runtime needs a restart, which tsx watch does on any save anyway.
const topDirectories = new Set(
fs
.readdirSync(routesDirectory, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
);
// Sub-apps run on a fresh context; copy the outer vars in so handlers see middleware state
// (e.g. parameters), and copy back out so ctx.set('data') reaches the upstream template middleware.
const bridge: MiddlewareHandler = async (ctx, next) => {
const outer = outerContexts.get(ctx.req.raw);
if (outer) {
for (const [key, value] of Object.entries(outer.var)) {
ctx.set(key as never, value as never);
}
}
await next();
if (outer) {
for (const [key, value] of Object.entries(ctx.var)) {
outer.set(key as never, value as never);
}
}
const vars = ctx.var as Record<string, unknown>;
if (!ctx.finalized && (vars.data || vars.apiData)) {
// Data-producing handlers return undefined (the outer template middleware renders the
// bridged vars); finalize so Hono does not raise "Context is not finalized".
ctx.res = new Response(null, { status: 204 });
}
};
const loadTopDirectory = async (name: string): Promise<Hono> => {
const modules = (await directoryImport({
targetDirectoryPath: path.join(routesDirectory, name),
importPattern: /\.tsx?$/,
})) as ModulesType;
// directoryImport keys are relative to the imported directory; restore the lib/routes-relative form
const prefixed: ModulesType = {};
for (const key in modules) {
prefixed[`/${name}${key}`] = modules[key];
}
applyModulesToNamespaces(prefixed, namespaces);
const subApp = new Hono();
// Rethrow so subApp.fetch rejects and handler errors reach the outer app's error handler
// instead of Hono's bare text-500 default.
subApp.onError((error) => {
throw error;
});
subApp.use('*', bridge);
const subset = Object.fromEntries(Object.entries(namespaces).filter(([key]) => key === name || key.startsWith(`${name}/`)));
registerRssRoutes(subApp, subset);
registerApiRoutes(subApp, subset);
return subApp;
};
const ensureTopDirectory = async (name: string): Promise<Hono> => {
let promise = subApps.get(name);
if (!promise) {
promise = loadTopDirectory(name);
subApps.set(name, promise);
}
try {
return await promise;
} catch (error) {
// Import errors are not memoized so a fixed file works on the next request
subApps.delete(name);
throw error;
}
};
const middleware: MiddlewareHandler = async (ctx, next) => {
const segments = ctx.req.path.split('/').filter(Boolean);
const candidate = segments[0] === 'api' ? segments[1] : segments[0];
if (!candidate || !topDirectories.has(candidate)) {
return next();
}
const subApp = await ensureTopDirectory(candidate);
outerContexts.set(ctx.req.raw, ctx);
try {
const response = await subApp.fetch(ctx.req.raw);
if (ctx.get('data') || ctx.get('apiData')) {
// The upstream template middleware renders from the bridged vars
return;
}
if (response.status === 404) {
return next();
}
return response;
} finally {
outerContexts.delete(ctx.req.raw);
}
};
const ensureAllLoaded = async (): Promise<void> => {
await Promise.all([...topDirectories].map((name) => ensureTopDirectory(name)));
};
return { middleware, ensureAllLoaded };
}