@gati-framework/runtime
Version:
Gati runtime execution engine for running handler-based applications
414 lines • 13.9 kB
JavaScript
/**
* @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