@auto-engineer/message-bus
Version:
Message bus for handling commands, events, and queries
494 lines (408 loc) • 17.9 kB
text/typescript
import { Command, Event, CommandHandler, EventHandler, EventSubscription } from './types';
import createDebug from 'debug';
const debug = createDebug('message-bus');
const debugCommand = createDebug('message-bus:command');
const debugEvent = createDebug('message-bus:event');
const debugHandler = createDebug('message-bus:handler');
// Set non-error-like colors for debug namespaces
// Colors: 0=gray, 1=red, 2=green, 3=yellow, 4=blue, 5=magenta, 6=cyan
debug.color = '6'; // cyan
debugCommand.color = '4'; // blue
debugEvent.color = '2'; // green
debugHandler.color = '6'; // cyan
type MessageBusState = {
commandHandlers: Record<string, CommandHandler>;
eventHandlers: Record<string, EventHandler[]>;
allEventHandlers: EventHandler[]; // Handlers that receive ALL events
};
// DSL functions moved to CLI package
export function createMessageBus() {
debug('Creating new message bus instance');
const state: MessageBusState = {
commandHandlers: {},
eventHandlers: {},
allEventHandlers: [],
};
debug('Message bus state initialized');
function registerCommandHandler<TCommand extends Command>(commandHandler: CommandHandler<TCommand>): void {
debugHandler('Registering command handler: %s', commandHandler.name);
if (state.commandHandlers[commandHandler.name] !== undefined) {
const error = `Command handler already registered for command: ${commandHandler.name}`;
debugHandler('ERROR: %s', error);
throw new Error(error);
}
state.commandHandlers[commandHandler.name] = commandHandler as CommandHandler;
debugHandler('Handler registered successfully, total handlers: %d', Object.keys(state.commandHandlers).length);
}
async function sendCommand<TCommand extends Command>(command: TCommand): Promise<void> {
debugCommand('Sending command: %s', command.type);
debugCommand(' Request ID: %s', command.requestId ?? 'none');
debugCommand(' Correlation ID: %s', command.correlationId ?? 'none');
debugCommand(' Data keys: %o', Object.keys(command.data));
const commandHandler = state.commandHandlers[command.type];
if (commandHandler === undefined) {
debugCommand('ERROR: No handler found for command: %s', command.type);
debugCommand('Available handlers: %o', Object.keys(state.commandHandlers));
throw new Error(`Command handler not found for command: ${command.type}`);
}
debugCommand('Handler found for command: %s', command.type);
try {
debugCommand('Executing handler for: %s', command.type);
const startTime = Date.now();
const result = await commandHandler.handle(command);
const duration = Date.now() - startTime;
debugCommand('Handler executed successfully in %dms', duration);
// If handler returned events, publish them
if (result) {
const events = Array.isArray(result) ? result : [result];
for (const event of events) {
debugCommand('Publishing event from command handler: %s', event.type);
await publishEvent(event);
}
}
} catch (error) {
debugCommand('ERROR: Handler failed for command %s: %O', command.type, error);
debugCommand('ERROR: Failed command data: %O', command.data);
throw new Error(`Command handling failed: ${error instanceof Error ? error.message : 'Unknown error occurred'}`);
}
}
function subscribeToEvent<TEvent extends Event>(eventType: string, handler: EventHandler<TEvent>): EventSubscription {
debugEvent('Subscribing to event: %s with handler: %s', eventType, handler.name);
if (state.eventHandlers[eventType] === undefined) {
state.eventHandlers[eventType] = [];
debugEvent('Created new handler array for event type: %s', eventType);
}
state.eventHandlers[eventType].push(handler as EventHandler);
debugEvent('Handler added, total handlers for %s: %d', eventType, state.eventHandlers[eventType].length);
return {
unsubscribe: () => {
debugEvent('Unsubscribing handler %s from event %s', handler.name, eventType);
const handlers = state.eventHandlers[eventType];
if (handlers !== undefined) {
const index = handlers.indexOf(handler as EventHandler);
if (index > -1) {
handlers.splice(index, 1);
debugEvent('Handler removed, remaining handlers for %s: %d', eventType, handlers.length);
if (handlers.length === 0) {
delete state.eventHandlers[eventType];
debugEvent('No handlers left for %s, removed from state', eventType);
}
}
}
},
};
}
async function publishEvent<TEvent extends Event>(event: TEvent): Promise<void> {
debugEvent('Publishing event: %s', event.type);
debugEvent(' Request ID: %s', event.requestId ?? 'none');
debugEvent(' Correlation ID: %s', event.correlationId ?? 'none');
debugEvent(' Timestamp: %s', event.timestamp || 'none');
debugEvent(' Data keys: %o', Object.keys(event.data));
// Log full event data for error events or when debugging is enabled
if (event.type.includes('Failed') || event.type.includes('Error')) {
debugEvent(' Event data (error event): %O', event.data);
}
// Get both specific handlers and all-event handlers
const specificHandlers = state.eventHandlers[event.type] ?? [];
const allHandlers = state.allEventHandlers;
const handlers = [...specificHandlers, ...allHandlers];
debugEvent(
'Found %d specific + %d all-event handlers for event %s',
specificHandlers.length,
allHandlers.length,
event.type,
);
if (handlers.length === 0) {
debugEvent('No handlers registered for event: %s', event.type);
return;
}
const results = await Promise.allSettled(
handlers.map((handler) => {
debugEvent('Executing handler %s for event %s', handler.name, event.type);
try {
return handler.handle(event);
} catch (error) {
debugEvent('ERROR: Handler %s failed for event %s: %O', handler.name, event.type, error);
throw error;
}
}),
);
const failures = results.filter((r) => r.status === 'rejected');
if (failures.length > 0) {
debugEvent('ERROR: %d/%d handlers failed for event %s', failures.length, handlers.length, event.type);
failures.forEach((failure, index) => {
if (failure.status === 'rejected') {
debugEvent(' Handler failure %d: %O', index + 1, failure.reason);
}
});
} else {
debugEvent('All handlers executed successfully for event %s', event.type);
}
}
function subscribeAll<TEvent extends Event = Event>(handler: EventHandler<TEvent>): EventSubscription {
debugEvent('Subscribing to ALL events with handler: %s', handler.name);
state.allEventHandlers.push(handler as EventHandler);
debugEvent('All-event handler added, total all-event handlers: %d', state.allEventHandlers.length);
return {
unsubscribe: () => {
debugEvent('Unsubscribing all-event handler: %s', handler.name);
const index = state.allEventHandlers.indexOf(handler as EventHandler);
if (index > -1) {
state.allEventHandlers.splice(index, 1);
debugEvent('All-event handler removed, remaining: %d', state.allEventHandlers.length);
}
},
};
}
function registerEventHandler<TEvent extends Event>(eventHandler: EventHandler<TEvent>): EventSubscription {
debugHandler('Registering event handler: %s', eventHandler.name);
// For backward compatibility, infer event type from handler name
const eventType = eventHandler.name.replace(/Handler$/, '');
return subscribeToEvent(eventType, eventHandler);
}
debug('Message bus creation complete');
debug(
' Available methods: registerCommandHandler, sendCommand, publishEvent, subscribeToEvent, subscribeAll, registerEventHandler',
);
function getCommandHandlers(): Record<string, CommandHandler> {
return { ...state.commandHandlers };
}
return {
registerCommandHandler,
registerEventHandler,
sendCommand,
publishEvent,
subscribeToEvent,
subscribeAll,
getCommandHandlers,
};
}
export type MessageBus = ReturnType<typeof createMessageBus>;
/*
Architecture Overview
packages/cli/
├── src/
│ ├── server/
│ │ ├── message-bus-server.ts # Express + Socket.io server
│ │ ├── config-loader.ts # Load and execute DSL from config
│ │ ├── state-manager.ts # Functional state management with fold
│ │ └── dsl-executor.ts # Execute on() and dispatch() functions
│ ├── dsl/
│ │ ├── index.ts # Executable DSL functions
│ │ └── types.ts # DSL type definitions
│ ├── commands/
│ │ └── serve.ts # Server command (default when no args)
│ └── index.ts # Modified to start server by default
packages/message-bus/
└── src/
└── message-bus.ts # Remove DSL stubs, keep core bus
Key Design Decisions
1. Executable DSL Functions
- on() will register event handlers that execute dispatch calls
- dispatch() will send commands to the message bus
- fold() will be a pure function: (state, event) => newState
2. Functional State Management
type FoldFunction<S, E> = (state: S, event: E) => S;
class StateManager {
private state: any = {};
private folds: Map<string, FoldFunction<any, any>>;
applyEvent(event: Event) {
const fold = this.folds.get(event.type);
if (fold) {
this.state = fold(this.state, event);
}
}
}
3. Direct Message Bus Integration
- HTTP POST /command → Message Bus → Events → Handlers
- No CLI command execution, direct bus communication
- Add TODO comments for future type validation
4. Event Flow
- Events only trigger message bus handlers
- Comment placeholder for future event store
- WebSocket broadcasts handled separately if needed
5. CLI Default Behavior
- auto with no args → starts server
- auto <command> → executes command via message bus if server running
- auto --local <command> → force local execution
Implementation Steps
Step 1: Move and Implement DSL Functions
// packages/cli/src/dsl/index.ts
export function on<T extends Event>(
eventType: string,
handler: (event: T) => Command | Command[] | void
): EventRegistration {
return { type: 'on', eventType, handler };
}
export function dispatch<T extends Command>(command: T): DispatchAction {
return { type: 'dispatch', command };
}
dispatch.parallel = <T extends Command>(commands: T[]): DispatchAction => ({
type: 'dispatch-parallel',
commands
});
dispatch.sequence = <T extends Command>(commands: T[]): DispatchAction => ({
type: 'dispatch-sequence',
commands
});
export function fold<S, E extends Event>(
eventType: string,
reducer: (state: S, event: E) => S
): FoldRegistration {
return { type: 'fold', eventType, reducer };
}
Step 2: Config Loader with DSL Execution
// packages/cli/src/server/config-loader.ts
export async function loadMessageBusConfig(configPath: string) {
const jiti = createJiti(import.meta.url, { interopDefault: true });
// Import config with executable DSL functions
const configModule = await jiti.import(configPath);
const config = configModule.default || configModule;
// Extract registrations from executed DSL
const registrations = {
eventHandlers: [],
foldFunctions: [],
state: config.state || {}
};
// Parse messageBus.handlers if it exists
if (config.messageBus?.handlers) {
// Execute handlers to get registrations
const handlers = config.messageBus.handlers;
if (typeof handlers === 'function') {
handlers({ on, dispatch, fold });
}
}
return registrations;
}
Step 3: Message Bus Server
// packages/cli/src/server/message-bus-server.ts
import express from 'express';
import { Server as SocketIOServer } from 'socket.io';
import { createMessageBus } from '@auto-engineer/message-bus';
export class MessageBusServer {
private app: express.Application;
private io: SocketIOServer;
private messageBus: MessageBus;
private stateManager: StateManager;
async start(port = 5555, wsPort = 5556) {
this.app = express();
this.app.use(express.json());
// HTTP endpoint for commands
this.app.post('/command', async (req, res) => {
try {
const command = req.body;
// TODO: Add type validation based on command types
// validateCommand(command);
// Send to message bus (non-blocking)
this.messageBus.sendCommand(command)
.catch(err => console.error('Command failed:', err));
res.json({ status: 'ack', commandId: command.requestId });
} catch (error) {
res.status(400).json({ status: 'nack', error: error.message });
}
});
// WebSocket server
this.io = new SocketIOServer(wsPort, {
cors: { origin: '*' }
});
this.io.on('connection', (socket) => {
console.log('WebSocket client connected');
// WebSocket handling for future use
});
// TODO: Add event store integration here
// this.eventStore = new EventStore();
// this.messageBus.subscribeAll(event => this.eventStore.append(event));
await this.app.listen(port);
console.log(`Message bus server running on port ${port}`);
console.log(`WebSocket server running on port ${wsPort}`);
}
}
Step 4: Update CLI Default Behavior
// packages/cli/src/index.ts
if (process.argv.length === 2) {
// No arguments provided, start server
const server = new MessageBusServer();
await server.start();
} else {
// Parse and execute commands
program.parse(process.argv);
}
Step 5: Integration Test
// packages/cli/src/server/server.test.ts
describe('Message Bus Server Integration', () => {
it('should load config and handle commands', async () => {
// Create test config with DSL
const testConfig = `
export default {
messageBus: {
handlers: ({ on, dispatch }) => {
on('OrderCreated', (event) =>
dispatch({
type: 'SendEmail',
data: { orderId: event.data.orderId }
})
);
},
state: {
orders: []
},
folds: ({ fold }) => {
fold('OrderCreated', (state, event) => ({
...state,
orders: [...state.orders, event.data]
}));
}
}
};
`;
// Start server with config
const server = new MessageBusServer();
await server.loadConfig(testConfig);
await server.start();
// Send command via HTTP
const response = await fetch('http://localhost:5555/command', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'CreateOrder',
data: { customerId: '123', items: [] }
})
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
status: 'ack',
commandId: expect.any(String)
});
});
});
File Structure Summary
New Files:
1. packages/cli/src/dsl/index.ts - Executable DSL functions
2. packages/cli/src/dsl/types.ts - DSL type definitions
3. packages/cli/src/server/message-bus-server.ts - Express/Socket.io server
4. packages/cli/src/server/config-loader.ts - Config parser with DSL execution
5. packages/cli/src/server/state-manager.ts - Functional state management
6. packages/cli/src/server/dsl-executor.ts - Execute on/dispatch registrations
7. packages/cli/src/server/server.test.ts - Integration tests
Modified Files:
1. packages/cli/src/index.ts - Default to server mode
2. packages/message-bus/src/message-bus.ts - Remove DSL stubs
3. packages/cli/package.json - Add express, socket.io dependencies
Dependencies to Add
{
"dependencies": {
"express": "^4.18.0",
"socket.io": "^4.7.5",
"cors": "^2.8.5"
},
"devDependencies": {
"@types/express": "^4.17.0",
"@types/cors": "^2.8.0"
}
}
Does this plan align with your vision? Should I proceed with the implementation?
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ > │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
⏵⏵ bypass permissions on (shift+tab to cycle) ⧉ In message-bus.ts
*/