@johnhenry/wsh 0.7.0 → 0.14.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,101 +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
- /**
33
- * The mux frame-type byte values, exported for anything outside this
34
- * module that needs to name or inspect them correctly — tooling,
35
- * logging, or an alternative from-scratch implementation of this
36
- * transport in another runtime, which needs these exact wire values to
37
- * interoperate. Not related to `MSG` (messages.gen.mjs): that's the
38
- * CBOR-layer protocol-message opcode space carried *inside* a
39
- * FRAME_CONTROL payload, a separate numbering space at a different
40
- * layer of the stack. (An earlier, since-removed `MSG.WS_DATA` conflated
41
- * the two and collided with a real MSG opcode — see CHANGELOG 0.6.0.)
42
- */
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).
43
35
  export const WS_FRAME_TYPE = Object.freeze({
44
- CONTROL: FRAME_CONTROL,
45
- DATA: FRAME_DATA,
46
- OPEN_STREAM: FRAME_OPEN_STREAM,
47
- CLOSE_STREAM: FRAME_CLOSE_STREAM,
36
+ CONTROL: 0x01,
37
+ DATA: 0x02,
38
+ OPEN_STREAM: 0x03,
39
+ CLOSE_STREAM: 0x04,
48
40
  });
49
41
 
50
- // ── Header helpers ───────────────────────────────────────────────────
51
-
52
- const HEADER_SIZE = 5; // 1 byte type + 4 bytes stream ID
53
-
54
- /**
55
- * Build a multiplexing frame.
56
- * @param {number} type Frame type byte.
57
- * @param {number} streamId 32-bit stream ID.
58
- * @param {Uint8Array} [payload] Optional payload bytes.
59
- * @returns {Uint8Array}
60
- */
61
- function buildFrame(type, streamId, payload) {
62
- const payloadLen = payload ? payload.byteLength : 0;
63
- const frame = new Uint8Array(HEADER_SIZE + payloadLen);
64
- const view = new DataView(frame.buffer);
65
- view.setUint8(0, type);
66
- view.setUint32(1, streamId);
67
- if (payload) {
68
- frame.set(payload, HEADER_SIZE);
69
- }
70
- return frame;
71
- }
72
-
73
- /**
74
- * Parse a multiplexing frame header + payload.
75
- * @param {Uint8Array} data
76
- * @returns {{ type: number, streamId: number, payload: Uint8Array }}
77
- */
78
- function parseFrame(data) {
79
- if (data.byteLength < HEADER_SIZE) {
80
- throw new Error(`Frame too short: ${data.byteLength} bytes`);
81
- }
82
- const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
83
- return {
84
- type: view.getUint8(0),
85
- streamId: view.getUint32(1),
86
- payload: data.subarray(HEADER_SIZE),
87
- };
88
- }
89
-
90
- // ── Virtual stream ───────────────────────────────────────────────────
42
+ // ── DOM Streams adapter over a QMuxStream ───────────────────────────
91
43
 
92
44
  /**
93
- * A virtual bidirectional stream multiplexed over a single WebSocket.
94
- *
95
- * Exposes standard ReadableStream / WritableStream interfaces so that
96
- * 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.
97
48
  */
