/** * JSON-RPC over stdio transport for LSP. * * Handles Content-Length framing, request/response matching, and notification dispatch. */ import { spawn, type ChildProcess } from 'node:child_process'; import type { JsonRpcMessage } from './types'; type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void; timer: ReturnType; }; type NotificationHandler = (method: string, params: unknown) => void; type ServerRequestHandler = (id: number | string, method: string, params: unknown) => void; export class LspConnection { private process: ChildProcess | null = null; private buffer = Buffer.alloc(0); private nextId = 1; private pending = new Map(); private onNotification: NotificationHandler = () => {}; private onServerRequest: ServerRequestHandler = () => {}; private onExit: ((code: number | null) => void) | null = null; private onStderr: ((text: string) => void) | null = null; private disposed = false; constructor( private command: string, private args: string[], private options?: { cwd?: string; env?: Record }, ) {} // ── Lifecycle ───────────────────────────────────────────────────────── spawn(): void { if (this.process) return; this.process = spawn(this.command, this.args, { stdio: ['pipe', 'pipe', 'pipe'], cwd: this.options?.cwd, env: { ...process.env, ...this.options?.env }, shell: process.platform === 'win32', }); this.process.stdout!.on('data', (chunk: Buffer) => { this.buffer = Buffer.concat([this.buffer, chunk]); this.drainBuffer(); }); this.process.stderr!.on('data', (chunk: Buffer) => { this.onStderr?.(chunk.toString('utf8')); }); this.process.on('exit', (code) => { this.rejectAllPending('Server process exited'); this.onExit?.(code); }); this.process.on('error', (err) => { this.rejectAllPending(`Server process error: ${err.message}`); }); } dispose(): void { if (this.disposed) return; this.disposed = true; this.rejectAllPending('Connection disposed'); if (this.process) { this.process.stdin?.end(); this.process.kill(); this.process = null; } } get alive(): boolean { return this.process !== null && !this.disposed && this.process.exitCode === null; } // ── Handlers ────────────────────────────────────────────────────────── setNotificationHandler(handler: NotificationHandler): void { this.onNotification = handler; } setServerRequestHandler(handler: ServerRequestHandler): void { this.onServerRequest = handler; } setExitHandler(handler: (code: number | null) => void): void { this.onExit = handler; } setStderrHandler(handler: (text: string) => void): void { this.onStderr = handler; } // ── Sending ─────────────────────────────────────────────────────────── sendRequest(method: string, params: unknown, timeoutMs = 30_000): Promise { if (!this.alive) return Promise.reject(new Error('Connection not alive')); const id = this.nextId++; return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`LSP request '${method}' timed out after ${timeoutMs}ms`)); }, timeoutMs); this.pending.set(id, { resolve, reject, timer }); this.writeMessage({ jsonrpc: '2.0', id, method, params }); }); } sendNotification(method: string, params: unknown): void { if (!this.alive) return; this.writeMessage({ jsonrpc: '2.0', method, params }); } sendResponse(id: number | string, result: unknown): void { if (!this.alive) return; this.writeMessage({ jsonrpc: '2.0', id, result } as unknown as JsonRpcMessage); } // ── Framing ─────────────────────────────────────────────────────────── private writeMessage(message: JsonRpcMessage): void { const body = JSON.stringify(message); const length = Buffer.byteLength(body, 'utf8'); const header = `Content-Length: ${length}\r\n\r\n`; this.process?.stdin?.write(header + body); } private drainBuffer(): void { const HEADER_DELIMITER = '\r\n\r\n'; while (true) { const headerEnd = this.buffer.indexOf(HEADER_DELIMITER); if (headerEnd === -1) break; const headerText = this.buffer.subarray(0, headerEnd).toString('ascii'); const match = headerText.match(/Content-Length:\s*(\d+)/i); if (!match) { // Malformed header — skip past it this.buffer = this.buffer.subarray(headerEnd + HEADER_DELIMITER.length); continue; } const contentLength = parseInt(match[1], 10); const bodyStart = headerEnd + HEADER_DELIMITER.length; const bodyEnd = bodyStart + contentLength; if (this.buffer.length < bodyEnd) break; // Incomplete body const body = this.buffer.subarray(bodyStart, bodyEnd).toString('utf8'); this.buffer = this.buffer.subarray(bodyEnd); try { this.handleMessage(JSON.parse(body) as JsonRpcMessage); } catch { // Malformed JSON — skip } } } private handleMessage(message: JsonRpcMessage): void { const msg = message as unknown as Record; // Response to one of our requests if (msg.id !== undefined && (msg.result !== undefined || msg.error !== undefined)) { const pending = this.pending.get(msg.id as number); if (pending) { clearTimeout(pending.timer); this.pending.delete(msg.id as number); if (msg.error) { const err = msg.error as { code: number; message: string }; pending.reject(new Error(`LSP error ${err.code}: ${err.message}`)); } else { pending.resolve(msg.result); } } return; } // Notification or server-initiated request if (typeof msg.method === 'string') { if (msg.id !== undefined) { // Server request — needs a response this.onServerRequest(msg.id as number, msg.method, msg.params); } else { // Notification this.onNotification(msg.method, msg.params); } } } private rejectAllPending(reason: string): void { for (const [id, pending] of this.pending) { clearTimeout(pending.timer); pending.reject(new Error(reason)); this.pending.delete(id); } } }