UNPKG

@gati-framework/runtime

Version:

Gati runtime execution engine for running handler-based applications

414 lines 13.9 kB
/** * @module runtime/hook-orchestrator * @description Hook orchestration system for request lifecycle management * * Implements Task 5: LCC Lifecycle Orchestration * - Executes hooks in correct order (global → route → local) * - Supports async hooks with timeout and retry * - Emits lifecycle events for observability * - Integrates request/response validation */ import { validate, ValidationException } from './gtype/index.js'; import { HookPlayback } from './playground/hook-playback.js'; /** * Hook orchestrator for managing request lifecycle */ export class HookOrchestrator { beforeHooks = []; afterHooks = []; catchHooks = []; compensatingActions = []; config; playback = null; constructor(config = {}) { this.config = { defaultTimeout: config.defaultTimeout ?? 5000, defaultRetries: config.defaultRetries ?? 0, emitEvents: config.emitEvents ?? true, onEvent: config.onEvent ?? (() => { }), onAlert: config.onAlert, }; } /** * Enable hook playback recording */ enablePlayback() { if (!this.playback) { this.playback = new HookPlayback(); } this.playback.enable(); return this.playback; } /** * Disable hook playback recording */ disablePlayback() { if (this.playback) { this.playback.disable(); } } /** * Get playback instance */ getPlayback() { return this.playback; } /** * Register a before hook */ registerBefore(hook) { this.beforeHooks.push({ ...hook, async: this.isAsync(hook.fn), timeout: hook.timeout ?? this.config.defaultTimeout, retries: hook.retries ?? this.config.defaultRetries, }); // Sort by level: global → route → local this.sortHooks(this.beforeHooks); } /** * Register an after hook */ registerAfter(hook) { this.afterHooks.push({ ...hook, async: this.isAsync(hook.fn), timeout: hook.timeout ?? this.config.defaultTimeout, retries: hook.retries ?? this.config.defaultRetries, }); // Sort by level: local → route → global (reverse order) this.sortHooks(this.afterHooks, true); } /** * Register a catch hook */ registerCatch(hook) { this.catchHooks.push({ ...hook, async: this.isAsync(hook.fn), timeout: hook.timeout ?? this.config.defaultTimeout, retries: hook.retries ?? this.config.defaultRetries, }); // Sort by level: local → route → global (reverse order) this.sortHooks(this.catchHooks, true); } /** * Execute before hooks */ async executeBefore(lctx, gctx) { for (const hook of this.beforeHooks) { await this.executeHook(hook, lctx, gctx); } } /** * Execute after hooks */ async executeAfter(lctx, gctx) { for (const hook of this.afterHooks) { await this.executeHook(hook, lctx, gctx); } } /** * Execute catch hooks */ async executeCatch(error, lctx, gctx) { // Execute compensating actions first (in reverse order) await this.executeCompensatingActions(lctx); // Then execute catch hooks for (const hook of this.catchHooks) { try { await this.executeHook(hook, lctx, gctx); } catch (hookError) { // Log but don't throw - catch hooks should not fail the request console.error(`Catch hook ${hook.id} failed:`, hookError); } } } /** * Register a compensating action * Compensating actions are executed in reverse order when an error occurs */ registerCompensatingAction(action, id) { const actionId = id ?? `compensation-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; this.compensatingActions.push({ id: actionId, action, registeredAt: Date.now(), }); } /** * Execute all compensating actions in reverse order */ async executeCompensatingActions(lctx) { if (this.compensatingActions.length === 0) { return; } // Execute in reverse order (LIFO) const actionsToExecute = [...this.compensatingActions].reverse(); for (const entry of actionsToExecute) { this.emitEvent({ type: 'compensation:start', timestamp: Date.now(), requestId: lctx.requestId, metadata: { actionId: entry.id, registeredAt: entry.registeredAt }, }); const start = Date.now(); try { await Promise.resolve(entry.action()); const duration = Date.now() - start; this.emitEvent({ type: 'compensation:end', timestamp: Date.now(), requestId: lctx.requestId, duration, metadata: { actionId: entry.id }, }); } catch (compensationError) { const error = compensationError instanceof Error ? compensationError : new Error(String(compensationError)); const duration = Date.now() - start; this.emitEvent({ type: 'compensation:error', timestamp: Date.now(), requestId: lctx.requestId, error, duration, metadata: { actionId: entry.id }, }); // Emit alert for compensating action failure const alertMessage = `Compensating action ${entry.id} failed: ${error.message}`; console.error(alertMessage, error); this.emitEvent({ type: 'compensation:alert', timestamp: Date.now(), requestId: lctx.requestId, error, metadata: { actionId: entry.id, message: alertMessage, }, }); // Call alert handler if configured if (this.config.onAlert) { this.config.onAlert(alertMessage, error, { actionId: entry.id, requestId: lctx.requestId, registeredAt: entry.registeredAt, }); } // Continue executing other compensating actions even if one fails } } // Clear compensating actions after execution this.compensatingActions = []; } /** * Clear all compensating actions without executing them */ clearCompensatingActions() { this.compensatingActions = []; } /** * Get all registered compensating actions */ getCompensatingActions() { return [...this.compensatingActions]; } /** * Validate request against schema */ validateRequest(req, schema, lctx) { this.emitEvent({ type: 'validation:start', timestamp: Date.now(), requestId: lctx.requestId, metadata: { phase: 'request' }, }); const start = Date.now(); const result = validate(req.body, schema); const duration = Date.now() - start; if (!result.valid) { this.emitEvent({ type: 'validation:error', timestamp: Date.now(), requestId: lctx.requestId, duration, metadata: { phase: 'request', errors: result.errors }, }); throw new ValidationException(result.errors, 'Request validation failed'); } this.emitEvent({ type: 'validation:end', timestamp: Date.now(), requestId: lctx.requestId, duration, metadata: { phase: 'request' }, }); } /** * Validate response against schema */ validateResponse(data, schema, lctx) { this.emitEvent({ type: 'validation:start', timestamp: Date.now(), requestId: lctx.requestId, metadata: { phase: 'response' }, }); const start = Date.now(); const result = validate(data, schema); const duration = Date.now() - start; if (!result.valid) { this.emitEvent({ type: 'validation:error', timestamp: Date.now(), requestId: lctx.requestId, duration, metadata: { phase: 'response', errors: result.errors }, }); throw new ValidationException(result.errors, 'Response validation failed'); } this.emitEvent({ type: 'validation:end', timestamp: Date.now(), requestId: lctx.requestId, duration, metadata: { phase: 'response' }, }); } /** * Execute a single hook with timeout and retry */ async executeHook(hook, lctx, gctx) { let lastError; const maxAttempts = (hook.retries ?? 0) + 1; for (let attempt = 0; attempt < maxAttempts; attempt++) { if (attempt > 0) { this.emitEvent({ type: 'hook:retry', timestamp: Date.now(), requestId: lctx.requestId, hookId: hook.id, metadata: { attempt, maxAttempts }, }); } this.emitEvent({ type: 'hook:start', timestamp: Date.now(), requestId: lctx.requestId, hookId: hook.id, metadata: { level: hook.level, attempt }, }); const start = Date.now(); try { await this.executeWithTimeout(() => hook.fn(lctx, gctx), hook.timeout || this.config.defaultTimeout); const end = Date.now(); const duration = end - start; this.emitEvent({ type: 'hook:end', timestamp: Date.now(), requestId: lctx.requestId, hookId: hook.id, duration, metadata: { level: hook.level }, }); // Record successful execution if (this.playback?.isEnabled()) { this.playback.recordHookExecution(lctx.requestId, hook.id, this.getHookType(hook), hook.level, start, end, true); } return; // Success } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); const end = Date.now(); const duration = end - start; this.emitEvent({ type: 'hook:error', timestamp: Date.now(), requestId: lctx.requestId, hookId: hook.id, error: lastError, duration, metadata: { level: hook.level, attempt }, }); // Record failed execution if (this.playback?.isEnabled()) { this.playback.recordHookExecution(lctx.requestId, hook.id, this.getHookType(hook), hook.level, start, end, false, lastError); } // If this was the last attempt, throw if (attempt === maxAttempts - 1) { throw lastError; } } } // This should never be reached, but TypeScript needs it throw lastError ?? new Error('Hook execution failed'); } /** * Execute function with timeout */ async executeWithTimeout(fn, timeoutMs) { return Promise.race([ Promise.resolve(fn()), new Promise((_, reject) => setTimeout(() => reject(new Error(`Hook timeout after ${timeoutMs}ms`)), timeoutMs)), ]); } /** * Check if function is async */ isAsync(fn) { return fn.constructor.name === 'AsyncFunction'; } /** * Sort hooks by level */ sortHooks(hooks, reverse = false) { const levelOrder = { global: 0, route: 1, local: 2 }; hooks.sort((a, b) => { const orderA = levelOrder[a.level]; const orderB = levelOrder[b.level]; return reverse ? orderB - orderA : orderA - orderB; }); } /** * Emit lifecycle event */ emitEvent(event) { if (this.config.emitEvents) { this.config.onEvent(event); } } /** * Get hook type from hook arrays */ getHookType(hook) { if (this.beforeHooks.includes(hook)) return 'before'; if (this.afterHooks.includes(hook)) return 'after'; return 'catch'; } /** * Get all registered hooks */ getHooks() { return { before: [...this.beforeHooks], after: [...this.afterHooks], catch: [...this.catchHooks], }; } /** * Clear all hooks and compensating actions */ clear() { this.beforeHooks = []; this.afterHooks = []; this.catchHooks = []; this.compensatingActions = []; } } //# sourceMappingURL=hook-orchestrator.js.map