Spaces:
Runtime error
Runtime error
| /** | |
| * Pre-request Hook Registry | |
| * | |
| * Singleton registry for pre-request middleware hooks. | |
| * Follows the same globalThis pattern as GuardrailRegistry. | |
| * | |
| * Hooks execute in priority order (lower = first) BEFORE provider | |
| * selection and combo routing. They can: | |
| * - Mutate the request body/headers | |
| * - Redirect to a different model/combo | |
| * - Short-circuit with a custom response | |
| * - Skip remaining hooks | |
| */ | |
| import { | |
| type HookMiddleware, | |
| type HookConfig, | |
| type PreRequestHookContext, | |
| type HookResult, | |
| type HookScope, | |
| type HookLogEntry, | |
| HookPriority, | |
| } from "./types"; | |
| // ── State (globalThis singleton) ────────────────────────────────────────── | |
| declare global { | |
| var __omniroutePreRequestRegistry: | |
| | { | |
| initialized: boolean; | |
| hooks: Map<string, HookConfig>; | |
| middlewares: Map<string, HookMiddleware>; | |
| logs: HookLogEntry[]; | |
| maxLogs: number; | |
| } | |
| | undefined; | |
| } | |
| function getRegistryState() { | |
| if (!globalThis.__omniroutePreRequestRegistry) { | |
| globalThis.__omniroutePreRequestRegistry = { | |
| initialized: false, | |
| hooks: new Map(), | |
| middlewares: new Map(), | |
| logs: [], | |
| maxLogs: 1000, | |
| }; | |
| } | |
| return globalThis.__omniroutePreRequestRegistry; | |
| } | |
| // ── Compile hook code into middleware function ──────────────────────────── | |
| function compileHookCode(code: string, hookName: string): HookMiddleware { | |
| try { | |
| // Wrap in async function that returns HookResult | |
| // eslint-disable-next-line no-new-func | |
| const fn = new Function("context", `return (async () => { ${code} })();`) as ( | |
| context: PreRequestHookContext | |
| ) => Promise<HookResult>; | |
| return fn; | |
| } catch (err: unknown) { | |
| const message = err instanceof Error ? err.message : "Compilation error"; | |
| throw new Error(`Failed to compile hook "${hookName}": ${message}`); | |
| } | |
| } | |
| // ── Default context factory ────────────────────────────────────────────── | |
| export function createHookContext(params: { | |
| body: Record<string, unknown>; | |
| headers: Record<string, string | string[] | undefined>; | |
| model: string; | |
| combo?: string; | |
| apiKeyInfo?: Record<string, unknown>; | |
| log?: any; | |
| }): PreRequestHookContext { | |
| const logger = params.log || console; | |
| return { | |
| body: { ...params.body }, | |
| headers: { ...params.headers }, | |
| model: params.model, | |
| combo: params.combo, | |
| apiKeyInfo: params.apiKeyInfo ? { ...params.apiKeyInfo } : undefined, | |
| metadata: {}, | |
| log: { | |
| info: (tag: string, msg: string) => logger.info?.(tag, msg) ?? console.log(`[${tag}] ${msg}`), | |
| warn: (tag: string, msg: string) => | |
| logger.warn?.(tag, msg) ?? console.warn(`[${tag}] ${msg}`), | |
| error: (tag: string, msg: string) => | |
| logger.error?.(tag, msg) ?? console.error(`[${tag}] ${msg}`), | |
| }, | |
| }; | |
| } | |
| // ── Public API ──────────────────────────────────────────────────────────── | |
| /** | |
| * Register a pre-request hook. | |
| */ | |
| export function registerHook(config: HookConfig, middleware?: HookMiddleware): void { | |
| const state = getRegistryState(); | |
| if (state.hooks.has(config.name)) { | |
| throw new Error(`Hook "${config.name}" is already registered`); | |
| } | |
| state.hooks.set(config.name, { ...config }); | |
| if (middleware) { | |
| state.middlewares.set(config.name, middleware); | |
| } else { | |
| // Compile from code | |
| const compiled = compileHookCode(config.code, config.name); | |
| state.middlewares.set(config.name, compiled); | |
| } | |
| } | |
| /** | |
| * Unregister a hook by name. | |
| */ | |
| export function unregisterHook(name: string): boolean { | |
| const state = getRegistryState(); | |
| const removed = state.hooks.delete(name); | |
| state.middlewares.delete(name); | |
| return removed; | |
| } | |
| /** | |
| * Update an existing hook's config and optionally recompile. | |
| */ | |
| export function updateHook(name: string, updates: Partial<HookConfig>): boolean { | |
| const state = getRegistryState(); | |
| const existing = state.hooks.get(name); | |
| if (!existing) return false; | |
| const updated = { ...existing, ...updates, updatedAt: new Date().toISOString() }; | |
| state.hooks.set(name, updated); | |
| // Recompile if code changed | |
| if (updates.code) { | |
| try { | |
| const compiled = compileHookCode(updated.code, name); | |
| state.middlewares.set(name, compiled); | |
| } catch (err: unknown) { | |
| state.hooks.set(name, { ...existing, lastError: (err as Error).message }); | |
| throw err; | |
| } | |
| } | |
| return true; | |
| } | |
| /** | |
| * Get a hook config by name. | |
| */ | |
| export function getHook(name: string): HookConfig | undefined { | |
| return getRegistryState().hooks.get(name); | |
| } | |
| /** | |
| * Get all registered hooks. | |
| */ | |
| export function getAllHooks(): HookConfig[] { | |
| return Array.from(getRegistryState().hooks.values()); | |
| } | |
| /** | |
| * Load hooks from DB config rows into the registry. | |
| * This is called at startup to restore persisted hooks. | |
| */ | |
| export function loadHooksFromConfig(rows: HookConfig[]): void { | |
| const state = getRegistryState(); | |
| for (const row of rows) { | |
| if (!state.hooks.has(row.name)) { | |
| state.hooks.set(row.name, row); | |
| try { | |
| const compiled = compileHookCode(row.code, row.name); | |
| state.middlewares.set(row.name, compiled); | |
| } catch (err) { | |
| console.error(`[Middleware] Failed to compile hook "${row.name}":`, err); | |
| } | |
| } | |
| } | |
| } | |
| /** | |
| * Execute all enabled hooks for the given context. | |
| * Returns the final context with all mutations applied. | |
| * | |
| * If any hook short-circuits, returns { response } immediately | |
| * and stops processing. | |
| */ | |
| export async function runHooks( | |
| context: PreRequestHookContext, | |
| comboId?: string | |
| ): Promise<{ | |
| context: PreRequestHookContext; | |
| response?: { status: number; body: Record<string, unknown> }; | |
| }> { | |
| const state = getRegistryState(); | |
| const hooks = Array.from(state.hooks.values()) | |
| .filter( | |
| (h) => | |
| h.enabled && | |
| (h.scope.type === "global" || | |
| (h.scope.type === "combo" && comboId && h.scope.comboId === comboId)) | |
| ) | |
| .sort((a, b) => a.priority - b.priority); | |
| for (const hook of hooks) { | |
| const middleware = state.middlewares.get(hook.name); | |
| if (!middleware) continue; | |
| const startTime = Date.now(); | |
| try { | |
| const result = await middleware(context); | |
| // Apply mutations | |
| if (result.body) { | |
| context.body = { ...context.body, ...result.body }; | |
| } | |
| if (result.headers) { | |
| context.headers = { ...context.headers, ...result.headers }; | |
| } | |
| if (result.model) { | |
| context.model = result.model; | |
| } | |
| if (result.combo) { | |
| context.combo = result.combo; | |
| } | |
| // Update run count | |
| hook.runCount = (hook.runCount || 0) + 1; | |
| // Record execution log | |
| const logEntry: HookLogEntry = { | |
| id: `${hook.name}-${Date.now()}`, | |
| hookName: hook.name, | |
| requestId: `${Date.now()}`, | |
| durationMs: Date.now() - startTime, | |
| mutated: !!(result.body || result.headers || result.model || result.combo), | |
| skipped: !!result.skipRemaining, | |
| timestamp: new Date().toISOString(), | |
| }; | |
| state.logs.push(logEntry); | |
| if (state.logs.length > state.maxLogs) { | |
| state.logs.splice(0, state.logs.length - state.maxLogs); | |
| } | |
| // Short-circuit | |
| if (result.response) { | |
| return { context, response: result.response }; | |
| } | |
| // Skip remaining | |
| if (result.skipRemaining) { | |
| break; | |
| } | |
| } catch (err: unknown) { | |
| const message = err instanceof Error ? err.message : "Unknown error"; | |
| hook.lastError = message; | |
| hook.runCount = (hook.runCount || 0) + 1; | |
| state.logs.push({ | |
| id: `${hook.name}-err-${Date.now()}`, | |
| hookName: hook.name, | |
| requestId: `${Date.now()}`, | |
| durationMs: Date.now() - startTime, | |
| mutated: false, | |
| skipped: false, | |
| error: message, | |
| timestamp: new Date().toISOString(), | |
| }); | |
| console.error(`[Middleware] Hook "${hook.name}" failed:`, message); | |
| } | |
| } | |
| return { context }; | |
| } | |
| /** | |
| * Get execution logs. | |
| */ | |
| export function getHookLogs(hookName?: string, limit = 50): HookLogEntry[] { | |
| const state = getRegistryState(); | |
| let logs = state.logs; | |
| if (hookName) { | |
| logs = logs.filter((l) => l.hookName === hookName); | |
| } | |
| return logs.slice(-limit); | |
| } | |
| /** | |
| * Initialize registry (idempotent). | |
| */ | |
| export function initPreRequestRegistry(): void { | |
| getRegistryState().initialized = true; | |
| } | |
| /** | |
| * Clear all hooks (for testing). | |
| */ | |
| export function clearAllHooks(): void { | |
| const state = getRegistryState(); | |
| state.hooks.clear(); | |
| state.middlewares.clear(); | |
| state.logs = []; | |
| } | |
| /** | |
| * Get registry stats for health monitoring. | |
| */ | |
| export function getRegistryStats(): { | |
| totalHooks: number; | |
| enabledHooks: number; | |
| globalHooks: number; | |
| comboScopedHooks: number; | |
| recentLogs: number; | |
| } { | |
| const state = getRegistryState(); | |
| const hooks = Array.from(state.hooks.values()); | |
| return { | |
| totalHooks: hooks.length, | |
| enabledHooks: hooks.filter((h) => h.enabled).length, | |
| globalHooks: hooks.filter((h) => h.scope.type === "global").length, | |
| comboScopedHooks: hooks.filter((h) => h.scope.type === "combo").length, | |
| recentLogs: state.logs.length, | |
| }; | |
| } | |