@triflux/remote 10.27.0 → 10.28.1
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.
- package/hub/team/claude-daemon-control.mjs +214 -0
- package/hub/team/claude-native-bridge.mjs +45 -73
- package/hub/team/daemon-pty-tmux-bridge.mjs +146 -0
- package/hub/team/headless.mjs +23 -42
- package/hub/team/interactive-native-launcher.mjs +88 -75
- package/hub/team/retry-state-machine.mjs +2 -2
- package/hub/workers/codex-app-server-worker.mjs +4 -1
- package/hub/workers/lib/jsonrpc-ws-uds.mjs +686 -0
- package/package.json +1 -1
- package/scripts/lib/mcp-gateway-health-check.mjs +65 -0
- package/scripts/lib/mcp-guard-engine.mjs +52 -8
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
// hub/workers/lib/jsonrpc-ws-uds.mjs
|
|
2
|
+
// JSON-RPC 2.0 client over WebSocket-over-Unix-Domain-Socket.
|
|
3
|
+
//
|
|
4
|
+
// codex `app-server --listen unix://PATH` does NOT speak newline-delimited JSON
|
|
5
|
+
// on the socket — it speaks WebSocket (RFC 6455) after a standard HTTP/1.1
|
|
6
|
+
// Upgrade handshake, one JSON-RPC message per text frame. Confirmed live against
|
|
7
|
+
// codex-cli 0.135.0 (see experiments/native-bridge-feasibility/
|
|
8
|
+
// codex-app-server-uds-probe.mjs). This is the UDS sibling of the stdio
|
|
9
|
+
// `JsonRpcStdioClient`: same public surface (request/notify/onNotification/
|
|
10
|
+
// close/isOpen), different framing.
|
|
11
|
+
//
|
|
12
|
+
// Wire framing notes:
|
|
13
|
+
// - client -> server frames MUST be masked (RFC 6455 §5.3).
|
|
14
|
+
// - server -> client frames are unmasked.
|
|
15
|
+
// - one JSON-RPC object per text frame; fragmented frames are reassembled.
|
|
16
|
+
// - outbound JSON-RPC omits the `"jsonrpc":"2.0"` header (OpenAI App Server
|
|
17
|
+
// JSONL variant), matching JsonRpcStdioClient.
|
|
18
|
+
//
|
|
19
|
+
// Zero new dependencies — minimal hand-rolled WS client (no `ws` package).
|
|
20
|
+
|
|
21
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
22
|
+
import net from "node:net";
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
JsonRpcProtocolError,
|
|
26
|
+
JsonRpcTransportError,
|
|
27
|
+
} from "./jsonrpc-stdio.mjs";
|
|
28
|
+
|
|
29
|
+
const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
30
|
+
const DEFAULT_MAX_FRAME_SIZE = 16 * 1024 * 1024; // 16 MiB
|
|
31
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
|
|
32
|
+
// After a graceful close("closing") we wait this long for the peer's close/EOF
|
|
33
|
+
// before force-destroying the socket so a silent peer cannot pin it open.
|
|
34
|
+
const CLOSING_DEADLINE_MS = 2_000;
|
|
35
|
+
const MAX_CONTROL_PAYLOAD = 125; // RFC 6455 §5.5: control frames <=125 bytes
|
|
36
|
+
const CLOSED_MESSAGE = "JsonRpcWsUdsClient closed";
|
|
37
|
+
|
|
38
|
+
// RFC 6455 opcodes
|
|
39
|
+
const OP_CONTINUATION = 0x0;
|
|
40
|
+
const OP_TEXT = 0x1;
|
|
41
|
+
const OP_BINARY = 0x2;
|
|
42
|
+
const OP_CLOSE = 0x8;
|
|
43
|
+
const OP_PING = 0x9;
|
|
44
|
+
const OP_PONG = 0xa;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Encode a single client->server WebSocket frame (always masked).
|
|
48
|
+
* @param {number} opcode
|
|
49
|
+
* @param {Buffer} payload
|
|
50
|
+
* @returns {Buffer}
|
|
51
|
+
*/
|
|
52
|
+
function encodeClientFrame(opcode, payload) {
|
|
53
|
+
const len = payload.length;
|
|
54
|
+
const mask = randomBytes(4);
|
|
55
|
+
let header;
|
|
56
|
+
if (len < 126) {
|
|
57
|
+
header = Buffer.from([0x80 | opcode, 0x80 | len]);
|
|
58
|
+
} else if (len < 65536) {
|
|
59
|
+
header = Buffer.alloc(4);
|
|
60
|
+
header[0] = 0x80 | opcode;
|
|
61
|
+
header[1] = 0x80 | 126;
|
|
62
|
+
header.writeUInt16BE(len, 2);
|
|
63
|
+
} else {
|
|
64
|
+
header = Buffer.alloc(10);
|
|
65
|
+
header[0] = 0x80 | opcode;
|
|
66
|
+
header[1] = 0x80 | 127;
|
|
67
|
+
header.writeBigUInt64BE(BigInt(len), 2);
|
|
68
|
+
}
|
|
69
|
+
const masked = Buffer.allocUnsafe(len);
|
|
70
|
+
for (let i = 0; i < len; i++) masked[i] = payload[i] ^ mask[i % 4];
|
|
71
|
+
return Buffer.concat([header, mask, masked]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* JSON-RPC 2.0 client over WebSocket-over-UDS.
|
|
76
|
+
*/
|
|
77
|
+
export class JsonRpcWsUdsClient {
|
|
78
|
+
/**
|
|
79
|
+
* @param {object} options
|
|
80
|
+
* @param {string} options.socketPath Absolute path to the AF_UNIX socket.
|
|
81
|
+
* @param {(err: Error) => void} [options.onError]
|
|
82
|
+
* @param {number} [options.maxFrameSize] Max bytes per inbound frame payload.
|
|
83
|
+
* @param {number} [options.connectTimeoutMs]
|
|
84
|
+
*/
|
|
85
|
+
constructor({
|
|
86
|
+
socketPath,
|
|
87
|
+
onError,
|
|
88
|
+
maxFrameSize = DEFAULT_MAX_FRAME_SIZE,
|
|
89
|
+
connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS,
|
|
90
|
+
} = {}) {
|
|
91
|
+
if (typeof socketPath !== "string" || !socketPath) {
|
|
92
|
+
throw new TypeError("JsonRpcWsUdsClient requires a socketPath string");
|
|
93
|
+
}
|
|
94
|
+
this._socketPath = socketPath;
|
|
95
|
+
this._onError = typeof onError === "function" ? onError : null;
|
|
96
|
+
this._maxFrameSize =
|
|
97
|
+
Number.isFinite(maxFrameSize) && maxFrameSize > 0
|
|
98
|
+
? maxFrameSize
|
|
99
|
+
: DEFAULT_MAX_FRAME_SIZE;
|
|
100
|
+
this._connectTimeoutMs = Number.isFinite(connectTimeoutMs)
|
|
101
|
+
? connectTimeoutMs
|
|
102
|
+
: DEFAULT_CONNECT_TIMEOUT_MS;
|
|
103
|
+
|
|
104
|
+
/** @type {'idle'|'running'|'closing'|'closed'} */
|
|
105
|
+
this._state = "idle";
|
|
106
|
+
this._socket = null;
|
|
107
|
+
this._nextRequestId = 1;
|
|
108
|
+
/** @type {Map<number, { resolve: Function, reject: Function, timer: any, method: string }>} */
|
|
109
|
+
this._pendingRequests = new Map();
|
|
110
|
+
/** @type {Map<string, Set<Function>>} */
|
|
111
|
+
this._notificationHandlers = new Map();
|
|
112
|
+
|
|
113
|
+
// RX framing state
|
|
114
|
+
this._rxBuf = Buffer.alloc(0);
|
|
115
|
+
/** @type {Buffer[]} accumulated payloads of a fragmented message */
|
|
116
|
+
this._fragmentChunks = [];
|
|
117
|
+
/** @type {number|null} opcode of the in-progress fragmented message */
|
|
118
|
+
this._fragmentOpcode = null;
|
|
119
|
+
this._fragmentSize = 0;
|
|
120
|
+
/** @type {ReturnType<typeof setTimeout>|null} */
|
|
121
|
+
this._closingTimer = null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Open the socket and complete the WebSocket HTTP Upgrade handshake.
|
|
126
|
+
* Resolves once the connection is in the `running` state.
|
|
127
|
+
* @returns {Promise<void>}
|
|
128
|
+
*/
|
|
129
|
+
connect() {
|
|
130
|
+
if (this._state !== "idle") {
|
|
131
|
+
return Promise.reject(
|
|
132
|
+
new Error(
|
|
133
|
+
`JsonRpcWsUdsClient.connect() invalid in state ${this._state}`,
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
return new Promise((resolve, reject) => {
|
|
138
|
+
const key = randomBytes(16).toString("base64");
|
|
139
|
+
const expectedAccept = createHash("sha1")
|
|
140
|
+
.update(key + WS_GUID)
|
|
141
|
+
.digest("base64");
|
|
142
|
+
let handshakeBuf = Buffer.alloc(0);
|
|
143
|
+
let settled = false;
|
|
144
|
+
|
|
145
|
+
const socket = net.connect(this._socketPath);
|
|
146
|
+
this._socket = socket;
|
|
147
|
+
|
|
148
|
+
const timer = setTimeout(() => {
|
|
149
|
+
if (settled) return;
|
|
150
|
+
settled = true;
|
|
151
|
+
try {
|
|
152
|
+
socket.destroy();
|
|
153
|
+
} catch {}
|
|
154
|
+
this._state = "closed";
|
|
155
|
+
reject(
|
|
156
|
+
new JsonRpcTransportError(
|
|
157
|
+
`WebSocket-over-UDS connect timed out after ${this._connectTimeoutMs}ms: ${this._socketPath}`,
|
|
158
|
+
),
|
|
159
|
+
);
|
|
160
|
+
}, this._connectTimeoutMs);
|
|
161
|
+
|
|
162
|
+
const onConnectError = (err) => {
|
|
163
|
+
if (settled) return;
|
|
164
|
+
settled = true;
|
|
165
|
+
clearTimeout(timer);
|
|
166
|
+
this._state = "closed";
|
|
167
|
+
reject(
|
|
168
|
+
new JsonRpcTransportError(
|
|
169
|
+
`WebSocket-over-UDS connect error: ${err?.message || err}`,
|
|
170
|
+
{ cause: err },
|
|
171
|
+
),
|
|
172
|
+
);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
socket.once("error", onConnectError);
|
|
176
|
+
socket.on("connect", () => {
|
|
177
|
+
const req =
|
|
178
|
+
"GET / HTTP/1.1\r\n" +
|
|
179
|
+
"Host: localhost\r\n" +
|
|
180
|
+
"Upgrade: websocket\r\n" +
|
|
181
|
+
"Connection: Upgrade\r\n" +
|
|
182
|
+
`Sec-WebSocket-Key: ${key}\r\n` +
|
|
183
|
+
"Sec-WebSocket-Version: 13\r\n\r\n";
|
|
184
|
+
socket.write(req);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const onHandshakeData = (chunk) => {
|
|
188
|
+
if (settled) return;
|
|
189
|
+
handshakeBuf = Buffer.concat([handshakeBuf, chunk]);
|
|
190
|
+
const sep = handshakeBuf.indexOf("\r\n\r\n");
|
|
191
|
+
if (sep < 0) {
|
|
192
|
+
if (handshakeBuf.length > 64 * 1024) {
|
|
193
|
+
settled = true;
|
|
194
|
+
clearTimeout(timer);
|
|
195
|
+
socket.removeListener("error", onConnectError);
|
|
196
|
+
socket.removeListener("data", onHandshakeData);
|
|
197
|
+
try {
|
|
198
|
+
socket.destroy();
|
|
199
|
+
} catch {}
|
|
200
|
+
this._state = "closed";
|
|
201
|
+
reject(
|
|
202
|
+
new JsonRpcProtocolError(
|
|
203
|
+
"WebSocket handshake response headers exceeded 64 KiB",
|
|
204
|
+
),
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const rawHeaders = handshakeBuf.subarray(0, sep).toString("utf8");
|
|
210
|
+
const leftover = handshakeBuf.subarray(sep + 4);
|
|
211
|
+
|
|
212
|
+
const lines = rawHeaders.split("\r\n");
|
|
213
|
+
const ok101 = /^HTTP\/1\.\d 101/.test(lines[0] || "");
|
|
214
|
+
const headerMap = new Map();
|
|
215
|
+
for (let i = 1; i < lines.length; i++) {
|
|
216
|
+
const idx = lines[i].indexOf(":");
|
|
217
|
+
if (idx < 0) continue;
|
|
218
|
+
headerMap.set(
|
|
219
|
+
lines[i].slice(0, idx).trim().toLowerCase(),
|
|
220
|
+
lines[i].slice(idx + 1).trim(),
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
const acceptOk =
|
|
224
|
+
headerMap.get("sec-websocket-accept") === expectedAccept;
|
|
225
|
+
|
|
226
|
+
if (!ok101 || !acceptOk) {
|
|
227
|
+
settled = true;
|
|
228
|
+
clearTimeout(timer);
|
|
229
|
+
socket.removeListener("error", onConnectError);
|
|
230
|
+
socket.removeListener("data", onHandshakeData);
|
|
231
|
+
try {
|
|
232
|
+
socket.destroy();
|
|
233
|
+
} catch {}
|
|
234
|
+
this._state = "closed";
|
|
235
|
+
reject(
|
|
236
|
+
new JsonRpcProtocolError(
|
|
237
|
+
`WebSocket upgrade rejected (status101=${ok101}, acceptValid=${acceptOk})`,
|
|
238
|
+
),
|
|
239
|
+
);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Handshake complete — switch to running and wire frame handling.
|
|
244
|
+
settled = true;
|
|
245
|
+
clearTimeout(timer);
|
|
246
|
+
socket.removeListener("error", onConnectError);
|
|
247
|
+
socket.removeListener("data", onHandshakeData);
|
|
248
|
+
this._state = "running";
|
|
249
|
+
|
|
250
|
+
socket.on("data", (buf) => this._onSocketData(buf));
|
|
251
|
+
socket.on("error", (err) => this._handleStreamError("socket", err));
|
|
252
|
+
socket.on("close", () => {
|
|
253
|
+
if (this._state === "running") {
|
|
254
|
+
const err = new JsonRpcTransportError(
|
|
255
|
+
"WebSocket-over-UDS closed unexpectedly (EOF during running)",
|
|
256
|
+
);
|
|
257
|
+
this._emitError(err);
|
|
258
|
+
this._closeWith("closed", err);
|
|
259
|
+
} else if (this._state !== "closed") {
|
|
260
|
+
this._closeWith("closed");
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
if (leftover.length > 0) {
|
|
265
|
+
this._rxBuf = Buffer.concat([this._rxBuf, leftover]);
|
|
266
|
+
this._drainFrames();
|
|
267
|
+
}
|
|
268
|
+
resolve();
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
socket.on("data", onHandshakeData);
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Issue a JSON-RPC request and resolve with the server's `result`.
|
|
277
|
+
* @param {string} method
|
|
278
|
+
* @param {unknown} params
|
|
279
|
+
* @param {number} [timeoutMs=60000]
|
|
280
|
+
* @returns {Promise<any>}
|
|
281
|
+
*/
|
|
282
|
+
request(method, params, timeoutMs = 60000) {
|
|
283
|
+
if (this._state !== "running") {
|
|
284
|
+
return Promise.reject(new Error(CLOSED_MESSAGE));
|
|
285
|
+
}
|
|
286
|
+
const id = this._nextRequestId++;
|
|
287
|
+
const frame = { id, method };
|
|
288
|
+
if (params !== undefined) frame.params = params;
|
|
289
|
+
|
|
290
|
+
return new Promise((resolve, reject) => {
|
|
291
|
+
let timer = null;
|
|
292
|
+
if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
|
|
293
|
+
timer = setTimeout(() => {
|
|
294
|
+
const pending = this._pendingRequests.get(id);
|
|
295
|
+
if (!pending) return;
|
|
296
|
+
this._pendingRequests.delete(id);
|
|
297
|
+
reject(
|
|
298
|
+
new Error(
|
|
299
|
+
`JSON-RPC request timed out after ${timeoutMs}ms: ${method}`,
|
|
300
|
+
),
|
|
301
|
+
);
|
|
302
|
+
}, timeoutMs);
|
|
303
|
+
}
|
|
304
|
+
this._pendingRequests.set(id, { resolve, reject, timer, method });
|
|
305
|
+
try {
|
|
306
|
+
this._sendText(JSON.stringify(frame));
|
|
307
|
+
} catch (err) {
|
|
308
|
+
this._pendingRequests.delete(id);
|
|
309
|
+
if (timer) clearTimeout(timer);
|
|
310
|
+
reject(err);
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Send a JSON-RPC notification (no id, no response).
|
|
317
|
+
* @param {string} method
|
|
318
|
+
* @param {unknown} [params]
|
|
319
|
+
*/
|
|
320
|
+
notify(method, params) {
|
|
321
|
+
if (this._state !== "running") return;
|
|
322
|
+
const frame = { method };
|
|
323
|
+
if (params !== undefined) frame.params = params;
|
|
324
|
+
try {
|
|
325
|
+
this._sendText(JSON.stringify(frame));
|
|
326
|
+
} catch (err) {
|
|
327
|
+
this._emitError(err instanceof Error ? err : new Error(String(err)));
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Subscribe to inbound notifications. `"*"` = catch-all receiving
|
|
333
|
+
* `(params, method)`; targeted handlers receive `(params)`.
|
|
334
|
+
* @param {string} method
|
|
335
|
+
* @param {(params: any, method?: string) => void} callback
|
|
336
|
+
* @returns {() => void} unsubscribe
|
|
337
|
+
*/
|
|
338
|
+
onNotification(method, callback) {
|
|
339
|
+
if (typeof callback !== "function") {
|
|
340
|
+
throw new TypeError("onNotification requires a callback function");
|
|
341
|
+
}
|
|
342
|
+
let set = this._notificationHandlers.get(method);
|
|
343
|
+
if (!set) {
|
|
344
|
+
set = new Set();
|
|
345
|
+
this._notificationHandlers.set(method, set);
|
|
346
|
+
}
|
|
347
|
+
set.add(callback);
|
|
348
|
+
return () => {
|
|
349
|
+
const handlers = this._notificationHandlers.get(method);
|
|
350
|
+
if (!handlers) return;
|
|
351
|
+
handlers.delete(callback);
|
|
352
|
+
if (handlers.size === 0) this._notificationHandlers.delete(method);
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Close the client. `reason === "closing"` transitions to an intermediate
|
|
358
|
+
* graceful state where a subsequent EOF is treated as normal. Idempotent.
|
|
359
|
+
* @param {string} [reason]
|
|
360
|
+
*/
|
|
361
|
+
close(reason) {
|
|
362
|
+
if (this._state === "closed") return;
|
|
363
|
+
if (reason === "closing" && this._state === "running") {
|
|
364
|
+
this._state = "closing";
|
|
365
|
+
// best-effort WS close frame
|
|
366
|
+
try {
|
|
367
|
+
this._socket?.write(encodeClientFrame(OP_CLOSE, Buffer.alloc(0)));
|
|
368
|
+
} catch {}
|
|
369
|
+
// Arm a deadline so a peer that never replies with close/EOF cannot pin
|
|
370
|
+
// the socket + pending requests open forever.
|
|
371
|
+
this._closingTimer = setTimeout(() => {
|
|
372
|
+
if (this._state === "closing") this._closeWith("closed");
|
|
373
|
+
}, CLOSING_DEADLINE_MS);
|
|
374
|
+
if (typeof this._closingTimer.unref === "function") {
|
|
375
|
+
this._closingTimer.unref();
|
|
376
|
+
}
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
this._closeWith("closed");
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** @returns {boolean} */
|
|
383
|
+
isOpen() {
|
|
384
|
+
return this._state === "running";
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** @returns {'idle'|'running'|'closing'|'closed'} */
|
|
388
|
+
getState() {
|
|
389
|
+
return this._state;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// --- internals ---------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
_sendText(text) {
|
|
395
|
+
if (this._state === "closed") throw new Error(CLOSED_MESSAGE);
|
|
396
|
+
const frame = encodeClientFrame(OP_TEXT, Buffer.from(text, "utf8"));
|
|
397
|
+
this._socket.write(frame, (err) => {
|
|
398
|
+
if (err) this._handleStreamError("socket-write", err);
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
_onSocketData(chunk) {
|
|
403
|
+
if (this._state === "closed") return;
|
|
404
|
+
this._rxBuf = Buffer.concat([this._rxBuf, chunk]);
|
|
405
|
+
this._drainFrames();
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Parse as many complete WebSocket frames as are buffered. Control frames
|
|
410
|
+
* (ping/pong/close) are handled inline; text/continuation frames are
|
|
411
|
+
* reassembled into a full JSON-RPC message before dispatch.
|
|
412
|
+
*/
|
|
413
|
+
_drainFrames() {
|
|
414
|
+
while (this._rxBuf.length >= 2) {
|
|
415
|
+
const b0 = this._rxBuf[0];
|
|
416
|
+
const b1 = this._rxBuf[1];
|
|
417
|
+
const fin = (b0 & 0x80) !== 0;
|
|
418
|
+
const opcode = b0 & 0x0f;
|
|
419
|
+
const masked = (b1 & 0x80) !== 0;
|
|
420
|
+
let len = b1 & 0x7f;
|
|
421
|
+
let cursor = 2;
|
|
422
|
+
|
|
423
|
+
if (len === 126) {
|
|
424
|
+
if (this._rxBuf.length < cursor + 2) return;
|
|
425
|
+
len = this._rxBuf.readUInt16BE(cursor);
|
|
426
|
+
cursor += 2;
|
|
427
|
+
} else if (len === 127) {
|
|
428
|
+
if (this._rxBuf.length < cursor + 8) return;
|
|
429
|
+
const big = this._rxBuf.readBigUInt64BE(cursor);
|
|
430
|
+
if (big > BigInt(this._maxFrameSize)) {
|
|
431
|
+
const err = new JsonRpcProtocolError(
|
|
432
|
+
`WebSocket frame payload ${big} exceeds max ${this._maxFrameSize}`,
|
|
433
|
+
);
|
|
434
|
+
this._emitError(err);
|
|
435
|
+
this._closeWith("closed", err);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
len = Number(big);
|
|
439
|
+
cursor += 8;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (len > this._maxFrameSize) {
|
|
443
|
+
const err = new JsonRpcProtocolError(
|
|
444
|
+
`WebSocket frame payload ${len} exceeds max ${this._maxFrameSize}`,
|
|
445
|
+
);
|
|
446
|
+
this._emitError(err);
|
|
447
|
+
this._closeWith("closed", err);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// RFC 6455 §5.1: server -> client frames MUST NOT be masked.
|
|
452
|
+
if (masked) {
|
|
453
|
+
this._protocolClose("WebSocket server frame is masked (RFC 6455 §5.1)");
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (this._rxBuf.length < cursor + len) return; // wait for full payload
|
|
457
|
+
|
|
458
|
+
// Detach from rxBuf before we slice it off.
|
|
459
|
+
const payload = Buffer.from(this._rxBuf.subarray(cursor, cursor + len));
|
|
460
|
+
this._rxBuf = this._rxBuf.subarray(cursor + len);
|
|
461
|
+
|
|
462
|
+
this._handleFrame(fin, opcode, payload);
|
|
463
|
+
if (this._state === "closed") return;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
_handleFrame(fin, opcode, payload) {
|
|
468
|
+
// Control frames (>=0x8): must be FIN and <=125 bytes (RFC 6455 §5.5).
|
|
469
|
+
if (opcode >= 0x8) {
|
|
470
|
+
if (!fin || payload.length > MAX_CONTROL_PAYLOAD) {
|
|
471
|
+
this._protocolClose(
|
|
472
|
+
`Invalid control frame (opcode 0x${opcode.toString(16)}, fin=${fin}, len=${payload.length})`,
|
|
473
|
+
);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (opcode === OP_PING) {
|
|
477
|
+
try {
|
|
478
|
+
this._socket?.write(encodeClientFrame(OP_PONG, payload));
|
|
479
|
+
} catch {}
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
if (opcode === OP_PONG) return;
|
|
483
|
+
if (opcode === OP_CLOSE) {
|
|
484
|
+
if (this._state === "running") {
|
|
485
|
+
const err = new JsonRpcTransportError(
|
|
486
|
+
"WebSocket server sent close frame",
|
|
487
|
+
);
|
|
488
|
+
this._emitError(err);
|
|
489
|
+
this._closeWith("closed", err);
|
|
490
|
+
} else {
|
|
491
|
+
this._closeWith("closed");
|
|
492
|
+
}
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
this._protocolClose(`Unknown control opcode 0x${opcode.toString(16)}`);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Data frames: text / binary (message start) or continuation.
|
|
500
|
+
if (opcode === OP_TEXT || opcode === OP_BINARY) {
|
|
501
|
+
if (this._fragmentOpcode !== null) {
|
|
502
|
+
this._protocolClose(
|
|
503
|
+
"New data frame received while a fragmented message is active",
|
|
504
|
+
);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
if (opcode === OP_BINARY) {
|
|
508
|
+
this._protocolClose(
|
|
509
|
+
"Binary frames are not supported (expected text JSON-RPC)",
|
|
510
|
+
);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
if (fin) {
|
|
514
|
+
this._handleMessage(payload.toString("utf8"));
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
this._fragmentOpcode = opcode;
|
|
518
|
+
this._fragmentChunks = [payload];
|
|
519
|
+
this._fragmentSize = payload.length;
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (opcode === OP_CONTINUATION) {
|
|
524
|
+
if (this._fragmentOpcode === null) {
|
|
525
|
+
this._protocolClose(
|
|
526
|
+
"Continuation frame with no active fragmented message",
|
|
527
|
+
);
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
this._fragmentSize += payload.length;
|
|
531
|
+
if (this._fragmentSize > this._maxFrameSize) {
|
|
532
|
+
this._protocolClose(
|
|
533
|
+
`Fragmented message ${this._fragmentSize} exceeds max ${this._maxFrameSize}`,
|
|
534
|
+
);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
this._fragmentChunks.push(payload);
|
|
538
|
+
if (!fin) return;
|
|
539
|
+
const full = Buffer.concat(this._fragmentChunks);
|
|
540
|
+
this._fragmentChunks = [];
|
|
541
|
+
this._fragmentOpcode = null;
|
|
542
|
+
this._fragmentSize = 0;
|
|
543
|
+
this._handleMessage(full.toString("utf8"));
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
this._protocolClose(`Unknown WebSocket opcode 0x${opcode.toString(16)}`);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
_protocolClose(message) {
|
|
551
|
+
const err = new JsonRpcProtocolError(message);
|
|
552
|
+
this._emitError(err);
|
|
553
|
+
this._closeWith("closed", err);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
_handleMessage(line) {
|
|
557
|
+
if (this._state === "closed") return;
|
|
558
|
+
if (line.length === 0) return;
|
|
559
|
+
|
|
560
|
+
let frame;
|
|
561
|
+
try {
|
|
562
|
+
frame = JSON.parse(line);
|
|
563
|
+
} catch (err) {
|
|
564
|
+
const pErr = new JsonRpcProtocolError(
|
|
565
|
+
`JSON-RPC parse error: ${err instanceof Error ? err.message : String(err)}`,
|
|
566
|
+
{ cause: err },
|
|
567
|
+
);
|
|
568
|
+
this._emitError(pErr);
|
|
569
|
+
if (this._state === "running") this._closeWith("closed", pErr);
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (!frame || typeof frame !== "object") {
|
|
574
|
+
const pErr = new JsonRpcProtocolError(
|
|
575
|
+
"JSON-RPC protocol error: frame is not an object",
|
|
576
|
+
);
|
|
577
|
+
this._emitError(pErr);
|
|
578
|
+
if (this._state === "running") this._closeWith("closed", pErr);
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (
|
|
583
|
+
Object.hasOwn(frame, "id") &&
|
|
584
|
+
frame.id !== null &&
|
|
585
|
+
(Object.hasOwn(frame, "result") || Object.hasOwn(frame, "error"))
|
|
586
|
+
) {
|
|
587
|
+
this._dispatchResponse(frame);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (typeof frame.method === "string" && !Object.hasOwn(frame, "id")) {
|
|
592
|
+
this._dispatchNotification(frame);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
this._emitError(
|
|
597
|
+
new JsonRpcProtocolError(
|
|
598
|
+
"JSON-RPC protocol error: unrecognized frame shape",
|
|
599
|
+
),
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
_dispatchResponse(frame) {
|
|
604
|
+
const pending = this._pendingRequests.get(frame.id);
|
|
605
|
+
if (!pending) return;
|
|
606
|
+
this._pendingRequests.delete(frame.id);
|
|
607
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
608
|
+
|
|
609
|
+
if (Object.hasOwn(frame, "error") && frame.error) {
|
|
610
|
+
const { code, message, data } = frame.error;
|
|
611
|
+
const err = new Error(
|
|
612
|
+
`JSON-RPC error${typeof code === "number" ? ` ${code}` : ""}: ${message || "unknown"}`,
|
|
613
|
+
);
|
|
614
|
+
if (code !== undefined) err.code = code;
|
|
615
|
+
if (data !== undefined) err.data = data;
|
|
616
|
+
pending.reject(err);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
pending.resolve(frame.result);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
_dispatchNotification(frame) {
|
|
623
|
+
const { method, params } = frame;
|
|
624
|
+
const targeted = this._notificationHandlers.get(method);
|
|
625
|
+
if (targeted && targeted.size > 0) {
|
|
626
|
+
for (const cb of targeted) {
|
|
627
|
+
try {
|
|
628
|
+
cb(params);
|
|
629
|
+
} catch (err) {
|
|
630
|
+
this._emitError(err instanceof Error ? err : new Error(String(err)));
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
const wildcard = this._notificationHandlers.get("*");
|
|
635
|
+
if (wildcard && wildcard.size > 0) {
|
|
636
|
+
for (const cb of wildcard) {
|
|
637
|
+
try {
|
|
638
|
+
cb(params, method);
|
|
639
|
+
} catch (err) {
|
|
640
|
+
this._emitError(err instanceof Error ? err : new Error(String(err)));
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
_handleStreamError(which, err) {
|
|
647
|
+
if (this._state === "closed") return;
|
|
648
|
+
const base = err instanceof Error ? err : new Error(String(err));
|
|
649
|
+
const wrapped = new JsonRpcTransportError(
|
|
650
|
+
`WebSocket-over-UDS stream error on ${which}: ${base.message}`,
|
|
651
|
+
{ cause: base },
|
|
652
|
+
);
|
|
653
|
+
this._emitError(wrapped);
|
|
654
|
+
this._closeWith("closed", wrapped);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
_closeWith(target, rejectReason = null) {
|
|
658
|
+
if (this._state === "closed") return;
|
|
659
|
+
this._state = target;
|
|
660
|
+
if (this._closingTimer) {
|
|
661
|
+
clearTimeout(this._closingTimer);
|
|
662
|
+
this._closingTimer = null;
|
|
663
|
+
}
|
|
664
|
+
const rejectErr =
|
|
665
|
+
rejectReason instanceof Error ? rejectReason : new Error(CLOSED_MESSAGE);
|
|
666
|
+
for (const [, pending] of this._pendingRequests) {
|
|
667
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
668
|
+
pending.reject(rejectErr);
|
|
669
|
+
}
|
|
670
|
+
this._pendingRequests.clear();
|
|
671
|
+
try {
|
|
672
|
+
this._socket?.destroy();
|
|
673
|
+
} catch {}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
_emitError(err) {
|
|
677
|
+
if (!this._onError) return;
|
|
678
|
+
try {
|
|
679
|
+
this._onError(err);
|
|
680
|
+
} catch {
|
|
681
|
+
/* never throw out of the dispatch loop */
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
export default JsonRpcWsUdsClient;
|