@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.
package/src/qmux.mjs ADDED
@@ -0,0 +1,620 @@
1
+ /**
2
+ * QMux wire codec — QUIC-v1 frame encoding running directly over an
3
+ * existing reliable, ordered, authenticated byte stream (a WebSocket
4
+ * connection), per draft-ietf-quic-qmux-02, plus RESET_STREAM_AT from
5
+ * draft-ietf-quic-reliable-stream-reset-09.
6
+ *
7
+ * This module is the wire codec only: varint encode/decode, the QMux
8
+ * Record framing (`{Size varint, Frames}`), and encode/decode for every
9
+ * frame type wsh uses. It has no notion of streams, flow-control state,
10
+ * or a state machine — see transport-ws.mjs for that.
11
+ *
12
+ * Because QMux's underlying transport already guarantees in-order,
13
+ * lossless delivery (unlike raw QUIC over UDP), this codec omits
14
+ * everything raw QUIC needs to survive an unreliable network: packet
15
+ * numbers, ACK frames, retransmission, and reordering/reassembly. That
16
+ * is QMux's whole point (draft-ietf-quic-qmux-02 Section 1): reuse
17
+ * QUIC's well-specified multi-stream framing and flow control without
18
+ * reimplementing QUIC's loss-recovery machinery on top of a transport
19
+ * that doesn't need it.
20
+ */
21
+
22
+ // ── Variable-length integer (QUIC varint, RFC 9000 §16) ────────────────
23
+ //
24
+ // A 2-bit length prefix in the first byte's high bits selects 1/2/4/8-byte
25
+ // encoding, big-endian, with the prefix bits masked out of the value:
26
+ // 00 -> 1 byte (6 data bits, 0..2^6-1)
27
+ // 01 -> 2 bytes (14 data bits, 0..2^14-1)
28
+ // 10 -> 4 bytes (30 data bits, 0..2^30-1)
29
+ // 11 -> 8 bytes (62 data bits, 0..2^62-1)
30
+
31
+ const VARINT_MAX_1 = 2 ** 6 - 1;
32
+ const VARINT_MAX_2 = 2 ** 14 - 1;
33
+ const VARINT_MAX_4 = 2 ** 30 - 1;
34
+ // 2^62-1 exceeds Number.MAX_SAFE_INTEGER (2^53-1); values that need the
35
+ // 8-byte form are represented as BigInt on both the encode and decode
36
+ // side (see encodeVarint/decodeVarint below).
37
+ const VARINT_MAX_8 = (1n << 62n) - 1n;
38
+
39
+ /**
40
+ * Encode a non-negative integer as a QUIC variable-length integer.
41
+ * Accepts a Number (must be a safe, non-negative integer) or a BigInt
42
+ * (for values that don't fit in a JS Number) and always returns the
43
+ * shortest encoding that fits the value.
44
+ *
45
+ * @param {number|bigint} value
46
+ * @returns {Uint8Array}
47
+ */
48
+ export function encodeVarint(value) {
49
+ const big = typeof value === 'bigint' ? value : BigInt(value);
50
+ if (big < 0n) {
51
+ throw new RangeError('varint value must be non-negative');
52
+ }
53
+ if (big > VARINT_MAX_8) {
54
+ throw new RangeError('varint value exceeds the 62-bit QUIC varint range');
55
+ }
56
+
57
+ if (big <= BigInt(VARINT_MAX_1)) {
58
+ return new Uint8Array([Number(big)]);
59
+ }
60
+ if (big <= BigInt(VARINT_MAX_2)) {
61
+ const out = new Uint8Array(2);
62
+ new DataView(out.buffer).setUint16(0, Number(big) | 0x4000);
63
+ return out;
64
+ }
65
+ if (big <= BigInt(VARINT_MAX_4)) {
66
+ const out = new Uint8Array(4);
67
+ new DataView(out.buffer).setUint32(0, Number(big) | 0x80000000);
68
+ return out;
69
+ }
70
+ const out = new Uint8Array(8);
71
+ new DataView(out.buffer).setBigUint64(0, big | 0xc000000000000000n);
72
+ return out;
73
+ }
74
+
75
+ /**
76
+ * Decode a QUIC variable-length integer starting at `offset`.
77
+ *
78
+ * Returns the value as a plain Number when it fits safely
79
+ * (<= Number.MAX_SAFE_INTEGER), otherwise as a BigInt — callers that
80
+ * only ever deal with wsh-scale sizes (well under 2^53) can safely
81
+ * assume a Number back; only pathological/hostile input produces a
82
+ * BigInt here, since 62-bit varints can exceed what a Number can hold
83
+ * exactly.
84
+ *
85
+ * @param {Uint8Array} data
86
+ * @param {number} offset
87
+ * @returns {{ value: number|bigint, length: number }}
88
+ */
89
+ export function decodeVarint(data, offset = 0) {
90
+ if (offset >= data.byteLength) {
91
+ throw new RangeError('varint: no data at offset');
92
+ }
93
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
94
+ const first = view.getUint8(offset);
95
+ const prefix = first >> 6;
96
+ const length = 1 << prefix; // 1, 2, 4, or 8
97
+
98
+ if (offset + length > data.byteLength) {
99
+ throw new RangeError('varint: truncated');
100
+ }
101
+
102
+ let value;
103
+ if (length === 1) {
104
+ value = first & 0x3f;
105
+ } else if (length === 2) {
106
+ value = (view.getUint16(offset) & 0x3fff);
107
+ } else if (length === 4) {
108
+ value = (view.getUint32(offset) & 0x3fffffff) >>> 0;
109
+ } else {
110
+ const raw = view.getBigUint64(offset) & 0x3fffffffffffffffn;
111
+ value = raw <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(raw) : raw;
112
+ }
113
+ return { value, length };
114
+ }
115
+
116
+ /** Number of bytes encodeVarint(value) would produce, without allocating. */
117
+ export function varintLength(value) {
118
+ const big = typeof value === 'bigint' ? value : BigInt(value);
119
+ if (big <= BigInt(VARINT_MAX_1)) return 1;
120
+ if (big <= BigInt(VARINT_MAX_2)) return 2;
121
+ if (big <= BigInt(VARINT_MAX_4)) return 4;
122
+ return 8;
123
+ }
124
+
125
+ /** Concatenate several Uint8Arrays into one. */
126
+ function concat(parts) {
127
+ let total = 0;
128
+ for (const p of parts) total += p.byteLength;
129
+ const out = new Uint8Array(total);
130
+ let offset = 0;
131
+ for (const p of parts) {
132
+ out.set(p, offset);
133
+ offset += p.byteLength;
134
+ }
135
+ return out;
136
+ }
137
+
138
+ // ── Frame type constants ────────────────────────────────────────────
139
+
140
+ export const FRAME_TYPE = Object.freeze({
141
+ PADDING: 0x00,
142
+ RESET_STREAM: 0x04,
143
+ STOP_SENDING: 0x05,
144
+ // STREAM base type; the low 3 bits (FIN=0x01, LEN=0x02, OFF=0x04) are
145
+ // combined in per-frame, not a single fixed value — see encodeStream.
146
+ STREAM: 0x08,
147
+ MAX_DATA: 0x10,
148
+ MAX_STREAM_DATA: 0x11,
149
+ MAX_STREAMS_BIDI: 0x12,
150
+ MAX_STREAMS_UNI: 0x13,
151
+ DATA_BLOCKED: 0x14,
152
+ STREAM_DATA_BLOCKED: 0x15,
153
+ STREAMS_BLOCKED_BIDI: 0x16,
154
+ STREAMS_BLOCKED_UNI: 0x17,
155
+ CONNECTION_CLOSE_TRANSPORT: 0x1c,
156
+ CONNECTION_CLOSE_APPLICATION: 0x1d,
157
+ // DATAGRAM base type; low bit is the LEN flag (RFC 9221).
158
+ DATAGRAM: 0x30,
159
+ // RESET_STREAM_AT (draft-ietf-quic-reliable-stream-reset-09 §3).
160
+ RESET_STREAM_AT: 0x24,
161
+ });
162
+
163
+ /**
164
+ * QX_TRANSPORT_PARAMETERS frame type, encoded as a QUIC varint (it's a
165
+ * 62-bit constant chosen to double as an ALPN-adjacent protocol
166
+ * discriminator — draft-ietf-quic-qmux-02 §4.1). Always 8 bytes on the
167
+ * wire (the top 2 bits are set, forcing the 8-byte varint form).
168
+ */
169
+ export const QX_TRANSPORT_PARAMETERS_TYPE = 0x3f5153300d0a0d0an;
170
+
171
+ // ── QUIC transport error codes (RFC 9000 §20.1, values used by wsh) ───
172
+
173
+ export const ERROR_CODE = Object.freeze({
174
+ NO_ERROR: 0x00,
175
+ INTERNAL_ERROR: 0x01,
176
+ FLOW_CONTROL_ERROR: 0x03,
177
+ STREAM_LIMIT_ERROR: 0x04,
178
+ STREAM_STATE_ERROR: 0x05,
179
+ FINAL_SIZE_ERROR: 0x06,
180
+ FRAME_ENCODING_ERROR: 0x07,
181
+ TRANSPORT_PARAMETER_ERROR: 0x08,
182
+ PROTOCOL_VIOLATION: 0x0a,
183
+ APPLICATION_ERROR: 0x0c,
184
+ });
185
+
186
+ // ── QMux Record framing ─────────────────────────────────────────────
187
+ //
188
+ // { Size (varint): length of Frames in bytes, Frames (Size bytes) }.
189
+ // Records are self-delimiting and never split a frame across records.
190
+
191
+ /**
192
+ * Wrap already-encoded frame bytes in a QMux Record.
193
+ * @param {Uint8Array} framesBytes - one or more concatenated encoded frames
194
+ * @returns {Uint8Array}
195
+ */
196
+ export function encodeRecord(framesBytes) {
197
+ return concat([encodeVarint(framesBytes.byteLength), framesBytes]);
198
+ }
199
+
200
+ /**
201
+ * Incrementally accumulates bytes from the underlying transport and
202
+ * yields complete QMux Records (the raw Frames bytes, not yet parsed
203
+ * into individual frames).
204
+ */
205
+ export class RecordDecoder {
206
+ #buffer = new Uint8Array(0);
207
+
208
+ /**
209
+ * @param {Uint8Array} chunk
210
+ * @returns {Uint8Array[]} Frames payloads of any complete records now available.
211
+ */
212
+ feed(chunk) {
213
+ const merged = new Uint8Array(this.#buffer.byteLength + chunk.byteLength);
214
+ merged.set(this.#buffer, 0);
215
+ merged.set(chunk, this.#buffer.byteLength);
216
+ this.#buffer = merged;
217
+
218
+ const records = [];
219
+ while (true) {
220
+ let sizeField;
221
+ try {
222
+ sizeField = decodeVarint(this.#buffer, 0);
223
+ } catch {
224
+ break; // not enough bytes yet for the Size varint
225
+ }
226
+ const size = Number(sizeField.value);
227
+ const total = sizeField.length + size;
228
+ if (this.#buffer.byteLength < total) break;
229
+
230
+ records.push(this.#buffer.slice(sizeField.length, total));
231
+ this.#buffer = this.#buffer.slice(total);
232
+ }
233
+ return records;
234
+ }
235
+
236
+ reset() {
237
+ this.#buffer = new Uint8Array(0);
238
+ }
239
+ }
240
+
241
+ // ── Frame encoders ───────────────────────────────────────────────────
242
+
243
+ export function encodePadding(length) {
244
+ return new Uint8Array(length); // all-zero bytes; 0x00 is PADDING's type
245
+ }
246
+
247
+ export function encodeResetStream({ streamId, errorCode, finalSize }) {
248
+ return concat([
249
+ encodeVarint(FRAME_TYPE.RESET_STREAM),
250
+ encodeVarint(streamId),
251
+ encodeVarint(errorCode),
252
+ encodeVarint(finalSize),
253
+ ]);
254
+ }
255
+
256
+ export function encodeResetStreamAt({ streamId, errorCode, finalSize, reliableSize }) {
257
+ return concat([
258
+ encodeVarint(FRAME_TYPE.RESET_STREAM_AT),
259
+ encodeVarint(streamId),
260
+ encodeVarint(errorCode),
261
+ encodeVarint(finalSize),
262
+ encodeVarint(reliableSize),
263
+ ]);
264
+ }
265
+
266
+ export function encodeStopSending({ streamId, errorCode }) {
267
+ return concat([
268
+ encodeVarint(FRAME_TYPE.STOP_SENDING),
269
+ encodeVarint(streamId),
270
+ encodeVarint(errorCode),
271
+ ]);
272
+ }
273
+
274
+ /**
275
+ * Encode a STREAM frame. Offset and Length are always included (OFF=1,
276
+ * LEN=1) for simplicity and because QMux Records already delimit frame
277
+ * boundaries so omitting Length saves nothing at the mux-record level
278
+ * the way it can in a raw QUIC packet.
279
+ */
280
+ export function encodeStream({ streamId, offset, data, fin }) {
281
+ const flags = 0x02 /* LEN */ | 0x04 /* OFF */ | (fin ? 0x01 : 0x00);
282
+ return concat([
283
+ encodeVarint(FRAME_TYPE.STREAM | flags),
284
+ encodeVarint(streamId),
285
+ encodeVarint(offset),
286
+ encodeVarint(data.byteLength),
287
+ data,
288
+ ]);
289
+ }
290
+
291
+ export function encodeMaxData(maxData) {
292
+ return concat([encodeVarint(FRAME_TYPE.MAX_DATA), encodeVarint(maxData)]);
293
+ }
294
+
295
+ export function encodeMaxStreamData({ streamId, maxStreamData }) {
296
+ return concat([
297
+ encodeVarint(FRAME_TYPE.MAX_STREAM_DATA),
298
+ encodeVarint(streamId),
299
+ encodeVarint(maxStreamData),
300
+ ]);
301
+ }
302
+
303
+ export function encodeMaxStreams({ unidirectional, maxStreams }) {
304
+ return concat([
305
+ encodeVarint(unidirectional ? FRAME_TYPE.MAX_STREAMS_UNI : FRAME_TYPE.MAX_STREAMS_BIDI),
306
+ encodeVarint(maxStreams),
307
+ ]);
308
+ }
309
+
310
+ export function encodeDataBlocked(dataLimit) {
311
+ return concat([encodeVarint(FRAME_TYPE.DATA_BLOCKED), encodeVarint(dataLimit)]);
312
+ }
313
+
314
+ export function encodeStreamDataBlocked({ streamId, streamDataLimit }) {
315
+ return concat([
316
+ encodeVarint(FRAME_TYPE.STREAM_DATA_BLOCKED),
317
+ encodeVarint(streamId),
318
+ encodeVarint(streamDataLimit),
319
+ ]);
320
+ }
321
+
322
+ export function encodeStreamsBlocked({ unidirectional, streamLimit }) {
323
+ return concat([
324
+ encodeVarint(unidirectional ? FRAME_TYPE.STREAMS_BLOCKED_UNI : FRAME_TYPE.STREAMS_BLOCKED_BIDI),
325
+ encodeVarint(streamLimit),
326
+ ]);
327
+ }
328
+
329
+ const textEncoder = new TextEncoder();
330
+ const textDecoder = new TextDecoder();
331
+
332
+ export function encodeConnectionClose({ application = false, errorCode, frameType, reason = '' }) {
333
+ const reasonBytes = textEncoder.encode(reason);
334
+ const parts = [
335
+ encodeVarint(application ? FRAME_TYPE.CONNECTION_CLOSE_APPLICATION : FRAME_TYPE.CONNECTION_CLOSE_TRANSPORT),
336
+ encodeVarint(errorCode),
337
+ ];
338
+ if (!application) {
339
+ parts.push(encodeVarint(frameType ?? 0));
340
+ }
341
+ parts.push(encodeVarint(reasonBytes.byteLength), reasonBytes);
342
+ return concat(parts);
343
+ }
344
+
345
+ export function encodeDatagram(data) {
346
+ return concat([
347
+ encodeVarint(FRAME_TYPE.DATAGRAM | 0x01 /* LEN present */),
348
+ encodeVarint(data.byteLength),
349
+ data,
350
+ ]);
351
+ }
352
+
353
+ /**
354
+ * Encode the QX_TRANSPORT_PARAMETERS frame. `params` is a plain object
355
+ * of QUIC-varint-valued transport parameters this codec understands
356
+ * (see TRANSPORT_PARAM_ID below); unknown/unsupported parameters are
357
+ * simply not emitted (QMux only permits a small allowed subset anyway).
358
+ */
359
+ export function encodeTransportParameters(params) {
360
+ const encodedParams = [];
361
+ for (const [name, id] of Object.entries(TRANSPORT_PARAM_ID)) {
362
+ if (params[name] === undefined) continue;
363
+ const valueBytes = encodeVarint(params[name]);
364
+ encodedParams.push(concat([
365
+ encodeVarint(id),
366
+ encodeVarint(valueBytes.byteLength),
367
+ valueBytes,
368
+ ]));
369
+ }
370
+ const paramsBytes = concat(encodedParams);
371
+ const typeBytes = encodeVarint(QX_TRANSPORT_PARAMETERS_TYPE);
372
+ return concat([typeBytes, encodeVarint(paramsBytes.byteLength), paramsBytes]);
373
+ }
374
+
375
+ /**
376
+ * Transport parameter codepoints QMux permits (draft-ietf-quic-qmux-02
377
+ * §5.1) plus RESET_STREAM_AT's negotiation parameter
378
+ * (draft-ietf-quic-reliable-stream-reset-09 §4) and DATAGRAM's
379
+ * (RFC 9221 §3). QMux's own max_record_size parameter
380
+ * (0x0571c59429cd0845) is intentionally omitted from this codec's
381
+ * negotiated set — see transport-ws.mjs for why wsh fixes it instead of
382
+ * negotiating it.
383
+ */
384
+ export const TRANSPORT_PARAM_ID = Object.freeze({
385
+ max_idle_timeout: 0x01,
386
+ initial_max_data: 0x04,
387
+ initial_max_stream_data_bidi_local: 0x05,
388
+ initial_max_stream_data_bidi_remote: 0x06,
389
+ initial_max_stream_data_uni: 0x07,
390
+ initial_max_streams_bidi: 0x08,
391
+ initial_max_streams_uni: 0x09,
392
+ max_datagram_frame_size: 0x20,
393
+ });
394
+ const TRANSPORT_PARAM_NAME = Object.freeze(
395
+ Object.fromEntries(Object.entries(TRANSPORT_PARAM_ID).map(([k, v]) => [v, k]))
396
+ );
397
+
398
+ // ── Frame decoder ─────────────────────────────────────────────────────
399
+
400
+ /**
401
+ * Decode every frame in a Frames payload (the contents of one QMux
402
+ * Record). Throws on the first malformed frame — per QMux, a malformed
403
+ * frame is a connection-level protocol error, not a recoverable one.
404
+ *
405
+ * @param {Uint8Array} data
406
+ * @returns {Array<object>} Decoded frames, each `{ frameType, ... }`.
407
+ */
408
+ export function decodeFrames(data) {
409
+ const frames = [];
410
+ let offset = 0;
411
+
412
+ while (offset < data.byteLength) {
413
+ const typeField = decodeVarint(data, offset);
414
+ const rawType = typeField.value;
415
+ offset += typeField.length;
416
+
417
+ if (rawType === QX_TRANSPORT_PARAMETERS_TYPE) {
418
+ const len = decodeVarint(data, offset);
419
+ offset += len.length;
420
+ const paramsBytes = data.subarray(offset, offset + Number(len.value));
421
+ offset += Number(len.value);
422
+ frames.push({ frameType: 'QX_TRANSPORT_PARAMETERS', params: decodeTransportParameters(paramsBytes) });
423
+ continue;
424
+ }
425
+
426
+ // STREAM frames occupy the type range 0x08-0x0f (base 0x08 | 3 flag bits).
427
+ if (typeof rawType === 'number' && rawType >= 0x08 && rawType <= 0x0f) {
428
+ const fin = (rawType & 0x01) !== 0;
429
+ const hasLen = (rawType & 0x02) !== 0;
430
+ const hasOff = (rawType & 0x04) !== 0;
431
+
432
+ const streamIdField = decodeVarint(data, offset);
433
+ offset += streamIdField.length;
434
+
435
+ let streamOffset = 0;
436
+ if (hasOff) {
437
+ const off = decodeVarint(data, offset);
438
+ offset += off.length;
439
+ streamOffset = off.value;
440
+ }
441
+
442
+ let length;
443
+ if (hasLen) {
444
+ const lenField = decodeVarint(data, offset);
445
+ offset += lenField.length;
446
+ length = Number(lenField.value);
447
+ } else {
448
+ length = data.byteLength - offset; // extends to end of Frames payload
449
+ }
450
+
451
+ const streamData = data.slice(offset, offset + length);
452
+ offset += length;
453
+
454
+ frames.push({
455
+ frameType: 'STREAM',
456
+ streamId: streamIdField.value,
457
+ offset: streamOffset,
458
+ data: streamData,
459
+ fin,
460
+ });
461
+ continue;
462
+ }
463
+
464
+ // DATAGRAM frames occupy 0x30-0x31 (base 0x30 | 1 LEN flag bit).
465
+ if (rawType === FRAME_TYPE.DATAGRAM || rawType === (FRAME_TYPE.DATAGRAM | 0x01)) {
466
+ const hasLen = (Number(rawType) & 0x01) !== 0;
467
+ let length;
468
+ if (hasLen) {
469
+ const lenField = decodeVarint(data, offset);
470
+ offset += lenField.length;
471
+ length = Number(lenField.value);
472
+ } else {
473
+ length = data.byteLength - offset;
474
+ }
475
+ const payload = data.slice(offset, offset + length);
476
+ offset += length;
477
+ frames.push({ frameType: 'DATAGRAM', data: payload });
478
+ continue;
479
+ }
480
+
481
+ switch (rawType) {
482
+ case FRAME_TYPE.PADDING: {
483
+ frames.push({ frameType: 'PADDING' });
484
+ break;
485
+ }
486
+ case FRAME_TYPE.RESET_STREAM: {
487
+ const streamId = decodeVarint(data, offset); offset += streamId.length;
488
+ const errorCode = decodeVarint(data, offset); offset += errorCode.length;
489
+ const finalSize = decodeVarint(data, offset); offset += finalSize.length;
490
+ frames.push({ frameType: 'RESET_STREAM', streamId: streamId.value, errorCode: errorCode.value, finalSize: finalSize.value });
491
+ break;
492
+ }
493
+ case FRAME_TYPE.RESET_STREAM_AT: {
494
+ const streamId = decodeVarint(data, offset); offset += streamId.length;
495
+ const errorCode = decodeVarint(data, offset); offset += errorCode.length;
496
+ const finalSize = decodeVarint(data, offset); offset += finalSize.length;
497
+ const reliableSize = decodeVarint(data, offset); offset += reliableSize.length;
498
+ if (Number(reliableSize.value) > Number(finalSize.value)) {
499
+ throw new QMuxProtocolError(ERROR_CODE.FRAME_ENCODING_ERROR, 'RESET_STREAM_AT: reliableSize > finalSize');
500
+ }
501
+ frames.push({ frameType: 'RESET_STREAM_AT', streamId: streamId.value, errorCode: errorCode.value, finalSize: finalSize.value, reliableSize: reliableSize.value });
502
+ break;
503
+ }
504
+ case FRAME_TYPE.STOP_SENDING: {
505
+ const streamId = decodeVarint(data, offset); offset += streamId.length;
506
+ const errorCode = decodeVarint(data, offset); offset += errorCode.length;
507
+ frames.push({ frameType: 'STOP_SENDING', streamId: streamId.value, errorCode: errorCode.value });
508
+ break;
509
+ }
510
+ case FRAME_TYPE.MAX_DATA: {
511
+ const maxData = decodeVarint(data, offset); offset += maxData.length;
512
+ frames.push({ frameType: 'MAX_DATA', maxData: maxData.value });
513
+ break;
514
+ }
515
+ case FRAME_TYPE.MAX_STREAM_DATA: {
516
+ const streamId = decodeVarint(data, offset); offset += streamId.length;
517
+ const maxStreamData = decodeVarint(data, offset); offset += maxStreamData.length;
518
+ frames.push({ frameType: 'MAX_STREAM_DATA', streamId: streamId.value, maxStreamData: maxStreamData.value });
519
+ break;
520
+ }
521
+ case FRAME_TYPE.MAX_STREAMS_BIDI:
522
+ case FRAME_TYPE.MAX_STREAMS_UNI: {
523
+ const maxStreams = decodeVarint(data, offset); offset += maxStreams.length;
524
+ frames.push({ frameType: 'MAX_STREAMS', unidirectional: rawType === FRAME_TYPE.MAX_STREAMS_UNI, maxStreams: maxStreams.value });
525
+ break;
526
+ }
527
+ case FRAME_TYPE.DATA_BLOCKED: {
528
+ const dataLimit = decodeVarint(data, offset); offset += dataLimit.length;
529
+ frames.push({ frameType: 'DATA_BLOCKED', dataLimit: dataLimit.value });
530
+ break;
531
+ }
532
+ case FRAME_TYPE.STREAM_DATA_BLOCKED: {
533
+ const streamId = decodeVarint(data, offset); offset += streamId.length;
534
+ const streamDataLimit = decodeVarint(data, offset); offset += streamDataLimit.length;
535
+ frames.push({ frameType: 'STREAM_DATA_BLOCKED', streamId: streamId.value, streamDataLimit: streamDataLimit.value });
536
+ break;
537
+ }
538
+ case FRAME_TYPE.STREAMS_BLOCKED_BIDI:
539
+ case FRAME_TYPE.STREAMS_BLOCKED_UNI: {
540
+ const streamLimit = decodeVarint(data, offset); offset += streamLimit.length;
541
+ frames.push({ frameType: 'STREAMS_BLOCKED', unidirectional: rawType === FRAME_TYPE.STREAMS_BLOCKED_UNI, streamLimit: streamLimit.value });
542
+ break;
543
+ }
544
+ case FRAME_TYPE.CONNECTION_CLOSE_TRANSPORT:
545
+ case FRAME_TYPE.CONNECTION_CLOSE_APPLICATION: {
546
+ const application = rawType === FRAME_TYPE.CONNECTION_CLOSE_APPLICATION;
547
+ const errorCode = decodeVarint(data, offset); offset += errorCode.length;
548
+ let frameType;
549
+ if (!application) {
550
+ const ft = decodeVarint(data, offset); offset += ft.length;
551
+ frameType = ft.value;
552
+ }
553
+ const reasonLen = decodeVarint(data, offset); offset += reasonLen.length;
554
+ const reasonBytes = data.slice(offset, offset + Number(reasonLen.value));
555
+ offset += Number(reasonLen.value);
556
+ frames.push({ frameType: 'CONNECTION_CLOSE', application, errorCode: errorCode.value, closeFrameType: frameType, reason: textDecoder.decode(reasonBytes) });
557
+ break;
558
+ }
559
+ default:
560
+ throw new QMuxProtocolError(ERROR_CODE.FRAME_ENCODING_ERROR, `unknown/prohibited frame type: 0x${rawType.toString(16)}`);
561
+ }
562
+ }
563
+
564
+ return frames;
565
+ }
566
+
567
+ function decodeTransportParameters(data) {
568
+ const params = {};
569
+ let offset = 0;
570
+ while (offset < data.byteLength) {
571
+ const id = decodeVarint(data, offset); offset += id.length;
572
+ const len = decodeVarint(data, offset); offset += len.length;
573
+ const valueBytes = data.subarray(offset, offset + Number(len.value));
574
+ offset += Number(len.value);
575
+
576
+ const name = TRANSPORT_PARAM_NAME[Number(id.value)];
577
+ if (name) {
578
+ params[name] = valueBytes.byteLength === 0 ? true : decodeVarint(valueBytes, 0).value;
579
+ }
580
+ // Unknown parameter codepoints are ignored per QMux/QUIC's standard
581
+ // forward-compatibility rule -- not every codepoint we might see
582
+ // needs to be one we understand.
583
+ }
584
+ return params;
585
+ }
586
+
587
+ /** Thrown for a connection-level QMux/QUIC framing violation. */
588
+ export class QMuxProtocolError extends Error {
589
+ constructor(errorCode, message) {
590
+ super(message);
591
+ this.name = 'QMuxProtocolError';
592
+ this.errorCode = errorCode;
593
+ }
594
+ }
595
+
596
+ // ── Stream ID helpers (RFC 9000 §2.1) ───────────────────────────────
597
+ //
598
+ // Low bit: initiator (0 = client, 1 = server).
599
+ // Second-lowest bit: directionality (0 = bidirectional, 1 = unidirectional).
600
+ // wsh only ever uses bidirectional streams.
601
+
602
+ export const STREAM_INITIATOR = Object.freeze({ CLIENT: 0, SERVER: 1 });
603
+
604
+ export function isClientInitiated(streamId) {
605
+ return (Number(streamId) & 0x01) === STREAM_INITIATOR.CLIENT;
606
+ }
607
+
608
+ export function isBidirectional(streamId) {
609
+ return (Number(streamId) & 0x02) === 0;
610
+ }
611
+
612
+ /** First bidirectional stream ID for the given initiator (0 or 1). */
613
+ export function firstBidiStreamId(initiator) {
614
+ return initiator;
615
+ }
616
+
617
+ /** Next bidirectional stream ID after `id` for the same initiator (+4). */
618
+ export function nextBidiStreamId(id) {
619
+ return id + 4;
620
+ }