@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
JavaScript
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