98
- class VirtualStream {
49
+ class QMuxStreamAdapter {
99
50
  /** @type {number} */
100
51
  id;
101
52
 
@@ -105,98 +56,52 @@ class VirtualStream {
105
56
  /** @type {WritableStream<Uint8Array>} */
106
57
  writable;
107
58
 
108
- /** @type {ReadableStreamDefaultController} */
109
- #readController = null;
110
-
111
- /** @type {boolean} */
112
- #readClosed = false;
113
-
114
- /** @type {boolean} */
115
- #writeClosed = false;
116
-
117
59
  /**
118
- * @param {number} id
119
- * @param {function(number, Uint8Array): void} sendData Send data frame callback.
120
- * @param {function(number): void} sendClose Send close frame callback.
60
+ * @param {import('./qmux-connection.mjs').QMuxConnection} qs
121
61
  */
122
- constructor(id, sendData, sendClose) {
123
- this.id = id;
62
+ constructor(qs) {
63
+ this.id = qs.id;
124
64
 
125
65
  this.readable = new ReadableStream({
126
66
  start: (controller) => {
127
- 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
+ };
128
79
  },
129
80
  cancel: () => {
130
- this.#readClosed = true;
81
+ qs.stopSending();
131
82
  },
132
83
  });
133
84
 
134
85
  this.writable = new WritableStream({
135
- write: (chunk) => {
136
- if (this.#writeClosed) {
137
- throw new Error(`Stream ${id} writable is closed`);
138
- }
139
- const bytes = chunk instanceof Uint8Array
140
- ? chunk
141
- : new Uint8Array(chunk);
142
- sendData(id, bytes);
86
+ write: async (chunk) => {
87
+ const bytes = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
88
+ await qs.write(bytes);
143
89
  },
144
- close: () => {
145
- this.#writeClosed = true;
146
- sendClose(id);
90
+ close: async () => {
91
+ await qs.close();
147
92
  },
148
93
  abort: () => {
149
- this.#writeClosed = true;
150
- sendClose(id);
94
+ qs.reset();
151
95
  },
152
96
  });
153
97
  }
154
-
155
- /**
156
- * Push inbound data into the readable side.
157
- * @param {Uint8Array} data
158
- */
159
- _pushData(data) {
160
- if (this.#readClosed || !this.#readController) return;
161
- try {
162
- this.#readController.enqueue(data);
163
- } catch {
164
- // Controller may already be closed; ignore.
165
- }
166
- }
167
-
168
- /**
169
- * Signal the remote closed this stream's readable side.
170
- */
171
- _closeRead() {
172
- if (this.#readClosed) return;
173
- this.#readClosed = true;
174
- try {
175
- this.#readController?.close();
176
- } catch {
177
- // Already closed.
178
- }
179
- }
180
-
181
- /**
182
- * Forcibly error both sides (used on transport close).
183
- * @param {Error} err
184
- */
185
- _destroy(err) {
186
- if (!this.#readClosed) {
187
- this.#readClosed = true;
188
- try {
189
- this.#readController?.error(err);
190
- } catch { /* ignore */ }
191
- }
192
- this.#writeClosed = true;
193
- }
194
98
  }
195
99
 
196
100
  // ── WebSocket transport ──────────────────────────────────────────────
197
101
 
198
102
  /**
199
- * wsh transport over a single WebSocket with multiplexed virtual streams.
103
+ * wsh transport over a single WebSocket with QMux-multiplexed virtual
104
+ * streams.
200
105
  *
201
106
  * Provides the same interface as WebTransportTransport so that upper
202
107
  * layers (session, client) work identically over either transport.
@@ -205,42 +110,37 @@ export class WebSocketTransport extends WshTransport {
205
110
  /** @type {WebSocket} */
206
111
  #ws = null;
207
112
 
208
- /** @type {Map<number, VirtualStream>} Active virtual streams by ID. */
209
- #streams = new Map();
210
-
211
- /** @type {FrameDecoder} Decoder for inbound control messages on stream 0. */
212
- #decoder = new FrameDecoder();
113
+ /** @type {import('./qmux-connection.mjs').QMuxConnection} */
114
+ #qmux = null;
213
115
 
214
- /** Next stream ID for locally-opened streams (odd = client, even = server). */
215
- #nextLocalId = 1;
116
+ /** @type {import('./qmux-connection.mjs').QMuxConnection|null} The control channel (always QMux stream 0). */
117
+ #controlStream = null;
216
118
 
217
- /** Resolvers for pending open-stream requests. */
218
- #openResolvers = new Map();
119
+ /** @type {FrameDecoder} Decoder for CBOR control messages carried inside the control stream's byte pipe. */
120
+ #decoder = new FrameDecoder();
219
121
 
220
122
  /** Tracks whether we initiated the close. */
221
123
  #closedByUs = false;
222
124
 
223
125
  /**
224
- * Serializes dispatch of raw inbound WebSocket messages. Needed because
225
- * some `WebSocket` implementations (notably Node's `ws` package parsing
226
- * several frames out of one TCP read) can fire multiple synchronous
227
- * `message` events within a single JS task, all before any microtask
228
- * gets to run — see `SerialQueue`'s doc comment in transport.mjs for
229
- * 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.
230
132
  * @type {SerialQueue}
231
133
  */
232
- #inbox = new SerialQueue((raw) => this.#handleMessage(raw));
134
+ #inbox = new SerialQueue((raw) => this.#handleControlBytes(raw));
233
135
 
234
136
  // ── Lifecycle ──────────────────────────────────────────────────────
235
137
 
236
138
  /** @override */
237
139
  async _doConnect(url) {
238
- this.#streams.clear();
239
140
  this.#decoder.reset();
240
- this.#nextLocalId = 1;
241
- this.#openResolvers.clear();
242
141
  this.#closedByUs = false;
243
142
  this.#inbox.clear();
143
+ this.#controlStream = null;
244
144
 
245
145
  return new Promise((resolve, reject) => {
246
146
  // Normalize URL scheme: wsh:// → wss://, http:// → ws://.
@@ -253,9 +153,39 @@ export class WebSocketTransport extends WshTransport {
253
153
  ws.binaryType = 'arraybuffer';
254
154
  this.#ws = ws;
255
155
 
256
- 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 });
257
187
 
258
- ws.addEventListener('error', (ev) => {
188
+ ws.addEventListener('error', () => {
259
189
  if (this.state === 'connecting') {
260
190
  reject(new Error('WebSocket connection failed'));
261
191
  } else {
@@ -264,11 +194,15 @@ export class WebSocketTransport extends WshTransport {
264
194
  });
265
195
 
266
196
  ws.addEventListener('close', (ev) => {
267
- this.#handleClose(ev.code, ev.reason);
197
+ this.#handleClose(null, `WebSocket closed: ${ev.code} ${ev.reason}`);
268
198
  });
269
199
 
270
200
  ws.addEventListener('message', (ev) => {
271
- 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));
272
206
  });
273
207
  });
274
208
  }
@@ -276,8 +210,12 @@ export class WebSocketTransport extends WshTransport {
276
210
  /** @override */
277
211
  async _doClose() {
278
212
  this.#closedByUs = true;
279
- this.#destroyAllStreams(new Error('Transport closed'));
280
- 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'));
281
219
 
282
220
  if (this.#ws) {
283
221
  try {
@@ -291,86 +229,34 @@ export class WebSocketTransport extends WshTransport {
291
229
 
292
230
  /** @override */
293
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.
294
238
  const cbor = frameEncode(msg);
295
- const frame = buildFrame(FRAME_CONTROL, 0, cbor);
296
- this.#send(frame);
239
+ await this.#controlStream.write(cbor);
297
240
  }
298
241
 
299
242
  /** @override */
300
243
  async _doOpenStream() {
301
- const id = this.#nextLocalId;
302
- this.#nextLocalId += 2; // odd IDs for client
303
-
304
- // Send open request.
305
- this.#send(buildFrame(FRAME_OPEN_STREAM, id));
306
-
307
- // Create the virtual stream.
308
- const stream = new VirtualStream(
309
- id,
310
- (sid, data) => this.#sendData(sid, data),
311
- (sid) => this.#sendCloseStream(sid),
312
- );
313
- this.#streams.set(id, stream);
314
-
315
- return {
316
- readable: stream.readable,
317
- writable: stream.writable,
318
- id,
319
- };
244
+ const qs = await this.#qmux.openStream();
245
+ const adapter = new QMuxStreamAdapter(qs);
246
+ return { readable: adapter.readable, writable: adapter.writable, id: qs.id };
320
247
  }
321
248
 
322
- // ── Inbound message dispatch ───────────────────────────────────────
249
+ // ── Inbound control-stream dispatch ─────────────────────────────────
323
250
 
324
251
  /**
325
- * Handle a raw WebSocket message (ArrayBuffer). Called serially by
326
- * `#inbox` — never invoked directly from the `message` event listener.
327
- * Async (and awaited by `#inbox`'s SerialQueue) because the
328
- * FRAME_CONTROL case can itself dispatch several decoded messages —
329
- * see `#handleControlFrame`.
330
- * @param {ArrayBuffer} raw
331
- */
332
- async #handleMessage(raw) {
333
- if (typeof raw === 'string') {
334
- this._emitError(new Error('Received text WebSocket frame; expected binary'));
335
- return;
336
- }
337
- const data = new Uint8Array(raw);
338
-
339
- let frame;
340
- try {
341
- frame = parseFrame(data);
342
- } catch (err) {
343
- this._emitError(new Error(`Malformed frame: ${err.message}`));
344
- return;
345
- }
346
-
347
- switch (frame.type) {
348
- case FRAME_CONTROL:
349
- await this.#handleControlFrame(frame.payload);
350
- break;
351
- case FRAME_DATA:
352
- this.#handleDataFrame(frame.streamId, frame.payload);
353
- break;
354
- case FRAME_OPEN_STREAM:
355
- this.#handleOpenStream(frame.streamId);
356
- break;
357
- case FRAME_CLOSE_STREAM:
358
- this.#handleCloseStream(frame.streamId);
359
- break;
360
- default:
361
- this._emitError(new Error(`Unknown frame type: 0x${frame.type.toString(16)}`));
362
- }
363
- }
364
-
365
- /**
366
- * Decode CBOR-framed control messages from stream 0. A single mux
367
- * frame's payload can decode into more than one protocol message (the
368
- * 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
369
255
  * `dispatchSerially` — see its doc comment for why a plain for-loop
370
256
  * here would be unsafe.
371
257
  * @param {Uint8Array} payload
372
258
  */
373
- async #handleControlFrame(payload) {
259
+ async #handleControlBytes(payload) {
374
260
  let messages;
375
261
  try {
376
262
  messages = this.#decoder.feed(payload);
@@ -387,76 +273,28 @@ export class WebSocketTransport extends WshTransport {
387
273
  }
388
274
 
389
275
  /**
390
- * Route data to the appropriate virtual stream.
391
- * @param {number} streamId
392
- * @param {Uint8Array} payload
393
- */
394
- #handleDataFrame(streamId, payload) {
395
- const stream = this.#streams.get(streamId);
396
- if (!stream) {
397
- // Data for unknown stream; ignore silently (may arrive after close).
398
- return;
399
- }
400
- stream._pushData(payload);
401
- }
402
-
403
- /**
404
- * Server is opening a new stream (even IDs = server-initiated).
405
- * @param {number} streamId
406
- */
407
- #handleOpenStream(streamId) {
408
- if (this.#streams.has(streamId)) return; // already tracked
409
-
410
- const stream = new VirtualStream(
411
- streamId,
412
- (sid, data) => this.#sendData(sid, data),
413
- (sid) => this.#sendCloseStream(sid),
414
- );
415
- this.#streams.set(streamId, stream);
416
-
417
- this._emitStreamOpen({
418
- readable: stream.readable,
419
- writable: stream.writable,
420
- id: streamId,
421
- });
422
- }
423
-
424
- /**
425
- * Remote closed a stream.
426
- * @param {number} streamId
427
- */
428
- #handleCloseStream(streamId) {
429
- const stream = this.#streams.get(streamId);
430
- if (!stream) return;
431
- stream._closeRead();
432
- this.#streams.delete(streamId);
433
- }
434
-
435
- /**
436
- * WebSocket closed.
437
- * @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
438
280
  * @param {string} reason
439
281
  */
440
- #handleClose(code, reason) {
441
- const err = new Error(`WebSocket closed: ${code} ${reason}`);
442
- this.#destroyAllStreams(err);
443
- this.#rejectAllOpens(err);
444
- this.#decoder.reset();
445
- this.#ws = null;
446
-
447
- if (this.state !== 'closed') {
448
- this._setState('closed');
449
- if (!this.#closedByUs) {
450
- this._emitError(err);
451
- }
452
- 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);
453
289
  }
290
+ this._emitClose();
454
291
  }
455
292
 
456
- // ── Outbound helpers ───────────────────────────────────────────────
293
+ // ── Outbound helper ──────────────────────────────────────────────
457
294
 
458
295
  /**
459
- * Send raw bytes over the WebSocket.
296
+ * Send raw QMux record bytes over the WebSocket. Passed to
297
+ * QMuxConnection as its `send` callback.
460
298
  * @param {Uint8Array} data
461
299
  */
462
300
  #send(data) {
@@ -465,49 +303,4 @@ export class WebSocketTransport extends WshTransport {
465
303
  }
466
304
  this.#ws.send(data);
467
305
  }
468
-
469
- /**
470
- * Send a data frame for a virtual stream.
471
- * @param {number} streamId
472
- * @param {Uint8Array} payload
473
- */
474
- #sendData(streamId, payload) {
475
- this.#send(buildFrame(FRAME_DATA, streamId, payload));
476
- }
477
-
478
- /**
479
- * Send a close frame for a virtual stream.
480
- * @param {number} streamId
481
- */
482
- #sendCloseStream(streamId) {
483
- try {
484
- this.#send(buildFrame(FRAME_CLOSE_STREAM, streamId));
485
- } catch {
486
- // WebSocket may already be closed.
487
- }
488
- }
489
-
490
- // ── Cleanup ────────────────────────────────────────────────────────
491
-
492
- /**
493
- * Destroy all active virtual streams with an error.
494
- * @param {Error} err
495
- */
496
- #destroyAllStreams(err) {
497
- for (const stream of this.#streams.values()) {
498
- stream._destroy(err);
499
- }
500
- this.#streams.clear();
501
- }
502
-
503
- /**
504
- * Reject all pending open-stream promises.
505
- * @param {Error} err
506
- */
507
- #rejectAllOpens(err) {
508
- for (const { reject } of this.#openResolvers.values()) {
509
- reject(err);
510
- }
511
- this.#openResolvers.clear();
512
- }
513
306
  }