@johnhenry/wsh 0.23.1 → 0.25.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.
- package/README.md +108 -0
- package/package.json +11 -4
- package/src/auth.mjs +5 -1
- package/src/client.mjs +141 -1
- package/src/index.d.ts +181 -1
- package/src/index.mjs +8 -0
- package/src/rpc-mcp.mjs +153 -0
- package/src/rpc.mjs +617 -0
- package/src/server/connection.mjs +62 -0
- package/src/server/fs.mjs +36 -3
- package/src/server/index.d.ts +35 -0
- package/src/server/index.mjs +11 -3
- package/src/server/rpc.mjs +171 -0
package/src/rpc.mjs
ADDED
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed ("object-mode") RPC channels -- wsh #85.
|
|
3
|
+
*
|
|
4
|
+
* An `rpc` channel is an ordinary QMux stream whose payload is a CBOR sequence
|
|
5
|
+
* (RFC 8742) of JSON-RPC 2.0 messages, the way SSH's `sftp` subsystem is a
|
|
6
|
+
* typed protocol inside a byte-stream channel. This module is transport-free
|
|
7
|
+
* and browser-safe: `RpcChannel` is given a `write(bytes)` and fed received
|
|
8
|
+
* bytes; the client (`WshClient.openRpc`) and the Node host
|
|
9
|
+
* (`createWshServer({ rpc })`) each bind it to a stream.
|
|
10
|
+
*
|
|
11
|
+
* Messages (JSON-RPC 2.0 exactly, CBOR-encoded, so binary values are native
|
|
12
|
+
* byte strings and surface as `Uint8Array`):
|
|
13
|
+
* Request { jsonrpc: '2.0', id, method, params? }
|
|
14
|
+
* Notification { jsonrpc: '2.0', method, params? }
|
|
15
|
+
* Response { jsonrpc: '2.0', id, result } | { jsonrpc: '2.0', id, error: { code, message, data? } }
|
|
16
|
+
* plus two reserved notifications: `$/cancel { id }` and `$/progress { id, chunk }`.
|
|
17
|
+
* Responses are matched by `id` only -- never by message type.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { cborEncode, cborDecode } from './cbor.mjs';
|
|
21
|
+
|
|
22
|
+
// ── Names ─────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/** ServerHello feature: the host serves `rpc` sessions. */
|
|
25
|
+
export const RPC_FEATURE = 'rpc';
|
|
26
|
+
/** ServerHello feature prefix: one `rpc-protocol:<name>` per supported protocol. */
|
|
27
|
+
export const RPC_PROTOCOL_PREFIX = 'rpc-protocol:';
|
|
28
|
+
/** ServerHello feature prefix: `rpc-max-message:<bytes>`, the largest single message the host accepts. */
|
|
29
|
+
export const RPC_MAX_MESSAGE_PREFIX = 'rpc-max-message:';
|
|
30
|
+
/** Largest message when the host does not say (1 MiB). Larger payloads must stream (`$/progress`) or be chunked. */
|
|
31
|
+
export const RPC_DEFAULT_MAX_MESSAGE = 1024 * 1024;
|
|
32
|
+
|
|
33
|
+
/** @param {string} name */
|
|
34
|
+
export const rpcProtocolFeature = (name) => RPC_PROTOCOL_PREFIX + name;
|
|
35
|
+
|
|
36
|
+
/** A protocol name is carried in a feature string and in `Open.command`: keep it boring. */
|
|
37
|
+
export const RPC_PROTOCOL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
38
|
+
|
|
39
|
+
/** JSON-RPC's reserved codes plus wsh's. */
|
|
40
|
+
export const RPC_ERROR = Object.freeze({
|
|
41
|
+
PARSE: -32700,
|
|
42
|
+
INVALID_REQUEST: -32600,
|
|
43
|
+
METHOD_NOT_FOUND: -32601,
|
|
44
|
+
INVALID_PARAMS: -32602,
|
|
45
|
+
INTERNAL: -32603,
|
|
46
|
+
UNSUPPORTED_PROTOCOL: -32000,
|
|
47
|
+
CANCELLED: -32001,
|
|
48
|
+
STREAM_LIMIT: -32002,
|
|
49
|
+
UNAUTHORIZED: -32003,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Read the rpc advertisement out of `ServerHello.features`.
|
|
54
|
+
* @param {string[]} [features]
|
|
55
|
+
* @returns {{ enabled: boolean, protocols: string[], maxMessageBytes: number }}
|
|
56
|
+
*/
|
|
57
|
+
export function parseRpcFeatures(features = []) {
|
|
58
|
+
const protocols = [];
|
|
59
|
+
let maxMessageBytes = RPC_DEFAULT_MAX_MESSAGE;
|
|
60
|
+
let enabled = false;
|
|
61
|
+
for (const f of features) {
|
|
62
|
+
if (typeof f !== 'string') continue;
|
|
63
|
+
if (f === RPC_FEATURE) enabled = true;
|
|
64
|
+
else if (f.startsWith(RPC_PROTOCOL_PREFIX)) protocols.push(f.slice(RPC_PROTOCOL_PREFIX.length));
|
|
65
|
+
else if (f.startsWith(RPC_MAX_MESSAGE_PREFIX)) {
|
|
66
|
+
const n = Number(f.slice(RPC_MAX_MESSAGE_PREFIX.length));
|
|
67
|
+
if (Number.isSafeInteger(n) && n > 0) maxMessageBytes = n;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { enabled, protocols, maxMessageBytes };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Errors ────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
/** A JSON-RPC error: thrown by `request()` for an error response, and by handlers to answer with one. */
|
|
76
|
+
export class RpcError extends Error {
|
|
77
|
+
/**
|
|
78
|
+
* @param {number} code
|
|
79
|
+
* @param {string} message
|
|
80
|
+
* @param {unknown} [data]
|
|
81
|
+
* @param {string} [reason] - a short machine-readable cause (`channel-closed`, `timeout`, `UNSUPPORTED_PROTOCOL`, ...)
|
|
82
|
+
*/
|
|
83
|
+
constructor(code, message, data, reason) {
|
|
84
|
+
super(message);
|
|
85
|
+
this.name = 'RpcError';
|
|
86
|
+
this.code = code;
|
|
87
|
+
if (data !== undefined) this.data = data;
|
|
88
|
+
this.reason = reason ?? (data && typeof data === 'object' && typeof data.reason === 'string' ? data.reason : undefined);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** @returns {{ code: number, message: string, data?: unknown }} */
|
|
92
|
+
toJSON() {
|
|
93
|
+
const o = { code: this.code, message: this.message };
|
|
94
|
+
if (this.data !== undefined) o.data = this.data;
|
|
95
|
+
else if (this.reason) o.data = { reason: this.reason };
|
|
96
|
+
return o;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
static fromWire(e) {
|
|
100
|
+
const o = e && typeof e === 'object' ? e : {};
|
|
101
|
+
return new RpcError(Number.isInteger(o.code) ? o.code : RPC_ERROR.INTERNAL, typeof o.message === 'string' ? o.message : 'rpc error', o.data);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const tooLarge = (what, size, max) => new RpcError(RPC_ERROR.INVALID_REQUEST, `${what} of ${size} bytes exceeds the ${max} byte rpc message limit`, { reason: 'message-too-large' });
|
|
106
|
+
const closedError = (detail) => new RpcError(RPC_ERROR.CANCELLED, 'rpc channel closed', { reason: 'channel-closed', detail });
|
|
107
|
+
|
|
108
|
+
// ── CBOR sequence decoder ─────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
const MAX_DEPTH = 128;
|
|
111
|
+
|
|
112
|
+
class Incomplete extends Error {}
|
|
113
|
+
const INCOMPLETE = new Incomplete('incomplete');
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* End offset of the CBOR data item starting at `off`, or throws INCOMPLETE if `buf` ends first.
|
|
117
|
+
* Structure is validated just enough to find the item's end; `cborDecode` does the decoding.
|
|
118
|
+
*/
|
|
119
|
+
function scan(buf, off, max, depth) {
|
|
120
|
+
if (depth > MAX_DEPTH) throw new RpcError(RPC_ERROR.PARSE, 'cbor nesting too deep');
|
|
121
|
+
if (off >= buf.length) throw INCOMPLETE;
|
|
122
|
+
const initial = buf[off++];
|
|
123
|
+
const mt = initial >> 5;
|
|
124
|
+
const ai = initial & 0x1f;
|
|
125
|
+
if (ai >= 28 && ai <= 30) throw new RpcError(RPC_ERROR.PARSE, 'malformed cbor (reserved additional info)');
|
|
126
|
+
const indefinite = ai === 31;
|
|
127
|
+
let arg = 0;
|
|
128
|
+
if (ai < 24) arg = ai;
|
|
129
|
+
else if (ai <= 27) {
|
|
130
|
+
const n = ai === 24 ? 1 : ai === 25 ? 2 : ai === 26 ? 4 : 8;
|
|
131
|
+
if (off + n > buf.length) throw INCOMPLETE;
|
|
132
|
+
for (let i = 0; i < n; i++) arg = arg * 256 + buf[off + i];
|
|
133
|
+
off += n;
|
|
134
|
+
}
|
|
135
|
+
switch (mt) {
|
|
136
|
+
case 0: case 1:
|
|
137
|
+
if (indefinite) throw new RpcError(RPC_ERROR.PARSE, 'malformed cbor (indefinite integer)');
|
|
138
|
+
return off;
|
|
139
|
+
case 2: case 3: {
|
|
140
|
+
if (indefinite) {
|
|
141
|
+
for (;;) {
|
|
142
|
+
if (off >= buf.length) throw INCOMPLETE;
|
|
143
|
+
if (buf[off] === 0xff) return off + 1;
|
|
144
|
+
if ((buf[off] >> 5) !== mt || (buf[off] & 0x1f) === 31) throw new RpcError(RPC_ERROR.PARSE, 'malformed cbor (bad string chunk)');
|
|
145
|
+
off = scan(buf, off, max, depth + 1);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (arg > max) throw tooLarge('message', arg, max);
|
|
149
|
+
if (off + arg > buf.length) throw INCOMPLETE;
|
|
150
|
+
return off + arg;
|
|
151
|
+
}
|
|
152
|
+
case 4: case 5: {
|
|
153
|
+
const per = mt === 5 ? 2 : 1;
|
|
154
|
+
if (indefinite) {
|
|
155
|
+
for (;;) {
|
|
156
|
+
if (off >= buf.length) throw INCOMPLETE;
|
|
157
|
+
if (buf[off] === 0xff) return off + 1;
|
|
158
|
+
for (let i = 0; i < per; i++) off = scan(buf, off, max, depth + 1);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (arg > max) throw tooLarge('message', arg, max); // every element is at least a byte
|
|
162
|
+
for (let i = 0; i < arg * per; i++) off = scan(buf, off, max, depth + 1);
|
|
163
|
+
return off;
|
|
164
|
+
}
|
|
165
|
+
case 6:
|
|
166
|
+
if (indefinite) throw new RpcError(RPC_ERROR.PARSE, 'malformed cbor (indefinite tag)');
|
|
167
|
+
return scan(buf, off, max, depth + 1);
|
|
168
|
+
default: // 7: simple values and floats; 0xff outside an indefinite item is invalid
|
|
169
|
+
if (indefinite) throw new RpcError(RPC_ERROR.PARSE, 'malformed cbor (unexpected break)');
|
|
170
|
+
return off;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Incremental decoder for a CBOR sequence (RFC 8742): `feed()` bytes as they
|
|
176
|
+
* arrive, get back every item completed so far. Items are self-delimiting, so
|
|
177
|
+
* there is no length prefix. An item larger than `maxItemBytes` is refused as
|
|
178
|
+
* soon as it is detectable; malformed CBOR throws an `RpcError` (-32700).
|
|
179
|
+
*/
|
|
180
|
+
export class CborSequenceDecoder {
|
|
181
|
+
#buf = new Uint8Array(0);
|
|
182
|
+
#max;
|
|
183
|
+
|
|
184
|
+
/** @param {{ maxItemBytes?: number }} [opts] */
|
|
185
|
+
constructor({ maxItemBytes = RPC_DEFAULT_MAX_MESSAGE } = {}) {
|
|
186
|
+
this.#max = maxItemBytes;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Bytes buffered towards an incomplete item. */
|
|
190
|
+
get pending() {
|
|
191
|
+
return this.#buf.length;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* @param {Uint8Array} chunk
|
|
196
|
+
* @returns {unknown[]}
|
|
197
|
+
*/
|
|
198
|
+
feed(chunk) {
|
|
199
|
+
if (chunk.length) {
|
|
200
|
+
if (this.#buf.length === 0) this.#buf = chunk;
|
|
201
|
+
else {
|
|
202
|
+
const next = new Uint8Array(this.#buf.length + chunk.length);
|
|
203
|
+
next.set(this.#buf, 0);
|
|
204
|
+
next.set(chunk, this.#buf.length);
|
|
205
|
+
this.#buf = next;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const out = [];
|
|
209
|
+
let off = 0;
|
|
210
|
+
try {
|
|
211
|
+
while (off < this.#buf.length) {
|
|
212
|
+
let end;
|
|
213
|
+
try {
|
|
214
|
+
end = scan(this.#buf, off, this.#max, 0);
|
|
215
|
+
} catch (e) {
|
|
216
|
+
if (e === INCOMPLETE) {
|
|
217
|
+
if (this.#buf.length - off > this.#max) throw tooLarge('message', this.#buf.length - off, this.#max);
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
throw e;
|
|
221
|
+
}
|
|
222
|
+
if (end - off > this.#max) throw tooLarge('message', end - off, this.#max);
|
|
223
|
+
let item;
|
|
224
|
+
try {
|
|
225
|
+
item = cborDecode(this.#buf.subarray(off, end));
|
|
226
|
+
} catch (e) {
|
|
227
|
+
throw new RpcError(RPC_ERROR.PARSE, `malformed cbor: ${e.message}`);
|
|
228
|
+
}
|
|
229
|
+
out.push(item);
|
|
230
|
+
off = end;
|
|
231
|
+
}
|
|
232
|
+
} catch (e) {
|
|
233
|
+
this.#buf = new Uint8Array(0);
|
|
234
|
+
throw e;
|
|
235
|
+
}
|
|
236
|
+
this.#buf = off === this.#buf.length ? new Uint8Array(0) : this.#buf.slice(off);
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── Channel ───────────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
/** Deep copy without `undefined` members (CBOR has no undefined; JSON-RPC drops them). Byte strings are shared. */
|
|
244
|
+
function prune(v) {
|
|
245
|
+
if (Array.isArray(v)) return v.map((x) => (x === undefined ? null : prune(x)));
|
|
246
|
+
if (v && typeof v === 'object' && !ArrayBuffer.isView(v) && !(v instanceof ArrayBuffer)) {
|
|
247
|
+
const o = {};
|
|
248
|
+
for (const k of Object.keys(v)) if (v[k] !== undefined) o[k] = prune(v[k]);
|
|
249
|
+
return o;
|
|
250
|
+
}
|
|
251
|
+
return v;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const isId = (id) => typeof id === 'string' || (typeof id === 'number' && Number.isFinite(id));
|
|
255
|
+
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) && !ArrayBuffer.isView(v);
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* One end of an `rpc` channel: JSON-RPC 2.0 over a CBOR sequence, symmetric --
|
|
259
|
+
* either side may request, notify, and answer.
|
|
260
|
+
*
|
|
261
|
+
* @typedef {{ id: string|number, method: string, signal: AbortSignal, channel: RpcChannel, progress(chunk: unknown): Promise<void> }} RpcContext
|
|
262
|
+
*/
|
|
263
|
+
export class RpcChannel {
|
|
264
|
+
#write; #closeFn; #max; #timeoutMs; #maxInflight;
|
|
265
|
+
#nextId = 1;
|
|
266
|
+
/** Outbound requests awaiting a response, by id. */
|
|
267
|
+
#pending = new Map();
|
|
268
|
+
/** Inbound requests a handler is working on, by id. */
|
|
269
|
+
#inflight = new Map();
|
|
270
|
+
#handlers = new Map();
|
|
271
|
+
#notifications = new Map();
|
|
272
|
+
#fallbackRequest = null;
|
|
273
|
+
#fallbackNotification = null;
|
|
274
|
+
#decoder;
|
|
275
|
+
#writes = Promise.resolve();
|
|
276
|
+
#closed = false;
|
|
277
|
+
#closeReason = null;
|
|
278
|
+
#closing = null;
|
|
279
|
+
#held = false;
|
|
280
|
+
#heldItems = [];
|
|
281
|
+
|
|
282
|
+
/** Called once when the channel ends, with the reason. */
|
|
283
|
+
onClose = null;
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* @param {object} opts
|
|
287
|
+
* @param {(bytes: Uint8Array) => Promise<void> | void} opts.write - send bytes to the peer (in order)
|
|
288
|
+
* @param {() => Promise<void> | void} [opts.close] - end the underlying stream
|
|
289
|
+
* @param {number} [opts.maxMessageBytes=1048576]
|
|
290
|
+
* @param {number} [opts.timeoutMs] - default per-request timeout (none when omitted)
|
|
291
|
+
* @param {number} [opts.maxInflight=64] - concurrent inbound requests before `-32002`
|
|
292
|
+
*/
|
|
293
|
+
constructor({ write, close, maxMessageBytes = RPC_DEFAULT_MAX_MESSAGE, timeoutMs, maxInflight = 64 } = {}) {
|
|
294
|
+
if (typeof write !== 'function') throw new TypeError('RpcChannel: write(bytes) is required');
|
|
295
|
+
this.#write = write;
|
|
296
|
+
this.#closeFn = close ?? (() => {});
|
|
297
|
+
this.#max = maxMessageBytes;
|
|
298
|
+
this.#timeoutMs = timeoutMs > 0 ? timeoutMs : 0;
|
|
299
|
+
this.#maxInflight = maxInflight;
|
|
300
|
+
this.#decoder = new CborSequenceDecoder({ maxItemBytes: maxMessageBytes });
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
get closed() { return this.#closed; }
|
|
304
|
+
get closeReason() { return this.#closeReason; }
|
|
305
|
+
get maxMessageBytes() { return this.#max; }
|
|
306
|
+
/** Outbound requests still waiting for a response. */
|
|
307
|
+
get pendingCount() { return this.#pending.size; }
|
|
308
|
+
/** Inbound requests still being handled. */
|
|
309
|
+
get inflightCount() { return this.#inflight.size; }
|
|
310
|
+
|
|
311
|
+
// ── Registration ────────────────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Answer requests for `method`. The handler gets `(params, ctx)`; its return value is the result, a thrown
|
|
315
|
+
* `RpcError` is the error response, and any other throw becomes `-32603`. `ctx.signal` aborts on `$/cancel` or
|
|
316
|
+
* close; `ctx.progress(chunk)` sends a `$/progress` for this request.
|
|
317
|
+
* @param {string} method
|
|
318
|
+
* @param {(params: any, ctx: RpcContext) => unknown} handler
|
|
319
|
+
* @returns {() => void} unregister
|
|
320
|
+
*/
|
|
321
|
+
onRequest(method, handler) {
|
|
322
|
+
if (typeof handler !== 'function') throw new TypeError('onRequest: handler must be a function');
|
|
323
|
+
this.#handlers.set(method, handler);
|
|
324
|
+
return () => { if (this.#handlers.get(method) === handler) this.#handlers.delete(method); };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** @param {string} method @param {(params: any) => void} handler @returns {() => void} */
|
|
328
|
+
onNotification(method, handler) {
|
|
329
|
+
this.#notifications.set(method, handler);
|
|
330
|
+
return () => { if (this.#notifications.get(method) === handler) this.#notifications.delete(method); };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Catch-all for requests with no `onRequest` handler: `(method, params, ctx) => result`. Pass `null` to clear. */
|
|
334
|
+
setFallbackRequestHandler(fn) { this.#fallbackRequest = fn; }
|
|
335
|
+
|
|
336
|
+
/** Catch-all for notifications with no `onNotification` handler: `(method, params) => void`. */
|
|
337
|
+
setFallbackNotificationHandler(fn) { this.#fallbackNotification = fn; }
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Listen for `$/progress` chunks of a request already in flight (or pass `onProgress` to `request()`).
|
|
341
|
+
* @param {string | number} id - `request(...).id`
|
|
342
|
+
* @param {(chunk: any) => void} fn
|
|
343
|
+
*/
|
|
344
|
+
onProgress(id, fn) {
|
|
345
|
+
const p = this.#pending.get(id);
|
|
346
|
+
if (p) p.onProgress = fn;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Queue inbound messages instead of dispatching them, until `release()`. A host holds a fresh channel while its
|
|
351
|
+
* (possibly async) protocol handler registers methods, so a peer's first request cannot beat the registration.
|
|
352
|
+
*/
|
|
353
|
+
hold() { this.#held = true; }
|
|
354
|
+
|
|
355
|
+
/** Dispatch everything queued since `hold()`, in arrival order, and resume normal delivery. */
|
|
356
|
+
release() {
|
|
357
|
+
this.#held = false;
|
|
358
|
+
for (const item of this.#heldItems.splice(0)) {
|
|
359
|
+
if (this.#closed) return;
|
|
360
|
+
this.#dispatch(item);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ── Outbound ────────────────────────────────────────────────────────
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Send a request. The returned promise carries `.id` and `.cancel()`.
|
|
368
|
+
* Rejects with `RpcError`: the peer's error response, `-32001` on cancel / timeout / channel close.
|
|
369
|
+
* @param {string} method
|
|
370
|
+
* @param {unknown} [params]
|
|
371
|
+
* @param {{ onProgress?: (chunk: any) => void, signal?: AbortSignal, timeoutMs?: number, id?: string | number }} [opts]
|
|
372
|
+
* @returns {Promise<any> & { id: string | number, cancel(reason?: string): boolean }}
|
|
373
|
+
*/
|
|
374
|
+
request(method, params, { onProgress, signal, timeoutMs, id } = {}) {
|
|
375
|
+
if (typeof method !== 'string' || !method) throw new TypeError('request: method must be a non-empty string');
|
|
376
|
+
if (id !== undefined && !isId(id)) throw new TypeError('request: id must be a string or number');
|
|
377
|
+
const rid = id ?? this.#nextId++;
|
|
378
|
+
const promise = new Promise((resolve, reject) => this.#registerPending(rid, method, params, { resolve, reject, onProgress, signal, timeoutMs }));
|
|
379
|
+
promise.id = rid;
|
|
380
|
+
promise.cancel = (reason) => this.cancel(rid, reason);
|
|
381
|
+
return promise;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
#registerPending(id, method, params, { resolve, reject, onProgress, signal, timeoutMs }) {
|
|
385
|
+
if (this.#closed) return reject(closedError(this.#closeReason));
|
|
386
|
+
if (this.#pending.has(id)) return reject(new RpcError(RPC_ERROR.INVALID_REQUEST, `request id ${id} is already in flight`));
|
|
387
|
+
const entry = { method, resolve, reject, onProgress, timer: null, signal, onAbort: null };
|
|
388
|
+
let bytes;
|
|
389
|
+
try {
|
|
390
|
+
const msg = { jsonrpc: '2.0', id, method };
|
|
391
|
+
if (params !== undefined) msg.params = params;
|
|
392
|
+
bytes = this.#encode(msg);
|
|
393
|
+
} catch (e) { return reject(e); }
|
|
394
|
+
this.#pending.set(id, entry);
|
|
395
|
+
const ms = timeoutMs ?? this.#timeoutMs;
|
|
396
|
+
if (ms > 0) entry.timer = setTimeout(() => this.cancel(id, 'timeout', `request "${method}" timed out after ${ms} ms`), ms);
|
|
397
|
+
if (signal) {
|
|
398
|
+
if (signal.aborted) { queueMicrotask(() => this.cancel(id, 'aborted')); } else {
|
|
399
|
+
entry.onAbort = () => this.cancel(id, 'aborted');
|
|
400
|
+
signal.addEventListener('abort', entry.onAbort, { once: true });
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
this.#enqueue(bytes).catch((e) => this.#settlePending(id, (p) => p.reject(e instanceof RpcError ? e : closedError(e?.message))));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
#settlePending(id, fn) {
|
|
407
|
+
const p = this.#pending.get(id);
|
|
408
|
+
if (!p) return false;
|
|
409
|
+
this.#pending.delete(id);
|
|
410
|
+
clearTimeout(p.timer);
|
|
411
|
+
if (p.onAbort) p.signal.removeEventListener('abort', p.onAbort);
|
|
412
|
+
fn(p);
|
|
413
|
+
return true;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Cancel an outbound request: tell the callee (`$/cancel`), reject it locally with `-32001`, and drop any late
|
|
418
|
+
* response. Returns false if `id` is not in flight.
|
|
419
|
+
* @param {string | number} id
|
|
420
|
+
* @param {string} [reason]
|
|
421
|
+
*/
|
|
422
|
+
cancel(id, reason = 'cancelled', message) {
|
|
423
|
+
const done = this.#settlePending(id, (p) => p.reject(new RpcError(RPC_ERROR.CANCELLED, message ?? `request "${p.method}" cancelled`, { reason })));
|
|
424
|
+
if (done && !this.#closed) this.notify('$/cancel', { id });
|
|
425
|
+
return done;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Send a notification (no response).
|
|
430
|
+
* @param {string} method
|
|
431
|
+
* @param {unknown} [params]
|
|
432
|
+
* @returns {Promise<void>}
|
|
433
|
+
*/
|
|
434
|
+
notify(method, params) {
|
|
435
|
+
if (typeof method !== 'string' || !method) throw new TypeError('notify: method must be a non-empty string');
|
|
436
|
+
if (this.#closed) return Promise.resolve();
|
|
437
|
+
const msg = { jsonrpc: '2.0', method };
|
|
438
|
+
if (params !== undefined) msg.params = params;
|
|
439
|
+
return this.#enqueue(this.#encode(msg)).catch(() => {});
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* End the channel: pending requests reject with `-32001` / `channel-closed`, in-flight handlers are aborted, and
|
|
444
|
+
* the underlying stream is closed.
|
|
445
|
+
* @param {string} [reason]
|
|
446
|
+
*/
|
|
447
|
+
async close(reason = 'closed') {
|
|
448
|
+
if (this.#closing) return this.#closing;
|
|
449
|
+
if (this.#closed) return;
|
|
450
|
+
this.#closing = (async () => {
|
|
451
|
+
const flush = this.#writes;
|
|
452
|
+
this.#teardown(reason);
|
|
453
|
+
await Promise.race([flush, new Promise((r) => setTimeout(r, 1000))]);
|
|
454
|
+
try { await this.#closeFn(); } catch { /* already gone */ }
|
|
455
|
+
})();
|
|
456
|
+
return this.#closing;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** For the transport: the underlying stream ended or was reset. */
|
|
460
|
+
handleClose(reason = 'stream-closed') {
|
|
461
|
+
this.#teardown(reason);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
#teardown(reason) {
|
|
465
|
+
if (this.#closed) return;
|
|
466
|
+
this.#closed = true;
|
|
467
|
+
this.#closeReason = reason;
|
|
468
|
+
for (const id of [...this.#pending.keys()]) this.#settlePending(id, (p) => p.reject(closedError(reason)));
|
|
469
|
+
for (const rec of this.#inflight.values()) { rec.done = true; rec.ctl.abort(closedError(reason)); }
|
|
470
|
+
this.#inflight.clear();
|
|
471
|
+
const cb = this.onClose;
|
|
472
|
+
this.onClose = null;
|
|
473
|
+
try { cb?.(reason); } catch { /* a listener's problem */ }
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ── Inbound ─────────────────────────────────────────────────────────
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* For the transport: bytes received from the peer.
|
|
480
|
+
* @param {Uint8Array} bytes
|
|
481
|
+
*/
|
|
482
|
+
feed(bytes) {
|
|
483
|
+
if (this.#closed) return;
|
|
484
|
+
let items;
|
|
485
|
+
try {
|
|
486
|
+
items = this.#decoder.feed(bytes);
|
|
487
|
+
} catch (e) {
|
|
488
|
+
const err = e instanceof RpcError ? e : new RpcError(RPC_ERROR.PARSE, String(e?.message ?? e));
|
|
489
|
+
this.#sendRaw({ jsonrpc: '2.0', id: null, error: err.toJSON() });
|
|
490
|
+
void this.close(err.code === RPC_ERROR.PARSE ? 'parse-error' : (err.reason ?? 'protocol-error'));
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
for (const item of items) {
|
|
494
|
+
if (this.#closed) return;
|
|
495
|
+
if (this.#held) this.#heldItems.push(item);
|
|
496
|
+
else this.#dispatch(item);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
#dispatch(m) {
|
|
501
|
+
if (!isObj(m) || m.jsonrpc !== '2.0') return this.#invalid(m);
|
|
502
|
+
const hasMethod = typeof m.method === 'string';
|
|
503
|
+
if (hasMethod) {
|
|
504
|
+
if (m.params !== undefined && m.params !== null && typeof m.params !== 'object') return this.#invalid(m);
|
|
505
|
+
if (m.id === undefined) return this.#notification(m);
|
|
506
|
+
if (!isId(m.id)) return this.#sendRaw({ jsonrpc: '2.0', id: null, error: { code: RPC_ERROR.INVALID_REQUEST, message: 'invalid request id' } });
|
|
507
|
+
return this.#request(m);
|
|
508
|
+
}
|
|
509
|
+
if (m.method !== undefined) return this.#invalid(m);
|
|
510
|
+
// A response.
|
|
511
|
+
if (m.id === null || m.id === undefined) return; // an error about a message we could not have correlated
|
|
512
|
+
if (!isId(m.id) || ('result' in m) === ('error' in m)) return;
|
|
513
|
+
this.#settlePending(m.id, (p) => {
|
|
514
|
+
if ('error' in m) p.reject(RpcError.fromWire(m.error));
|
|
515
|
+
else p.resolve(m.result);
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
#invalid(m) {
|
|
520
|
+
const id = isObj(m) && isId(m.id) ? m.id : null;
|
|
521
|
+
this.#sendRaw({ jsonrpc: '2.0', id, error: { code: RPC_ERROR.INVALID_REQUEST, message: 'invalid JSON-RPC 2.0 message' } });
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
#notification(m) {
|
|
525
|
+
const params = m.params ?? undefined;
|
|
526
|
+
if (m.method === '$/cancel') {
|
|
527
|
+
const id = params?.id;
|
|
528
|
+
const rec = isId(id) ? this.#inflight.get(id) : undefined;
|
|
529
|
+
if (!rec || rec.done) return;
|
|
530
|
+
rec.done = true;
|
|
531
|
+
this.#inflight.delete(id);
|
|
532
|
+
rec.ctl.abort(new RpcError(RPC_ERROR.CANCELLED, 'request cancelled by the caller', { reason: 'cancelled' }));
|
|
533
|
+
this.#sendRaw({ jsonrpc: '2.0', id, error: { code: RPC_ERROR.CANCELLED, message: 'request cancelled', data: { reason: 'cancelled' } } });
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
if (m.method === '$/progress') {
|
|
537
|
+
const p = isId(params?.id) ? this.#pending.get(params.id) : undefined;
|
|
538
|
+
if (p?.onProgress) {
|
|
539
|
+
try { p.onProgress(params.chunk); } catch { /* a listener's problem */ }
|
|
540
|
+
}
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const handler = this.#notifications.get(m.method);
|
|
544
|
+
try {
|
|
545
|
+
if (handler) handler(params);
|
|
546
|
+
else this.#fallbackNotification?.(m.method, params);
|
|
547
|
+
} catch { /* a notification has no one to tell */ }
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
#request(m) {
|
|
551
|
+
const { id, method } = m;
|
|
552
|
+
const params = m.params ?? undefined;
|
|
553
|
+
const respond = (msg) => this.#sendRaw(msg);
|
|
554
|
+
if (this.#inflight.has(id)) return respond({ jsonrpc: '2.0', id, error: { code: RPC_ERROR.INVALID_REQUEST, message: `request id ${id} is already in flight` } });
|
|
555
|
+
if (this.#inflight.size >= this.#maxInflight) {
|
|
556
|
+
return respond({ jsonrpc: '2.0', id, error: { code: RPC_ERROR.STREAM_LIMIT, message: `more than ${this.#maxInflight} requests in flight`, data: { reason: 'stream-limit' } } });
|
|
557
|
+
}
|
|
558
|
+
const handler = this.#handlers.get(method);
|
|
559
|
+
const fallback = this.#fallbackRequest;
|
|
560
|
+
if (!handler && !fallback) return respond({ jsonrpc: '2.0', id, error: { code: RPC_ERROR.METHOD_NOT_FOUND, message: `method not found: ${method}` } });
|
|
561
|
+
const ctl = new AbortController();
|
|
562
|
+
const rec = { ctl, done: false };
|
|
563
|
+
this.#inflight.set(id, rec);
|
|
564
|
+
/** @type {RpcContext} */
|
|
565
|
+
const ctx = {
|
|
566
|
+
id, method, signal: ctl.signal, channel: this,
|
|
567
|
+
progress: (chunk) => (rec.done || this.#closed ? Promise.resolve() : this.#progress(id, chunk)),
|
|
568
|
+
};
|
|
569
|
+
(async () => {
|
|
570
|
+
let msg;
|
|
571
|
+
try {
|
|
572
|
+
const result = handler ? await handler(params, ctx) : await fallback(method, params, ctx);
|
|
573
|
+
msg = { jsonrpc: '2.0', id, result: result === undefined ? null : result };
|
|
574
|
+
} catch (e) {
|
|
575
|
+
const err = e instanceof RpcError ? e : new RpcError(RPC_ERROR.INTERNAL, String(e?.message ?? e));
|
|
576
|
+
msg = { jsonrpc: '2.0', id, error: err.toJSON() };
|
|
577
|
+
}
|
|
578
|
+
if (rec.done) return; // cancelled or closed while working: the answer was already sent (or is moot)
|
|
579
|
+
rec.done = true;
|
|
580
|
+
this.#inflight.delete(id);
|
|
581
|
+
try {
|
|
582
|
+
this.#enqueue(this.#encode(msg)).catch(() => {});
|
|
583
|
+
} catch (e) {
|
|
584
|
+
if (!(e instanceof RpcError)) throw e;
|
|
585
|
+
this.#sendRaw({ jsonrpc: '2.0', id, error: { code: RPC_ERROR.INTERNAL, message: e.message, data: { reason: e.reason } } });
|
|
586
|
+
}
|
|
587
|
+
})();
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
#progress(id, chunk) {
|
|
591
|
+
try {
|
|
592
|
+
return this.#enqueue(this.#encode({ jsonrpc: '2.0', method: '$/progress', params: { id, chunk } }));
|
|
593
|
+
} catch (e) {
|
|
594
|
+
return Promise.reject(e);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// ── Wire ────────────────────────────────────────────────────────────
|
|
599
|
+
|
|
600
|
+
#encode(msg) {
|
|
601
|
+
const bytes = cborEncode(prune(msg));
|
|
602
|
+
if (bytes.length > this.#max) throw tooLarge('message', bytes.length, this.#max);
|
|
603
|
+
return bytes;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** Best-effort send of a small control message (errors, never throws). */
|
|
607
|
+
#sendRaw(msg) {
|
|
608
|
+
if (this.#closed && msg.error?.code !== RPC_ERROR.PARSE && msg.error?.code !== RPC_ERROR.INVALID_REQUEST) return;
|
|
609
|
+
try { this.#enqueue(cborEncode(prune(msg))).catch(() => {}); } catch { /* nothing to be done */ }
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
#enqueue(bytes) {
|
|
613
|
+
const p = this.#writes.then(() => this.#write(bytes));
|
|
614
|
+
this.#writes = p.catch(() => {});
|
|
615
|
+
return p;
|
|
616
|
+
}
|
|
617
|
+
}
|