@ask-llm/plugin 0.18.0 → 0.19.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,173 @@
1
+ // Source of truth: broker-rpc.mts. The sibling .mjs is generated by `yarn workspace @ask-llm/plugin build:hooks`; never edit it.
2
+ let nextId = 1;
3
+ function takeNextId() {
4
+ const id = nextId;
5
+ nextId = (nextId + 1) | 0; // wrap at 2^31 (effectively never)
6
+ if (nextId <= 0)
7
+ nextId = 1;
8
+ return id;
9
+ }
10
+ // The caller owns the WebSocket lifetime; this client owns request and notification state.
11
+ export function createRpcClient(connection, options = {}) {
12
+ const { defaultTimeoutMs = 30000, onNotification = () => { }, onProtocolError = () => { } } = options;
13
+ const pending = new Map();
14
+ // Subscribe before turn/start so a fast completion cannot be missed.
15
+ const notificationSubscribers = new Set();
16
+ let closed = false;
17
+ connection.on("message", (text) => {
18
+ let env;
19
+ try {
20
+ env = JSON.parse(text);
21
+ }
22
+ catch (err) {
23
+ onProtocolError(new Error(`broker-rpc: malformed JSON from server: ${err?.message ?? String(err)}`));
24
+ return;
25
+ }
26
+ if (env && typeof env === "object" && "id" in env && env.id != null) {
27
+ const entry = pending.get(env.id);
28
+ if (!entry) {
29
+ // Late or unknown responses are soft protocol errors.
30
+ onProtocolError(new Error(`broker-rpc: response for unknown id ${env.id}`));
31
+ return;
32
+ }
33
+ pending.delete(env.id);
34
+ clearTimeout(entry.timer);
35
+ if (env.error) {
36
+ const err = new Error(env.error.message ?? `JSON-RPC error ${env.error.code ?? "?"}`);
37
+ err.code = env.error.code;
38
+ err.data = env.error.data;
39
+ entry.reject(err);
40
+ }
41
+ else {
42
+ entry.resolve(env.result);
43
+ }
44
+ return;
45
+ }
46
+ if (env && typeof env === "object" && typeof env.method === "string") {
47
+ // Pass server-pushed messages to callers even when they resemble server requests.
48
+ const notification = { method: env.method, params: env.params, id: env.id };
49
+ // Snapshot subscribers because dispatch removes resolved waiters.
50
+ for (const sub of [...notificationSubscribers]) {
51
+ if (sub.method === env.method) {
52
+ try {
53
+ if (!sub.predicate || sub.predicate(notification)) {
54
+ notificationSubscribers.delete(sub);
55
+ if (sub.timer)
56
+ clearTimeout(sub.timer);
57
+ sub.resolve(notification);
58
+ }
59
+ }
60
+ catch (err) {
61
+ notificationSubscribers.delete(sub);
62
+ if (sub.timer)
63
+ clearTimeout(sub.timer);
64
+ sub.reject(err);
65
+ }
66
+ }
67
+ }
68
+ onNotification(notification);
69
+ return;
70
+ }
71
+ onProtocolError(new Error(`broker-rpc: envelope has neither id nor method: ${text.slice(0, 120)}`));
72
+ });
73
+ connection.on("close", () => {
74
+ closed = true;
75
+ for (const [id, entry] of pending.entries()) {
76
+ clearTimeout(entry.timer);
77
+ entry.reject(new Error("broker-rpc: connection closed before response"));
78
+ pending.delete(id);
79
+ }
80
+ // Also reject any notification waiters — they'll never fire post-close.
81
+ for (const sub of notificationSubscribers) {
82
+ if (sub.timer)
83
+ clearTimeout(sub.timer);
84
+ sub.reject(new Error("broker-rpc: connection closed before notification"));
85
+ }
86
+ notificationSubscribers.clear();
87
+ });
88
+ connection.on("error", (err) => {
89
+ // Surface transport errors before pending request timers expire.
90
+ for (const [id, entry] of pending.entries()) {
91
+ clearTimeout(entry.timer);
92
+ entry.reject(err);
93
+ pending.delete(id);
94
+ }
95
+ // Reject waiters on transport error even if no close event follows.
96
+ for (const sub of notificationSubscribers) {
97
+ if (sub.timer)
98
+ clearTimeout(sub.timer);
99
+ sub.reject(err);
100
+ }
101
+ notificationSubscribers.clear();
102
+ });
103
+ return {
104
+ request(method, params, opts = {}) {
105
+ if (closed)
106
+ return Promise.reject(new Error("broker-rpc: client is closed"));
107
+ const id = takeNextId();
108
+ const timeoutMs = opts.timeoutMs ?? defaultTimeoutMs;
109
+ const envelope = JSON.stringify({ jsonrpc: "2.0", id, method, params });
110
+ return new Promise((resolve, reject) => {
111
+ const timer = setTimeout(() => {
112
+ pending.delete(id);
113
+ reject(new Error(`broker-rpc: timeout after ${timeoutMs}ms (method=${method}, id=${id})`));
114
+ }, timeoutMs);
115
+ timer.unref?.();
116
+ pending.set(id, { resolve: resolve, reject, timer });
117
+ try {
118
+ connection.sendText(envelope);
119
+ }
120
+ catch (err) {
121
+ pending.delete(id);
122
+ clearTimeout(timer);
123
+ reject(err);
124
+ }
125
+ });
126
+ },
127
+ notify(method, params) {
128
+ // Notifications have no id and expect no response.
129
+ if (closed)
130
+ throw new Error("broker-rpc: client is closed");
131
+ connection.sendText(JSON.stringify({ jsonrpc: "2.0", method, params }));
132
+ },
133
+ // Register waitFor before the request that triggers its notification.
134
+ waitFor(method, predicate, timeoutMs) {
135
+ if (closed)
136
+ return Promise.reject(new Error("broker-rpc: client is closed"));
137
+ let sub;
138
+ const promise = new Promise((resolve, reject) => {
139
+ sub = { method, predicate, resolve, reject, timer: null };
140
+ const subscriber = sub;
141
+ sub.timer = setTimeout(() => {
142
+ notificationSubscribers.delete(subscriber);
143
+ // Preserve a structured timeout marker for callers.
144
+ const err = new Error(`broker-rpc: waitFor(${method}) timed out after ${timeoutMs}ms`);
145
+ err.timeout = true;
146
+ reject(err);
147
+ }, timeoutMs);
148
+ sub.timer.unref?.();
149
+ notificationSubscribers.add(sub);
150
+ });
151
+ promise.cancel = () => {
152
+ if (!notificationSubscribers.delete(sub))
153
+ return;
154
+ if (sub.timer)
155
+ clearTimeout(sub.timer);
156
+ sub.resolve(null);
157
+ };
158
+ return promise;
159
+ },
160
+ get pendingCount() {
161
+ return pending.size;
162
+ },
163
+ get closed() {
164
+ return closed;
165
+ },
166
+ };
167
+ }
168
+ // Exports for tests
169
+ export const __testing__ = {
170
+ resetIdCounter() {
171
+ nextId = 1;
172
+ },
173
+ };
@@ -0,0 +1,327 @@
1
+ // Source of truth: broker-transport.mts. The sibling .mjs is generated by `yarn workspace @ask-llm/plugin build:hooks`; never edit it.
2
+ // App-server responses may omit jsonrpc; the RPC layer correlates by id.
3
+ import { Buffer } from "node:buffer";
4
+ import { createHash, randomBytes } from "node:crypto";
5
+ import { connect } from "node:net";
6
+ // RFC 6455 frame opcodes we recognize.
7
+ const OPCODE_CONTINUATION = 0x0;
8
+ const OPCODE_TEXT = 0x1;
9
+ const OPCODE_BINARY = 0x2;
10
+ const OPCODE_CLOSE = 0x8;
11
+ const OPCODE_PING = 0x9;
12
+ const OPCODE_PONG = 0xa;
13
+ // RFC 6455 magic GUID for the Sec-WebSocket-Accept derivation (§4.2.2).
14
+ const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
15
+ export function parseTransportUrl(url) {
16
+ if (typeof url !== "string") {
17
+ throw new TypeError(`broker-transport: transport URL must be string, got ${typeof url}`);
18
+ }
19
+ if (url.startsWith("unix://")) {
20
+ const path = url.slice("unix://".length);
21
+ if (!path)
22
+ throw new Error(`broker-transport: unix:// URL has empty path`);
23
+ return { connectOptions: { path }, host: "localhost", isUnix: true };
24
+ }
25
+ if (url.startsWith("ws://")) {
26
+ const rest = url.slice("ws://".length);
27
+ const slashIdx = rest.indexOf("/");
28
+ const authority = slashIdx === -1 ? rest : rest.slice(0, slashIdx);
29
+ const colonIdx = authority.lastIndexOf(":");
30
+ const host = colonIdx === -1 ? authority : authority.slice(0, colonIdx);
31
+ const port = colonIdx === -1 ? 80 : Number(authority.slice(colonIdx + 1));
32
+ if (!Number.isFinite(port) || port <= 0) {
33
+ throw new Error(`broker-transport: ws:// URL has invalid port: ${authority}`);
34
+ }
35
+ return { connectOptions: { host, port }, host: authority, isUnix: false };
36
+ }
37
+ throw new Error(`broker-transport: unsupported transport URL scheme: ${url} (need unix:// or ws://)`);
38
+ }
39
+ // HTTP upgrade needs a Host header even over a Unix socket.
40
+ function buildUpgradeRequest(host, secKey) {
41
+ return [
42
+ `GET / HTTP/1.1`,
43
+ `Host: ${host}`,
44
+ `Upgrade: websocket`,
45
+ `Connection: Upgrade`,
46
+ `Sec-WebSocket-Key: ${secKey}`,
47
+ `Sec-WebSocket-Version: 13`,
48
+ ``,
49
+ ``,
50
+ ].join("\r\n");
51
+ }
52
+ // Verify the required status and accept hash; optional upgrade headers are ignored.
53
+ function validateUpgradeResponse(headerText, sentKey) {
54
+ const lines = headerText.split("\r\n");
55
+ if (!lines[0] || !/^HTTP\/1\.[01]\s+101\b/.test(lines[0])) {
56
+ throw new Error(`broker-transport: upgrade rejected, status line: ${lines[0] ?? "(empty)"}`);
57
+ }
58
+ let accept = null;
59
+ for (let i = 1; i < lines.length; i++) {
60
+ const colon = lines[i].indexOf(":");
61
+ if (colon === -1)
62
+ continue;
63
+ const name = lines[i].slice(0, colon).trim().toLowerCase();
64
+ if (name === "sec-websocket-accept") {
65
+ accept = lines[i].slice(colon + 1).trim();
66
+ break;
67
+ }
68
+ }
69
+ const expected = createHash("sha1")
70
+ .update(sentKey + WS_GUID)
71
+ .digest("base64");
72
+ if (accept !== expected) {
73
+ throw new Error(`broker-transport: Sec-WebSocket-Accept mismatch (got ${accept ?? "(missing)"}, expected ${expected})`);
74
+ }
75
+ }
76
+ // RFC 6455 requires masking client frames.
77
+ function encodeTextFrame(text) {
78
+ const payload = Buffer.from(text, "utf-8");
79
+ const mask = randomBytes(4);
80
+ const masked = Buffer.allocUnsafe(payload.length);
81
+ for (let i = 0; i < payload.length; i++)
82
+ masked[i] = payload[i] ^ mask[i % 4];
83
+ let lenBytes;
84
+ if (payload.length < 126) {
85
+ lenBytes = Buffer.from([0x80 | payload.length]); // MASK bit + 7-bit length
86
+ }
87
+ else if (payload.length < 0x10000) {
88
+ lenBytes = Buffer.allocUnsafe(3);
89
+ lenBytes[0] = 0x80 | 126;
90
+ lenBytes.writeUInt16BE(payload.length, 1);
91
+ }
92
+ else {
93
+ lenBytes = Buffer.allocUnsafe(9);
94
+ lenBytes[0] = 0x80 | 127;
95
+ // BigInt handles 64-bit frame lengths before safe conversion.
96
+ lenBytes.writeBigUInt64BE(BigInt(payload.length), 1);
97
+ }
98
+ return Buffer.concat([
99
+ Buffer.from([0x80 | OPCODE_TEXT]), // FIN bit + TEXT opcode
100
+ lenBytes,
101
+ mask,
102
+ masked,
103
+ ]);
104
+ }
105
+ // Control-frame payloads cannot exceed 125 bytes under RFC 6455.
106
+ const MAX_CONTROL_FRAME_PAYLOAD = 125;
107
+ // Truncate CLOSE reasons to the control-frame payload limit.
108
+ function encodeCloseFrame(code = 1000, reason = "") {
109
+ let reasonBuf = Buffer.from(reason, "utf-8");
110
+ // Reserve two control-frame bytes for the close status code.
111
+ if (2 + reasonBuf.length > MAX_CONTROL_FRAME_PAYLOAD) {
112
+ reasonBuf = reasonBuf.slice(0, MAX_CONTROL_FRAME_PAYLOAD - 2);
113
+ }
114
+ const payload = Buffer.allocUnsafe(2 + reasonBuf.length);
115
+ payload.writeUInt16BE(code, 0);
116
+ reasonBuf.copy(payload, 2);
117
+ const mask = randomBytes(4);
118
+ const masked = Buffer.allocUnsafe(payload.length);
119
+ for (let i = 0; i < payload.length; i++)
120
+ masked[i] = payload[i] ^ mask[i % 4];
121
+ return Buffer.concat([Buffer.from([0x80 | OPCODE_CLOSE, 0x80 | payload.length]), mask, masked]);
122
+ }
123
+ // Truncate malformed oversized PING payloads before echoing PONG.
124
+ function encodePongFrame(payload) {
125
+ const capped = payload.length > MAX_CONTROL_FRAME_PAYLOAD ? payload.slice(0, MAX_CONTROL_FRAME_PAYLOAD) : payload;
126
+ const mask = randomBytes(4);
127
+ const masked = Buffer.allocUnsafe(capped.length);
128
+ for (let i = 0; i < capped.length; i++)
129
+ masked[i] = capped[i] ^ mask[i % 4];
130
+ return Buffer.concat([Buffer.from([0x80 | OPCODE_PONG, 0x80 | capped.length]), mask, masked]);
131
+ }
132
+ // Corrupted framing stops parsing; the connector destroys the socket to reject pending RPCs.
133
+ function createFrameParser(onFrame, onError) {
134
+ let buf = Buffer.alloc(0);
135
+ let corrupted = false;
136
+ return (chunk) => {
137
+ if (corrupted)
138
+ return; // already reported fatal — discard further bytes
139
+ buf = Buffer.concat([buf, chunk]);
140
+ while (buf.length >= 2) {
141
+ const first = buf[0];
142
+ const second = buf[1];
143
+ const fin = (first & 0x80) !== 0;
144
+ const opcode = first & 0x0f;
145
+ let len = second & 0x7f;
146
+ let offset = 2;
147
+ if (len === 126) {
148
+ if (buf.length < 4)
149
+ return; // need more
150
+ len = buf.readUInt16BE(2);
151
+ offset = 4;
152
+ }
153
+ else if (len === 127) {
154
+ if (buf.length < 10)
155
+ return;
156
+ // Reject frame lengths outside the safe integer range before converting from BigInt.
157
+ len = Number(buf.readBigUInt64BE(2));
158
+ offset = 10;
159
+ }
160
+ // Server frames must be unmasked; skip an unexpected mask if present.
161
+ const maskBit = (second & 0x80) !== 0;
162
+ if (maskBit)
163
+ offset += 4;
164
+ if (buf.length < offset + len)
165
+ return; // need more
166
+ if (!fin && opcode !== OPCODE_CONTINUATION) {
167
+ // Unsupported fragmentation corrupts the parser and closes the connection.
168
+ corrupted = true;
169
+ buf = Buffer.alloc(0);
170
+ onError(new Error(`broker-transport: fragmentation not supported (opcode=${opcode}) — connection terminating`));
171
+ return;
172
+ }
173
+ const payload = buf.slice(offset, offset + len);
174
+ buf = buf.slice(offset + len);
175
+ onFrame({ opcode, payload });
176
+ }
177
+ };
178
+ }
179
+ // The caller owns the connection after upgrade; the handshake timeout applies only until then.
180
+ export async function connectWebSocket(transportUrl, options = {}) {
181
+ const { handshakeTimeoutMs = 5000 } = options;
182
+ const { connectOptions, host } = parseTransportUrl(transportUrl);
183
+ return new Promise((resolve, reject) => {
184
+ const socket = connect(connectOptions);
185
+ const listeners = { message: [], close: [], error: [] };
186
+ let upgraded = false;
187
+ let headerBuf = Buffer.alloc(0);
188
+ let parser = null;
189
+ const timer = setTimeout(() => {
190
+ if (!upgraded) {
191
+ socket.destroy();
192
+ reject(new Error(`broker-transport: handshake timeout after ${handshakeTimeoutMs}ms`));
193
+ }
194
+ }, handshakeTimeoutMs);
195
+ timer.unref?.();
196
+ const secKey = randomBytes(16).toString("base64");
197
+ // Keep the error listener after upgrade to avoid an unhandled second socket error.
198
+ socket.on("error", (err) => {
199
+ if (!upgraded) {
200
+ clearTimeout(timer);
201
+ reject(err);
202
+ }
203
+ else {
204
+ for (const cb of listeners.error)
205
+ cb(err);
206
+ }
207
+ });
208
+ // Send the HTTP upgrade after transport connection; TCP and Unix sockets both require it.
209
+ socket.once("connect", () => {
210
+ try {
211
+ socket.write(buildUpgradeRequest(host, secKey));
212
+ }
213
+ catch (err) {
214
+ if (!upgraded) {
215
+ clearTimeout(timer);
216
+ reject(err);
217
+ }
218
+ }
219
+ });
220
+ socket.once("close", () => {
221
+ clearTimeout(timer);
222
+ if (!upgraded)
223
+ reject(new Error("broker-transport: socket closed before upgrade"));
224
+ else
225
+ for (const cb of listeners.close)
226
+ cb();
227
+ });
228
+ socket.on("data", (chunk) => {
229
+ if (upgraded) {
230
+ parser?.(chunk);
231
+ return;
232
+ }
233
+ headerBuf = Buffer.concat([headerBuf, chunk]);
234
+ const end = headerBuf.indexOf("\r\n\r\n");
235
+ if (end === -1)
236
+ return;
237
+ const headerText = headerBuf.slice(0, end).toString("utf-8");
238
+ const tail = headerBuf.slice(end + 4);
239
+ try {
240
+ validateUpgradeResponse(headerText, secKey);
241
+ }
242
+ catch (err) {
243
+ socket.destroy();
244
+ clearTimeout(timer);
245
+ reject(err);
246
+ return;
247
+ }
248
+ upgraded = true;
249
+ clearTimeout(timer);
250
+ parser = createFrameParser(({ opcode, payload }) => {
251
+ if (opcode === OPCODE_TEXT) {
252
+ const text = payload.toString("utf-8");
253
+ for (const cb of listeners.message)
254
+ cb(text);
255
+ }
256
+ else if (opcode === OPCODE_PING) {
257
+ // Auto-respond with PONG mirroring the payload (RFC §5.5.2).
258
+ socket.write(encodePongFrame(payload));
259
+ }
260
+ else if (opcode === OPCODE_CLOSE) {
261
+ // Echo close + half-close (RFC §5.5.1).
262
+ try {
263
+ socket.write(encodeCloseFrame(1000, ""));
264
+ }
265
+ catch {
266
+ // best-effort
267
+ }
268
+ socket.end();
269
+ }
270
+ else if (opcode === OPCODE_BINARY) {
271
+ // codex doesn't send binary for JSON-RPC; ignore silently.
272
+ }
273
+ // PONG and CONTINUATION are no-ops here.
274
+ }, (err) => {
275
+ for (const cb of listeners.error)
276
+ cb(err);
277
+ // Destroy the socket on parser corruption so pending RPCs reject.
278
+ try {
279
+ socket.destroy();
280
+ }
281
+ catch {
282
+ // best-effort
283
+ }
284
+ });
285
+ const conn = {
286
+ sendText(text) {
287
+ socket.write(encodeTextFrame(text));
288
+ },
289
+ close(code = 1000, reason = "") {
290
+ try {
291
+ socket.write(encodeCloseFrame(code, reason));
292
+ }
293
+ catch {
294
+ // best-effort — caller treats close as fire-and-forget
295
+ }
296
+ socket.end();
297
+ },
298
+ on(event, cb) {
299
+ if (!listeners[event])
300
+ throw new Error(`broker-transport: unknown event ${event}`);
301
+ listeners[event].push(cb);
302
+ },
303
+ get destroyed() {
304
+ return socket.destroyed;
305
+ },
306
+ // For tests / diagnostics
307
+ _underlyingSocket() {
308
+ return socket;
309
+ },
310
+ };
311
+ // If the upgrade response had body bytes already buffered, feed them.
312
+ if (tail.length > 0)
313
+ parser(tail);
314
+ resolve(conn);
315
+ });
316
+ });
317
+ }
318
+ // Exports for tests
319
+ export const __testing__ = {
320
+ encodeTextFrame,
321
+ encodeCloseFrame,
322
+ encodePongFrame,
323
+ createFrameParser,
324
+ validateUpgradeResponse,
325
+ buildUpgradeRequest,
326
+ WS_GUID,
327
+ };