UNPKG

theater-client

Version:

TypeScript client library for Theater actor system TCP protocol

313 lines (263 loc) 8.74 kB
/** * Channel stream implementation for real-time communication with Theater actors */ import { EventEmitter } from 'node:events'; import type { TheaterConnection } from '../connection/TheaterConnection.js'; import type { TheaterClient } from './TheaterClient.js'; import type { ChannelId, // ChannelParticipant, ManagementCommand, ManagementResponse } from '../types/protocol.js'; import type { ChannelStream, ChannelMessage } from '../types/client.js'; import { TheaterError, // TheaterConnectionError, // TheaterProtocolError } from '../types/client.js'; import { createLogger } from '../utils/logger.js'; import { uint8ArrayToNumbers, numbersToUint8Array } from '../utils/serialization.js'; const log = createLogger('ChannelStream'); /** * Implementation of ChannelStream for real-time communication * Manages a long-lived connection for bidirectional messaging */ export class ChannelStreamImpl extends EventEmitter implements ChannelStream { private messageHandlers = new Set<(message: ChannelMessage) => void>(); private closeHandlers = new Set<() => void>(); private errorHandlers = new Set<(error: Error) => void>(); private _isOpen = true; private messageLoopRunning = false; constructor( public readonly channelId: ChannelId, 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 message listening loop this.startMessageLoop(); log.info(`Channel stream created: ${channelId}`); } get isOpen(): boolean { return this._isOpen && this.connection.connected; } /** * Start the continuous message listening loop */ private async startMessageLoop(): Promise<void> { if (this.messageLoopRunning) { return; } this.messageLoopRunning = true; log.debug(`Starting message loop for channel: ${this.channelId}`); try { while (this._isOpen && this.connection.connected) { try { const response = await this.connection.receive(); this.handleResponse(response); } catch (error) { if (this._isOpen) { log.error(`Error in message loop: ${error}`); this.handleError(error instanceof Error ? error : new Error(String(error))); } break; } } } finally { this.messageLoopRunning = false; log.debug(`Message loop ended for channel: ${this.channelId}`); } } /** * Handle incoming responses from the connection */ private handleResponse(response: ManagementResponse): void { log.debug(`Received response: ${JSON.stringify(response)}`); if ('ChannelMessage' in response && response.ChannelMessage) { if (response.ChannelMessage.channel_id === this.channelId) { const message: ChannelMessage = { senderId: response.ChannelMessage.sender_id, data: numbersToUint8Array(response.ChannelMessage.message), timestamp: new Date() }; log.debug(`Received channel message from ${JSON.stringify(message.senderId)}, length: ${message.data.length}`); this.notifyMessageHandlers(message); } } else if ('ChannelClosed' in response && response.ChannelClosed) { if (response.ChannelClosed.channel_id === this.channelId) { log.info(`Channel closed: ${this.channelId}`); this.handleClose(); } } else if ('Error' in response && response.Error) { log.error(`Channel error: ${JSON.stringify(response.Error)}`); this.handleError(new TheaterError('Channel error', 'CHANNEL_ERROR', response.Error.error)); } else { log.debug(`Ignoring response type: ${Object.keys(response)[0]}`); } } /** * Notify all message handlers of a new message */ private notifyMessageHandlers(message: ChannelMessage): void { this.messageHandlers.forEach(handler => { try { handler(message); } catch (error) { log.error(`Error in message handler: ${error}`); } }); } /** * Handle channel close event */ private handleClose(): void { if (!this._isOpen) { return; } this._isOpen = false; log.info(`Channel stream closed: ${this.channelId}`); 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(`Channel 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 a message handler */ onMessage(handler: (message: ChannelMessage) => void): () => void { log.debug(`Adding message handler, total handlers: ${this.messageHandlers.size + 1}`); this.messageHandlers.add(handler); return () => { log.debug(`Removing message handler, remaining: ${this.messageHandlers.size - 1}`); this.messageHandlers.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); }; } /** * Send a message on this channel */ async sendMessage(data: Uint8Array): Promise<void> { if (!this.isOpen) { throw new TheaterError('Cannot send message on closed channel', 'CHANNEL_CLOSED'); } log.info(`Sending message on channel ${this.channelId}, length: ${data.length}`); // Create a new connection for sending (hygienic pattern) // The receiving connection stays dedicated to listening const sendConnection = new (await import('../connection/TheaterConnection.js')).TheaterConnection( (this.client as any).host, (this.client as any).port, (this.client as any).config ); try { await sendConnection.connect(); const command: ManagementCommand = { SendOnChannel: { channel_id: this.channelId, message: uint8ArrayToNumbers(data) } }; await sendConnection.send(command); // Wait for confirmation while (true) { const response = await sendConnection.receive(); if ('MessageSent' in response && response.MessageSent) { if (response.MessageSent.channel_id === this.channelId) { log.info(`Message sent on channel: ${this.channelId}`); return; } } if ('Error' in response && response.Error) { throw new TheaterError('Failed to send message', 'SEND_ERROR', response.Error.error); } log.debug(`Ignoring response type while sending: ${Object.keys(response)[0]}`); } } finally { sendConnection.close(); } } /** * Close the channel */ close(): void { if (!this._isOpen) { return; } log.info(`Closing channel: ${this.channelId}`); // Send close command on a separate connection (hygienic pattern) const closeChannel = async () => { const closeConnection = new (await import('../connection/TheaterConnection.js')).TheaterConnection( (this.client as any).host, (this.client as any).port, (this.client as any).config ); try { await closeConnection.connect(); const command: ManagementCommand = { CloseChannel: { channel_id: this.channelId } }; await closeConnection.send(command); // Don't wait for response, just close } catch (error) { log.error(`Error closing channel: ${error}`); } finally { closeConnection.close(); } }; // Fire and forget the close command closeChannel().catch(error => { log.error(`Failed to send close command: ${error}`); }); // Close our receiving connection this.connection.close(); this.handleClose(); } }