@johnhenry/wsh 0.6.0 → 0.13.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.
@@ -1,83 +1,52 @@
1
1
  /**
2
2
  * wsh transport — WebSocket fallback with multiplexed virtual streams.
3
3
  *
4
- * Wire format per frame:
5
- * [1-byte type][4-byte stream_id (big-endian)][payload]
4
+ * Wire format: QMux (draft-ietf-quic-qmux-02) framing directly over the
5
+ * WebSocket's binary message stream -- see qmux.mjs for the wire codec
6
+ * and qmux-connection.mjs for the stream state machine and flow control
7
+ * this module wires into the DOM Streams API (ReadableStream/
8
+ * WritableStream) the rest of wsh expects from a transport.
6
9
  *
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.
10
+ * Stream 0 (the first client-initiated bidirectional QMux stream) is
11
+ * always opened on connect and carries CBOR control messages using the
12
+ * same length-prefixed framing as before (frameEncode/FrameDecoder) --
13
+ * QMux streams are raw byte pipes with no message-boundary framing of
14
+ * their own, so that inner framing is still needed to know where one
15
+ * control message ends and the next begins. Every other stream (opened
16
+ * via openStream(), or peer-initiated for reverse-mode/server-pushed
17
+ * channels) carries a session's raw data with no extra framing, exactly
18
+ * as before.
16
19
  */
17
20
 
18
21
  import { frameEncode, FrameDecoder, FrameSizeError } from './cbor.mjs';
19
22
  import { WshTransport, dispatchSerially, SerialQueue } from './transport.mjs';
23
+ import { QMuxConnection } from './qmux-connection.mjs';
20
24
 
21
- // ── Frame type constants ─────────────────────────────────────────────
25
+ // ── Frame type constants (kept for backward-compat callers) ──────────
22
26
  //
23
- // Unrelated to the CBOR-layer MSG opcodes (messages.gen.mjs) -- these are
24
- // the outer envelope's frame-type byte. See spec/wsh-v1.yaml's
25
- // `transport.websocket` section for the authoritative framing docs.
26
-
27
- const FRAME_CONTROL = 0x01;
28
- const FRAME_DATA = 0x02;
29
- const FRAME_OPEN_STREAM = 0x03;
30
- const FRAME_CLOSE_STREAM = 0x04;
31
-
32
- // ── Header helpers ───────────────────────────────────────────────────
33
-
34
- const HEADER_SIZE = 5; // 1 byte type + 4 bytes stream ID
35
-
36
- /**
37
- * Build a multiplexing frame.
38
- * @param {number} type Frame type byte.
39
- * @param {number} streamId 32-bit stream ID.
40
- * @param {Uint8Array} [payload] Optional payload bytes.
41
- * @returns {Uint8Array}
42
- */
43
- function buildFrame(type, streamId, payload) {
44
- const payloadLen = payload ? payload.byteLength : 0;
45
- const frame = new Uint8Array(HEADER_SIZE + payloadLen);
46
- const view = new DataView(frame.buffer);
47
- view.setUint8(0, type);
48
- view.setUint32(1, streamId);
49
- if (payload) {
50
- frame.set(payload, HEADER_SIZE);
51
- }
52
- return frame;
53
- }
54
-
55
- /**
56
- * Parse a multiplexing frame header + payload.
57
- * @param {Uint8Array} data
58
- * @returns {{ type: number, streamId: number, payload: Uint8Array }}
59
- */
60
- function parseFrame(data) {
61
- if (data.byteLength < HEADER_SIZE) {
62
- throw new Error(`Frame too short: ${data.byteLength} bytes`);
63
- }
64
- const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
65
- return {
66
- type: view.getUint8(0),
67
- streamId: view.getUint32(1),
68
- payload: data.subarray(HEADER_SIZE),
69
- };
70
- }
71
-
72
- // ── Virtual stream ───────────────────────────────────────────────────
27
+ // The old hand-rolled 5-byte mux's frame-type byte values. wsh now
28
+ // speaks QMux instead (see the file doc comment) -- this export is kept
29
+ // so nothing importing WS_FRAME_TYPE breaks, but nothing in this module
30
+ // produces or consumes these values anymore. Mirrors QMux's own frame
31
+ // type space where it lines up (STREAM/RESET_STREAM), for whatever
32
+ // documentation value that has; DATA/OPEN_STREAM/CLOSE_STREAM have no
33
+ // QMux equivalent (QMux streams are implicitly opened by reference, and
34
+ // "data" is just STREAM frame payload).
35
+ export const WS_FRAME_TYPE = Object.freeze({
36
+ CONTROL: 0x01,
37
+ DATA: 0x02,
38
+ OPEN_STREAM: 0x03,
39
+ CLOSE_STREAM: 0x04,
40
+ });
41
+
42
+ // ── DOM Streams adapter over a QMuxStream ───────────────────────────
73
43
 
