@johnhenry/wsh 0.0.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/LICENSE +21 -0
- package/README.md +150 -0
- package/package.json +42 -0
- package/src/auth.mjs +314 -0
- package/src/cbor.mjs +389 -0
- package/src/client.mjs +1842 -0
- package/src/file-transfer.mjs +413 -0
- package/src/index.d.ts +1454 -0
- package/src/index.mjs +70 -0
- package/src/keystore.mjs +473 -0
- package/src/mcp-bridge.mjs +228 -0
- package/src/messages.gen.mjs +928 -0
- package/src/messages.mjs +3 -0
- package/src/recording.mjs +402 -0
- package/src/session.mjs +557 -0
- package/src/transport-ws.mjs +462 -0
- package/src/transport.mjs +343 -0
- package/src/virtual-session.mjs +160 -0
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wsh transport — WebSocket fallback with multiplexed virtual streams.
|
|
3
|
+
*
|
|
4
|
+
* Wire format per frame:
|
|
5
|
+
* [1-byte type][4-byte stream_id (big-endian)][payload]
|
|
6
|
+
*
|
|
7
|
+
* Frame types:
|
|
8
|
+
* 0x01 = CONTROL — CBOR-framed message on stream 0
|
|
9
|
+
* 0x02 = DATA — raw bytes on a numbered stream
|
|
10
|
+
* 0x03 = OPEN_STREAM — request to open a new stream (payload = empty)
|
|
11
|
+
* 0x04 = CLOSE_STREAM — notify stream closure (payload = empty)
|
|
12
|
+
*
|
|
13
|
+
* Stream 0 is reserved for control messages and uses length-prefixed
|
|
14
|
+
* CBOR framing (frameEncode / FrameDecoder) inside the payload.
|
|
15
|
+
* Data streams carry raw bytes with no additional framing.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { frameEncode, FrameDecoder } from './cbor.mjs';
|
|
19
|
+
import { WshTransport } from './transport.mjs';
|
|
20
|
+
|
|
21
|
+
// ── Frame type constants ─────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
const FRAME_CONTROL = 0x01;
|
|
24
|
+
const FRAME_DATA = 0x02;
|
|
25
|
+
const FRAME_OPEN_STREAM = 0x03;
|
|
26
|
+
const FRAME_CLOSE_STREAM = 0x04;
|
|
27
|
+
|
|
28
|
+
// ── Header helpers ───────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
const HEADER_SIZE = 5; // 1 byte type + 4 bytes stream ID
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build a multiplexing frame.
|
|
34
|
+
* @param {number} type Frame type byte.
|
|
35
|
+
* @param {number} streamId 32-bit stream ID.
|
|
36
|
+
* @param {Uint8Array} [payload] Optional payload bytes.
|
|
37
|
+
* @returns {Uint8Array}
|
|
38
|
+
*/
|
|
39
|
+
function buildFrame(type, streamId, payload) {
|
|
40
|
+
const payloadLen = payload ? payload.byteLength : 0;
|
|
41
|
+
const frame = new Uint8Array(HEADER_SIZE + payloadLen);
|
|
42
|
+
const view = new DataView(frame.buffer);
|
|
43
|
+
view.setUint8(0, type);
|
|
44
|
+
view.setUint32(1, streamId);
|
|
45
|
+
if (payload) {
|
|
46
|
+
frame.set(payload, HEADER_SIZE);
|
|
47
|
+
}
|
|
48
|
+
return frame;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Parse a multiplexing frame header + payload.
|
|
53
|
+
* @param {Uint8Array} data
|
|
54
|
+
* @returns {{ type: number, streamId: number, payload: Uint8Array }}
|
|
55
|
+
*/
|
|
56
|
+
function parseFrame(data) {
|
|
57
|
+
if (data.byteLength < HEADER_SIZE) {
|
|
58
|
+
throw new Error(`Frame too short: ${data.byteLength} bytes`);
|
|
59
|
+
}
|
|
60
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
61
|
+
return {
|
|
62
|
+
type: view.getUint8(0),
|
|
63
|
+
streamId: view.getUint32(1),
|
|
64
|
+
payload: data.subarray(HEADER_SIZE),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── Virtual stream ───────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A virtual bidirectional stream multiplexed over a single WebSocket.
|
|
72
|
+
*
|
|
73
|
+
* Exposes standard ReadableStream / WritableStream interfaces so that
|
|
74
|
+
* consumers see the same API as native WebTransport streams.
|
|
75
|
+
*/
|
|
76
|
+
class VirtualStream {
|
|
77
|
+
/** @type {number} */
|
|
78
|
+
id;
|
|
79
|
+
|
|
80
|
+
/** @type {ReadableStream<Uint8Array>} */
|
|
81
|
+
readable;
|
|
82
|
+
|
|
83
|
+
/** @type {WritableStream<Uint8Array>} */
|
|
84
|
+
writable;
|
|
85
|
+
|
|
86
|
+
/** @type {ReadableStreamDefaultController} */
|
|
87
|
+
#readController = null;
|
|
88
|
+
|
|
89
|
+
/** @type {boolean} */
|
|
90
|
+
#readClosed = false;
|
|
91
|
+
|
|
92
|
+
/** @type {boolean} */
|
|
93
|
+
#writeClosed = false;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @param {number} id
|
|
97
|
+
* @param {function(number, Uint8Array): void} sendData Send data frame callback.
|
|
98
|
+
* @param {function(number): void} sendClose Send close frame callback.
|
|
99
|
+
*/
|
|
100
|
+
constructor(id, sendData, sendClose) {
|
|
101
|
+
this.id = id;
|
|
102
|
+
|
|
103
|
+
this.readable = new ReadableStream({
|
|
104
|
+
start: (controller) => {
|
|
105
|
+
this.#readController = controller;
|
|
106
|
+
},
|
|
107
|
+
cancel: () => {
|
|
108
|
+
this.#readClosed = true;
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
this.writable = new WritableStream({
|
|
113
|
+
write: (chunk) => {
|
|
114
|
+
if (this.#writeClosed) {
|
|
115
|
+
throw new Error(`Stream ${id} writable is closed`);
|
|
116
|
+
}
|
|
117
|
+
const bytes = chunk instanceof Uint8Array
|
|
118
|
+
? chunk
|
|
119
|
+
: new Uint8Array(chunk);
|
|
120
|
+
sendData(id, bytes);
|
|
121
|
+
},
|
|
122
|
+
close: () => {
|
|
123
|
+
this.#writeClosed = true;
|
|
124
|
+
sendClose(id);
|
|
125
|
+
},
|
|
126
|
+
abort: () => {
|
|
127
|
+
this.#writeClosed = true;
|
|
128
|
+
sendClose(id);
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Push inbound data into the readable side.
|
|
135
|
+
* @param {Uint8Array} data
|
|
136
|
+
*/
|
|
137
|
+
_pushData(data) {
|
|
138
|
+
if (this.#readClosed || !this.#readController) return;
|
|
139
|
+
try {
|
|
140
|
+
this.#readController.enqueue(data);
|
|
141
|
+
} catch {
|
|
142
|
+
// Controller may already be closed; ignore.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Signal the remote closed this stream's readable side.
|
|
148
|
+
*/
|
|
149
|
+
_closeRead() {
|
|
150
|
+
if (this.#readClosed) return;
|
|
151
|
+
this.#readClosed = true;
|
|
152
|
+
try {
|
|
153
|
+
this.#readController?.close();
|
|
154
|
+
} catch {
|
|
155
|
+
// Already closed.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Forcibly error both sides (used on transport close).
|
|
161
|
+
* @param {Error} err
|
|
162
|
+
*/
|
|
163
|
+
_destroy(err) {
|
|
164
|
+
if (!this.#readClosed) {
|
|
165
|
+
this.#readClosed = true;
|
|
166
|
+
try {
|
|
167
|
+
this.#readController?.error(err);
|
|
168
|
+
} catch { /* ignore */ }
|
|
169
|
+
}
|
|
170
|
+
this.#writeClosed = true;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── WebSocket transport ──────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* wsh transport over a single WebSocket with multiplexed virtual streams.
|
|
178
|
+
*
|
|
179
|
+
* Provides the same interface as WebTransportTransport so that upper
|
|
180
|
+
* layers (session, client) work identically over either transport.
|
|
181
|
+
*/
|
|
182
|
+
export class WebSocketTransport extends WshTransport {
|
|
183
|
+
/** @type {WebSocket} */
|
|
184
|
+
#ws = null;
|
|
185
|
+
|
|
186
|
+
/** @type {Map<number, VirtualStream>} Active virtual streams by ID. */
|
|
187
|
+
#streams = new Map();
|
|
188
|
+
|
|
189
|
+
/** @type {FrameDecoder} Decoder for inbound control messages on stream 0. */
|
|
190
|
+
#decoder = new FrameDecoder();
|
|
191
|
+
|
|
192
|
+
/** Next stream ID for locally-opened streams (odd = client, even = server). */
|
|
193
|
+
#nextLocalId = 1;
|
|
194
|
+
|
|
195
|
+
/** Resolvers for pending open-stream requests. */
|
|
196
|
+
#openResolvers = new Map();
|
|
197
|
+
|
|
198
|
+
/** Tracks whether we initiated the close. */
|
|
199
|
+
#closedByUs = false;
|
|
200
|
+
|
|
201
|
+
// ── Lifecycle ──────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
/** @override */
|
|
204
|
+
async _doConnect(url) {
|
|
205
|
+
this.#streams.clear();
|
|
206
|
+
this.#decoder.reset();
|
|
207
|
+
this.#nextLocalId = 1;
|
|
208
|
+
this.#openResolvers.clear();
|
|
209
|
+
this.#closedByUs = false;
|
|
210
|
+
|
|
211
|
+
return new Promise((resolve, reject) => {
|
|
212
|
+
// Normalize URL scheme: wsh:// → wss://, http:// → ws://.
|
|
213
|
+
const wsUrl = url
|
|
214
|
+
.replace(/^wsh:\/\//, 'wss://')
|
|
215
|
+
.replace(/^https:\/\//, 'wss://')
|
|
216
|
+
.replace(/^http:\/\//, 'ws://');
|
|
217
|
+
|
|
218
|
+
const ws = new WebSocket(wsUrl);
|
|
219
|
+
ws.binaryType = 'arraybuffer';
|
|
220
|
+
this.#ws = ws;
|
|
221
|
+
|
|
222
|
+
ws.addEventListener('open', () => resolve(), { once: true });
|
|
223
|
+
|
|
224
|
+
ws.addEventListener('error', (ev) => {
|
|
225
|
+
if (this.state === 'connecting') {
|
|
226
|
+
reject(new Error('WebSocket connection failed'));
|
|
227
|
+
} else {
|
|
228
|
+
this._emitError(new Error('WebSocket error'));
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
ws.addEventListener('close', (ev) => {
|
|
233
|
+
this.#handleClose(ev.code, ev.reason);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
ws.addEventListener('message', (ev) => {
|
|
237
|
+
this.#handleMessage(ev.data);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** @override */
|
|
243
|
+
async _doClose() {
|
|
244
|
+
this.#closedByUs = true;
|
|
245
|
+
this.#destroyAllStreams(new Error('Transport closed'));
|
|
246
|
+
this.#rejectAllOpens(new Error('Transport closed'));
|
|
247
|
+
|
|
248
|
+
if (this.#ws) {
|
|
249
|
+
try {
|
|
250
|
+
this.#ws.close(1000, 'client close');
|
|
251
|
+
} catch { /* may already be closed */ }
|
|
252
|
+
this.#ws = null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
this.#decoder.reset();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** @override */
|
|
259
|
+
async _doSendControl(msg) {
|
|
260
|
+
const cbor = frameEncode(msg);
|
|
261
|
+
const frame = buildFrame(FRAME_CONTROL, 0, cbor);
|
|
262
|
+
this.#send(frame);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** @override */
|
|
266
|
+
async _doOpenStream() {
|
|
267
|
+
const id = this.#nextLocalId;
|
|
268
|
+
this.#nextLocalId += 2; // odd IDs for client
|
|
269
|
+
|
|
270
|
+
// Send open request.
|
|
271
|
+
this.#send(buildFrame(FRAME_OPEN_STREAM, id));
|
|
272
|
+
|
|
273
|
+
// Create the virtual stream.
|
|
274
|
+
const stream = new VirtualStream(
|
|
275
|
+
id,
|
|
276
|
+
(sid, data) => this.#sendData(sid, data),
|
|
277
|
+
(sid) => this.#sendCloseStream(sid),
|
|
278
|
+
);
|
|
279
|
+
this.#streams.set(id, stream);
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
readable: stream.readable,
|
|
283
|
+
writable: stream.writable,
|
|
284
|
+
id,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── Inbound message dispatch ───────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Handle a raw WebSocket message (ArrayBuffer).
|
|
292
|
+
* @param {ArrayBuffer} raw
|
|
293
|
+
*/
|
|
294
|
+
#handleMessage(raw) {
|
|
295
|
+
if (typeof raw === 'string') {
|
|
296
|
+
this._emitError(new Error('Received text WebSocket frame; expected binary'));
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const data = new Uint8Array(raw);
|
|
300
|
+
|
|
301
|
+
let frame;
|
|
302
|
+
try {
|
|
303
|
+
frame = parseFrame(data);
|
|
304
|
+
} catch (err) {
|
|
305
|
+
this._emitError(new Error(`Malformed frame: ${err.message}`));
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
switch (frame.type) {
|
|
310
|
+
case FRAME_CONTROL:
|
|
311
|
+
this.#handleControlFrame(frame.payload);
|
|
312
|
+
break;
|
|
313
|
+
case FRAME_DATA:
|
|
314
|
+
this.#handleDataFrame(frame.streamId, frame.payload);
|
|
315
|
+
break;
|
|
316
|
+
case FRAME_OPEN_STREAM:
|
|
317
|
+
this.#handleOpenStream(frame.streamId);
|
|
318
|
+
break;
|
|
319
|
+
case FRAME_CLOSE_STREAM:
|
|
320
|
+
this.#handleCloseStream(frame.streamId);
|
|
321
|
+
break;
|
|
322
|
+
default:
|
|
323
|
+
this._emitError(new Error(`Unknown frame type: 0x${frame.type.toString(16)}`));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Decode CBOR-framed control messages from stream 0.
|
|
329
|
+
* @param {Uint8Array} payload
|
|
330
|
+
*/
|
|
331
|
+
#handleControlFrame(payload) {
|
|
332
|
+
const messages = this.#decoder.feed(payload);
|
|
333
|
+
for (const msg of messages) {
|
|
334
|
+
this._emitControl(msg);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Route data to the appropriate virtual stream.
|
|
340
|
+
* @param {number} streamId
|
|
341
|
+
* @param {Uint8Array} payload
|
|
342
|
+
*/
|
|
343
|
+
#handleDataFrame(streamId, payload) {
|
|
344
|
+
const stream = this.#streams.get(streamId);
|
|
345
|
+
if (!stream) {
|
|
346
|
+
// Data for unknown stream; ignore silently (may arrive after close).
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
stream._pushData(payload);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Server is opening a new stream (even IDs = server-initiated).
|
|
354
|
+
* @param {number} streamId
|
|
355
|
+
*/
|
|
356
|
+
#handleOpenStream(streamId) {
|
|
357
|
+
if (this.#streams.has(streamId)) return; // already tracked
|
|
358
|
+
|
|
359
|
+
const stream = new VirtualStream(
|
|
360
|
+
streamId,
|
|
361
|
+
(sid, data) => this.#sendData(sid, data),
|
|
362
|
+
(sid) => this.#sendCloseStream(sid),
|
|
363
|
+
);
|
|
364
|
+
this.#streams.set(streamId, stream);
|
|
365
|
+
|
|
366
|
+
this._emitStreamOpen({
|
|
367
|
+
readable: stream.readable,
|
|
368
|
+
writable: stream.writable,
|
|
369
|
+
id: streamId,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Remote closed a stream.
|
|
375
|
+
* @param {number} streamId
|
|
376
|
+
*/
|
|
377
|
+
#handleCloseStream(streamId) {
|
|
378
|
+
const stream = this.#streams.get(streamId);
|
|
379
|
+
if (!stream) return;
|
|
380
|
+
stream._closeRead();
|
|
381
|
+
this.#streams.delete(streamId);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* WebSocket closed.
|
|
386
|
+
* @param {number} code
|
|
387
|
+
* @param {string} reason
|
|
388
|
+
*/
|
|
389
|
+
#handleClose(code, reason) {
|
|
390
|
+
const err = new Error(`WebSocket closed: ${code} ${reason}`);
|
|
391
|
+
this.#destroyAllStreams(err);
|
|
392
|
+
this.#rejectAllOpens(err);
|
|
393
|
+
this.#decoder.reset();
|
|
394
|
+
this.#ws = null;
|
|
395
|
+
|
|
396
|
+
if (this.state !== 'closed') {
|
|
397
|
+
this._setState('closed');
|
|
398
|
+
if (!this.#closedByUs) {
|
|
399
|
+
this._emitError(err);
|
|
400
|
+
}
|
|
401
|
+
this._emitClose();
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ── Outbound helpers ───────────────────────────────────────────────
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Send raw bytes over the WebSocket.
|
|
409
|
+
* @param {Uint8Array} data
|
|
410
|
+
*/
|
|
411
|
+
#send(data) {
|
|
412
|
+
if (!this.#ws || this.#ws.readyState !== WebSocket.OPEN) {
|
|
413
|
+
throw new Error('WebSocket is not open');
|
|
414
|
+
}
|
|
415
|
+
this.#ws.send(data);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Send a data frame for a virtual stream.
|
|
420
|
+
* @param {number} streamId
|
|
421
|
+
* @param {Uint8Array} payload
|
|
422
|
+
*/
|
|
423
|
+
#sendData(streamId, payload) {
|
|
424
|
+
this.#send(buildFrame(FRAME_DATA, streamId, payload));
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Send a close frame for a virtual stream.
|
|
429
|
+
* @param {number} streamId
|
|
430
|
+
*/
|
|
431
|
+
#sendCloseStream(streamId) {
|
|
432
|
+
try {
|
|
433
|
+
this.#send(buildFrame(FRAME_CLOSE_STREAM, streamId));
|
|
434
|
+
} catch {
|
|
435
|
+
// WebSocket may already be closed.
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ── Cleanup ────────────────────────────────────────────────────────
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Destroy all active virtual streams with an error.
|
|
443
|
+
* @param {Error} err
|
|
444
|
+
*/
|
|
445
|
+
#destroyAllStreams(err) {
|
|
446
|
+
for (const stream of this.#streams.values()) {
|
|
447
|
+
stream._destroy(err);
|
|
448
|
+
}
|
|
449
|
+
this.#streams.clear();
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Reject all pending open-stream promises.
|
|
454
|
+
* @param {Error} err
|
|
455
|
+
*/
|
|
456
|
+
#rejectAllOpens(err) {
|
|
457
|
+
for (const { reject } of this.#openResolvers.values()) {
|
|
458
|
+
reject(err);
|
|
459
|
+
}
|
|
460
|
+
this.#openResolvers.clear();
|
|
461
|
+
}
|
|
462
|
+
}
|