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