74
44
  /**
75
- * A virtual bidirectional stream multiplexed over a single WebSocket.
76
- *
77
- * Exposes standard ReadableStream / WritableStream interfaces so that
78
- * consumers see the same API as native WebTransport streams.
45
+ * Wraps a qmux-connection.mjs `QMuxStream` (a push/pull data API with
46
+ * no DOM dependency) in the ReadableStream/WritableStream pair the rest
47
+ * of wsh expects from a transport stream.
79
48
  */
80
- class VirtualStream {
49
+ class QMuxStreamAdapter {
81
50
  /** @type {number} */
82
51
  id;
83
52
 
@@ -87,98 +56,52 @@ class VirtualStream {
87
56
  /** @type {WritableStream<Uint8Array>} */
88
57
  writable;
89
58
 
90
- /** @type {ReadableStreamDefaultController} */
91
- #readController = null;
92
-
93
- /** @type {boolean} */
94
- #readClosed = false;
95
-
96
- /** @type {boolean} */
97
- #writeClosed = false;
98
-
99
59
  /**
100
- * @param {number} id
101
- * @param {function(number, Uint8Array): void} sendData Send data frame callback.
102
- * @param {function(number): void} sendClose Send close frame callback.
60
+ * @param {import('./qmux-connection.mjs').QMuxConnection} qs
103
61
  */
104
- constructor(id, sendData, sendClose) {
105
- this.id = id;
62
+ constructor(qs) {
63
+ this.id = qs.id;
106
64
 
107
65
  this.readable = new ReadableStream({
108
66
  start: (controller) => {
109
- this.#readController = controller;
67
+ qs.onData = (data) => {
68
+ try { controller.enqueue(data); } catch { /* controller already closed/errored */ }
69
+ };
70
+ qs.onEnd = () => {
71
+ try { controller.close(); } catch { /* already closed */ }
72
+ };
73
+ qs.onReset = (errorCode) => {
74
+ try { controller.error(new Error(`Stream ${qs.id} was reset by peer (error code ${errorCode})`)); } catch { /* already settled */ }
75
+ };
76
+ qs.onDestroy = (err) => {
77
+ try { controller.error(err); } catch { /* already settled */ }
78
+ };
110
79
  },
111
80
  cancel: () => {
112
- this.#readClosed = true;
81
+ qs.stopSending();
113
82
  },
114
83
  });
115
84
 
116
85
  this.writable = new WritableStream({
117
- write: (chunk) => {
118
- if (this.#writeClosed) {
119
- throw new Error(`Stream ${id} writable is closed`);
120
- }
121
- const bytes = chunk instanceof Uint8Array
122
- ? chunk
123
- : new Uint8Array(chunk);
124
- sendData(id, bytes);
86
+ write: async (chunk) => {
87
+ const bytes = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
88
+ await qs.write(bytes);
125
89
  },
126
- close: () => {
127
- this.#writeClosed = true;
128
- sendClose(id);
90
+ close: async () => {
91
+ await qs.close();
129
92
  },
130
93
  abort: () => {
131
- this.#writeClosed = true;
132
- sendClose(id);
94
+ qs.reset();
133
95
  },
134
96
  });
135
97
  }
136
-
137
- /**
138
- * Push inbound data into the readable side.
139
- * @param {Uint8Array} data
140
- */
141
- _pushData(data) {
142
- if (this.#readClosed || !this.#readController) return;
143
- try {
144
- this.#readController.enqueue(data);
145
- } catch {
146
- // Controller may already be closed; ignore.
147
- }
148
- }
149
-
150
- /**
151
- * Signal the remote closed this stream's readable side.
152
- */
153
- _closeRead() {
154
- if (this.#readClosed) return;
155
- this.#readClosed = true;
156
- try {
157
- this.#readController?.close();
158
- } catch {
159
- // Already closed.
160
- }
161
- }
162
-
163
- /**
164
- * Forcibly error both sides (used on transport close).
165
- * @param {Error} err
166
- */
167
- _destroy(err) {
168
- if (!this.#readClosed) {
169
- this.#readClosed = true;
170
- try {
171
- this.#readController?.error(err);
172
- } catch { /* ignore */ }
173
- }
174
- this.#writeClosed = true;
175
- }
176
98
  }
177
99
 
178
100
  // ── WebSocket transport ──────────────────────────────────────────────
179
101
 
180
102
  /**
181
- * wsh transport over a single WebSocket with multiplexed virtual streams.
103
+ * wsh transport over a single WebSocket with QMux-multiplexed virtual
104
+ * streams.
182
105
  *
183
106
  * Provides the same interface as WebTransportTransport so that upper
184
107
  * layers (session, client) work identically over either transport.
@@ -187,42 +110,37 @@ export class WebSocketTransport extends WshTransport {
187
110
  /** @type {WebSocket} */
188
111
  #ws = null;
189
112
 
190
- /** @type {Map<number, VirtualStream>} Active virtual streams by ID. */
191
- #streams = new Map();
113
+ /** @type {import('./qmux-connection.mjs').QMuxConnection} */
114
+ #qmux = null;
192
115
 
193
- /** @type {FrameDecoder} Decoder for inbound control messages on stream 0. */
194
- #decoder = new FrameDecoder();
195
-
196
- /** Next stream ID for locally-opened streams (odd = client, even = server). */
197
- #nextLocalId = 1;
116
+ /** @type {import('./qmux-connection.mjs').QMuxConnection|null} The control channel (always QMux stream 0). */
117
+ #controlStream = null;
198
118
 
199
- /** Resolvers for pending open-stream requests. */
200
- #openResolvers = new Map();
119
+ /** @type {FrameDecoder} Decoder for CBOR control messages carried inside the control stream's byte pipe. */
120
+ #decoder = new FrameDecoder();
201
121
 
202
122
  /** Tracks whether we initiated the close. */
203
123
  #closedByUs = false;
204
124
 
205
125
  /**
206
- * Serializes dispatch of raw inbound WebSocket messages. Needed because
207
- * some `WebSocket` implementations (notably Node's `ws` package parsing
208
- * several frames out of one TCP read) can fire multiple synchronous
209
- * `message` events within a single JS task, all before any microtask
210
- * gets to run — see `SerialQueue`'s doc comment in transport.mjs for
211
- * why dispatching those inline would be unsafe.
126
+ * Serializes dispatch of inbound control-stream bytes. Needed because
127
+ * a single WebSocket 'message' event can decode into QMux STREAM
128
+ * frames for the control stream whose data callback fires
129
+ * synchronously and could otherwise interleave with — or race —
130
+ * whatever microtask continuation the *previous* chunk's dispatch
131
+ * triggers. See `SerialQueue`'s doc comment in transport.mjs.
212
132
  * @type {SerialQueue}
213
133
  */
214
- #inbox = new SerialQueue((raw) => this.#handleMessage(raw));
134
+ #inbox = new SerialQueue((raw) => this.#handleControlBytes(raw));
215
135
 
216
136
  // ── Lifecycle ──────────────────────────────────────────────────────
217
137
 
218
138
  /** @override */
219
139
  async _doConnect(url) {
220
- this.#streams.clear();
221
140
  this.#decoder.reset();
222
- this.#nextLocalId = 1;
223
- this.#openResolvers.clear();
224
141
  this.#closedByUs = false;
225
142
  this.#inbox.clear();
143
+ this.#controlStream = null;
226
144
 
227
145
  return new Promise((resolve, reject) => {
228
146
  // Normalize URL scheme: wsh:// → wss://, http:// → ws://.
@@ -235,9 +153,39 @@ export class WebSocketTransport extends WshTransport {
235
153
  ws.binaryType = 'arraybuffer';
236
154
  this.#ws = ws;
237
155
 
238
- ws.addEventListener('open', () => resolve(), { once: true });
156
+ const qmux = new QMuxConnection({
157
+ isClient: true,
158
+ send: (bytes) => this.#send(bytes),
159
+ });
160
+ this.#qmux = qmux;
161
+
162
+ qmux.onStreamOpen = (qs) => {
163
+ const adapter = new QMuxStreamAdapter(qs);
164
+ this._emitStreamOpen({ readable: adapter.readable, writable: adapter.writable, id: qs.id });
165
+ };
166
+ qmux.onError = (err) => this._emitError(err);
167
+ qmux.onClose = (errorCode, reason) => {
168
+ this.#handleClose(errorCode, reason || 'peer sent CONNECTION_CLOSE');
169
+ };
170
+
171
+ ws.addEventListener('open', async () => {
172
+ try {
173
+ qmux.sendHandshake();
174
+ // The control channel is always the first client-initiated
175
+ // QMux stream (ID 0) -- opened here, once, up front.
176
+ const controlQs = await qmux.openStream();
177
+ controlQs.onData = (data) => this.#inbox.push(data);
178
+ controlQs.onReset = (errorCode) => {
179
+ this._emitError(new Error(`control stream reset by peer (error code ${errorCode})`));
180
+ };
181
+ this.#controlStream = controlQs;
182
+ resolve();
183
+ } catch (err) {
184
+ reject(err);
185
+ }
186
+ }, { once: true });
239
187
 
240
- ws.addEventListener('error', (ev) => {
188
+ ws.addEventListener('error', () => {
241
189
  if (this.state === 'connecting') {
242
190
  reject(new Error('WebSocket connection failed'));
243
191
  } else {
@@ -246,11 +194,15 @@ export class WebSocketTransport extends WshTransport {
246
194
  });
247
195
 
248
196
  ws.addEventListener('close', (ev) => {
249
- this.#handleClose(ev.code, ev.reason);
197
+ this.#handleClose(null, `WebSocket closed: ${ev.code} ${ev.reason}`);
250
198
  });
251
199
 
252
200
  ws.addEventListener('message', (ev) => {
253
- this.#inbox.push(ev.data);
201
+ if (typeof ev.data === 'string') {
202
+ this._emitError(new Error('Received text WebSocket frame; expected binary'));
203
+ return;
204
+ }
205
+ qmux.receiveBytes(new Uint8Array(ev.data));
254
206
  });
255
207
  });
256
208
  }
@@ -258,8 +210,12 @@ export class WebSocketTransport extends WshTransport {
258
210
  /** @override */
259
211
  async _doClose() {
260
212
  this.#closedByUs = true;
261
- this.#destroyAllStreams(new Error('Transport closed'));
262
- this.#rejectAllOpens(new Error('Transport closed'));
213
+ // Tell the peer we're closing gracefully, then locally tear down our
214
+ // own stream objects -- without this, any reader still waiting on a
215
+ // stream's readable side would hang forever, since nothing else
216
+ // errors or closes it once the peer can no longer respond.
217
+ this.#qmux?.close();
218
+ this.#qmux?.destroy(new Error('Transport closed'));
263
219
 
264
220
  if (this.#ws) {
265
221
  try {
@@ -273,86 +229,34 @@ export class WebSocketTransport extends WshTransport {
273
229
 
274
230
  /** @override */
275
231
  async _doSendControl(msg) {
232
+ if (!this.#controlStream) {
233
+ throw new Error('Control stream is not open');
234
+ }
235
+ // Length-prefixed CBOR inside the raw QMux byte stream -- QMux
236
+ // streams carry bytes, not pre-delimited messages, same reason the
237
+ // old hand-rolled mux's control channel needed this framing too.
276
238
  const cbor = frameEncode(msg);
277
- const frame = buildFrame(FRAME_CONTROL, 0, cbor);
278
- this.#send(frame);
239
+ await this.#controlStream.write(cbor);
279
240
  }
280
241
 
281
242
  /** @override */
282
243
  async _doOpenStream() {
283
- const id = this.#nextLocalId;
284
- this.#nextLocalId += 2; // odd IDs for client
285
-
286
- // Send open request.
287
- this.#send(buildFrame(FRAME_OPEN_STREAM, id));
288
-
289
- // Create the virtual stream.
290
- const stream = new VirtualStream(
291
- id,
292
- (sid, data) => this.#sendData(sid, data),
293
- (sid) => this.#sendCloseStream(sid),
294
- );
295
- this.#streams.set(id, stream);
296
-
297
- return {
298
- readable: stream.readable,
299
- writable: stream.writable,
300
- id,
301
- };
244
+ const qs = await this.#qmux.openStream();
245
+ const adapter = new QMuxStreamAdapter(qs);
246
+ return { readable: adapter.readable, writable: adapter.writable, id: qs.id };
302
247
  }
303
248
 
304
- // ── Inbound message dispatch ───────────────────────────────────────
249
+ // ── Inbound control-stream dispatch ─────────────────────────────────
305
250
 
306
251
  /**
307
- * Handle a raw WebSocket message (ArrayBuffer). Called serially by
308
- * `#inbox` — never invoked directly from the `message` event listener.
309
- * Async (and awaited by `#inbox`'s SerialQueue) because the
310
- * FRAME_CONTROL case can itself dispatch several decoded messages —
311
- * see `#handleControlFrame`.
312
- * @param {ArrayBuffer} raw
313
- */
314
- async #handleMessage(raw) {
315
- if (typeof raw === 'string') {
316
- this._emitError(new Error('Received text WebSocket frame; expected binary'));
317
- return;
318
- }
319
- const data = new Uint8Array(raw);
320
-
321
- let frame;
322
- try {
323
- frame = parseFrame(data);
324
- } catch (err) {
325
- this._emitError(new Error(`Malformed frame: ${err.message}`));
326
- return;
327
- }
328
-
329
- switch (frame.type) {
330
- case FRAME_CONTROL:
331
- await this.#handleControlFrame(frame.payload);
332
- break;
333
- case FRAME_DATA:
334
- this.#handleDataFrame(frame.streamId, frame.payload);
335
- break;
336
- case FRAME_OPEN_STREAM:
337
- this.#handleOpenStream(frame.streamId);
338
- break;
339
- case FRAME_CLOSE_STREAM:
340
- this.#handleCloseStream(frame.streamId);
341
- break;
342
- default:
343
- this._emitError(new Error(`Unknown frame type: 0x${frame.type.toString(16)}`));
344
- }
345
- }
346
-
347
- /**
348
- * Decode CBOR-framed control messages from stream 0. A single mux
349
- * frame's payload can decode into more than one protocol message (the
350
- * CBOR decoder is stateful/streaming), so dispatch uses
252
+ * Decode CBOR-framed control messages from the control stream's byte
253
+ * pipe. A single chunk can decode into more than one protocol message
254
+ * (the CBOR decoder is stateful/streaming), so dispatch uses
351
255
  * `dispatchSerially` — see its doc comment for why a plain for-loop
352
256
  * here would be unsafe.
353
257
  * @param {Uint8Array} payload
354
258
  */
355
- async #handleControlFrame(payload) {
259
+ async #handleControlBytes(payload) {
356
260
  let messages;
357
261
  try {
358
262
  messages = this.#decoder.feed(payload);
@@ -369,76 +273,28 @@ export class WebSocketTransport extends WshTransport {
369
273
  }
370
274
 
371
275
  /**
372
- * Route data to the appropriate virtual stream.
373
- * @param {number} streamId
374
- * @param {Uint8Array} payload
375
- */
376
- #handleDataFrame(streamId, payload) {
377
- const stream = this.#streams.get(streamId);
378
- if (!stream) {
379
- // Data for unknown stream; ignore silently (may arrive after close).
380
- return;
381
- }
382
- stream._pushData(payload);
383
- }
384
-
385
- /**
386
- * Server is opening a new stream (even IDs = server-initiated).
387
- * @param {number} streamId
388
- */
389
- #handleOpenStream(streamId) {
390
- if (this.#streams.has(streamId)) return; // already tracked
391
-
392
- const stream = new VirtualStream(
393
- streamId,
394
- (sid, data) => this.#sendData(sid, data),
395
- (sid) => this.#sendCloseStream(sid),
396
- );
397
- this.#streams.set(streamId, stream);
398
-
399
- this._emitStreamOpen({
400
- readable: stream.readable,
401
- writable: stream.writable,
402
- id: streamId,
403
- });
404
- }
405
-
406
- /**
407
- * Remote closed a stream.
408
- * @param {number} streamId
409
- */
410
- #handleCloseStream(streamId) {
411
- const stream = this.#streams.get(streamId);
412
- if (!stream) return;
413
- stream._closeRead();
414
- this.#streams.delete(streamId);
415
- }
416
-
417
- /**
418
- * WebSocket closed.
419
- * @param {number} code
276
+ * Connection ended, either because the peer sent CONNECTION_CLOSE
277
+ * (QMux-graceful) or the underlying WebSocket itself closed (abrupt --
278
+ * QMux's own terms for a termination with no prior CONNECTION_CLOSE).
279
+ * @param {number|null} errorCode - QMux/QUIC error code, if this came from a CONNECTION_CLOSE frame
420
280
  * @param {string} reason
421
281
  */
422
- #handleClose(code, reason) {
423
- const err = new Error(`WebSocket closed: ${code} ${reason}`);
424
- this.#destroyAllStreams(err);
425
- this.#rejectAllOpens(err);
426
- this.#decoder.reset();
427
- this.#ws = null;
428
-
429
- if (this.state !== 'closed') {
430
- this._setState('closed');
431
- if (!this.#closedByUs) {
432
- this._emitError(err);
433
- }
434
- this._emitClose();
282
+ #handleClose(errorCode, reason) {
283
+ if (this.state === 'closed') return;
284
+ const err = new Error(reason);
285
+ this.#qmux?.destroy(err);
286
+ this._setState('closed');
287
+ if (!this.#closedByUs) {
288
+ this._emitError(err);
435
289
  }
290
+ this._emitClose();
436
291
  }
437
292
 
438
- // ── Outbound helpers ───────────────────────────────────────────────
293
+ // ── Outbound helper ──────────────────────────────────────────────
439
294
 
440
295
  /**
441
- * Send raw bytes over the WebSocket.
296
+ * Send raw QMux record bytes over the WebSocket. Passed to
297
+ * QMuxConnection as its `send` callback.
442
298
  * @param {Uint8Array} data
443
299
  */
444
300
  #send(data) {
@@ -447,49 +303,4 @@ export class WebSocketTransport extends WshTransport {
447
303
  }
448
304
  this.#ws.send(data);
449
305
  }
450
-
451
- /**
452
- * Send a data frame for a virtual stream.
453
- * @param {number} streamId
454
- * @param {Uint8Array} payload
455
- */
456
- #sendData(streamId, payload) {
457
- this.#send(buildFrame(FRAME_DATA, streamId, payload));
458
- }
459
-
460
- /**
461
- * Send a close frame for a virtual stream.
462
- * @param {number} streamId
463
- */
464
- #sendCloseStream(streamId) {
465
- try {
466
- this.#send(buildFrame(FRAME_CLOSE_STREAM, streamId));
467
- } catch {
468
- // WebSocket may already be closed.
469
- }
470
- }
471
-
472
- // ── Cleanup ────────────────────────────────────────────────────────
473
-
474
- /**
475
- * Destroy all active virtual streams with an error.
476
- * @param {Error} err
477
- */
478
- #destroyAllStreams(err) {
479
- for (const stream of this.#streams.values()) {
480
- stream._destroy(err);
481
- }
482
- this.#streams.clear();
483
- }
484
-
485
- /**
486
- * Reject all pending open-stream promises.
487
- * @param {Error} err
488
- */
489
- #rejectAllOpens(err) {
490
- for (const { reject } of this.#openResolvers.values()) {
491
- reject(err);
492
- }
493
- this.#openResolvers.clear();
494
- }
495
306
  }