UNPKG

@pulzar/core

Version:

Next-generation Node.js framework for ultra-fast web applications with zero-reflection DI, GraphQL, WebSockets, events, and edge runtime support

695 lines 23.8 kB
import { EventError, EventValidationError, EventTimeoutError, EventBackpressureError, DEFAULT_TIMEOUTS, DEFAULT_RETRY_CONFIG, DEFAULT_BACKPRESSURE_CONFIG, } from "./types"; import MemoryEventAdapter from "./adapters/memory"; import { logger } from "../utils/logger"; import { trace, SpanStatusCode, SpanKind } from "@opentelemetry/api"; const tracer = trace.getTracer("@pulzar/events", "1.0.0"); export class EventBus { adapter; config; dlq; schemaRegistry; middleware = []; connected = false; // Backpressure control publishQueue = []; processingQueue = false; activeTasks = new Set(); concurrencySemaphore = new Map(); // Metrics stats = { published: 0, delivered: 0, acknowledged: 0, failed: 0, retries: 0, dlqSize: 0, activeSubscriptions: 0, throughputPerSecond: 0, averageLatency: 0, errorRate: 0, backpressureEvents: 0, lastActivity: new Date(), memoryUsage: 0, queueSize: 0, concurrentHandlers: 0, schemasRegistered: 0, adaptersConnected: [], }; // Subscriptions tracking subscriptions = new Map(); replyHandlers = new Map(); constructor(config) { this.config = this.mergeConfig(config); this.adapter = this.createAdapter(); if (this.config.dlq.enabled) { this.dlq = this.createDLQ(); } if (this.config.schemas.enabled) { this.schemaRegistry = this.createSchemaRegistry(); } } /** * Connect to the event system */ async connect() { if (this.connected) { logger.warn("EventBus already connected"); return; } const span = tracer.startSpan("eventbus.connect", { kind: SpanKind.CLIENT, }); try { await this.adapter.connect(); if (this.dlq) { // DLQ connect if it has one } this.connected = true; this.stats.adaptersConnected = [this.adapter.name]; span.setStatus({ code: SpanStatusCode.OK }); logger.info("EventBus connected", { adapter: this.adapter.name, capabilities: this.adapter.capabilities, dlq: this.config.dlq.enabled, schemas: this.config.schemas.enabled, }); } catch (error) { span.recordException(error); span.setStatus({ code: SpanStatusCode.ERROR, message: error.message, }); logger.error("Failed to connect EventBus", { error }); throw error; } finally { span.end(); } } /** * Disconnect from the event system */ async disconnect() { if (!this.connected) { return; } const span = tracer.startSpan("eventbus.disconnect"); try { // Wait for pending operations await this.waitForPendingOperations(); // Unsubscribe all for (const [, subscription] of this.subscriptions) { await subscription.unsubscribe(); } this.subscriptions.clear(); await this.adapter.disconnect(); this.connected = false; this.stats.adaptersConnected = []; span.setStatus({ code: SpanStatusCode.OK }); logger.info("EventBus disconnected"); } catch (error) { span.recordException(error); span.setStatus({ code: SpanStatusCode.ERROR, message: error.message, }); logger.error("Failed to disconnect EventBus", { error }); throw error; } finally { span.end(); } } /** * Publish an event with schema validation and middleware */ async publish(subject, data, options = {}) { if (!this.connected) { throw new EventError("EventBus not connected", "NOT_CONNECTED"); } // Check backpressure if (this.publishQueue.length >= this.config.backpressure.publishQueueLimit) { this.stats.backpressureEvents++; throw new EventBackpressureError("Publish queue limit exceeded", this.publishQueue.length); } const span = tracer.startSpan("eventbus.publish", { kind: SpanKind.PRODUCER, attributes: { "messaging.system": this.adapter.name, "messaging.destination": subject, "messaging.operation": "send", }, }); const envelope = { id: this.generateId(), subject, data, headers: options.headers || {}, metadata: { id: this.generateId(), timestamp: new Date().toISOString(), version: "1.0.0", priority: options.priority || 0, traceId: span.spanContext().traceId, spanId: span.spanContext().spanId, traceFlags: span.spanContext().traceFlags, ...options.metadata, }, schema: options.schema, }; // Add to queue for backpressure control return new Promise((resolve, reject) => { this.publishQueue.push(async () => { try { await this.publishInternal(envelope, options, span); resolve(); } catch (error) { reject(error); } }); // Process queue if not already processing if (!this.processingQueue) { this.processPublishQueue(); } }); } /** * Subscribe to events with concurrency control */ async subscribe(subject, handler, options = {}) { if (!this.connected) { throw new EventError("EventBus not connected", "NOT_CONNECTED"); } const span = tracer.startSpan("eventbus.subscribe", { attributes: { "messaging.system": this.adapter.name, "messaging.destination": subject, }, }); try { // Wrap handler with middleware, concurrency control, and error handling const wrappedHandler = this.wrapHandler(handler, subject, options); const subscription = await this.adapter.subscribe(subject, wrappedHandler, options); this.subscriptions.set(subscription.id, subscription); this.stats.activeSubscriptions = this.subscriptions.size; span.setStatus({ code: SpanStatusCode.OK }); logger.debug("Subscribed to events", { subject, subscriptionId: subscription.id, }); return subscription; } catch (error) { span.recordException(error); span.setStatus({ code: SpanStatusCode.ERROR, message: error.message, }); throw error; } finally { span.end(); } } /** * Request-Reply pattern */ async request(subject, data, options = {}) { const replySubject = `_REPLY.${this.generateId()}`; const timeout = options.replyTimeout || this.config.timeouts.handler; const correlationId = options.correlationId || this.generateId(); return new Promise(async (resolve, reject) => { const timeoutId = setTimeout(() => { reject(new EventTimeoutError("Request timeout", timeout)); }, timeout); // Subscribe to reply const subscription = await this.subscribe(replySubject, async (event) => { clearTimeout(timeoutId); await subscription.unsubscribe(); resolve(event.data); }, { ackPolicy: "auto" }); // Publish request await this.publish(subject, { ...data, _replyTo: replySubject, _correlationId: correlationId, }, { ...options, metadata: { ...options.metadata, correlationId, }, }); }); } /** * Register reply handler */ async reply(subject, handler, options = {}) { return this.subscribe(subject, async (event) => { const { _replyTo, _correlationId, ...requestData } = event.data; if (!_replyTo) { logger.warn("Received request without replyTo", { subject, eventId: event.id, }); return; } try { const response = await handler(requestData, event.metadata); await this.publish(_replyTo, response, { metadata: { correlationId: _correlationId, causationId: event.metadata.id, }, }); } catch (error) { logger.error("Reply handler error", { subject, error, eventId: event.id, }); // Send error response await this.publish(_replyTo, { error: { message: error.message, code: error.code || "HANDLER_ERROR", }, }, { metadata: { correlationId: _correlationId, causationId: event.metadata.id, }, }); } }, options); } /** * Register an event schema */ async registerSchema(schema) { if (!this.schemaRegistry) { throw new EventError("Schema registry not enabled", "SCHEMA_REGISTRY_DISABLED"); } await this.schemaRegistry.register(schema); this.stats.schemasRegistered++; logger.debug("Schema registered", { name: schema.name, version: schema.version, subject: schema.subject, }); } /** * Add middleware */ use(middleware) { this.middleware.push(middleware); logger.debug("Middleware added", { name: middleware.name }); } /** * Get event bus statistics */ async getStats() { const adapterStats = await this.adapter.getStats(); this.stats = { ...this.stats, ...adapterStats, queueSize: this.publishQueue.length, concurrentHandlers: this.activeTasks.size, memoryUsage: this.getMemoryUsage(), dlqSize: this.dlq ? (await this.dlq.getStats()).total : 0, }; return { ...this.stats }; } /** * Get DLQ instance */ getDLQ() { return this.dlq; } /** * Check if connected */ isConnected() { return this.connected && this.adapter.isConnected(); } /** * Internal publish with validation and middleware */ async publishInternal(envelope, options, span) { const startTime = Date.now(); try { // Schema validation if (this.schemaRegistry && envelope.schema && !options.skipValidation) { const validationResult = await this.schemaRegistry.validate(envelope.schema, envelope.data); if (!validationResult.valid) { throw new EventValidationError("Schema validation failed", validationResult.errors || [], envelope); } } // Before middleware let processedEnvelope = envelope; for (const middleware of this.middleware) { if (middleware.before) { processedEnvelope = await middleware.before(processedEnvelope); } } // Publish to adapter const result = await this.adapter.publish(envelope.subject, processedEnvelope); // Update stats this.stats.published++; this.stats.lastActivity = new Date(); // After middleware for (const middleware of this.middleware) { if (middleware.after) { await middleware.after(processedEnvelope, result); } } span.setAttributes({ "messaging.message_id": result.messageId, "messaging.partition": result.partition?.toString(), }); span.setStatus({ code: SpanStatusCode.OK }); logger.debug("Event published", { subject: envelope.subject, eventId: envelope.id, messageId: result.messageId, latency: Date.now() - startTime, }); } catch (error) { this.stats.failed++; // Error middleware for (const middleware of this.middleware) { if (middleware.error) { await middleware.error(error, envelope); } } // Send to DLQ if enabled if (this.dlq && this.shouldSendToDLQ(error)) { await this.dlq.add(envelope, error, envelope.subject); } span.recordException(error); span.setStatus({ code: SpanStatusCode.ERROR, message: error.message, }); logger.error("Failed to publish event", { subject: envelope.subject, eventId: envelope.id, error, }); throw error; } } /** * Wrap handler with middleware, concurrency control, and error handling */ wrapHandler(handler, subject, options) { return async (event) => { // Concurrency control const maxConcurrency = options.concurrency || this.config.backpressure.handlerConcurrency; const currentConcurrency = this.concurrencySemaphore.get(subject) || 0; if (currentConcurrency >= maxConcurrency) { this.stats.backpressureEvents++; throw new EventBackpressureError(`Handler concurrency limit exceeded for ${subject}`, currentConcurrency); } this.concurrencySemaphore.set(subject, currentConcurrency + 1); const span = tracer.startSpan("eventbus.handler", { kind: SpanKind.CONSUMER, attributes: { "messaging.system": this.adapter.name, "messaging.destination": subject, "messaging.operation": "receive", "messaging.message_id": event.id, }, }); const task = this.handleEvent(event, handler, options, span, subject); this.activeTasks.add(task); try { await task; } finally { this.activeTasks.delete(task); this.concurrencySemaphore.set(subject, Math.max(0, currentConcurrency - 1)); } }; } /** * Handle event with timeout and middleware */ async handleEvent(event, handler, options, span, subject) { const timeout = options.ackTimeout || this.config.timeouts.handler; const startTime = Date.now(); try { // Set trace context if (event.metadata.traceId) { span.setAttributes({ "trace.trace_id": event.metadata.traceId, "trace.span_id": event.metadata.spanId, }); } // Run handler with timeout await Promise.race([ handler(event), new Promise((_, reject) => setTimeout(() => reject(new EventTimeoutError("Handler timeout", timeout, event)), timeout)), ]); // Auto-ack if configured if (options.ackPolicy === "auto" || options.ackPolicy === undefined) { await this.adapter.ack(event); this.stats.acknowledged++; } this.stats.delivered++; span.setStatus({ code: SpanStatusCode.OK }); logger.debug("Event handled successfully", { subject, eventId: event.id, latency: Date.now() - startTime, }); } catch (error) { this.stats.failed++; // NACK if configured if (options.ackPolicy === "explicit") { await this.adapter.nack(event, true); // requeue } span.recordException(error); span.setStatus({ code: SpanStatusCode.ERROR, message: error.message, }); logger.error("Handler failed", { subject, eventId: event.id, error, latency: Date.now() - startTime, }); throw error; } finally { span.end(); } } /** * Process publish queue with backpressure control */ async processPublishQueue() { if (this.processingQueue) { return; } this.processingQueue = true; try { while (this.publishQueue.length > 0) { const task = this.publishQueue.shift(); if (task) { await task(); } } } finally { this.processingQueue = false; } } /** * Wait for all pending operations to complete */ async waitForPendingOperations() { // Wait for publish queue while (this.publishQueue.length > 0 || this.processingQueue) { await new Promise((resolve) => setTimeout(resolve, 10)); } // Wait for active tasks await Promise.allSettled(Array.from(this.activeTasks)); // Flush adapter if (this.adapter.flush) { await this.adapter.flush(); } } /** * Check if error should be sent to DLQ */ shouldSendToDLQ(error) { // Don't send validation errors to DLQ if (error instanceof EventValidationError) { return false; } // Don't send timeout errors to DLQ if (error instanceof EventTimeoutError) { return false; } return true; } /** * Create adapter instance */ createAdapter() { if (typeof this.config.adapter === "object") { return this.config.adapter; } switch (this.config.adapter) { case "memory": return new MemoryEventAdapter(); case "redis": return this.createRedisAdapter(); case "nats": return this.createNatsAdapter(); case "kafka": return this.createKafkaAdapter(); case "amqp": return this.createAmqpAdapter(); default: throw new EventError(`Unsupported adapter: ${this.config.adapter}`, "UNSUPPORTED_ADAPTER"); } } /** * Create Redis adapter */ createRedisAdapter() { // Redis doesn't have native pub/sub with persistence // Use Redis Streams or fall back to memory adapter logger.warn("Redis adapter using memory fallback - Redis Streams not implemented"); return new MemoryEventAdapter(); } /** * Create NATS adapter */ createNatsAdapter() { const NATSEventAdapter = require("./adapters/nats").default; return new NATSEventAdapter(this.config.adapterOptions); } /** * Create Kafka adapter */ createKafkaAdapter() { const KafkaEventAdapter = require("./adapters/kafka").default; return new KafkaEventAdapter(this.config.adapterOptions); } /** * Create AMQP adapter (placeholder) */ createAmqpAdapter() { throw new EventError("AMQP adapter not implemented", "NOT_IMPLEMENTED"); } /** * Create DLQ instance */ createDLQ() { const dlqAdapter = this.config.dlq.adapter || "redis"; if (dlqAdapter === "redis") { const RedisDLQ = require("./adapters/redis-dlq").default; const dlq = new RedisDLQ(this.config.adapterOptions); // Connect DLQ if needed if (dlq.connect) { dlq.connect().catch((error) => { logger.error("Failed to connect DLQ", { error }); }); } return dlq; } throw new EventError(`Unsupported DLQ adapter: ${dlqAdapter}`, "UNSUPPORTED_DLQ_ADAPTER"); } /** * Create schema registry */ createSchemaRegistry() { const registryAdapter = this.config.schemas.registry ? "custom" : "redis"; if (this.config.schemas.registry) { return this.config.schemas.registry; } if (registryAdapter === "redis") { const RedisSchemaRegistry = require("./adapters/redis-schema-registry").default; const registry = new RedisSchemaRegistry(this.config.adapterOptions); // Connect registry if needed if (registry.connect) { registry.connect().catch((error) => { logger.error("Failed to connect Schema Registry", { error }); }); } return registry; } throw new EventError(`Unsupported schema registry adapter: ${registryAdapter}`, "UNSUPPORTED_REGISTRY_ADAPTER"); } /** * Merge configuration with defaults */ mergeConfig(config) { return { adapter: config.adapter || "memory", adapterOptions: config.adapterOptions || {}, retries: { ...DEFAULT_RETRY_CONFIG, ...config.retries }, dlq: { enabled: false, ttl: 7 * 24 * 60 * 60, // 7 days maxSize: 10000, retryStrategy: "exponential", ...config.dlq, }, backpressure: { ...DEFAULT_BACKPRESSURE_CONFIG, ...config.backpressure }, schemas: { enabled: false, strict: false, ...config.schemas, }, serialization: { format: "json", ...config.serialization, }, observability: { metrics: true, tracing: true, logging: true, healthChecks: true, ...config.observability, }, timeouts: { ...DEFAULT_TIMEOUTS, ...config.timeouts }, development: config.development || { logEvents: false, validateSchemas: false, enableDebugMode: false, }, }; } /** * Generate unique ID */ generateId() { return `${Date.now()}-${Math.random().toString(36).slice(2)}`; } /** * Get memory usage estimate */ getMemoryUsage() { return process.memoryUsage().heapUsed; } } /** * Create an event bus instance */ export function createEventBus(config = {}) { return new EventBus(config); } export default EventBus; //# sourceMappingURL=bus.js.map