@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.
@@ -0,0 +1,698 @@
1
+ /**
2
+ * QMux connection: stream state machine + flow-control accounting on
3
+ * top of the wire codec in qmux.mjs.
4
+ *
5
+ * This module has no DOM Streams API dependency and no transport
6
+ * dependency of its own — it takes a `send(bytes)` callback for
7
+ * outbound record bytes and a `receiveBytes(chunk)` method fed by
8
+ * whatever owns the underlying connection (transport-ws.mjs). That
9
+ * keeps the state machine testable without a real or fake WebSocket.
10
+ *
11
+ * Because the underlying transport (a WebSocket) already guarantees
12
+ * in-order, lossless delivery, there is no packet loss, reordering, or
13
+ * retransmission to handle — flow control here exists purely to bound
14
+ * memory (backpressure), not to manage congestion. See qmux.mjs's file
15
+ * doc comment for why that's QMux's whole design point.
16
+ */
17
+
18
+ import {
19
+ encodeRecord, RecordDecoder, decodeFrames,
20
+ encodeStream, encodeResetStream, encodeResetStreamAt, encodeStopSending,
21
+ encodeMaxData, encodeMaxStreamData, encodeMaxStreams,
22
+ encodeDataBlocked, encodeStreamDataBlocked, encodeStreamsBlocked,
23
+ encodeConnectionClose, encodeDatagram, encodeTransportParameters,
24
+ ERROR_CODE, STREAM_INITIATOR, firstBidiStreamId, nextBidiStreamId,
25
+ isClientInitiated, QMuxProtocolError,
26
+ } from './qmux.mjs';
27
+
28
+ export const DEFAULTS = Object.freeze({
29
+ initialMaxData: 8 * 1024 * 1024, // 8 MiB per connection
30
+ initialMaxStreamData: 1024 * 1024, // 1 MiB per stream
31
+ initialMaxStreamsBidi: 100,
32
+ });
33
+
34
+ // Send a window update once the peer has consumed at least this
35
+ // fraction of the previously-granted window, rather than after every
36
+ // single read (classic QUIC-style windowed flow control).
37
+ const WINDOW_UPDATE_THRESHOLD = 0.5;
38
+
39
+ // ── Per-stream send/receive state ───────────────────────────────────
40
+
41
+ const SEND_STATE = Object.freeze({
42
+ READY: 'ready', // nothing sent yet
43
+ SEND: 'send', // sending data
44
+ RESET_SENT: 'reset_sent', // we sent RESET_STREAM/RESET_STREAM_AT
45
+ DATA_SENT: 'data_sent', // we sent FIN (final size known to peer)
46
+ });
47
+
48
+ const RECV_STATE = Object.freeze({
49
+ RECV: 'recv', // receiving data, final size unknown
50
+ SIZE_KNOWN: 'size_known', // FIN or RESET_STREAM_AT seen; final size known
51
+ DATA_RECVD: 'data_recvd', // all bytes up to final/reliable size delivered to app
52
+ RESET_RECVD: 'reset_recvd', // peer reset with no reliable prefix left to deliver
53
+ });
54
+
55
+ /**
56
+ * One multiplexed stream. Exposes a minimal push/pull data API (not a
57
+ * DOM ReadableStream/WritableStream directly) — transport-ws.mjs wraps
58
+ * this in the actual Streams API objects the rest of wsh expects.
59
+ */
60
+ class QMuxStream {
61
+ id;
62
+ #conn;
63
+
64
+ // Send side
65
+ #sendState = SEND_STATE.READY;
66
+ #sendOffset = 0;
67
+ #sendWindow; // bytes we're currently allowed to send beyond #sendOffset
68
+ #sendWaiters = []; // {resolve} queue, FIFO, woken as window opens
69
+
70
+ // Receive side
71
+ #recvState = RECV_STATE.RECV;
72
+ #recvOffset = 0; // bytes delivered to the application (onData)
73
+ #recvBufferedUpTo = 0; // bytes received from the wire (buffered + delivered)
74
+ #recvWindow; // bytes we've told the peer they may still send
75
+ #recvWindowGranted; // the window value last announced via MAX_STREAM_DATA
76
+ #finalSize = null; // set once FIN or RESET_STREAM_AT/RESET_STREAM is seen
77
+ #reliableSize = null; // set on RESET_STREAM_AT; null means "no reset"
78
+ #resetErrorCode = null;
79
+ #outOfOrderChunks = new Map(); // offset -> data, for frames that arrive with gaps (shouldn't happen over an in-order transport, but guarded)
80
+
81
+ onData = null; // (Uint8Array) => void -- in-order delivery
82
+ onEnd = null; // () => void -- clean FIN, all data delivered
83
+ onReset = null; // (errorCode) => void -- peer aborted (after any reliable prefix delivered)
84
+ onDestroy = null; // (Error) => void -- the whole connection died (transport closed/errored), not a protocol-level reset
85
+
86
+ constructor(conn, id, { sendWindow, recvWindow }) {
87
+ this.#conn = conn;
88
+ this.id = id;
89
+ this.#sendWindow = sendWindow;
90
+ this.#recvWindow = recvWindow;
91
+ this.#recvWindowGranted = recvWindow;
92
+ }
93
+
94
+ get sendState() { return this.#sendState; }
95
+ get recvState() { return this.#recvState; }
96
+
97
+ /** Both directions have reached a terminal state (sent/reset, and received/reset). */
98
+ _isFullyClosed() {
99
+ const sendDone = this.#sendState === SEND_STATE.DATA_SENT || this.#sendState === SEND_STATE.RESET_SENT;
100
+ const recvDone = this.#recvState === RECV_STATE.DATA_RECVD || this.#recvState === RECV_STATE.RESET_RECVD;
101
+ return sendDone && recvDone;
102
+ }
103
+
104
+ /**
105
+ * Write `bytes` to the stream. Resolves once the bytes have been
106
+ * hand off to the connection (which may itself wait on connection-
107
+ * level flow control) -- QMux has no real acknowledgment, so "sent"
108
+ * is as strong a guarantee as this layer offers (the underlying
109
+ * transport is reliable, so that's sufficient).
110
+ * @param {Uint8Array} bytes
111
+ */
112
+ async write(bytes) {
113
+ if (this.#sendState === SEND_STATE.RESET_SENT) {
114
+ throw new Error(`Cannot write to stream ${this.id}: already reset`);
115
+ }
116
+ if (this.#sendState === SEND_STATE.DATA_SENT) {
117
+ throw new Error(`Cannot write to stream ${this.id}: already closed (FIN sent)`);
118
+ }
119
+ this.#sendState = SEND_STATE.SEND;
120
+
121
+ let offset = 0;
122
+ while (offset < bytes.byteLength) {
123
+ const chunk = await this.#waitForSendWindow(bytes.byteLength - offset);
124
+ const slice = bytes.subarray(offset, offset + chunk);
125
+ this.#conn._sendFrame(encodeStream({
126
+ streamId: this.id,
127
+ offset: this.#sendOffset,
128
+ data: slice,
129
+ fin: false,
130
+ }), slice.byteLength);
131
+ this.#sendOffset += slice.byteLength;
132
+ this.#sendWindow -= slice.byteLength;
133
+ offset += slice.byteLength;
134
+ }
135
+ }
136
+
137
+ /** Half-close the send side: send FIN. No more write() calls after this. */
138
+ async close() {
139
+ if (this.#sendState === SEND_STATE.RESET_SENT || this.#sendState === SEND_STATE.DATA_SENT) return;
140
+ this.#conn._sendFrame(encodeStream({
141
+ streamId: this.id,
142
+ offset: this.#sendOffset,
143
+ data: new Uint8Array(0),
144
+ fin: true,
145
+ }), 0);
146
+ this.#sendState = SEND_STATE.DATA_SENT;
147
+ this.#conn._maybeStreamClosed(this.id);
148
+ }
149
+
150
+ /**
151
+ * Abort the send side. `reliableSize` (bytes already written via
152
+ * write(), counted from offset 0) are guaranteed delivered even
153
+ * though the stream is reset -- draft-ietf-quic-reliable-stream-
154
+ * reset-09. Defaults to 0 (an ordinary abrupt reset, no prefix
155
+ * preserved) to match RESET_STREAM's semantics exactly when omitted.
156
+ */
157
+ reset(errorCode = ERROR_CODE.APPLICATION_ERROR, reliableSize = 0) {
158
+ if (this.#sendState === SEND_STATE.RESET_SENT || this.#sendState === SEND_STATE.DATA_SENT) return;
159
+ const finalSize = this.#sendOffset;
160
+ if (reliableSize > 0) {
161
+ this.#conn._sendFrame(encodeResetStreamAt({
162
+ streamId: this.id, errorCode, finalSize, reliableSize: Math.min(reliableSize, finalSize),
163
+ }), 0);
164
+ } else {
165
+ this.#conn._sendFrame(encodeResetStream({ streamId: this.id, errorCode, finalSize }), 0);
166
+ }
167
+ this.#sendState = SEND_STATE.RESET_SENT;
168
+ this.#failSendWaiters(new Error(`Stream ${this.id} was reset`));
169
+ this.#conn._maybeStreamClosed(this.id);
170
+ }
171
+
172
+ /** Tell the peer to stop sending (we're no longer interested in their data). */
173
+ stopSending(errorCode = ERROR_CODE.APPLICATION_ERROR) {
174
+ this.#conn._sendFrame(encodeStopSending({ streamId: this.id, errorCode }), 0);
175
+ }
176
+
177
+ /**
178
+ * Forcibly tear down this stream because the whole connection died
179
+ * (transport closed/errored) -- not a protocol-level RESET_STREAM, so
180
+ * no frame is sent (there's nowhere to send it). No-ops if the stream
181
+ * already reached a terminal state on its own.
182
+ * @param {Error} err
183
+ */
184
+ _destroy(err) {
185
+ if (this.#sendState !== SEND_STATE.RESET_SENT && this.#sendState !== SEND_STATE.DATA_SENT) {
186
+ this.#sendState = SEND_STATE.RESET_SENT;
187
+ }
188
+ this.#failSendWaiters(err);
189
+ if (this.#recvState !== RECV_STATE.DATA_RECVD && this.#recvState !== RECV_STATE.RESET_RECVD) {
190
+ this.#recvState = RECV_STATE.RESET_RECVD;
191
+ try { this.onDestroy?.(err); } catch (handlerErr) { console.error('[qmux] onDestroy handler error:', handlerErr); }
192
+ }
193
+ }
194
+
195
+ // ── Flow control (send side) ────────────────────────────────────
196
+
197
+ /** Wait until at least 1 byte of send window is available; returns how many bytes may be sent now (<= requested). */
198
+ #waitForSendWindow(requested) {
199
+ const available = Math.min(requested, this.#sendWindow, this.#conn._connSendWindow());
200
+ if (available > 0) {
201
+ // Also reserve against the connection-level window immediately,
202
+ // synchronously with this stream's own window check, so two
203
+ // streams can't both observe "room" and together overrun it.
204
+ this.#conn._reserveConnSendWindow(available);
205
+ return Promise.resolve(available);
206
+ }
207
+
208
+ // Tell the peer which limit we're blocked on -- informational (we'll
209
+ // proceed as soon as a MAX_DATA/MAX_STREAM_DATA arrives regardless),
210
+ // but real QUIC implementations rely on *_BLOCKED to prioritize whose
211
+ // window to raise first, so a full-spec implementation sends them.
212
+ if (this.#sendWindow <= 0) {
213
+ this.#conn._sendFrame(encodeStreamDataBlocked({ streamId: this.id, streamDataLimit: this.#sendOffset + this.#sendWindow }), 0);
214
+ }
215
+ if (this.#conn._connSendWindow() <= 0) {
216
+ this.#conn._sendDataBlocked();
217
+ }
218
+
219
+ return new Promise((resolve, reject) => {
220
+ this.#sendWaiters.push({ resolve, reject, requested });
221
+ });
222
+ }
223
+
224
+ /** Called by the connection when peer grants more stream-level window (MAX_STREAM_DATA). */
225
+ _grantSendWindow(newLimit) {
226
+ // newLimit is an absolute offset limit, not a delta; MAX_STREAM_DATA
227
+ // frames MUST NOT be allowed to reduce the window (RFC 9000 §4.1) --
228
+ // a lower/stale value (possible if frames could reorder) is ignored.
229
+ const newWindow = newLimit - this.#sendOffset;
230
+ if (newWindow <= this.#sendWindow) return;
231
+ this.#sendWindow = newWindow;
232
+ this.#wakeSendWaiters();
233
+ }
234
+
235
+ /**
236
+ * Called by the connection when connection-level MAX_DATA increases --
237
+ * this stream's own window is unchanged, but a waiter blocked purely
238
+ * on the connection-level window may now be able to proceed.
239
+ */
240
+ _recheckSendWindow() {
241
+ this.#wakeSendWaiters();
242
+ }
243
+
244
+ #wakeSendWaiters() {
245
+ while (this.#sendWaiters.length > 0 && this.#sendWindow > 0) {
246
+ const waiter = this.#sendWaiters[0];
247
+ const available = Math.min(waiter.requested, this.#sendWindow, this.#conn._connSendWindow());
248
+ if (available <= 0) break;
249
+ this.#sendWaiters.shift();
250
+ this.#conn._reserveConnSendWindow(available);
251
+ waiter.resolve(available);
252
+ }
253
+ }
254
+
255
+ #failSendWaiters(err) {
256
+ for (const waiter of this.#sendWaiters.splice(0)) waiter.reject(err);
257
+ }
258
+
259
+ // ── Flow control (receive side) ─────────────────────────────────
260
+
261
+ /**
262
+ * Handle an inbound STREAM frame's payload for this stream. Delivers
263
+ * data to onData in order (the underlying transport is already
264
+ * in-order, so out-of-order arrival here would itself be a protocol
265
+ * violation per QMux -- guarded defensively, not expected).
266
+ */
267
+ /** @returns {number} bytes newly accounted for (for the connection's own MAX_DATA bookkeeping) */
268
+ _receiveStreamData(offset, data, fin) {
269
+ if (offset !== this.#recvBufferedUpTo) {
270
+ throw new QMuxProtocolError(
271
+ ERROR_CODE.PROTOCOL_VIOLATION,
272
+ `stream ${this.id}: out-of-order STREAM frame (expected offset ${this.#recvBufferedUpTo}, got ${offset})`
273
+ );
274
+ }
275
+ if (this.#recvState === RECV_STATE.RESET_RECVD) return 0; // peer already reset; ignore trailing data
276
+
277
+ this.#recvBufferedUpTo += data.byteLength;
278
+ if (data.byteLength > 0) {
279
+ this.#recvOffset += data.byteLength;
280
+ try { this.onData?.(data); } catch (err) { console.error('[qmux] onData handler error:', err); }
281
+ this.#maybeGrantMoreWindow();
282
+ }
283
+
284
+ if (fin) {
285
+ this.#finalSize = this.#recvBufferedUpTo;
286
+ this.#recvState = RECV_STATE.DATA_RECVD;
287
+ try { this.onEnd?.(); } catch (err) { console.error('[qmux] onEnd handler error:', err); }
288
+ this.#conn._maybeStreamClosed(this.id);
289
+ }
290
+
291
+ return data.byteLength;
292
+ }
293
+
294
+ /** Handle an inbound RESET_STREAM/RESET_STREAM_AT for this stream. */
295
+ _receiveReset(errorCode, finalSize, reliableSize) {
296
+ if (this.#recvState === RECV_STATE.RESET_RECVD || this.#recvState === RECV_STATE.DATA_RECVD) return;
297
+
298
+ this.#finalSize = finalSize;
299
+ this.#reliableSize = reliableSize;
300
+ this.#resetErrorCode = errorCode;
301
+ this.#recvState = RECV_STATE.SIZE_KNOWN;
302
+
303
+ // If we've already received (and delivered) everything up to the
304
+ // reliable prefix, the abort is actionable immediately. Otherwise
305
+ // wait -- more STREAM frames for the reliable prefix are still
306
+ // coming (guaranteed by the sender's obligation to deliver them).
307
+ this.#maybeCompleteReset();
308
+ }
309
+
310
+ #maybeCompleteReset() {
311
+ if (this.#reliableSize === null) return;
312
+ if (this.#recvBufferedUpTo < this.#reliableSize) return;
313
+ if (this.#recvState === RECV_STATE.RESET_RECVD) return;
314
+ this.#recvState = RECV_STATE.RESET_RECVD;
315
+ try { this.onReset?.(this.#resetErrorCode); } catch (err) { console.error('[qmux] onReset handler error:', err); }
316
+ this.#conn._maybeStreamClosed(this.id);
317
+ }
318
+
319
+ /**
320
+ * Top up the stream-level window once the peer has used up more than
321
+ * WINDOW_UPDATE_THRESHOLD of the *target* window size (#recvWindow,
322
+ * a fixed size we aim to always keep available) -- not a threshold of
323
+ * the absolute granted limit, which only grows over the stream's
324
+ * lifetime and would make updates rarer and rarer over time if used
325
+ * directly.
326
+ */
327
+ #maybeGrantMoreWindow() {
328
+ this.#maybeCompleteReset();
329
+ const remaining = this.#recvWindowGranted - this.#recvBufferedUpTo;
330
+ if (remaining <= this.#recvWindow * (1 - WINDOW_UPDATE_THRESHOLD)) {
331
+ const newLimit = this.#recvBufferedUpTo + this.#recvWindow;
332
+ this.#recvWindowGranted = newLimit;
333
+ this.#conn._sendFrame(encodeMaxStreamData({ streamId: this.id, maxStreamData: newLimit }), 0);
334
+ }
335
+ }
336
+ }
337
+
338
+ // ── Connection ───────────────────────────────────────────────────────
339
+
340
+ export class QMuxConnection {
341
+ #isClient;
342
+ #send; // (Uint8Array) => void, raw transport write
343
+ #decoder = new RecordDecoder();
344
+ #streams = new Map(); // streamId -> QMuxStream
345
+ #nextLocalStreamId;
346
+ #closed = false;
347
+
348
+ // Connection-level flow control (send side: bytes *we* may still send)
349
+ #connSendOffset = 0;
350
+ #connSendWindow;
351
+ #lastDataBlockedAt = -1; // last connSendOffset a DATA_BLOCKED was announced for, to avoid duplicates
352
+
353
+ // Connection-level flow control (receive side: bytes we allow the peer to send)
354
+ #connRecvBufferedUpTo = 0;
355
+ #connRecvWindow;
356
+ #connRecvWindowGranted;
357
+
358
+ #maxStreamsBidi;
359
+ #streamsOpened = 0; // locally-initiated bidi streams created so far
360
+ #peerMaxStreamsBidi; // MAX_STREAMS grant from the peer (undefined until received)
361
+ #streamOpenWaiters = [];
362
+
363
+ // MAX_STREAMS we grant the peer: streamsGranted starts at #maxStreamsBidi
364
+ // (sent in the handshake) and is topped up as peer-initiated streams
365
+ // close, so the peer doesn't permanently lose capacity as streams
366
+ // come and go over a long-lived connection.
367
+ #peerStreamsOpened = 0; // cumulative count of peer-initiated streams accepted
368
+ #peerStreamsGranted; // last MAX_STREAMS value announced (== #maxStreamsBidi until topped up)
369
+
370
+ #initialMaxStreamData;
371
+
372
+ onStreamOpen = null; // (QMuxStream) => void, for peer-initiated streams
373
+ onDatagram = null; // (Uint8Array) => void
374
+ onClose = null; // (errorCode, reason) => void
375
+ onError = null; // (Error) => void
376
+
377
+ constructor({
378
+ isClient,
379
+ send,
380
+ initialMaxData = DEFAULTS.initialMaxData,
381
+ initialMaxStreamData = DEFAULTS.initialMaxStreamData,
382
+ initialMaxStreamsBidi = DEFAULTS.initialMaxStreamsBidi,
383
+ }) {
384
+ this.#isClient = isClient;
385
+ this.#send = send;
386
+ this.#connSendWindow = initialMaxData; // updated once we learn the peer's real initial_max_data
387
+ this.#connRecvWindow = initialMaxData;
388
+ this.#connRecvWindowGranted = initialMaxData;
389
+ this.#initialMaxStreamData = initialMaxStreamData;
390
+ this.#maxStreamsBidi = initialMaxStreamsBidi;
391
+ this.#peerStreamsGranted = initialMaxStreamsBidi;
392
+ this.#nextLocalStreamId = firstBidiStreamId(isClient ? STREAM_INITIATOR.CLIENT : STREAM_INITIATOR.SERVER);
393
+ }
394
+
395
+ /** Send the QX_TRANSPORT_PARAMETERS handshake frame — must be the first frame sent (QMux §4.1). */
396
+ sendHandshake() {
397
+ this.#send(encodeRecord(encodeTransportParameters({
398
+ initial_max_data: this.#connRecvWindowGranted,
399
+ initial_max_stream_data_bidi_local: this.#initialMaxStreamData,
400
+ initial_max_stream_data_bidi_remote: this.#initialMaxStreamData,
401
+ initial_max_streams_bidi: this.#maxStreamsBidi,
402
+ })));
403
+ }
404
+
405
+ /** Open a new locally-initiated bidirectional stream. */
406
+ async openStream() {
407
+ if (this.#peerMaxStreamsBidi !== undefined && this.#streamsOpened >= this.#peerMaxStreamsBidi) {
408
+ this.#send(encodeRecord(encodeStreamsBlocked({ unidirectional: false, streamLimit: this.#peerMaxStreamsBidi })));
409
+ await new Promise((resolve) => this.#streamOpenWaiters.push(resolve));
410
+ }
411
+ const id = this.#nextLocalStreamId;
412
+ this.#nextLocalStreamId = nextBidiStreamId(id);
413
+ this.#streamsOpened++;
414
+ return this.#createStream(id);
415
+ }
416
+
417
+ #createStream(id) {
418
+ const stream = new QMuxStream(this, id, {
419
+ sendWindow: this.#initialMaxStreamData,
420
+ recvWindow: this.#initialMaxStreamData,
421
+ });
422
+ this.#streams.set(id, stream);
423
+ return stream;
424
+ }
425
+
426
+ /**
427
+ * Look up a stream by ID, lazily creating (and firing onStreamOpen for)
428
+ * a peer-initiated one that hasn't been referenced yet -- QMux/QUIC
429
+ * streams aren't explicitly opened on the wire, so any frame
430
+ * referencing an unseen peer-initiated stream ID implicitly creates
431
+ * it, not just STREAM frames. Returns undefined (without creating
432
+ * anything) for an ID this endpoint should itself have allocated,
433
+ * since that can never legitimately be "peer-initiated."
434
+ */
435
+ #getOrCreatePeerStream(id) {
436
+ let stream = this.#streams.get(id);
437
+ if (stream) return stream;
438
+ if (isClientInitiated(id) === this.#isClient) return undefined;
439
+
440
+ const streamOrdinal = Math.floor(id / 4) + 1;
441
+ if (streamOrdinal > this.#peerStreamsGranted) {
442
+ throw new QMuxProtocolError(ERROR_CODE.STREAM_LIMIT_ERROR, `peer referenced stream ${id} beyond the granted MAX_STREAMS limit (${this.#peerStreamsGranted})`);
443
+ }
444
+ this.#peerStreamsOpened = Math.max(this.#peerStreamsOpened, streamOrdinal);
445
+ stream = this.#createStream(id);
446
+ try { this.onStreamOpen?.(stream); } catch (err) { console.error('[qmux] onStreamOpen handler error:', err); }
447
+ return stream;
448
+ }
449
+
450
+ getStream(id) {
451
+ return this.#streams.get(id);
452
+ }
453
+
454
+ /**
455
+ * Called by a QMuxStream whenever either of its directions reaches a
456
+ * terminal state; removes it from bookkeeping once *both* have, and
457
+ * for a peer-initiated stream, tops up the MAX_STREAMS grant so a
458
+ * long-lived connection doesn't permanently lose capacity as streams
459
+ * come and go.
460
+ */
461
+ _maybeStreamClosed(id) {
462
+ const stream = this.#streams.get(id);
463
+ if (!stream || !stream._isFullyClosed()) return;
464
+ this.#streams.delete(id);
465
+
466
+ if (isClientInitiated(id) !== this.#isClient) {
467
+ // Peer-initiated: consider raising MAX_STREAMS once less than half
468
+ // the original window's worth of headroom remains, mirroring the
469
+ // same threshold heuristic used for MAX_DATA/MAX_STREAM_DATA.
470
+ const headroom = this.#peerStreamsGranted - this.#peerStreamsOpened;
471
+ if (headroom <= this.#maxStreamsBidi * (1 - WINDOW_UPDATE_THRESHOLD)) {
472
+ this.#peerStreamsGranted = this.#peerStreamsOpened + this.#maxStreamsBidi;
473
+ this.#send(encodeRecord(encodeMaxStreams({ unidirectional: false, maxStreams: this.#peerStreamsGranted })));
474
+ }
475
+ }
476
+ }
477
+
478
+ /** Send an unreliable-in-the-QUIC-sense-but-actually-reliable-here datagram (RFC 9221, via QMux). */
479
+ sendDatagram(bytes) {
480
+ this.#send(encodeRecord(encodeDatagram(bytes)));
481
+ }
482
+
483
+ /** Feed raw bytes received from the underlying transport. */
484
+ receiveBytes(chunk) {
485
+ let records;
486
+ try {
487
+ records = this.#decoder.feed(chunk);
488
+ } catch (err) {
489
+ this.#emitError(err);
490
+ return;
491
+ }
492
+ for (const record of records) {
493
+ this.#handleRecord(record);
494
+ if (this.#closed) break;
495
+ }
496
+ }
497
+
498
+ #handleRecord(record) {
499
+ let frames;
500
+ try {
501
+ frames = decodeFrames(record);
502
+ } catch (err) {
503
+ if (err instanceof QMuxProtocolError) {
504
+ this.#closeLocally(err.errorCode, err.message);
505
+ }
506
+ this.#emitError(err);
507
+ return;
508
+ }
509
+ for (const frame of frames) {
510
+ try {
511
+ this.#handleFrame(frame);
512
+ } catch (err) {
513
+ if (err instanceof QMuxProtocolError) {
514
+ this.#closeLocally(err.errorCode, err.message);
515
+ }
516
+ this.#emitError(err);
517
+ return;
518
+ }
519
+ }
520
+ }
521
+
522
+ #handleFrame(frame) {
523
+ switch (frame.frameType) {
524
+ case 'QX_TRANSPORT_PARAMETERS': {
525
+ if (frame.params.initial_max_data !== undefined) {
526
+ this.#connSendWindow = Number(frame.params.initial_max_data) - this.#connSendOffset;
527
+ }
528
+ this.#peerMaxStreamsBidi = frame.params.initial_max_streams_bidi !== undefined
529
+ ? Number(frame.params.initial_max_streams_bidi)
530
+ : DEFAULTS.initialMaxStreamsBidi;
531
+ this.#wakeStreamOpenWaiters();
532
+ break;
533
+ }
534
+ case 'STREAM': {
535
+ const stream = this.#getOrCreatePeerStream(Number(frame.streamId));
536
+ if (!stream) break; // an ID this endpoint should have allocated itself -- ignore/guard
537
+ const delivered = stream._receiveStreamData(Number(frame.offset), frame.data, frame.fin);
538
+ this.#accountConnRecv(delivered);
539
+ break;
540
+ }
541
+ case 'RESET_STREAM': {
542
+ // RESET_STREAM (like RESET_STREAM_AT below) can legitimately be
543
+ // the *first* frame ever seen for a peer-initiated stream (reset
544
+ // before ever writing anything) -- must lazily create it too,
545
+ // not only the STREAM frame handler, or the reset is silently lost.
546
+ const stream = this.#getOrCreatePeerStream(Number(frame.streamId));
547
+ stream?._receiveReset(Number(frame.errorCode), Number(frame.finalSize), 0);
548
+ break;
549
+ }
550
+ case 'RESET_STREAM_AT': {
551
+ const stream = this.#getOrCreatePeerStream(Number(frame.streamId));
552
+ stream?._receiveReset(Number(frame.errorCode), Number(frame.finalSize), Number(frame.reliableSize));
553
+ break;
554
+ }
555
+ case 'STOP_SENDING': {
556
+ const stream = this.#streams.get(Number(frame.streamId));
557
+ // We're being told to stop sending; reset our send side with no reliable prefix owed.
558
+ stream?.reset(ERROR_CODE.NO_ERROR, 0);
559
+ break;
560
+ }
561
+ case 'MAX_DATA': {
562
+ const newWindow = Number(frame.maxData) - this.#connSendOffset;
563
+ if (newWindow > this.#connSendWindow) {
564
+ this.#connSendWindow = newWindow;
565
+ this.#wakeAllStreamSendWaiters();
566
+ }
567
+ break;
568
+ }
569
+ case 'MAX_STREAM_DATA': {
570
+ const stream = this.#streams.get(Number(frame.streamId));
571
+ stream?._grantSendWindow(Number(frame.maxStreamData));
572
+ break;
573
+ }
574
+ case 'MAX_STREAMS': {
575
+ if (!frame.unidirectional) {
576
+ this.#peerMaxStreamsBidi = Number(frame.maxStreams);
577
+ this.#wakeStreamOpenWaiters();
578
+ }
579
+ break;
580
+ }
581
+ case 'DATA_BLOCKED':
582
+ case 'STREAM_DATA_BLOCKED':
583
+ case 'STREAMS_BLOCKED': {
584
+ // Informational: the peer is blocked on a limit we control.
585
+ // Nothing to do here since we already grant more window
586
+ // proactively as data is consumed (see #maybeGrantMoreWindow).
587
+ break;
588
+ }
589
+ case 'CONNECTION_CLOSE': {
590
+ this.#closed = true;
591
+ try { this.onClose?.(frame.errorCode, frame.reason); } catch (err) { console.error('[qmux] onClose handler error:', err); }
592
+ break;
593
+ }
594
+ case 'DATAGRAM': {
595
+ try { this.onDatagram?.(frame.data); } catch (err) { console.error('[qmux] onDatagram handler error:', err); }
596
+ break;
597
+ }
598
+ case 'PADDING':
599
+ break;
600
+ default:
601
+ break;
602
+ }
603
+ }
604
+
605
+ /** Gracefully close the connection with CONNECTION_CLOSE. */
606
+ close(errorCode = ERROR_CODE.NO_ERROR, reason = '') {
607
+ if (this.#closed) return;
608
+ this.#send(encodeRecord(encodeConnectionClose({ application: true, errorCode, reason })));
609
+ this.#closed = true;
610
+ }
611
+
612
+ /**
613
+ * Forcibly tear down every stream because the underlying transport
614
+ * itself died (closed or errored below the QMux layer -- e.g. the
615
+ * WebSocket connection dropped) -- no CONNECTION_CLOSE is sent, since
616
+ * there's nowhere left to send it.
617
+ * @param {Error} err
618
+ */
619
+ destroy(err) {
620
+ this.#closed = true;
621
+ for (const stream of this.#streams.values()) {
622
+ stream._destroy(err);
623
+ }
624
+ this.#streams.clear();
625
+ }
626
+
627
+ #closeLocally(errorCode, reason) {
628
+ if (this.#closed) return;
629
+ try {
630
+ this.#send(encodeRecord(encodeConnectionClose({ application: false, errorCode, reason })));
631
+ } catch { /* best-effort */ }
632
+ this.#closed = true;
633
+ }
634
+
635
+ #emitError(err) {
636
+ try { this.onError?.(err); } catch (handlerErr) { console.error('[qmux] onError handler error:', handlerErr); }
637
+ }
638
+
639
+ #wakeStreamOpenWaiters() {
640
+ while (
641
+ this.#streamOpenWaiters.length > 0 &&
642
+ (this.#peerMaxStreamsBidi === undefined || this.#streamsOpened < this.#peerMaxStreamsBidi)
643
+ ) {
644
+ this.#streamOpenWaiters.shift()();
645
+ }
646
+ }
647
+
648
+ /**
649
+ * Connection-level MAX_DATA increased -- recheck every stream's send
650
+ * waiters, since any of them could have been blocked purely on the
651
+ * connection-level window rather than their own stream-level window.
652
+ */
653
+ #wakeAllStreamSendWaiters() {
654
+ for (const stream of this.#streams.values()) {
655
+ stream._recheckSendWindow();
656
+ }
657
+ }
658
+
659
+ /**
660
+ * Account newly-delivered bytes against the connection-level receive
661
+ * window and top it up (MAX_DATA) once consumption crosses the same
662
+ * threshold used for individual streams' MAX_STREAM_DATA.
663
+ */
664
+ #accountConnRecv(byteCount) {
665
+ if (byteCount <= 0) return;
666
+ this.#connRecvBufferedUpTo += byteCount;
667
+ const remaining = this.#connRecvWindowGranted - this.#connRecvBufferedUpTo;
668
+ if (remaining <= this.#connRecvWindow * (1 - WINDOW_UPDATE_THRESHOLD)) {
669
+ const newLimit = this.#connRecvBufferedUpTo + this.#connRecvWindow;
670
+ this.#connRecvWindowGranted = newLimit;
671
+ this.#send(encodeRecord(encodeMaxData(newLimit)));
672
+ }
673
+ }
674
+
675
+ // ── Internal hooks used by QMuxStream ───────────────────────────
676
+
677
+ _sendFrame(frameBytes, dataLength) {
678
+ this.#send(encodeRecord(frameBytes));
679
+ this.#connSendOffset += dataLength;
680
+ }
681
+
682
+ _connSendWindow() {
683
+ return this.#connSendWindow;
684
+ }
685
+
686
+ _reserveConnSendWindow(n) {
687
+ this.#connSendWindow -= n;
688
+ }
689
+
690
+ _sendDataBlocked() {
691
+ // Avoid spamming an identical DATA_BLOCKED for the same limit on
692
+ // every single blocked write -- only announce when the limit we're
693
+ // blocked on has actually changed since the last announcement.
694
+ if (this.#lastDataBlockedAt === this.#connSendOffset) return;
695
+ this.#lastDataBlockedAt = this.#connSendOffset;
696
+ this.#send(encodeRecord(encodeDataBlocked(this.#connSendOffset + this.#connSendWindow)));
697
+ }
698
+ }