UNPKG

@comake/skl-js-engine

Version:

Standard Knowledge Language Javascript Engine

309 lines (266 loc) 10.3 kB
import { Logger } from '../../../logger'; import type { ClientTransport } from '../../jsonRpc/JsonRpcClient'; import { JsonRpcClient } from '../../jsonRpc/JsonRpcClient'; import type { JsonRpcServer } from '../../jsonRpc/JsonRpcServer'; import type { LogNotification, StatusRequest, StatusResponse } from '../../jsonRpc/types'; import { STANDARD_METHODS } from '../../jsonRpc/types'; import type { ExecutionOptions } from '../../types'; import { BaseTransport } from '../base/BaseTransport'; import { ProcessManager } from '../process/ProcessManager'; import type { TransportConfig } from '../Transport'; import { TransportStatus } from '../Transport'; import { isResponse, MessageBuffer } from '../utils/MessageUtils'; import { pollUntilSuccess } from '../utils/PollingUtils'; /** * Client transport implementation for stdio communication */ export class StdioClientTransport implements ClientTransport { private messageHandler?: (message: string) => void; private name?: string; private readonly logger = Logger.getInstance(); public constructor(private readonly processManager: ProcessManager) {} public setName(name: string): void { this.name = name; this.logger.setMetadata({ name, transport: 'StdioClientTransport' }); } public async send(message: string): Promise<void> { this.logger.log('Sending message', message); this.processManager.write(`${message}\n`); } public onMessage(handler: (message: string) => void): void { this.messageHandler = handler; // Note: Message handling is done in the main ParentStdioTransport class // This is here for interface compliance } public async close(): Promise<void> { this.messageHandler = undefined; } } /** * StdioTransport implementation for parent process communication with child * This is the parent-side transport that manages a child process */ export class ParentStdioTransport extends BaseTransport { private readonly processManager = new ProcessManager(); private readonly server: JsonRpcServer; private readonly client: JsonRpcClient; private readonly clientTransport: StdioClientTransport; private readonly executorScriptPath: string; private readonly messageBuffer = new MessageBuffer(); public constructor(executorScriptPath: string, server: JsonRpcServer) { super(); this.executorScriptPath = executorScriptPath; this.server = server; this.client = new JsonRpcClient({ // 1 hour defaultTimeout: 60 * 60 * 1000 }); this.clientTransport = new StdioClientTransport(this.processManager); this.setupServerMethods(); this.setupEventHandlers(); } /** * Initialize the transport connection */ public async initialize(config?: TransportConfig, executionOptions?: ExecutionOptions): Promise<void> { if (this.internalStatus !== TransportStatus.disconnected) { throw new Error(`Cannot initialize transport in ${this.internalStatus} state`); } try { this.setStatus(TransportStatus.connecting); // Set up client transport this.client.setTransport(this.clientTransport); // Spawn the process // Use spawnTimeout if provided, otherwise fall back to timeout or default const spawnTimeout = config?.spawnTimeout ?? config?.timeout ?? 3000; await this.processManager.spawn({ scriptPath: this.executorScriptPath, executionOptions, startupTimeout: spawnTimeout }); // Set up process communication this.setupProcessCommunication(); // Wait for process to be ready // Use readyTimeout if provided, otherwise fall back to timeout or default const readyTimeout = config?.readyTimeout ?? config?.timeout ?? 10000; await this.waitForReady(readyTimeout); this.setStatus(TransportStatus.connected); } catch (error: unknown) { this.setStatus(TransportStatus.error); throw error; } } /** * Send a message through the transport */ public async send<TRequest, TResponse>(message: TRequest): Promise<TResponse> { if (!this.isReady()) { throw new Error('Transport is not ready'); } // Expect message to have method and params for direct RPC calls const rpcMessage = message as any; if (rpcMessage.method) { return this.client.request(rpcMessage.method, rpcMessage.params); } throw new Error('Message must have a method property for RPC calls'); } /** * Make a direct RPC request to the child process */ public async request<TParams = any, TResult = any>( method: string, params?: TParams, options?: { timeout?: number; retries?: number } ): Promise<TResult> { if (!this.isReady()) { throw new Error('Transport is not ready'); } return this.client.request<TParams, TResult>(method, params, options); } /** * Send a notification to the child process (no response expected) */ public async notify<TParams = any>(method: string, params?: TParams): Promise<void> { if (!this.isReady()) { throw new Error('Transport is not ready'); } return this.client.notify(method, params); } /** * Close the transport connection */ public async close(): Promise<void> { if (this.internalStatus === TransportStatus.disconnected) { return; } this.setStatus(TransportStatus.disconnected); // Close JSON-RPC client await this.client.close(); // Terminate child process await this.processManager.terminate(); } /** * Check if the transport is ready for communication */ public isReady(): boolean { return this.internalStatus === TransportStatus.connected && this.processManager.isRunning(); } /** * Set up server methods for handling incoming requests from Deno process */ private setupServerMethods(): void { // Handle status requests this.server.registerMethod<StatusRequest, StatusResponse>(STANDARD_METHODS.getStatus, async() => ({ status: 'ready', uptime: process.uptime() * 1000, memoryUsage: { used: process.memoryUsage().heapUsed, total: process.memoryUsage().heapTotal } })); // Handle ping requests this.server.registerMethod(STANDARD_METHODS.ping, async() => 'pong'); // Handle log notifications this.server.registerMethod<LogNotification, void>(STANDARD_METHODS.log, async params => { if (this.messageHandler) { await this.messageHandler(params); } }); } /** * Set up event handlers */ private setupEventHandlers(): void { this.server.on('error', error => { this.emit('error', error); }); this.client.on('error', error => { this.emit('error', error); }); this.processManager.on('error', error => { this.emit('error', error); }); this.processManager.on('exit', (code, signal) => { if (this.internalStatus === TransportStatus.connected) { const error = new Error(`Process exited unexpectedly (code: ${code}, signal: ${signal})`); this.setStatus(TransportStatus.error); this.emit('error', error); } }); } /** * Set up process communication handlers */ private setupProcessCommunication(): void { // Handle stdout data (responses from Deno process) this.processManager.on('stdout', (data: Buffer) => { const messages = this.messageBuffer.addData(data.toString()); for (const messageData of messages) { this.logger.log('Received message', messageData); this.handleIncomingMessage(messageData).catch(error => { this.emit('error', error); }); } }); // Handle stderr data (log messages and errors from Deno process) this.processManager.on('stderr', (data: Buffer) => { const stderrOutput = data.toString().trim(); // Only treat messages starting with "ERROR:" as actual errors if (stderrOutput.startsWith('ERROR:')) { this.emit('error', new Error(`Deno process error: ${stderrOutput}`)); } else { // Log normal stderr output as debug information this.logger.log('Child process log', stderrOutput); } }); } /** * Handle incoming message from Deno process with bidirectional routing */ private async handleIncomingMessage(messageData: string): Promise<void> { try { const message = JSON.parse(messageData); // Check if this is a response to our request (has 'result' or 'error' and 'id') if (isResponse(message)) { // This is a response to a request we made - route to client this.logger.log('Routing response to parent client', { id: message.id }); await this.client.handleIncomingMessage(messageData); } else { // This is a request or notification for us - route to server this.logger.log('Routing request to parent server', { method: message.method, id: message.id }); const response = await this.server.processMessage(messageData); this.logger.log('Sending response to parent process', response); // Only send response if there is one (requests get responses, notifications don't) if (response) { const responseData = `${JSON.stringify(response)}\n`; this.processManager.write(responseData); } } } catch (error: unknown) { // Silently ignore invalid JSON messages (likely log output from child process) if (error instanceof SyntaxError && error.message.includes('JSON')) { this.logger.log('Ignoring invalid JSON message', { message: messageData }); return; } // Re-throw other types of errors this.emit('error', new Error(`Failed to handle incoming message: ${(error as Error).message}`)); } } /** * Wait for the process to be ready */ private async waitForReady(timeout: number): Promise<void> { // Use a reasonable ping timeout (1/4 of total timeout, min 500ms, max 2000ms) const pingTimeout = Math.max(500, Math.min(2000, timeout / 4)); await pollUntilSuccess( () => this.client.request(STANDARD_METHODS.ping, undefined, { timeout: pingTimeout }), { timeout, interval: 100, // Let process start before first ping initialDelay: 100 }, 'Transport initialization timed out' ); } }