Raw
1 import { EventEmitter } from "events";
2 import { Readable, Writable } from "stream";
3
4 /**
5 * A generic JSON-RPC 2.0 peer over newline-delimited JSON (ndjson).
6 *
7 * Reads from a readable stream (the agent's stdout) and writes to a writable
8 * stream (the agent's stdin). Both incoming requests/notifications and our own
9 * outbound requests/notifications are supported, so this is a symmetric peer
10 * rather than a one-directional client.
11 */
12
13 type JsonValue =
14 | null
15 | boolean
16 | number
17 | string
18 | JsonValue[]
19 | { [key: string]: JsonValue };
20
21 interface JsonRpcRequest {
22 jsonrpc: "2.0";
23 id: number | string;
24 method: string;
25 params?: unknown;
26 }
27
28 interface JsonRpcNotification {
29 jsonrpc: "2.0";
30 method: string;
31 params?: unknown;
32 }
33
34 interface JsonRpcResponse {
35 jsonrpc: "2.0";
36 id: number | string;
37 result?: unknown;
38 error?: JsonRpcError;
39 }
40
41 interface JsonRpcError {
42 code: number;
43 message: string;
44 data?: unknown;
45 }
46
47 type RequestHandler = (params: unknown) => unknown | Promise<unknown>;
48 type NotificationHandler = (params: unknown) => void;
49
50 interface PendingRequest {
51 resolve: (value: unknown) => void;
52 reject: (reason: unknown) => void;
53 }
54
55 export class Connection extends EventEmitter {
56 private readonly writable: Writable;
57 private readonly pending = new Map<number | string, PendingRequest>();
58 private readonly requestHandlers = new Map<string, RequestHandler>();
59 private readonly notificationHandlers = new Map<string, NotificationHandler>();
60 private buffer = "";
61 private nextId = 1;
62 private disposed = false;
63
64 /** Cap on buffered bytes awaiting a newline, so a misbehaving agent can't grow memory unboundedly. */
65 private static readonly MAX_BUFFER_LENGTH = 16 * 1024 * 1024;
66
67 constructor(readable: Readable, writable: Writable) {
68 super();
69 this.writable = writable;
70 readable.on("data", (chunk: Buffer | string) => this.onData(chunk.toString()));
71 readable.on("error", (err) => this.emit("error", err));
72 readable.on("close", () => {
73 // The agent is gone; unblock every in-flight request instead of hanging.
74 this.failPending(new Error("Agent closed the connection"));
75 this.emit("close");
76 });
77 }
78
79 /** Send a request and resolve with its result (or reject on error). */
80 request<T = unknown>(method: string, params?: unknown): Promise<T> {
81 if (this.disposed) {
82 return Promise.reject(new Error("Connection disposed"));
83 }
84 const id = this.nextId++;
85 const payload: JsonRpcRequest = { jsonrpc: "2.0", id, method, params };
86 return new Promise<T>((resolve, reject) => {
87 this.pending.set(id, {
88 resolve: resolve as (value: unknown) => void,
89 reject
90 });
91 this.send(payload);
92 });
93 }
94
95 /** Send a fire-and-forget notification. */
96 notify(method: string, params?: unknown): void {
97 if (this.disposed) {
98 return;
99 }
100 const payload: JsonRpcNotification = { jsonrpc: "2.0", method, params };
101 this.send(payload);
102 }
103
104 /** Register a handler for inbound requests of a given method. */
105 onRequest(method: string, handler: RequestHandler): void {
106 this.requestHandlers.set(method, handler);
107 }
108
109 /** Register a handler for inbound notifications of a given method. */
110 onNotification(method: string, handler: NotificationHandler): void {
111 this.notificationHandlers.set(method, handler);
112 }
113
114 dispose(): void {
115 if (this.disposed) {
116 return;
117 }
118 this.disposed = true;
119 this.failPending(new Error("Connection disposed"));
120 this.requestHandlers.clear();
121 this.notificationHandlers.clear();
122 this.removeAllListeners();
123 }
124
125 private failPending(err: Error): void {
126 for (const { reject } of this.pending.values()) {
127 reject(err);
128 }
129 this.pending.clear();
130 }
131
132 private send(message: JsonValue | JsonRpcRequest | JsonRpcNotification | JsonRpcResponse): void {
133 try {
134 this.writable.write(JSON.stringify(message) + "\n");
135 } catch (err) {
136 this.emit("error", err);
137 }
138 }
139
140 private onData(text: string): void {
141 this.buffer += text;
142 if (this.buffer.length > Connection.MAX_BUFFER_LENGTH && !this.buffer.includes("\n")) {
143 this.buffer = "";
144 this.emit(
145 "error",
146 new Error(`Agent sent more than ${Connection.MAX_BUFFER_LENGTH} bytes without a newline; discarding`)
147 );
148 return;
149 }
150 let newlineIndex: number;
151 while ((newlineIndex = this.buffer.indexOf("\n")) !== -1) {
152 const line = this.buffer.slice(0, newlineIndex).trim();
153 this.buffer = this.buffer.slice(newlineIndex + 1);
154 if (line.length === 0) {
155 continue;
156 }
157 this.handleLine(line);
158 }
159 }
160
161 private handleLine(line: string): void {
162 let message: JsonRpcRequest | JsonRpcResponse | JsonRpcNotification;
163 try {
164 message = JSON.parse(line);
165 } catch (err) {
166 // Truncate: the line is agent output and can be arbitrarily large.
167 this.emit("error", new Error(`Failed to parse message: ${line.slice(0, 256)}`));
168 return;
169 }
170
171 if ("id" in message && ("result" in message || "error" in message)) {
172 this.handleResponse(message as JsonRpcResponse);
173 } else if ("method" in message && "id" in message) {
174 void this.handleRequest(message as JsonRpcRequest);
175 } else if ("method" in message) {
176 this.handleNotification(message as JsonRpcNotification);
177 } else {
178 this.emit("error", new Error(`Unrecognized message: ${line}`));
179 }
180 }
181
182 private handleResponse(message: JsonRpcResponse): void {
183 const pending = this.pending.get(message.id);
184 if (!pending) {
185 return;
186 }
187 this.pending.delete(message.id);
188 if (message.error) {
189 const err = new Error(message.error.message);
190 (err as Error & { code?: number; data?: unknown }).code = message.error.code;
191 (err as Error & { code?: number; data?: unknown }).data = message.error.data;
192 pending.reject(err);
193 } else {
194 pending.resolve(message.result);
195 }
196 }
197
198 private async handleRequest(message: JsonRpcRequest): Promise<void> {
199 const handler = this.requestHandlers.get(message.method);
200 if (!handler) {
201 this.send({
202 jsonrpc: "2.0",
203 id: message.id,
204 error: { code: -32601, message: `Method not found: ${message.method}` }
205 });
206 return;
207 }
208 try {
209 const result = await handler(message.params);
210 this.send({ jsonrpc: "2.0", id: message.id, result: (result ?? null) as JsonValue });
211 } catch (err) {
212 const error = err as Error & { code?: number; data?: unknown };
213 this.send({
214 jsonrpc: "2.0",
215 id: message.id,
216 error: {
217 code: typeof error.code === "number" ? error.code : -32603,
218 message: error.message ?? "Internal error",
219 data: error.data
220 }
221 });
222 }
223 }
224
225 private handleNotification(message: JsonRpcNotification): void {
226 const handler = this.notificationHandlers.get(message.method);
227 if (handler) {
228 try {
229 handler(message.params);
230 } catch (err) {
231 this.emit("error", err);
232 }
233 }
234 }
235 }