@triflux/remote 10.28.1 → 10.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,299 @@
1
+ // hub/workers/lib/jsonrpc-core.mjs
2
+ // Shared JSON-RPC request/notification dispatch core for transport clients.
3
+
4
+ /**
5
+ * Thrown when the peer emits a malformed JSON-RPC frame.
6
+ */
7
+ export class JsonRpcProtocolError extends Error {
8
+ /** @param {string} message @param {{ cause?: unknown }} [options] */
9
+ constructor(message, options = {}) {
10
+ super(message, { cause: options.cause });
11
+ this.name = "JsonRpcProtocolError";
12
+ }
13
+ }
14
+
15
+ export class JsonRpcDispatchBase {
16
+ /**
17
+ * @param {object} options
18
+ * @param {(err: Error) => void} [options.onError] Protocol error sink.
19
+ * @param {string} options.closedMessage Error message for closed requests.
20
+ */
21
+ constructor({ onError, closedMessage }) {
22
+ this._onError = typeof onError === "function" ? onError : null;
23
+ this._closedMessage = closedMessage;
24
+ /** @type {'idle'|'running'|'closing'|'closed'} */
25
+ this._state = "closed";
26
+ this._nextRequestId = 1;
27
+ /** @type {Map<number, { resolve: Function, reject: Function, timer: any, method: string }>} */
28
+ this._pendingRequests = new Map();
29
+ /** @type {Map<string, Set<Function>>} */
30
+ this._notificationHandlers = new Map();
31
+ }
32
+
33
+ /**
34
+ * Issue a JSON-RPC request and resolve with the server's `result`.
35
+ * Rejects on error response, timeout, malformed payload, or close().
36
+ * @param {string} method
37
+ * @param {unknown} params
38
+ * @param {number} [timeoutMs=60000]
39
+ * @returns {Promise<any>}
40
+ */
41
+ request(method, params, timeoutMs = 60000) {
42
+ if (this._state !== "running") {
43
+ return Promise.reject(new Error(this._closedMessage));
44
+ }
45
+
46
+ const id = this._nextRequestId++;
47
+ // P1 #1 wire framing: omit `jsonrpc: "2.0"` on outbound. Peer decode remains
48
+ // lenient (OpenAI App Server JSONL variant spec).
49
+ const frame = { id, method };
50
+ if (params !== undefined) frame.params = params;
51
+
52
+ return new Promise((resolve, reject) => {
53
+ let timer = null;
54
+ if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
55
+ timer = setTimeout(() => {
56
+ const pending = this._pendingRequests.get(id);
57
+ if (!pending) return;
58
+ this._pendingRequests.delete(id);
59
+ reject(
60
+ new Error(
61
+ `JSON-RPC request timed out after ${timeoutMs}ms: ${method}`,
62
+ ),
63
+ );
64
+ }, timeoutMs);
65
+ }
66
+
67
+ this._pendingRequests.set(id, { resolve, reject, timer, method });
68
+
69
+ try {
70
+ this._encodeAndSend(frame);
71
+ } catch (err) {
72
+ this._pendingRequests.delete(id);
73
+ if (timer) clearTimeout(timer);
74
+ reject(err);
75
+ }
76
+ });
77
+ }
78
+
79
+ /**
80
+ * Send a JSON-RPC notification (no id, no response expected).
81
+ * Silently drops if the client is not in `running`.
82
+ * @param {string} method
83
+ * @param {unknown} [params]
84
+ */
85
+ notify(method, params) {
86
+ if (this._state !== "running") return;
87
+ // P1 #1 wire framing: omit jsonrpc header (outbound).
88
+ const frame = { method };
89
+ if (params !== undefined) frame.params = params;
90
+ try {
91
+ this._encodeAndSend(frame);
92
+ } catch (err) {
93
+ this._emitError(err instanceof Error ? err : new Error(String(err)));
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Subscribe to inbound notifications. Use `"*"` for a catch-all handler
99
+ * which receives `(params, method)`. Targeted handlers receive `(params)`.
100
+ * @param {string} method
101
+ * @param {(params: any, method?: string) => void} callback
102
+ * @returns {() => void} unsubscribe
103
+ */
104
+ onNotification(method, callback) {
105
+ if (typeof callback !== "function") {
106
+ throw new TypeError("onNotification requires a callback function");
107
+ }
108
+ let set = this._notificationHandlers.get(method);
109
+ if (!set) {
110
+ set = new Set();
111
+ this._notificationHandlers.set(method, set);
112
+ }
113
+ set.add(callback);
114
+ return () => {
115
+ const handlers = this._notificationHandlers.get(method);
116
+ if (!handlers) return;
117
+ handlers.delete(callback);
118
+ if (handlers.size === 0) this._notificationHandlers.delete(method);
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Close the client. `reason === "closing"` transitions to an intermediate
124
+ * graceful state where a subsequent EOF is treated as normal. Idempotent.
125
+ * @param {string} [reason]
126
+ */
127
+ close(reason) {
128
+ if (this._state === "closed") return;
129
+
130
+ if (reason === "closing" && this._state === "running") {
131
+ this._state = "closing";
132
+ this._onEnterClosing();
133
+ return;
134
+ }
135
+ this._closeWith("closed");
136
+ }
137
+
138
+ /**
139
+ * @returns {boolean} True if the client accepts new requests.
140
+ */
141
+ isOpen() {
142
+ return this._state === "running";
143
+ }
144
+
145
+ /**
146
+ * @returns {'idle'|'running'|'closing'|'closed'}
147
+ */
148
+ getState() {
149
+ return this._state;
150
+ }
151
+
152
+ _handleInboundMessage(line) {
153
+ if (this._state === "closed") return;
154
+ if (line.length === 0) return;
155
+
156
+ let frame;
157
+ try {
158
+ frame = JSON.parse(line);
159
+ } catch (err) {
160
+ const pErr = new JsonRpcProtocolError(
161
+ `JSON-RPC parse error: ${err instanceof Error ? err.message : String(err)}`,
162
+ { cause: err },
163
+ );
164
+ this._emitError(pErr);
165
+ // P1 #3 fail-fast: malformed frame during running -> reject in-flight + close
166
+ if (this._state === "running") this._closeWith("closed", pErr);
167
+ return;
168
+ }
169
+
170
+ if (!frame || typeof frame !== "object") {
171
+ const pErr = new JsonRpcProtocolError(
172
+ "JSON-RPC protocol error: frame is not an object",
173
+ );
174
+ this._emitError(pErr);
175
+ if (this._state === "running") this._closeWith("closed", pErr);
176
+ return;
177
+ }
178
+
179
+ // Response: has id + (result | error)
180
+ if (
181
+ Object.hasOwn(frame, "id") &&
182
+ frame.id !== null &&
183
+ (Object.hasOwn(frame, "result") || Object.hasOwn(frame, "error"))
184
+ ) {
185
+ this._dispatchResponse(frame);
186
+ return;
187
+ }
188
+
189
+ /*
190
+ * FUTURE-EXTENSION: server->client requests (inbound frames with both
191
+ * `id` and `method`) can dispatch from this shared routing point later,
192
+ * for example via a `_dispatchServerRequest(frame)` branch, so backlog #1
193
+ * only needs to touch the base. Do not implement that branch here.
194
+ */
195
+
196
+ // Notification: method + no id (or id === null for responses we treated above)
197
+ if (typeof frame.method === "string" && !Object.hasOwn(frame, "id")) {
198
+ this._dispatchNotification(frame);
199
+ return;
200
+ }
201
+
202
+ // Unknown / malformed envelope - surface but keep loop alive during running.
203
+ // Fail-fast only on structural errors (JSON parse, EOF, max-line).
204
+ this._emitError(
205
+ new JsonRpcProtocolError(
206
+ "JSON-RPC protocol error: unrecognized frame shape",
207
+ ),
208
+ );
209
+ }
210
+
211
+ _dispatchResponse(frame) {
212
+ const pending = this._pendingRequests.get(frame.id);
213
+ if (!pending) {
214
+ // Stray response - drop silently (notify() path, or late after timeout).
215
+ return;
216
+ }
217
+ this._pendingRequests.delete(frame.id);
218
+ if (pending.timer) clearTimeout(pending.timer);
219
+
220
+ if (Object.hasOwn(frame, "error") && frame.error) {
221
+ const { code, message, data } = frame.error;
222
+ const err = new Error(
223
+ `JSON-RPC error${typeof code === "number" ? ` ${code}` : ""}: ${message || "unknown"}`,
224
+ );
225
+ if (code !== undefined) err.code = code;
226
+ if (data !== undefined) err.data = data;
227
+ pending.reject(err);
228
+ return;
229
+ }
230
+
231
+ pending.resolve(frame.result);
232
+ }
233
+
234
+ _dispatchNotification(frame) {
235
+ const method = frame.method;
236
+ const params = frame.params;
237
+
238
+ const targeted = this._notificationHandlers.get(method);
239
+ if (targeted && targeted.size > 0) {
240
+ for (const cb of targeted) {
241
+ try {
242
+ cb(params);
243
+ } catch (err) {
244
+ this._emitError(err instanceof Error ? err : new Error(String(err)));
245
+ }
246
+ }
247
+ }
248
+
249
+ const wildcard = this._notificationHandlers.get("*");
250
+ if (wildcard && wildcard.size > 0) {
251
+ for (const cb of wildcard) {
252
+ try {
253
+ cb(params, method);
254
+ } catch (err) {
255
+ this._emitError(err instanceof Error ? err : new Error(String(err)));
256
+ }
257
+ }
258
+ }
259
+ }
260
+
261
+ _closeWith(target, rejectReason = null) {
262
+ if (this._state === "closed") return;
263
+ this._state = target;
264
+
265
+ this._beforeRejectPending();
266
+
267
+ const rejectErr =
268
+ rejectReason instanceof Error
269
+ ? rejectReason
270
+ : new Error(this._closedMessage);
271
+
272
+ for (const [, pending] of this._pendingRequests) {
273
+ if (pending.timer) clearTimeout(pending.timer);
274
+ pending.reject(rejectErr);
275
+ }
276
+ this._pendingRequests.clear();
277
+
278
+ this._teardownTransport();
279
+ }
280
+
281
+ _emitError(err) {
282
+ if (!this._onError) return;
283
+ try {
284
+ this._onError(err);
285
+ } catch {
286
+ // Never throw out of the dispatch loop.
287
+ }
288
+ }
289
+
290
+ _encodeAndSend() {
291
+ throw new Error("JsonRpcDispatchBase._encodeAndSend not implemented");
292
+ }
293
+
294
+ _onEnterClosing() {}
295
+
296
+ _beforeRejectPending() {}
297
+
298
+ _teardownTransport() {}
299
+ }
@@ -27,6 +27,10 @@
27
27
 
28
28
  import { createInterface } from "node:readline";
29
29
 
30
+ import { JsonRpcDispatchBase } from "./jsonrpc-core.mjs";
31
+
32
+ export { JsonRpcProtocolError } from "./jsonrpc-core.mjs";
33
+
30
34
  const DEFAULT_MAX_LINE_SIZE = 1024 * 1024; // 1 MiB
31
35
  const CLOSED_MESSAGE = "JsonRpcStdioClient closed";
32
36
 
@@ -46,18 +50,6 @@ export class MaxLineSizeExceededError extends Error {
46
50
  }
47
51
  }
48
52
 
49
- /**
50
- * Thrown (and used to reject in-flight execute()) when the peer emits a
51
- * malformed frame or the transport layer hits a structural error.
52
- */
53
- export class JsonRpcProtocolError extends Error {
54
- /** @param {string} message @param {{ cause?: unknown }} [options] */
55
- constructor(message, options = {}) {
56
- super(message, { cause: options.cause });
57
- this.name = "JsonRpcProtocolError";
58
- }
59
- }
60
-
61
53
  /**
62
54
  * Thrown when the underlying stream closes unexpectedly (EOF outside `closing`).
63
55
  */
@@ -72,7 +64,7 @@ export class JsonRpcTransportError extends Error {
72
64
  /**
73
65
  * Line-delimited JSON-RPC 2.0 client over a pair of Node streams.
74
66
  */
75
- export class JsonRpcStdioClient {
67
+ export class JsonRpcStdioClient extends JsonRpcDispatchBase {
76
68
  /**
77
69
  * @param {object} options
78
70
  * @param {NodeJS.ReadableStream} options.stdin Server -> client bytes.
@@ -88,9 +80,10 @@ export class JsonRpcStdioClient {
88
80
  throw new TypeError("JsonRpcStdioClient requires a writable stdout");
89
81
  }
90
82
 
83
+ super({ onError, closedMessage: CLOSED_MESSAGE });
84
+
91
85
  this._stdin = stdin;
92
86
  this._stdout = stdout;
93
- this._onError = typeof onError === "function" ? onError : null;
94
87
  this._maxLineSize =
95
88
  Number.isFinite(maxLineSize) && maxLineSize > 0
96
89
  ? maxLineSize
@@ -98,11 +91,6 @@ export class JsonRpcStdioClient {
98
91
 
99
92
  /** @type {'running'|'closing'|'closed'} */
100
93
  this._state = "running";
101
- this._nextRequestId = 1;
102
- /** @type {Map<number, { resolve: Function, reject: Function, timer: any, method: string }>} */
103
- this._pendingRequests = new Map();
104
- /** @type {Map<string, Set<Function>>} */
105
- this._notificationHandlers = new Map();
106
94
 
107
95
  // AC18: track bytes since last newline at the raw stream layer so an
108
96
  // oversized line is rejected *before* readline concatenates it internally.
@@ -124,7 +112,7 @@ export class JsonRpcStdioClient {
124
112
  }
125
113
 
126
114
  this._rl = createInterface({ input: this._stdin, crlfDelay: Infinity });
127
- this._rl.on("line", (line) => this._handleLine(line));
115
+ this._rl.on("line", (line) => this._handleInboundMessage(line));
128
116
  // readline re-emits the input stream 'error' on itself; the raw stdin
129
117
  // handler above already converts it into a JsonRpcTransportError, so
130
118
  // suppress the re-emit to avoid an unhandled 'error' on the Interface.
@@ -146,145 +134,21 @@ export class JsonRpcStdioClient {
146
134
  });
147
135
  }
148
136
 
149
- /**
150
- * Issue a JSON-RPC request and resolve with the server's `result`.
151
- * Rejects on error response, timeout, malformed payload, or close().
152
- * @param {string} method
153
- * @param {unknown} params
154
- * @param {number} [timeoutMs=60000]
155
- * @returns {Promise<any>}
156
- */
157
- request(method, params, timeoutMs = 60000) {
158
- if (this._state !== "running") {
159
- return Promise.reject(new Error(CLOSED_MESSAGE));
160
- }
161
-
162
- const id = this._nextRequestId++;
163
- // P1 #1 wire framing: omit `jsonrpc: "2.0"` on outbound. Peer decode remains
164
- // lenient (OpenAI App Server JSONL variant spec).
165
- const frame = { id, method };
166
- if (params !== undefined) frame.params = params;
167
-
168
- return new Promise((resolve, reject) => {
169
- let timer = null;
170
- if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
171
- timer = setTimeout(() => {
172
- const pending = this._pendingRequests.get(id);
173
- if (!pending) return;
174
- this._pendingRequests.delete(id);
175
- reject(
176
- new Error(
177
- `JSON-RPC request timed out after ${timeoutMs}ms: ${method}`,
178
- ),
179
- );
180
- }, timeoutMs);
181
- }
182
-
183
- this._pendingRequests.set(id, { resolve, reject, timer, method });
184
-
185
- try {
186
- this._writeFrame(frame);
187
- } catch (err) {
188
- this._pendingRequests.delete(id);
189
- if (timer) clearTimeout(timer);
190
- reject(err);
191
- }
192
- });
193
- }
137
+ // --- internals ---------------------------------------------------------
194
138
 
195
- /**
196
- * Send a JSON-RPC notification (no id, no response expected).
197
- * Silently drops if the client is not in `running`.
198
- * @param {string} method
199
- * @param {unknown} [params]
200
- */
201
- notify(method, params) {
202
- if (this._state !== "running") return;
203
- // P1 #1 wire framing: omit jsonrpc header (outbound).
204
- const frame = { method };
205
- if (params !== undefined) frame.params = params;
139
+ _encodeAndSend(frame) {
140
+ if (this._state === "closed") return;
141
+ const line = `${JSON.stringify(frame)}\n`;
206
142
  try {
207
- this._writeFrame(frame);
143
+ this._stdout.write(line, (err) => {
144
+ if (err) this._handleStreamError("stdout-write", err);
145
+ });
208
146
  } catch (err) {
209
- this._emitError(err);
210
- }
211
- }
212
-
213
- /**
214
- * Subscribe to inbound notifications. Use `"*"` for a catch-all handler
215
- * which receives `(params, method)`. Targeted handlers receive `(params)`.
216
- * @param {string} method
217
- * @param {(params: any, method?: string) => void} callback
218
- * @returns {() => void} unsubscribe
219
- */
220
- onNotification(method, callback) {
221
- if (typeof callback !== "function") {
222
- throw new TypeError("onNotification requires a callback function");
223
- }
224
- let set = this._notificationHandlers.get(method);
225
- if (!set) {
226
- set = new Set();
227
- this._notificationHandlers.set(method, set);
228
- }
229
- set.add(callback);
230
- return () => {
231
- const handlers = this._notificationHandlers.get(method);
232
- if (!handlers) return;
233
- handlers.delete(callback);
234
- if (handlers.size === 0) this._notificationHandlers.delete(method);
235
- };
236
- }
237
-
238
- /**
239
- * Close the client: reject all pending requests, stop tracking input,
240
- * and release the readline interface. Idempotent.
241
- *
242
- * Optional `reason` = `"closing"` transitions to the intermediate `closing`
243
- * state *without* terminating the readline loop, so a graceful shutdown can
244
- * issue a final request (e.g. `thread/unsubscribe`) before EOF triggers
245
- * full closure. Any subsequent EOF in `closing` is treated as normal.
246
- *
247
- * @param {string} [reason]
248
- */
249
- close(reason) {
250
- if (this._state === "closed") return;
251
-
252
- if (reason === "closing" && this._state === "running") {
253
- this._state = "closing";
254
- return;
147
+ this._handleStreamError("stdout-write", err);
255
148
  }
256
- this._closeWith("closed");
257
- }
258
-
259
- /**
260
- * @returns {boolean} True if the client accepts new requests.
261
- */
262
- isOpen() {
263
- return this._state === "running";
264
- }
265
-
266
- /**
267
- * @returns {'running'|'closing'|'closed'}
268
- */
269
- getState() {
270
- return this._state;
271
149
  }
272
150
 
273
- // --- internals ---------------------------------------------------------
274
-
275
- _closeWith(target, rejectReason = null) {
276
- if (this._state === "closed") return;
277
- this._state = target;
278
-
279
- const rejectErr =
280
- rejectReason instanceof Error ? rejectReason : new Error(CLOSED_MESSAGE);
281
-
282
- for (const [, pending] of this._pendingRequests) {
283
- if (pending.timer) clearTimeout(pending.timer);
284
- pending.reject(rejectErr);
285
- }
286
- this._pendingRequests.clear();
287
-
151
+ _teardownTransport() {
288
152
  try {
289
153
  this._stdin.off?.("data", this._onStdinData);
290
154
  } catch {
@@ -297,18 +161,6 @@ export class JsonRpcStdioClient {
297
161
  }
298
162
  }
299
163
 
300
- _writeFrame(frame) {
301
- if (this._state === "closed") return;
302
- const line = `${JSON.stringify(frame)}\n`;
303
- try {
304
- this._stdout.write(line, (err) => {
305
- if (err) this._handleStreamError("stdout-write", err);
306
- });
307
- } catch (err) {
308
- this._handleStreamError("stdout-write", err);
309
- }
310
- }
311
-
312
164
  /**
313
165
  * Convert a raw stream error into a JsonRpcTransportError, emit it to the
314
166
  * error sink, and close the client so pending requests are rejected.
@@ -349,115 +201,4 @@ export class JsonRpcStdioClient {
349
201
  }
350
202
  }
351
203
  }
352
-
353
- _handleLine(line) {
354
- if (this._state === "closed") return;
355
- if (line.length === 0) return;
356
-
357
- let frame;
358
- try {
359
- frame = JSON.parse(line);
360
- } catch (err) {
361
- const pErr = new JsonRpcProtocolError(
362
- `JSON-RPC parse error: ${err instanceof Error ? err.message : String(err)}`,
363
- { cause: err },
364
- );
365
- this._emitError(pErr);
366
- // P1 #3 fail-fast: malformed frame during running → reject in-flight + close
367
- if (this._state === "running") this._closeWith("closed", pErr);
368
- return;
369
- }
370
-
371
- if (!frame || typeof frame !== "object") {
372
- const pErr = new JsonRpcProtocolError(
373
- "JSON-RPC protocol error: frame is not an object",
374
- );
375
- this._emitError(pErr);
376
- if (this._state === "running") this._closeWith("closed", pErr);
377
- return;
378
- }
379
-
380
- // Response: has id + (result | error)
381
- if (
382
- Object.hasOwn(frame, "id") &&
383
- frame.id !== null &&
384
- (Object.hasOwn(frame, "result") || Object.hasOwn(frame, "error"))
385
- ) {
386
- this._dispatchResponse(frame);
387
- return;
388
- }
389
-
390
- // Notification: method + no id (or id === null for responses we treated above)
391
- if (typeof frame.method === "string" && !Object.hasOwn(frame, "id")) {
392
- this._dispatchNotification(frame);
393
- return;
394
- }
395
-
396
- // Unknown / malformed envelope — surface but keep loop alive during running.
397
- // Fail-fast only on structural errors (JSON parse, EOF, max-line).
398
- this._emitError(
399
- new JsonRpcProtocolError(
400
- "JSON-RPC protocol error: unrecognized frame shape",
401
- ),
402
- );
403
- }
404
-
405
- _dispatchResponse(frame) {
406
- const pending = this._pendingRequests.get(frame.id);
407
- if (!pending) {
408
- // Stray response — drop silently (notify() path, or late after timeout).
409
- return;
410
- }
411
- this._pendingRequests.delete(frame.id);
412
- if (pending.timer) clearTimeout(pending.timer);
413
-
414
- if (Object.hasOwn(frame, "error") && frame.error) {
415
- const { code, message, data } = frame.error;
416
- const err = new Error(
417
- `JSON-RPC error${typeof code === "number" ? ` ${code}` : ""}: ${message || "unknown"}`,
418
- );
419
- if (code !== undefined) err.code = code;
420
- if (data !== undefined) err.data = data;
421
- pending.reject(err);
422
- return;
423
- }
424
-
425
- pending.resolve(frame.result);
426
- }
427
-
428
- _dispatchNotification(frame) {
429
- const method = frame.method;
430
- const params = frame.params;
431
-
432
- const targeted = this._notificationHandlers.get(method);
433
- if (targeted && targeted.size > 0) {
434
- for (const cb of targeted) {
435
- try {
436
- cb(params);
437
- } catch (err) {
438
- this._emitError(err instanceof Error ? err : new Error(String(err)));
439
- }
440
- }
441
- }
442
-
443
- const wildcard = this._notificationHandlers.get("*");
444
- if (wildcard && wildcard.size > 0) {
445
- for (const cb of wildcard) {
446
- try {
447
- cb(params, method);
448
- } catch (err) {
449
- this._emitError(err instanceof Error ? err : new Error(String(err)));
450
- }
451
- }
452
- }
453
- }
454
-
455
- _emitError(err) {
456
- if (!this._onError) return;
457
- try {
458
- this._onError(err);
459
- } catch {
460
- // Never throw out of the dispatch loop.
461
- }
462
- }
463
204
  }