theater-client
Version:
TypeScript client library for Theater actor system TCP protocol
639 lines (515 loc) • 18.2 kB
text/typescript
/**
* High-level Theater client with hygienic connection management
* Each operation gets its own TCP connection to avoid response multiplexing
*/
import { TheaterConnection } from '../connection/TheaterConnection.js';
import type {
TheaterId,
// ChannelId,
ManagementCommand,
ManagementResponse,
// ManagementError,
ChannelParticipant,
ActorStatus,
ManifestConfig,
ChainEvent
} from '../types/protocol.js';
import type {
TheaterClientConfig,
StartActorParams,
ActorInfo,
ChannelStream,
ActorEventStream,
ActorCallbacks
} from '../types/client.js';
import {
TheaterError,
// TheaterConnectionError,
// TheaterTimeoutError,
TheaterProtocolError
} from '../types/client.js';
import { createLogger } from '../utils/logger.js';
import { uint8ArrayToNumbers, numbersToUint8Array } from '../utils/serialization.js';
import { Actor } from './Actor.js';
// Note: These imports are done dynamically to avoid circular dependencies
// import { ChannelStreamImpl } from './ChannelStream.js';
// import { ActorEventStreamImpl } from './ActorEventStream.js';
const log = createLogger('TheaterClient');
/**
* High-level Theater client that manages operations using hygienic connections
* Each operation gets its own TCP connection to avoid response multiplexing issues
*/
export class TheaterClient {
private readonly host: string;
private readonly port: number;
private readonly config: TheaterClientConfig;
constructor(host: string = '127.0.0.1', port: number = 9000, config: Partial<TheaterClientConfig> = {}) {
this.host = host;
this.port = port;
this.config = {
timeout: 30000,
retryAttempts: 3,
retryDelay: 1000,
...config
};
log.info(`Theater client initialized for ${host}:${port}`);
}
/**
* Create and connect a new Theater connection for a specific operation
*/
private async createConnection(): Promise<TheaterConnection> {
const connection = new TheaterConnection(this.host, this.port, this.config);
await connection.connect();
return connection;
}
/**
* Execute an operation with a dedicated connection and automatic cleanup
*/
private async withConnection<T>(
operation: (conn: TheaterConnection) => Promise<T>
): Promise<T> {
const connection = await this.createConnection();
try {
return await operation(connection);
} finally {
connection.close();
}
}
/**
* Handle error responses from the server
*/
private handleErrorResponse(response: ManagementResponse): never {
if ('Error' in response && response.Error) {
const error = response.Error.error;
let message: string;
if (typeof error === 'string') {
message = error;
} else if (typeof error === 'object' && error !== null) {
const errorObj = error as Record<string, any>;
const errorType = Object.keys(errorObj)[0];
if (errorType) {
const errorValue = errorObj[errorType];
message = typeof errorValue === 'string' ? `${errorType}: ${errorValue}` : errorType;
} else {
message = 'Unknown error object';
}
} else {
message = 'Unknown error';
}
throw new TheaterError(message, 'SERVER_ERROR', error);
}
throw new TheaterProtocolError(`Unexpected response: ${JSON.stringify(response)}`);
}
// ===== ACTOR MANAGEMENT OPERATIONS =====
/**
* Get an Actor wrapper for an existing actor ID
*/
actor(id: TheaterId): Actor {
return new Actor(id, this);
}
/**
* Start a new actor and return an Actor wrapper
*/
async startActor(params: StartActorParams): Promise<Actor> {
const needsSupervision = params.onEvent || params.onError || params.onActorResult;
if (needsSupervision) {
return this.startSupervisedActor(params);
} else {
return this.startRegularActor(params);
}
}
/**
* Start a supervised actor with event streaming
*/
private async startSupervisedActor(params: StartActorParams): Promise<Actor> {
log.info(`Starting supervised actor with manifest: ${params.manifest}`);
// Create dedicated connection for event stream
const eventConnection = await this.createConnection();
try {
const command: ManagementCommand = {
StartActor: {
manifest: params.manifest,
initial_state: params.initialState ? uint8ArrayToNumbers(params.initialState) : null,
parent: !!(params.onActorResult),
subscribe: !!(params.onEvent || params.onError)
}
};
await eventConnection.send(command);
// Wait for ActorStarted, but keep connection open
while (true) {
const response = await eventConnection.receive();
if ('ActorStarted' in response && response.ActorStarted) {
const actorId = response.ActorStarted.id;
log.info(`Supervised actor started: ${actorId}`);
// Return actor with event connection
const callbacks: ActorCallbacks = {};
if (params.onEvent) callbacks.onEvent = params.onEvent;
if (params.onError) callbacks.onError = params.onError;
if (params.onActorResult) callbacks.onActorResult = params.onActorResult;
return new Actor(actorId, this, eventConnection, callbacks);
}
if ('Error' in response) {
eventConnection.close();
this.handleErrorResponse(response);
}
// Ignore other message types and continue waiting
log.debug(`Ignoring response type while starting supervised actor: ${Object.keys(response)[0]}`);
}
} catch (error) {
eventConnection.close();
throw error;
}
}
/**
* Start a regular actor (current behavior)
*/
private async startRegularActor(params: StartActorParams): Promise<Actor> {
const actorId = await this.startActorRaw(params);
return new Actor(actorId, this);
}
/**
* Start a new actor with the given parameters (returns raw ID)
*/
async startActorRaw(params: StartActorParams): Promise<TheaterId> {
log.info(`Starting actor with manifest: ${params.manifest}`);
return this.withConnection(async (conn) => {
const command: ManagementCommand = {
StartActor: {
manifest: params.manifest,
initial_state: params.initialState ? uint8ArrayToNumbers(params.initialState) : null,
parent: false,
subscribe: false
}
};
await conn.send(command);
while (true) {
const response = await conn.receive();
if ('ActorStarted' in response && response.ActorStarted) {
log.info(`Actor started: ${response.ActorStarted.id}`);
return response.ActorStarted.id;
}
if ('Error' in response) {
this.handleErrorResponse(response);
}
// Ignore other message types and continue waiting
log.debug(`Ignoring response type: ${Object.keys(response)[0]}`);
}
});
}
/**
* Stop a running actor
*/
async stopActor(id: TheaterId): Promise<void> {
log.info(`Stopping actor: ${id}`);
return this.withConnection(async (conn) => {
const command: ManagementCommand = {
StopActor: { id }
};
await conn.send(command);
while (true) {
const response = await conn.receive();
if ('ActorStopped' in response && response.ActorStopped) {
log.info(`Actor stopped: ${id}`);
return;
}
if ('Error' in response) {
this.handleErrorResponse(response);
}
log.debug(`Ignoring response type: ${Object.keys(response)[0]}`);
}
});
}
/**
* List all running actors and return Actor wrappers
*/
async listActors(): Promise<Actor[]> {
const actorInfos = await this.listActorsRaw();
return actorInfos.map(info => new Actor(info.id, this));
}
/**
* List all running actors (returns raw actor info)
*/
async listActorsRaw(): Promise<ActorInfo[]> {
log.info('Listing actors');
return this.withConnection(async (conn) => {
const command: ManagementCommand = {
ListActors: {}
};
await conn.send(command);
while (true) {
const response = await conn.receive();
if ('ActorList' in response && response.ActorList) {
const actors = response.ActorList.actors.map(([id, manifest]) => ({
id,
manifest
}));
log.info(`Found ${actors.length} actors`);
return actors;
}
if ('Error' in response) {
this.handleErrorResponse(response);
}
log.debug(`Ignoring response type: ${Object.keys(response)[0]}`);
}
});
}
/**
* Get the status of a specific actor
*/
async getActorStatus(id: TheaterId): Promise<ActorStatus> {
log.info(`Getting status for actor: ${id}`);
return this.withConnection(async (conn) => {
const command: ManagementCommand = {
GetActorStatus: { id }
};
await conn.send(command);
while (true) {
const response = await conn.receive();
if ('ActorStatus' in response && response.ActorStatus) {
log.info(`Got status for actor: ${id}`);
return response.ActorStatus.status;
}
if ('Error' in response) {
this.handleErrorResponse(response);
}
log.debug(`Ignoring response type: ${Object.keys(response)[0]}`);
}
});
}
/**
* Restart an actor
*/
async restartActor(id: TheaterId): Promise<void> {
log.info(`Restarting actor: ${id}`);
return this.withConnection(async (conn) => {
const command: ManagementCommand = {
RestartActor: { id }
};
await conn.send(command);
while (true) {
const response = await conn.receive();
if ('Restarted' in response && response.Restarted) {
log.info(`Actor restarted: ${id}`);
return;
}
if ('Error' in response) {
this.handleErrorResponse(response);
}
log.debug(`Ignoring response type: ${Object.keys(response)[0]}`);
}
});
}
/**
* Get the manifest configuration of an actor
*/
async getActorManifest(id: TheaterId): Promise<ManifestConfig> {
log.info(`Getting manifest for actor: ${id}`);
return this.withConnection(async (conn) => {
const command: ManagementCommand = {
GetActorManifest: { id }
};
await conn.send(command);
while (true) {
const response = await conn.receive();
if ('ActorManifest' in response && response.ActorManifest) {
log.info(`Got manifest for actor: ${id}`);
return response.ActorManifest.manifest;
}
if ('Error' in response) {
this.handleErrorResponse(response);
}
log.debug(`Ignoring response type: ${Object.keys(response)[0]}`);
}
});
}
/**
* Get the current state of an actor
*/
async getActorState(id: TheaterId): Promise<Uint8Array | null> {
log.info(`Getting state for actor: ${id}`);
return this.withConnection(async (conn) => {
const command: ManagementCommand = {
GetActorState: { id }
};
await conn.send(command);
while (true) {
const response = await conn.receive();
if ('ActorState' in response && response.ActorState) {
log.info(`Got state for actor: ${id}`);
return response.ActorState.state ? numbersToUint8Array(response.ActorState.state) : null;
}
if ('Error' in response) {
this.handleErrorResponse(response);
}
log.debug(`Ignoring response type: ${Object.keys(response)[0]}`);
}
});
}
/**
* Get the event history for an actor
*/
async getActorEvents(id: TheaterId): Promise<ChainEvent[]> {
log.info(`Getting events for actor: ${id}`);
return this.withConnection(async (conn) => {
const command: ManagementCommand = {
GetActorEvents: { id }
};
await conn.send(command);
while (true) {
const response = await conn.receive();
if ('ActorEvents' in response && response.ActorEvents) {
log.info(`Got ${response.ActorEvents.events.length} events for actor: ${id}`);
return response.ActorEvents.events;
}
if ('Error' in response) {
this.handleErrorResponse(response);
}
log.debug(`Ignoring response type: ${Object.keys(response)[0]}`);
}
});
}
/**
* Get metrics for an actor
*/
async getActorMetrics(id: TheaterId): Promise<any> {
log.info(`Getting metrics for actor: ${id}`);
return this.withConnection(async (conn) => {
const command: ManagementCommand = {
GetActorMetrics: { id }
};
await conn.send(command);
while (true) {
const response = await conn.receive();
if ('ActorMetrics' in response && response.ActorMetrics) {
log.info(`Got metrics for actor: ${id}`);
return response.ActorMetrics.metrics;
}
if ('Error' in response) {
this.handleErrorResponse(response);
}
log.debug(`Ignoring response type: ${Object.keys(response)[0]}`);
}
});
}
// ===== MESSAGING OPERATIONS =====
/**
* Send a fire-and-forget message to an actor
*/
async sendActorMessage(id: TheaterId, data: Uint8Array): Promise<void> {
log.info(`Sending message to actor: ${id}`);
return this.withConnection(async (conn) => {
const command: ManagementCommand = {
SendActorMessage: {
id,
data: uint8ArrayToNumbers(data)
}
};
await conn.send(command);
while (true) {
const response = await conn.receive();
if ('SentMessage' in response && response.SentMessage) {
log.info(`Message sent to actor: ${id}`);
return;
}
if ('Error' in response) {
this.handleErrorResponse(response);
}
log.debug(`Ignoring response type: ${Object.keys(response)[0]}`);
}
});
}
/**
* Send a request message to an actor and wait for response
*/
async requestActorMessage(id: TheaterId, data: Uint8Array): Promise<Uint8Array> {
log.info(`Requesting message from actor: ${id}`);
return this.withConnection(async (conn) => {
const command: ManagementCommand = {
RequestActorMessage: {
id,
data: uint8ArrayToNumbers(data)
}
};
await conn.send(command);
while (true) {
const response = await conn.receive();
if ('RequestedMessage' in response && response.RequestedMessage) {
log.info(`Got response from actor: ${id}`);
return numbersToUint8Array(response.RequestedMessage.message);
}
if ('Error' in response) {
this.handleErrorResponse(response);
}
log.debug(`Ignoring response type: ${Object.keys(response)[0]}`);
}
});
}
// ===== CHANNEL OPERATIONS =====
/**
* Open a communication channel with an actor
*/
async openChannel(participant: ChannelParticipant, initialMessage?: Uint8Array): Promise<ChannelStream> {
log.info(`Opening channel with participant: ${JSON.stringify(participant)}`);
// Channels need long-lived connections, so we don't use withConnection here
const connection = await this.createConnection();
const command: ManagementCommand = {
OpenChannel: {
actor_id: participant,
initial_message: initialMessage ? uint8ArrayToNumbers(initialMessage) : []
}
};
await connection.send(command);
// Wait for channel to open
while (true) {
const response = await connection.receive();
if ('ChannelOpened' in response && response.ChannelOpened) {
log.info(`Channel opened: ${response.ChannelOpened.channel_id}`);
// Dynamically import to avoid circular dependencies
const { ChannelStreamImpl } = await import('./ChannelStream.js');
return new ChannelStreamImpl(
response.ChannelOpened.channel_id,
connection,
this
);
}
if ('Error' in response) {
connection.close();
this.handleErrorResponse(response);
}
log.debug(`Ignoring response type while waiting for channel: ${Object.keys(response)[0]}`);
}
}
// ===== SUBSCRIPTION OPERATIONS =====
/**
* Subscribe to events from an actor
*/
async subscribeToActor(id: TheaterId): Promise<ActorEventStream> {
log.info(`Subscribing to actor events: ${id}`);
// Subscriptions need long-lived connections, so we don't use withConnection here
const connection = await this.createConnection();
const command: ManagementCommand = {
SubscribeToActor: { id }
};
await connection.send(command);
// Wait for subscription confirmation
while (true) {
const response = await connection.receive();
if ('Subscribed' in response && response.Subscribed) {
log.info(`Subscribed to actor: ${id} with subscription ID: ${response.Subscribed.subscription_id}`);
// Dynamically import to avoid circular dependencies
const { ActorEventStreamImpl } = await import('./ActorEventStream.js');
return new ActorEventStreamImpl(
id,
response.Subscribed.subscription_id,
connection,
this
);
}
if ('Error' in response) {
connection.close();
this.handleErrorResponse(response);
}
log.debug(`Ignoring response type while waiting for subscription: ${Object.keys(response)[0]}`);
}
}
}