UNPKG

theater-client

Version:

TypeScript client library for Theater actor system TCP protocol

639 lines (515 loc) 18.2 kB
/** * 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]}`); } } }