@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.
@@ -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
+ }