/** * 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; middlewares: Map; 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; 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; headers: Record; model: string; combo?: string; apiKeyInfo?: Record; 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): 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 }; }> { 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, }; }