@comake/skl-js-engine
Version:
Standard Knowledge Language Javascript Engine
309 lines (266 loc) • 10.3 kB
text/typescript
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'
);
}
}