hook-engine
Version:
Production-grade webhook engine with comprehensive adapter support, security, reliability, structured logging, and CLI tools.
198 lines (197 loc) • 7.28 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.HookEngine = void 0;
const config_1 = require("../config");
const error_handler_1 = require("../errors/error-handler");
const retry_1 = require("./retry");
const storage_1 = require("../storage");
const adapters_1 = require("../adapters");
const body_1 = require("../utils/body");
const webhook_errors_1 = require("../errors/webhook-errors");
const timing_1 = require("../utils/timing");
/**
* Main hook engine class
*/
class HookEngine {
constructor(userConfig) {
// Load and validate configuration
this.config = (0, config_1.loadConfig)(userConfig);
// Initialize error handler
this.errorHandler = new error_handler_1.ErrorHandler({
strategy: 'retry',
maxRetries: this.config.retry.maxAttempts,
retryDelay: this.config.retry.initialDelayMs,
notifyOnError: this.config.environment === 'production'
});
// Initialize retry engine
this.retryEngine = new retry_1.RetryEngine(this.config.retry);
// Initialize storage
this.storage = (0, storage_1.createStorageAdapter)(this.config.storage);
}
/**
* Process incoming webhook request
*/
async processWebhook(req) {
const timer = new timing_1.Timer();
try {
// Parse webhook event
const event = await this.parseWebhookEvent(req);
// Check for duplicates
if (await this.storage.isDuplicate(event.id)) {
return {
success: true,
event,
message: `Duplicate event ${event.id} skipped`,
duration: timer.elapsed()
};
}
// Store event
await this.storage.storeEvent(event.id, event);
await this.storage.markSeen(event.id);
return {
success: true,
event,
message: `Event ${event.id} processed successfully`,
duration: timer.elapsed()
};
}
catch (error) {
const duration = timer.elapsed();
// Handle error through error handler
await this.errorHandler.handle(error, {
timestamp: new Date(),
metadata: { duration }
});
return {
success: false,
error: error.message,
message: `Failed to process webhook: ${error.message}`,
duration
};
}
}
/**
* Process webhook with custom business logic and retry
*/
async processWebhookWithRetry(req, businessLogic) {
const timer = new timing_1.Timer();
try {
// Parse webhook event
const event = await this.parseWebhookEvent(req);
// Check for duplicates
if (await this.storage.isDuplicate(event.id)) {
throw new webhook_errors_1.WebhookDuplicateError(event.id);
}
// Store event
await this.storage.storeEvent(event.id, event);
await this.storage.markSeen(event.id);
// Execute business logic with retry
const result = await this.retryEngine.execute(() => businessLogic(event), event);
if (!result.success) {
return {
success: false,
event,
error: result.error?.message,
message: `Business logic failed after ${result.attempts} attempts`,
duration: timer.elapsed(),
attempts: result.attempts
};
}
return {
success: true,
event,
message: `Event ${event.id} processed successfully`,
duration: timer.elapsed(),
attempts: result.attempts
};
}
catch (error) {
const duration = timer.elapsed();
// Handle error through error handler
await this.errorHandler.handle(error, {
timestamp: new Date(),
metadata: { duration }
});
return {
success: false,
error: error.message,
message: `Failed to process webhook: ${error.message}`,
duration
};
}
}
/**
* Get engine statistics
*/
getStats() {
return {
retry: this.retryEngine.getStats(),
errors: this.errorHandler.getErrorStats(),
storage: this.storage instanceof Object && 'getStats' in this.storage
? this.storage.getStats()
: null
};
}
/**
* Shutdown the engine gracefully
*/
async shutdown() {
await this.storage.close();
}
async parseWebhookEvent(req) {
// Determine source from request or use first available adapter
const source = this.determineSource(req);
const adapterConfig = this.config.adapters.find(a => a.source === source && a.enabled);
if (!adapterConfig) {
throw new webhook_errors_1.WebhookAdapterError(source);
}
// Create legacy config for backward compatibility
const legacyConfig = {
source: adapterConfig.source,
secret: adapterConfig.secret
};
// Get raw body
const rawBody = await (0, body_1.normalizeRequestBody)(req);
const adapter = (0, adapters_1.getAdapter)(legacyConfig.source);
if (!adapter) {
throw new webhook_errors_1.WebhookAdapterError(legacyConfig.source);
}
// Verify signature
const signature = adapter.getSignature(req);
if (!signature) {
throw new webhook_errors_1.WebhookSignatureError(legacyConfig.source, { reason: 'Missing signature header' });
}
const isValid = adapter.verifySignature(rawBody, signature, legacyConfig.secret);
if (!isValid) {
throw new webhook_errors_1.WebhookSignatureError(legacyConfig.source, { reason: 'Invalid signature' });
}
// Parse and normalize
const parsed = adapter.parsePayload(rawBody);
const normalized = adapter.normalize(parsed);
return {
id: normalized.id,
type: normalized.type,
timestamp: normalized.timestamp,
source: legacyConfig.source,
payload: normalized.payload,
raw: parsed,
};
}
determineSource(req) {
// Try to determine source from headers
const userAgent = req.headers['user-agent'] || '';
const contentType = req.headers['content-type'] || '';
// Check for Stripe
if (req.headers['stripe-signature']) {
return 'stripe';
}
// Check for GitHub
if (req.headers['x-github-event']) {
return 'github';
}
// Default to first enabled adapter
const firstAdapter = this.config.adapters.find(a => a.enabled);
return firstAdapter?.source || 'unknown';
}
}
exports.HookEngine = HookEngine;