UNPKG

theater-client

Version:

TypeScript client library for Theater actor system TCP protocol

252 lines (213 loc) 6.82 kB
/** * Actor event stream implementation for subscribing to actor events */ import { EventEmitter } from 'node:events'; import type { TheaterConnection } from '../connection/TheaterConnection.js'; import type { TheaterClient } from './TheaterClient.js'; import type { TheaterId, SubscriptionId, ManagementCommand, ManagementResponse, ChainEvent } from '../types/protocol.js'; import type { ActorEventStream } from '../types/client.js'; import { TheaterError, // TheaterConnectionError, // TheaterProtocolError } from '../types/client.js'; import { createLogger } from '../utils/logger.js'; const log = createLogger('ActorEventStream'); /** * Implementation of ActorEventStream for subscribing to actor events * Manages a long-lived connection for receiving actor events */ export class ActorEventStreamImpl extends EventEmitter implements ActorEventStream { private eventHandlers = new Set<(event: ChainEvent) => void>(); private closeHandlers = new Set<() => void>(); private errorHandlers = new Set<(error: Error) => void>(); private _isActive = true; private eventLoopRunning = false; constructor( public readonly actorId: TheaterId, public readonly subscriptionId: SubscriptionId, private connection: TheaterConnection, private client: TheaterClient ) { super(); // Set up connection event handlers this.connection.on('disconnect', () => { this.handleClose(); }); this.connection.on('error', (error: Error) => { this.handleError(error); }); // Start the event listening loop this.startEventLoop(); log.info(`Actor event stream created for actor: ${actorId}, subscription: ${subscriptionId}`); } get isActive(): boolean { return this._isActive && this.connection.connected; } /** * Start the continuous event listening loop */ private async startEventLoop(): Promise<void> { if (this.eventLoopRunning) { return; } this.eventLoopRunning = true; log.debug(`Starting event loop for actor: ${this.actorId}`); try { while (this._isActive && this.connection.connected) { try { const response = await this.connection.receive(); this.handleResponse(response); } catch (error) { if (this._isActive) { log.error(`Error in event loop: ${error}`); this.handleError(error instanceof Error ? error : new Error(String(error))); } break; } } } finally { this.eventLoopRunning = false; log.debug(`Event loop ended for actor: ${this.actorId}`); } } /** * Handle incoming responses from the connection */ private handleResponse(response: ManagementResponse): void { log.debug(`Received response: ${JSON.stringify(response)}`); if ('ActorEvent' in response && response.ActorEvent) { log.debug(`Received actor event for actor: ${this.actorId}`); this.notifyEventHandlers(response.ActorEvent.event); } else if ('Unsubscribed' in response && response.Unsubscribed) { if (response.Unsubscribed.id === this.actorId) { log.info(`Unsubscribed from actor: ${this.actorId}`); this.handleClose(); } } else if ('Error' in response && response.Error) { log.error(`Subscription error: ${JSON.stringify(response.Error)}`); this.handleError(new TheaterError('Subscription error', 'SUBSCRIPTION_ERROR', response.Error.error)); } else { log.debug(`Ignoring response type: ${Object.keys(response)[0]}`); } } /** * Notify all event handlers of a new event */ private notifyEventHandlers(event: ChainEvent): void { this.eventHandlers.forEach(handler => { try { handler(event); } catch (error) { log.error(`Error in event handler: ${error}`); } }); } /** * Handle subscription close event */ private handleClose(): void { if (!this._isActive) { return; } this._isActive = false; log.info(`Actor event stream closed for actor: ${this.actorId}`); this.closeHandlers.forEach(handler => { try { handler(); } catch (error) { log.error(`Error in close handler: ${error}`); } }); this.emit('close'); } /** * Handle error events */ private handleError(error: Error): void { log.error(`Actor event stream error: ${error.message}`); this.errorHandlers.forEach(handler => { try { handler(error); } catch (handlerError) { log.error(`Error in error handler: ${handlerError}`); } }); this.emit('error', error); } // ===== PUBLIC INTERFACE ===== /** * Register an event handler */ onEvent(handler: (event: ChainEvent) => void): () => void { log.debug(`Adding event handler, total handlers: ${this.eventHandlers.size + 1}`); this.eventHandlers.add(handler); return () => { log.debug(`Removing event handler, remaining: ${this.eventHandlers.size - 1}`); this.eventHandlers.delete(handler); }; } /** * Register a close handler */ onClose(handler: () => void): () => void { this.closeHandlers.add(handler); return () => { this.closeHandlers.delete(handler); }; } /** * Register an error handler */ onError(handler: (error: Error) => void): () => void { this.errorHandlers.add(handler); return () => { this.errorHandlers.delete(handler); }; } /** * Close the subscription */ close(): void { if (!this._isActive) { return; } log.info(`Closing subscription for actor: ${this.actorId}`); // Send unsubscribe command on a separate connection (hygienic pattern) const unsubscribe = async () => { const unsubConnection = new (await import('../connection/TheaterConnection.js')).TheaterConnection( (this.client as any).host, (this.client as any).port, (this.client as any).config ); try { await unsubConnection.connect(); const command: ManagementCommand = { UnsubscribeFromActor: { id: this.actorId, subscription_id: this.subscriptionId } }; await unsubConnection.send(command); // Don't wait for response, just close } catch (error) { log.error(`Error unsubscribing: ${error}`); } finally { unsubConnection.close(); } }; // Fire and forget the unsubscribe command unsubscribe().catch(error => { log.error(`Failed to send unsubscribe command: ${error}`); }); // Close our receiving connection this.connection.close(); this.handleClose(); } }