theater-client
Version:
TypeScript client library for Theater actor system TCP protocol
313 lines (263 loc) • 8.74 kB
text/typescript
/**
* 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();
}
}