UNPKG

peepee

Version:

Visual Programming Language Where You Connect Ports Of One EventEmitter to Ports Of Another EventEmitter

462 lines (394 loc) 13.6 kB
import { EventEmitter } from 'events'; /** * Generic Event Aggregator - Waits for multiple events before emitting completion * * Perfect for scenarios where you need to wait for N events with specific criteria * before proceeding. Much simpler than saga pattern for basic coordination. */ export class EventAggregator extends EventEmitter { constructor() { super(); this.aggregations = new Map(); // aggregationId -> aggregation config this.templates = new Map(); // aggregationId -> aggregation config this.eventCache = new Map(); // eventType -> array of cached events this.sequenceNumber = 0; } /** * Define an aggregation - what events to wait for and how to match them * @param {string} aggregationId - Unique identifier for this aggregation * @param {Object} config - Configuration object */ define(aggregationId, config) { const { events = [], // Array of event definitions to wait for timeout = 300000, // 5 minutes default onComplete = null, // Function to call when all events collected onTimeout = null, // Function to call on timeout onPartialMatch = null, // Function to call when some events match cacheEvents = false, // Whether to cache events for late joiners allowDuplicates = false, // Whether to allow multiple events of same type correlationKey = null, // Key to correlate events (optional) correlationValue = null // Value to match correlation key against } = config; const aggregation = { aggregationId, events: events.map((event, index) => ({ eventType: event.eventType, filter: event.filter || (() => true), alias: event.alias || `event_${index}`, required: event.required !== false, multiple: event.multiple === true, // Can capture multiple events of this type timeout: event.timeout || timeout })), timeout, onComplete, onTimeout, onPartialMatch, cacheEvents, allowDuplicates, correlationKey, correlationValue, state: 'WAITING', capturedEvents: new Map(), // alias -> event data (or array if multiple) requiredEvents: new Set(), startedAt: new Date(), timeoutHandle: null }; // Set up required events tracking aggregation.requiredEvents = new Set( aggregation.events .filter(event => event.required) .map(event => event.alias) ); this.templates.set(aggregationId, aggregation); // Set up event listeners for (const eventDef of aggregation.events) { this._setupEventListener(aggregationId, eventDef); } // Set up timeout if (aggregation.timeout > 0) { aggregation.timeoutHandle = setTimeout(() => { this._handleTimeout(aggregationId); }, aggregation.timeout); } // Check cached events if enabled if (cacheEvents) { this._checkCachedEvents(aggregationId); } return this; } /** * Start waiting for events with a specific correlation value * @param {string} aggregationId - ID of the aggregation definition * @param {string} correlationValue - Value to correlate events with * @param {Object} initialData - Initial data to include in completion */ start(aggregationId, correlationValue, initialData = {}) { const template = this.templates.get(aggregationId); if (!template) { throw new Error(`Aggregation '${aggregationId}' not defined`); } const instanceId = `${aggregationId}-${correlationValue}-${Date.now()}`; // console.log(`Starting aggregation instance ${instanceId} with:`, { // aggregationId, // correlationValue, // initialData // }); // Clone the template for this instance const instance = { ...template, instanceId, correlationValue, initialData, capturedEvents: new Map(), requiredEvents: new Set(template.requiredEvents), startedAt: new Date(), state: 'WAITING' }; // Set up timeout for this instance if (instance.timeout > 0) { instance.timeoutHandle = setTimeout(() => { this._handleTimeout(instanceId); }, instance.timeout); } this.aggregations.set(instanceId, instance); this.emit('aggregationStarted', { instanceId, aggregationId, correlationValue, requiredEvents: Array.from(instance.requiredEvents) }); return instanceId; } /** * Set up event listener for a specific event type */ _setupEventListener(aggregationId, eventDef) { const handler = (eventData) => { this._handleEvent(eventDef, eventData); }; // Store handler reference for cleanup if (!this.eventListeners) { this.eventListeners = new Map(); } const key = `${aggregationId}-${eventDef.eventType}`; this.eventListeners.set(key, handler); this.on(eventDef.eventType, handler); } /** * Handle incoming events */ _handleEvent(eventDef, eventData) { const eventRecord = { eventType: eventDef.eventType, eventData, receivedAt: new Date(), sequenceNumber: ++this.sequenceNumber }; // Cache event if caching is enabled if (this.cacheEvents) { if (!this.eventCache.has(eventDef.eventType)) { this.eventCache.set(eventDef.eventType, []); } this.eventCache.get(eventDef.eventType).push(eventRecord); } // Check all active aggregations // console.dir(this.aggregations) for (const [instanceId, aggregation] of this.aggregations) { if (aggregation.state !== 'WAITING') continue; //console.log('FFFF X', aggregation ) // Check if this event matches any of the aggregation's requirements const matchingEventDef = aggregation.events.find(e => { const matches = e.eventType === eventDef.eventType && e.filter(eventData, aggregation.initialData); // Debug log // if (e.eventType === eventDef.eventType) { // console.log(`Checking event ${e.eventType} for instance ${instanceId}:`, { // eventData, // initialData: aggregation.initialData, // matches // }); // } return matches; }); if (!matchingEventDef) continue; // Check correlation if specified if (aggregation.correlationKey && aggregation.correlationValue) { const eventCorrelationValue = eventData[aggregation.correlationKey]; if (eventCorrelationValue !== aggregation.correlationValue) { continue; } } this._captureEvent(instanceId, matchingEventDef, eventRecord); } } /** * Capture an event for an aggregation */ _captureEvent(instanceId, eventDef, eventRecord) { const aggregation = this.aggregations.get(instanceId); if (eventDef.multiple) { // Handle multiple events of the same type if (!aggregation.capturedEvents.has(eventDef.alias)) { aggregation.capturedEvents.set(eventDef.alias, []); } aggregation.capturedEvents.get(eventDef.alias).push(eventRecord); } else { // Handle single event if (aggregation.capturedEvents.has(eventDef.alias) && !aggregation.allowDuplicates) { return; // Already captured and duplicates not allowed } aggregation.capturedEvents.set(eventDef.alias, eventRecord); } // Mark as captured aggregation.requiredEvents.delete(eventDef.alias); this.emit('eventCaptured', { instanceId, aggregationId: aggregation.aggregationId, eventType: eventDef.eventType, alias: eventDef.alias, capturedCount: aggregation.capturedEvents.size, requiredCount: aggregation.requiredEvents.size, isComplete: aggregation.requiredEvents.size === 0 }); // Call partial match handler if provided if (aggregation.onPartialMatch) { try { aggregation.onPartialMatch({ instanceId, alias: eventDef.alias, eventData: eventRecord.eventData, capturedEvents: Object.fromEntries(aggregation.capturedEvents), remainingEvents: Array.from(aggregation.requiredEvents) }); } catch (error) { this.emit('partialMatchError', { instanceId, error }); } } // Check for completion this._checkCompletion(instanceId); } /** * Check if aggregation is complete */ _checkCompletion(instanceId) { const aggregation = this.aggregations.get(instanceId); if (aggregation.requiredEvents.size === 0) { this._completeAggregation(instanceId); } } /** * Complete an aggregation */ async _completeAggregation(instanceId) { const aggregation = this.aggregations.get(instanceId); aggregation.state = 'COMPLETED'; aggregation.completedAt = new Date(); // Clear timeout if (aggregation.timeoutHandle) { clearTimeout(aggregation.timeoutHandle); } const completionData = { instanceId, aggregationId: aggregation.aggregationId, correlationValue: aggregation.correlationValue, initialData: aggregation.initialData, capturedEvents: Object.fromEntries(aggregation.capturedEvents), duration: aggregation.completedAt - aggregation.startedAt, completedAt: aggregation.completedAt }; // Execute completion handler if (aggregation.onComplete) { try { await aggregation.onComplete(completionData); } catch (error) { this.emit('completionError', { instanceId, error }); } } this.emit('aggregationCompleted', completionData); // Clean up after a delay setTimeout(() => { this.aggregations.delete(instanceId); }, 60000); // 1 minute } /** * Handle timeout */ async _handleTimeout(instanceId) { const aggregation = this.aggregations.get(instanceId); if (!aggregation || aggregation.state !== 'WAITING') return; aggregation.state = 'TIMED_OUT'; aggregation.timedOutAt = new Date(); const timeoutData = { instanceId, aggregationId: aggregation.aggregationId, correlationValue: aggregation.correlationValue, capturedEvents: Object.fromEntries(aggregation.capturedEvents), missingEvents: Array.from(aggregation.requiredEvents), duration: aggregation.timedOutAt - aggregation.startedAt }; if (aggregation.onTimeout) { try { await aggregation.onTimeout(timeoutData); } catch (error) { this.emit('timeoutError', { instanceId, error }); } } this.emit('aggregationTimedOut', timeoutData); } /** * Check cached events for a new aggregation */ _checkCachedEvents(aggregationId) { const aggregation = this.aggregations.get(aggregationId); for (const eventDef of aggregation.events) { const cachedEvents = this.eventCache.get(eventDef.eventType) || []; for (const eventRecord of cachedEvents) { if (eventDef.filter(eventRecord.eventData, aggregation.initialData)) { this._captureEvent(aggregationId, eventDef, eventRecord); } } } } /** * Get aggregation status */ getAggregation(instanceId) { return this.aggregations.get(instanceId); } /** * Get all active aggregations */ getActiveAggregations() { return Array.from(this.aggregations.values()) .filter(agg => agg.state === 'WAITING'); } /** * Cancel an aggregation */ cancel(instanceId) { const aggregation = this.aggregations.get(instanceId); if (!aggregation || aggregation.state !== 'WAITING') return false; aggregation.state = 'CANCELLED'; if (aggregation.timeoutHandle) { clearTimeout(aggregation.timeoutHandle); } this.emit('aggregationCancelled', { instanceId }); return true; } /** * Clean up old completed aggregations */ cleanup(maxAge = 3600000) { // 1 hour default const now = Date.now(); const toDelete = []; for (const [instanceId, aggregation] of this.aggregations) { const age = now - aggregation.startedAt.getTime(); if (aggregation.state !== 'WAITING' && age > maxAge) { toDelete.push(instanceId); } } for (const instanceId of toDelete) { this.aggregations.delete(instanceId); } return toDelete.length; } } export default EventAggregator; /* Example Usage: const aggregator = new EventAggregator(); // Define a connection ready aggregation that works for any connection aggregator.define('connectionReady', { events: [ { eventType: 'portAdded', filter: (data, context) => data.id === context.fromPortId, alias: 'fromPort' }, { eventType: 'portAdded', filter: (data, context) => data.id === context.toPortId, alias: 'toPort' } ], timeout: 30000, onComplete: (data) => { console.log('Connection ready!', data); aggregator.emit('connectionReady', { connectionId: data.correlationValue, fromPortId: data.initialData.fromPortId, toPortId: data.initialData.toPortId, fromPort: data.capturedEvents.fromPort.eventData, toPort: data.capturedEvents.toPort.eventData }); } }); // Start waiting for a specific connection - pass context as initialData const instanceId = aggregator.start('connectionReady', 'conn-123', { fromPortId: 'port-1', toPortId: 'port-2' }); // Emit events - filters will use the context to match aggregator.emit('portAdded', { id: 'port-1', type: 'input' }); aggregator.emit('portAdded', { id: 'port-2', type: 'output' }); // -> Will emit 'connectionReady' event */