quico 0.4.0 → 0.8.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/h3.js CHANGED
@@ -12,6 +12,51 @@
12
12
  * https://github.com/colocohen/quico
13
13
  */
14
14
 
15
+ import {
16
+ createQpackTable,
17
+ createQpackHistory,
18
+ encodeHeaderBlock as qpackEncodeHeaderBlock,
19
+ encodeInsert as qpackEncodeInsert,
20
+ encodeSetCapacity as qpackEncodeSetCapacity,
21
+ parseDecoderInstructions as qpackParseDecoderInstructions,
22
+ encodeInsertCountIncrement as qpackEncodeAck,
23
+ encodeStreamCancellation as qpackEncodeStreamCancel,
24
+ decodeRequiredInsertCount,
25
+ encodeSectionAck as qpackEncodeSectionAck,
26
+ entrySize as qpackEntrySize
27
+ } from './qpack.js';
28
+ import { validateFieldSection, parseContentLength, parsePriority, formatPriority } from './http_common.js';
29
+
30
+ // ============================================================
31
+ // QPACK dynamic table (RFC 9204): ON.
32
+ //
33
+ // A kill switch rather than a constant, because the dynamic table is the one
34
+ // part of HTTP/3 whose failure is invisible to a quico-to-quico test: both
35
+ // ends of such a test share this file's encoder and decoder, so a mistake
36
+ // made the same way on both sides passes. That is exactly what happened
37
+ // once — the Required Insert Count was sent raw and read raw, the two agreed,
38
+ // and every real browser failed from its fourth request on.
39
+ //
40
+ // Verified since against independent implementations in both directions:
41
+ // aioquic/ls-qpack and Chromium. Set to false to fall back to static-table
42
+ // and Huffman compression only, with no other change.
43
+ // ============================================================
44
+ var QPACK_DYNAMIC_TABLE = true;
45
+
46
+ // The dynamic table's size, both ways: what we let the peer's encoder use, and
47
+ // the most our encoder uses of what the peer allows. 4096 is what browsers
48
+ // advertise, and it is enough: the fields that repeat and are not in the
49
+ // static table — user-agent, accept, sec-ch-ua — total a few hundred bytes.
50
+ var QPACK_TABLE_BYTES = 4096;
51
+
52
+ // Streams that may wait for the peer's encoder stream at once (SETTINGS_
53
+ // QPACK_BLOCKED_STREAMS, RFC 9204 §2.1.2): a field section may then
54
+ // reference an entry the encoder has only just inserted — its insertion and
55
+ // the section travel on different streams, and either can arrive first —
56
+ // rather than wait a round trip for our acknowledgement, or not compress the
57
+ // field at all. 0 was advertised. Chromium advertises 100.
58
+ var QPACK_BLOCKED_STREAMS = 100;
59
+
15
60
  import {
16
61
  DEBUG,
17
62
  Emitter,
@@ -20,301 +65,198 @@ import {
20
65
  readVarInt,
21
66
  wt_stream_header
22
67
  } from './utils.js';
68
+ import {
69
+ QPACK_STATIC,
70
+ readPrefixedInt,
71
+ decodeStringLiteral
72
+ } from './hpack_primitives.js';
23
73
 
24
74
 
25
75
  // ============================================================
26
- // QPACK Static Table (RFC 9204 Appendix A)
27
- // ============================================================
28
-
29
- var QPACK_STATIC = [
30
- [':authority',''], [':path','/'], ['age','0'], ['content-disposition',''],
31
- ['content-length','0'], ['cookie',''], ['date',''], ['etag',''],
32
- ['if-modified-since',''], ['if-none-match',''], ['last-modified',''],
33
- ['link',''], ['location',''], ['referer',''], ['set-cookie',''],
34
- [':method','CONNECT'], [':method','DELETE'], [':method','GET'],
35
- [':method','HEAD'], [':method','OPTIONS'], [':method','POST'],
36
- [':method','PUT'], [':scheme','http'], [':scheme','https'],
37
- [':status','103'], [':status','200'], [':status','304'],
38
- [':status','404'], [':status','503'], ['accept','*/*'],
39
- ['accept','application/dns-message'], ['accept-encoding','gzip, deflate, br'],
40
- ['accept-ranges','bytes'], ['access-control-allow-headers','cache-control'],
41
- ['access-control-allow-headers','content-type'],
42
- ['access-control-allow-origin','*'], ['cache-control','max-age=0'],
43
- ['cache-control','max-age=2592000'], ['cache-control','max-age=604800'],
44
- ['cache-control','no-cache'], ['cache-control','no-store'],
45
- ['cache-control','public, max-age=31536000'], ['content-encoding','br'],
46
- ['content-encoding','gzip'], ['content-type','application/dns-message'],
47
- ['content-type','application/javascript'], ['content-type','application/json'],
48
- ['content-type','application/x-www-form-urlencoded'],
49
- ['content-type','image/gif'], ['content-type','image/jpeg'],
50
- ['content-type','image/png'], ['content-type','text/css'],
51
- ['content-type','text/html; charset=utf-8'], ['content-type','text/plain'],
52
- ['content-type','text/plain;charset=utf-8'], ['range','bytes=0-'],
53
- ['strict-transport-security','max-age=31536000'],
54
- ['strict-transport-security','max-age=31536000; includesubdomains'],
55
- ['strict-transport-security','max-age=31536000; includesubdomains; preload'],
56
- ['vary','accept-encoding'], ['vary','origin'],
57
- ['x-content-type-options','nosniff'], ['x-xss-protection','1; mode=block'],
58
- [':status','100'], [':status','204'], [':status','206'], [':status','302'],
59
- [':status','400'], [':status','403'], [':status','421'], [':status','425'],
60
- [':status','500'], ['accept-language',''],
61
- ['access-control-allow-credentials','FALSE'],
62
- ['access-control-allow-credentials','TRUE'],
63
- ['access-control-allow-headers','*'],
64
- ['access-control-allow-methods','get'],
65
- ['access-control-allow-methods','get, post, options'],
66
- ['access-control-allow-methods','options'],
67
- ['access-control-expose-headers','content-length'],
68
- ['access-control-request-headers','content-type'],
69
- ['access-control-request-method','get'],
70
- ['access-control-request-method','post'], ['alt-svc','clear'],
71
- ['authorization',''],
72
- ['content-security-policy',"script-src 'none'; object-src 'none'; base-uri 'none'"],
73
- ['early-data','1'], ['expect-ct',''], ['forwarded',''], ['if-range',''],
74
- ['origin',''], ['purpose','prefetch'], ['server',''],
75
- ['timing-allow-origin','*'], ['upgrade-insecure-requests','1'],
76
- ['user-agent',''], ['x-forwarded-for',''],
77
- ['x-frame-options','deny'], ['x-frame-options','sameorigin']
78
- ];
79
-
80
-
81
- // ============================================================
82
- // Huffman (RFC 7541 Appendix B)
76
+ // QPACK header blocks (RFC 9204)
77
+ //
78
+ // The wire primitives — Huffman, prefixed integers, string literals — now live
79
+ // in hpack_primitives.js, because RFC 9204 defines them by reference to
80
+ // RFC 7541 and HTTP/2's HPACK needs byte-identical code. What stays here is
81
+ // what is genuinely QPACK: the RIC/base-index prefix, the encoder-stream
82
+ // instructions, and the static table's own contents.
83
+ //
84
+ // Names and values are turned into text with TextDecoder (UTF-8), which is
85
+ // what this file has always done. hpack_primitives exports bytesToLatin1 as a
86
+ // lossless alternative; switching to it is a deliberate behaviour change and
87
+ // deliberately NOT part of this move.
83
88
  // ============================================================
84
89
 
85
- var HUFF_CODES = new Uint32Array([
86
- 0x1ff8,0x7fffd8,0xfffffe2,0xfffffe3,0xfffffe4,0xfffffe5,0xfffffe6,0xfffffe7,
87
- 0xfffffe8,0xffffea,0x3ffffffc,0xfffffe9,0xfffffea,0x3ffffffd,0xfffffeb,0xfffffec,
88
- 0xfffffed,0xfffffee,0xfffffef,0xffffff0,0xffffff1,0xffffff2,0x3ffffffe,0xffffff3,
89
- 0xffffff4,0xffffff5,0xffffff6,0xffffff7,0xffffff8,0xffffff9,0xffffffa,0xffffffb,
90
- 0x14,0x3f8,0x3f9,0xffa,0x1ff9,0x15,0xf8,0x7fa,0x3fa,0x3fb,0xf9,0x7fb,0xfa,
91
- 0x16,0x17,0x18,0x0,0x1,0x2,0x19,0x1a,0x1b,0x1c,0x1d,0x1e,0x1f,0x5c,0xfb,
92
- 0x7ffc,0x20,0xffb,0x3fc,0x1ffa,0x21,0x5d,0x5e,0x5f,0x60,0x61,0x62,0x63,0x64,
93
- 0x65,0x66,0x67,0x68,0x69,0x6a,0x6b,0x6c,0x6d,0x6e,0x6f,0x70,0x71,0x72,0xfc,
94
- 0x73,0xfd,0x1ffb,0x7fff0,0x1ffc,0x3ffc,0x22,0x7ffd,0x3,0x23,0x4,0x24,0x5,0x25,
95
- 0x26,0x27,0x6,0x74,0x75,0x28,0x29,0x2a,0x7,0x2b,0x76,0x2c,0x8,0x9,0x2d,0x77,
96
- 0x78,0x79,0x7a,0x7b,0x7ffe,0x7fc,0x3ffd,0x1ffd,0xffffffc,0xfffe6,0x3fffd2,
97
- 0xfffe7,0xfffe8,0x3fffd3,0x3fffd4,0x3fffd5,0x7fffd9,0x3fffd6,0x7fffda,0x7fffdb,
98
- 0x7fffdc,0x7fffdd,0x7fffde,0xffffeb,0x7fffdf,0xffffec,0xffffed,0x3fffd7,0x7fffe0,
99
- 0xffffee,0x7fffe1,0x7fffe2,0x7fffe3,0x7fffe4,0x1fffdc,0x3fffd8,0x7fffe5,0x3fffd9,
100
- 0x7fffe6,0x7fffe7,0xffffef,0x3fffda,0x1fffdd,0xfffe9,0x3fffdb,0x3fffdc,0x7fffe8,
101
- 0x7fffe9,0x1fffde,0x7fffea,0x3fffdd,0x3fffde,0xfffff0,0x1fffdf,0x3fffdf,0x7fffeb,
102
- 0x7fffec,0x1fffe0,0x1fffe1,0x3fffe0,0x1fffe2,0x7fffed,0x3fffe1,0x7fffee,0x7fffef,
103
- 0xfffea,0x3fffe2,0x3fffe3,0x3fffe4,0x7ffff0,0x3fffe5,0x3fffe6,0x7ffff1,0x3ffffe0,
104
- 0x3ffffe1,0xfffeb,0x7fff1,0x3fffe7,0x7ffff2,0x3fffe8,0x1ffffec,0x3ffffe2,0x3ffffe3,
105
- 0x3ffffe4,0x7ffffde,0x7ffffdf,0x3ffffe5,0xfffff1,0x1ffffed,0x7fff2,0x1fffe3,
106
- 0x3ffffe6,0x7ffffe0,0x7ffffe1,0x3ffffe7,0x7ffffe2,0xfffff2,0x1fffe4,0x1fffe5,
107
- 0x3ffffe8,0x3ffffe9,0xffffffd,0x7ffffe3,0x7ffffe4,0x7ffffe5,0xfffec,0xfffff3,
108
- 0xfffed,0x1fffe6,0x3fffe9,0x1fffe7,0x1fffe8,0x7ffff3,0x3fffea,0x3fffeb,0x1ffffee,
109
- 0x1ffffef,0xfffff4,0xfffff5,0x3ffffea,0x7ffff4,0x3ffffeb,0x7ffffe6,0x3ffffec,
110
- 0x3ffffed,0x7ffffe7,0x7ffffe8,0x7ffffe9,0x7ffffea,0x7ffffeb,0xffffffe,0x7ffffec,
111
- 0x7ffffed,0x7ffffee,0x7ffffef,0x7fffff0,0x3ffffee,0x3fffffff
112
- ]);
113
-
114
- var HUFF_BITS = new Uint8Array([
115
- 13,23,28,28,28,28,28,28,28,24,30,28,28,30,28,28,28,28,28,28,28,28,30,28,28,
116
- 28,28,28,28,28,28,28,6,10,10,12,13,6,8,11,10,10,8,11,8,6,6,6,5,5,5,6,6,6,6,
117
- 6,6,6,7,8,15,6,12,10,13,6,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,8,7,
118
- 8,13,19,13,14,6,15,5,6,5,6,5,6,6,6,5,7,7,6,6,6,5,6,7,6,5,5,6,7,7,7,7,7,15,
119
- 11,14,13,28,20,22,20,20,22,22,22,23,22,23,23,23,23,23,24,23,24,24,22,23,24,
120
- 23,23,23,23,21,22,23,22,23,23,24,22,21,20,22,22,23,23,21,23,22,22,24,21,22,
121
- 23,23,21,21,22,21,23,22,23,23,20,22,22,22,23,22,22,23,26,26,20,19,22,23,22,
122
- 25,26,26,26,27,27,26,24,25,19,21,26,27,27,26,27,24,21,21,26,26,28,27,27,27,
123
- 20,24,20,21,22,21,21,23,22,22,25,25,24,24,26,23,26,27,26,26,27,27,27,27,27,
124
- 28,27,27,27,27,27,26,30
125
- ]);
126
-
127
- var _huffRoot = (function () {
128
- var root = {};
129
- for (var i = 0; i < HUFF_CODES.length; i++) {
130
- var code = HUFF_CODES[i], bits = HUFF_BITS[i], node = root;
131
- for (var j = bits - 1; j >= 0; j--) {
132
- var bit = (code >> j) & 1;
133
- if (!node[bit]) node[bit] = {};
134
- node = node[bit];
135
- }
136
- node.sym = i;
137
- }
138
- return root;
139
- })();
140
-
141
- function huffDecode(buf) {
142
- var out = [], node = _huffRoot, nbits = 0, current = 0;
143
- for (var i = 0; i < buf.length; i++) {
144
- current = (current << 8) | buf[i];
145
- nbits += 8;
146
- while (nbits > 0) {
147
- node = node[(current >> (nbits - 1)) & 1];
148
- if (!node) throw new Error('Invalid Huffman');
149
- nbits--;
150
- if (node.sym !== undefined) { out.push(node.sym); node = _huffRoot; }
151
- }
152
- }
153
- return new TextDecoder().decode(Uint8Array.from(out));
90
+ // One decoder for the module: decode() holds no state between calls (no
91
+ // `stream` option), and constructing one per name and per value cost more
92
+ // than the decoding itself.
93
+ var _utf8Decoder = new TextDecoder();
94
+ function _text(bytes) {
95
+ return _utf8Decoder.decode(bytes);
154
96
  }
155
97
 
98
+ // Every decodeStringLiteral() call below passes strict = true, enabling the
99
+ // padding checks of RFC 7541 §5.2: padding over 7 bits, padding that is not the
100
+ // EOS prefix, and a literal EOS symbol are all rejected. Without it an all-ones
101
+ // run decodes to symbol 256, which then silently truncates to a zero byte and
102
+ // lands in a header value — a malformed block quietly becomes a valid-looking
103
+ // request. Every peer that matters (nghttp2, quiche, msquic, Chrome) pads
104
+ // correctly, and the QPACK interop tests check that a decoder rejects the ones
105
+ // that do not.
106
+ //
107
+ // Note what strict does NOT cover: a literal cut short by the end of the
108
+ // available bytes still returns null and the parser waits for more, because on
109
+ // a QUIC stream that is ordinary partial delivery, not corruption. Telling the
110
+ // two apart needs the stream's FIN — a block still incomplete at FIN is a real
111
+ // error, and that check belongs with the per-stream state, not here.
112
+
113
+
114
+
115
+ function parseQpackHeaderBlock(buf, dynamicTable, totalInserts, maxEntries, maxSectionSize) {
116
+ // RFC 9204 §4.5. `dynamicTable` is newest-first; `totalInserts` is how many
117
+ // entries the peer has ever inserted, so absolute index A (zero-based) sits
118
+ // at array position totalInserts - 1 - A — if it has not been evicted.
119
+ //
120
+ // This used to read the Required Insert Count raw, compute Base with the
121
+ // sign bit's meaning reversed, and index the array by the absolute index.
122
+ // None of it ran while our advertised table capacity was 0; the moment it
123
+ // was raised, every conforming encoder broke on its first dynamic reference.
124
+ var pos = 0;
125
+ var ricEnc = readPrefixedInt(buf, 8, pos); if (!ricEnc) throw new Error('QPACK: truncated field section prefix');
126
+ pos = ricEnc.next;
127
+ if (pos >= buf.length) throw new Error('QPACK: truncated field section prefix');
128
+ var sign = (buf[pos] & 0x80) !== 0;
129
+ var db = readPrefixedInt(buf, 7, pos); if (!db) throw new Error('QPACK: truncated field section prefix');
130
+ pos = db.next;
131
+
132
+ var ric = decodeRequiredInsertCount(ricEnc.value, maxEntries || 0, totalInserts || 0);
133
+
134
+ // A section past the inserts received waits before it is decoded (the
135
+ // caller, sectionBlocked): here, it is one that should not have come.
136
+ if (ric > (totalInserts || 0)) {
137
+ throw new Error('QPACK: section needs ' + ric + ' inserts, have ' + (totalInserts || 0));
138
+ }
156
139
 
157
- // ============================================================
158
- // QPACK parse/build helpers
159
- // ============================================================
140
+ // §4.5.1.2: sign 0 → Base = RIC + Delta; sign 1 → Base = RIC - Delta - 1.
141
+ var base = sign ? ric - db.value - 1 : ric + db.value;
142
+ if (base < 0) throw new Error('QPACK: negative Base');
160
143
 
161
- // Read a QPACK/HPACK prefixed integer at buf[pos]. Returns { value, next } or
162
- // null if the buffer ends mid-integer (truncated). Used by both the header
163
- // block parser and the encoder-instruction parser.
164
- function readPrefixedInt(buf, prefixBits, pos) {
165
- if (pos >= buf.length) return null;
166
- var max = (1 << prefixBits) - 1;
167
- var value = buf[pos] & max;
168
- pos++;
169
- if (value < max) return { value: value, next: pos };
170
- var m = 0;
171
- while (pos < buf.length) {
172
- var byte = buf[pos++];
173
- value += (byte & 0x7f) << m;
174
- if ((byte & 0x80) === 0) return { value: value, next: pos };
175
- m += 7;
144
+ function entryAt(absolute) {
145
+ // Only entries below the declared Required Insert Count may be referenced.
146
+ if (absolute < 0 || absolute >= ric) {
147
+ throw new Error('QPACK: dynamic reference ' + absolute + ' outside Required Insert Count ' + ric);
148
+ }
149
+ var p = totalInserts - 1 - absolute;
150
+ if (!dynamicTable || p < 0 || p >= dynamicTable.length) {
151
+ throw new Error('QPACK: dynamic entry ' + absolute + ' unresolvable (evicted or never inserted)');
152
+ }
153
+ return dynamicTable[p];
176
154
  }
177
- return null; // truncated mid-integer
178
- }
179
-
180
- function parseQpackHeaderBlock(buf, dynamicTable, baseIndex) {
181
- var pos = 0;
182
- var ric = readPrefixedInt(buf, 8, pos); if (!ric) return {}; pos = ric.next;
183
- var postBase = (buf[pos] & 0x80) !== 0;
184
- var db = readPrefixedInt(buf, 7, pos); if (!db) return {}; pos = db.next;
185
- var bi = postBase ? ric.value + db.value : ric.value - db.value;
186
155
 
187
156
  var headers = {};
157
+ // The field names in the order they were decoded.
158
+ //
159
+ // An object cannot carry it — pseudo-headers and regular fields end up in
160
+ // whatever order the engine chooses — and the order is the strongest single
161
+ // signal in a protocol fingerprint: Chrome sends m,a,s,p, Firefox m,p,a,s,
162
+ // Safari m,s,p,a, each a constant in its own source. Attached as a
163
+ // non-enumerable property so nothing that iterates the headers is affected.
164
+ var _order = [];
165
+ // And every field as decoded, repeats included: the object kept only the
166
+ // last of a repeated name (two cookie fields, one survived), and has no
167
+ // way to say a name came twice. The section is validated, and the request's
168
+ // headers built, from this list (validateFieldSection, http_common).
169
+ var _list = [];
170
+ // The section's size as RFC 9114 §4.2.2 measures it: decoded, name and
171
+ // value in bytes plus 32 per field — what SETTINGS_MAX_FIELD_SECTION_SIZE
172
+ // limits. Only the ENCODED size of a HEADERS frame was bounded, and a
173
+ // reference to a dynamic-table entry is one byte however long the entry:
174
+ // 64 KB of references to one 4 KB entry decoded to 256 MB, joined into a
175
+ // single field value (HPACK's "decompression bomb", which HTTP/2 here
176
+ // bounds by MAX_HEADER_LIST_SIZE the same way).
177
+ var sectionSize = 0;
178
+ function field(name, value) {
179
+ if (maxSectionSize) {
180
+ sectionSize += qpackEntrySize(name, value);
181
+ if (sectionSize > maxSectionSize) {
182
+ var big = new Error('QPACK: field section exceeds ' + maxSectionSize + ' bytes decoded');
183
+ big.excessive = true;
184
+ throw big;
185
+ }
186
+ }
187
+ _order.push(name); _list.push([name, value]); if (name !== '__proto__') headers[name] = value;
188
+ }
189
+
188
190
  while (pos < buf.length) {
189
191
  var byte = buf[pos];
192
+ var idx, nameIdx, val, e;
190
193
 
191
194
  if ((byte & 0x80) === 0x80) {
192
- // Indexed
193
- var fromStatic = (byte & 0x40) !== 0;
194
- var idx = readPrefixedInt(buf, 6, pos); if (!idx) break; pos = idx.next;
195
- if (fromStatic && idx.value < QPACK_STATIC.length) {
196
- headers[QPACK_STATIC[idx.value][0]] = QPACK_STATIC[idx.value][1];
197
- } else if (!fromStatic && dynamicTable) {
198
- var di = bi - 1 - idx.value;
199
- if (di >= 0 && di < dynamicTable.length) {
200
- headers[dynamicTable[di][0]] = dynamicTable[di][1];
201
- }
195
+ // Indexed field line: 1 T xxxxxx
196
+ idx = readPrefixedInt(buf, 6, pos); if (!idx) throw new Error('QPACK: truncated field line');
197
+ pos = idx.next;
198
+ if (byte & 0x40) {
199
+ if (idx.value >= QPACK_STATIC.length) throw new Error('QPACK: static index ' + idx.value + ' out of range');
200
+ field(QPACK_STATIC[idx.value][0], QPACK_STATIC[idx.value][1]);
201
+ } else {
202
+ e = entryAt(base - 1 - idx.value);
203
+ field(e[0], e[1]);
202
204
  }
203
205
 
204
206
  } else if ((byte & 0xc0) === 0x40) {
205
- // Literal with name ref
206
- var fromStatic = (byte & 0x10) !== 0;
207
- var nameIdx = readPrefixedInt(buf, 4, pos); if (!nameIdx) break; pos = nameIdx.next;
208
- var valH = (buf[pos] & 0x80) !== 0;
209
- var valLen = readPrefixedInt(buf, 7, pos); if (!valLen) break; pos = valLen.next;
210
- var valBytes = buf.slice(pos, pos + valLen.value); pos += valLen.value;
211
- var value = valH ? huffDecode(valBytes) : new TextDecoder().decode(valBytes);
212
-
213
- var name = null;
214
- if (fromStatic && nameIdx.value < QPACK_STATIC.length) {
207
+ // Literal with name reference: 01 N T xxxx
208
+ nameIdx = readPrefixedInt(buf, 4, pos); if (!nameIdx) throw new Error('QPACK: truncated field line');
209
+ pos = nameIdx.next;
210
+ val = decodeStringLiteral(buf, 7, pos, true); if (!val) throw new Error('QPACK: truncated field line');
211
+ pos = val.next;
212
+ var name;
213
+ if (byte & 0x10) {
214
+ if (nameIdx.value >= QPACK_STATIC.length) throw new Error('QPACK: static name index ' + nameIdx.value + ' out of range');
215
215
  name = QPACK_STATIC[nameIdx.value][0];
216
- } else if (!fromStatic && dynamicTable) {
217
- var di = bi - 1 - nameIdx.value;
218
- if (di >= 0 && di < dynamicTable.length) name = dynamicTable[di][0];
216
+ } else {
217
+ name = entryAt(base - 1 - nameIdx.value)[0];
219
218
  }
220
- if (name) headers[name] = value;
219
+ field(name, _text(val.bytes));
221
220
 
222
221
  } else if ((byte & 0xe0) === 0x20) {
223
- // Literal with literal name
224
- var nameH = (byte & 0x08) !== 0;
225
- var nameLen = readPrefixedInt(buf, 3, pos); if (!nameLen) break; pos = nameLen.next;
226
- var nameBytes = buf.slice(pos, pos + nameLen.value); pos += nameLen.value;
227
- var name = nameH ? huffDecode(nameBytes) : new TextDecoder().decode(nameBytes);
228
- var valH = (buf[pos] & 0x80) !== 0;
229
- var valLen = readPrefixedInt(buf, 7, pos); if (!valLen) break; pos = valLen.next;
230
- var valBytes = buf.slice(pos, pos + valLen.value); pos += valLen.value;
231
- var value = valH ? huffDecode(valBytes) : new TextDecoder().decode(valBytes);
232
- headers[name] = value;
222
+ // Literal with literal name: 001 N H xxx
223
+ var nameLit = decodeStringLiteral(buf, 3, pos, true); if (!nameLit) throw new Error('QPACK: truncated field line');
224
+ pos = nameLit.next;
225
+ val = decodeStringLiteral(buf, 7, pos, true); if (!val) throw new Error('QPACK: truncated field line');
226
+ pos = val.next;
227
+ field(_text(nameLit.bytes), _text(val.bytes));
228
+
229
+ } else if ((byte & 0xf0) === 0x10) {
230
+ // Indexed field line with post-base index: 0001 xxxx (§4.5.3)
231
+ idx = readPrefixedInt(buf, 4, pos); if (!idx) throw new Error('QPACK: truncated field line');
232
+ pos = idx.next;
233
+ e = entryAt(base + idx.value);
234
+ field(e[0], e[1]);
233
235
 
234
236
  } else {
235
- break;
237
+ // Literal with post-base name reference: 0000 N xxx (§4.5.5)
238
+ nameIdx = readPrefixedInt(buf, 3, pos); if (!nameIdx) throw new Error('QPACK: truncated field line');
239
+ pos = nameIdx.next;
240
+ val = decodeStringLiteral(buf, 7, pos, true); if (!val) throw new Error('QPACK: truncated field line');
241
+ pos = val.next;
242
+ field(entryAt(base + nameIdx.value)[0], _text(val.bytes));
236
243
  }
237
244
  }
238
245
 
246
+ Object.defineProperty(headers, '_order', { value: _order, enumerable: false });
247
+ Object.defineProperty(headers, '_list', { value: _list, enumerable: false });
248
+ // For the Section Acknowledgment the decoder owes when this is non-zero.
249
+ Object.defineProperty(headers, '_ric', { value: ric, enumerable: false });
239
250
  return headers;
240
251
  }
241
252
 
242
- function encodeQpackInt(value, prefixBits) {
243
- var max = (1 << prefixBits) - 1;
244
- if (value < max) return [value];
245
- var bytes = [max];
246
- value -= max;
247
- while (value >= 128) { bytes.push((value & 0x7f) | 0x80); value >>= 7; }
248
- bytes.push(value);
249
- return bytes;
250
- }
251
-
252
-
253
- // ---- Static table lookup maps (built once) ----
254
-
255
- var _qpackFullMatch = {}; // "name\0value" → index
256
- var _qpackNameMatch = {}; // "name" → first index
257
-
258
- for (var _i = 0; _i < QPACK_STATIC.length; _i++) {
259
- var _key = QPACK_STATIC[_i][0] + '\0' + QPACK_STATIC[_i][1];
260
- if (!(_key in _qpackFullMatch)) _qpackFullMatch[_key] = _i;
261
- if (!(QPACK_STATIC[_i][0] in _qpackNameMatch)) _qpackNameMatch[QPACK_STATIC[_i][0]] = _i;
262
- }
263
-
264
-
265
- function buildQpackHeaderBlock(headers) {
266
- var out = [0x00, 0x00]; // QPACK prefix: RIC=0, delta base=0
267
-
268
- for (var name in headers) {
269
- var lname = name.toLowerCase();
270
- var value = String(headers[name]);
271
-
272
- // (1) Try full match: name + value in static table → indexed (1 byte)
273
- var fullKey = lname + '\0' + value;
274
- if (fullKey in _qpackFullMatch) {
275
- var idx = _qpackFullMatch[fullKey];
276
- // Indexed: 1 1 NNNNNN (T=1 static, 6-bit prefix)
277
- var enc = encodeQpackInt(idx, 6);
278
- out.push(0xc0 | enc[0]);
279
- for (var i = 1; i < enc.length; i++) out.push(enc[i]);
280
- continue;
281
- }
282
-
283
- // (2) Try name match: name in static table → literal with name ref
284
- if (lname in _qpackNameMatch) {
285
- var nameIdx = _qpackNameMatch[lname];
286
- // Literal with name reference: 01 N T PPPP (N=0, T=1 static, 4-bit prefix)
287
- var nameEnc = encodeQpackInt(nameIdx, 4);
288
- out.push(0x50 | nameEnc[0]);
289
- for (var i = 1; i < nameEnc.length; i++) out.push(nameEnc[i]);
290
- // Value: H=0 (no huffman), 7-bit length prefix
291
- var valBytes = new TextEncoder().encode(value);
292
- var valLenEnc = encodeQpackInt(valBytes.length, 7);
293
- out.push(valLenEnc[0]);
294
- for (var i = 1; i < valLenEnc.length; i++) out.push(valLenEnc[i]);
295
- for (var i = 0; i < valBytes.length; i++) out.push(valBytes[i]);
296
- continue;
297
- }
298
-
299
- // (3) No match: literal with literal name (fallback)
300
- var nameBytes = new TextEncoder().encode(lname);
301
- var nameLenEnc = encodeQpackInt(nameBytes.length, 3);
302
- out.push(0x20 | nameLenEnc[0]);
303
- for (var i = 1; i < nameLenEnc.length; i++) out.push(nameLenEnc[i]);
304
- for (var i = 0; i < nameBytes.length; i++) out.push(nameBytes[i]);
305
- var valBytes = new TextEncoder().encode(value);
306
- var valLenEnc = encodeQpackInt(valBytes.length, 7);
307
- out.push(valLenEnc[0]);
308
- for (var i = 1; i < valLenEnc.length; i++) out.push(valLenEnc[i]);
309
- for (var i = 0; i < valBytes.length; i++) out.push(valBytes[i]);
310
- }
311
-
312
- return new Uint8Array(out);
313
- }
314
253
 
315
254
 
316
255
  // ============================================================
317
- // H3 Frame build/parse
256
+ // H3 Frame build
257
+ //
258
+ // Frames are read in place, from each stream's own buffer, as the bytes
259
+ // arrive: processBidiStream and processControlStream in H3Connection.
318
260
  // ============================================================
319
261
 
320
262
  function buildH3Frames(frames) {
@@ -327,49 +269,13 @@ function buildH3Frames(frames) {
327
269
  return concatUint8Arrays(parts);
328
270
  }
329
271
 
330
- function extractH3Frames(chunks, fromOffset) {
331
- if (!chunks || chunks.length === 0) return { frames: [], new_from_offset: fromOffset };
332
-
333
- var buffers = [];
334
- var acc = 0;
335
- for (var i = 0; i < chunks.length; i++) {
336
- var nextAcc = acc + chunks[i].length;
337
- if (fromOffset < nextAcc) {
338
- buffers.push(chunks[i].slice(fromOffset - acc));
339
- for (var j = i + 1; j < chunks.length; j++) buffers.push(chunks[j]);
340
- break;
341
- }
342
- acc = nextAcc;
343
- }
344
-
345
- if (buffers.length === 0) return { frames: [], new_from_offset: fromOffset };
346
-
347
- var combined = concatUint8Arrays(buffers);
348
- var offset = 0, frames = [];
349
-
350
- while (offset < combined.length) {
351
- var start = offset;
352
- var ft = readVarInt(combined, offset);
353
- if (!ft) break;
354
- offset += ft.byteLength;
355
- var len = readVarInt(combined, offset);
356
- if (!len) { offset = start; break; }
357
- offset += len.byteLength;
358
- if (offset + len.value > combined.length) { offset = start; break; }
359
- frames.push({ frame_type: ft.value, payload: combined.slice(offset, offset + len.value) });
360
- offset += len.value;
361
- }
362
-
363
- return { frames: frames, new_from_offset: fromOffset + offset };
364
- }
365
-
366
-
367
272
  // ============================================================
368
273
  // H3 Settings
369
274
  // ============================================================
370
275
 
371
276
  var H3_SETTINGS_MAP = {
372
277
  SETTINGS_QPACK_MAX_TABLE_CAPACITY: 0x01,
278
+ SETTINGS_QPACK_BLOCKED_STREAMS: 0x07,
373
279
  SETTINGS_MAX_FIELD_SECTION_SIZE: 0x06,
374
280
  SETTINGS_ENABLE_CONNECT_PROTOCOL: 0x08,
375
281
  SETTINGS_H3_DATAGRAM: 0x33,
@@ -393,12 +299,38 @@ function buildSettingsPayload(settings) {
393
299
 
394
300
  function parseSettingsPayload(buf) {
395
301
  var out = {}, offset = 0;
302
+ // The same settings as an ordered list, on a non-enumerable property so it
303
+ // does not disturb anything that iterates the result. Order and unknown
304
+ // identifiers are both discarded by the object form and both matter for
305
+ // fingerprinting: which settings a peer sends, and in which order, is a
306
+ // property of its implementation.
307
+ var list = [];
308
+ var seen = {};
396
309
  while (offset < buf.length) {
397
310
  var id = readVarInt(buf, offset); if (!id) break; offset += id.byteLength;
398
311
  var val = readVarInt(buf, offset); if (!val) break; offset += val.byteLength;
312
+ // RFC 9114 §7.2.4: an identifier twice, or one of HTTP/2's that HTTP/3
313
+ // reserves (0x02-0x05), is H3_SETTINGS_ERROR.
314
+ if (seen[id.value] || (id.value >= 0x02 && id.value <= 0x05)) {
315
+ var se = new Error('SETTINGS identifier 0x' + id.value.toString(16) + (seen[id.value] ? ' repeated' : ' is reserved'));
316
+ se.h3ErrorCode = 0x0109;
317
+ throw se;
318
+ }
319
+ seen[id.value] = true;
320
+ // Settings that are a yes or no: SETTINGS_ENABLE_CONNECT_PROTOCOL (RFC
321
+ // 8441 §3, by RFC 9220 §3 for HTTP/3) and SETTINGS_H3_DATAGRAM (RFC 9297
322
+ // §2.1.1: "...any value other than 0 or 1 ... as a connection error of
323
+ // type H3_SETTINGS_ERROR").
324
+ if ((id.value === 0x08 || id.value === 0x33) && val.value > 1) {
325
+ var ve = new Error('SETTINGS identifier 0x' + id.value.toString(16) + ' with value ' + val.value + ', not 0 or 1');
326
+ ve.h3ErrorCode = 0x0109;
327
+ throw ve;
328
+ }
399
329
  var name = H3_ID_TO_NAME[id.value] || ('UNKNOWN_0x' + id.value.toString(16));
400
330
  out[name] = val.value;
331
+ list.push({ id: id.value, value: val.value });
401
332
  }
333
+ Object.defineProperty(out, '_ordered', { value: list, enumerable: false });
402
334
  return out;
403
335
  }
404
336
 
@@ -407,28 +339,25 @@ function parseSettingsPayload(buf) {
407
339
  // QPACK Encoder Instructions (from peer's encoder stream)
408
340
  // ============================================================
409
341
 
410
- function parseQpackEncoderInstructions(chunks, fromOffset) {
411
- if (!chunks || chunks.length === 0) return { instructions: [], new_from_offset: fromOffset };
412
-
413
- var buffers = [];
414
- var acc = 0;
415
- for (var i = 0; i < chunks.length; i++) {
416
- var nextAcc = acc + chunks[i].length;
417
- if (fromOffset < nextAcc) {
418
- buffers.push(chunks[i].slice(fromOffset - acc));
419
- for (var j = i + 1; j < chunks.length; j++) buffers.push(chunks[j]);
420
- break;
421
- }
422
- acc = nextAcc;
423
- }
424
- if (buffers.length === 0) return { instructions: [], new_from_offset: fromOffset };
425
-
426
- var combined = concatUint8Arrays(buffers);
342
+ // Parse the encoder-stream instructions in `combined` — the stream's unparsed
343
+ // bytes, from its read position. Returns the complete instructions, how many
344
+ // bytes they took (consumed), and, when the last one is cut short, how many
345
+ // bytes it needs (need). It used to take the stream's chunks and an offset
346
+ // and join them itself; the one caller already hands it a single buffer.
347
+ function parseQpackEncoderInstructions(combined, maxLiteral) {
348
+ if (!combined || combined.length === 0) return { instructions: [], consumed: 0, need: 0 };
349
+ if (maxLiteral === undefined) maxLiteral = Infinity;
427
350
  var pos = 0;
428
351
  var instructions = [];
429
-
430
- // Thin wrapper over the shared readPrefixedInt that advances the local `pos`
431
- // and returns the value (or null on truncation), preserving call-site usage.
352
+ // When the last instruction is cut short: how many bytes, from its start,
353
+ // it needs before parsing it can succeed (0 = unknown, try on any byte).
354
+ // The caller waits for that many instead of re-parsing on every arrival —
355
+ // a literal arriving a byte at a time was re-joined and re-parsed whole
356
+ // each time: quadratic.
357
+ var needAbs = 0;
358
+
359
+ // Thin wrappers over the shared primitives that advance the local `pos` and
360
+ // return the value (or null on truncation), preserving call-site usage.
432
361
  function safeVarInt(prefixBits) {
433
362
  var r = readPrefixedInt(combined, prefixBits, pos);
434
363
  if (!r) return null;
@@ -436,6 +365,26 @@ function parseQpackEncoderInstructions(chunks, fromOffset) {
436
365
  return r.value;
437
366
  }
438
367
 
368
+ // A string literal's length is checked before its bytes are awaited. No
369
+ // entry larger than the table capacity we advertised can ever be inserted
370
+ // (RFC 9204 §3.2.2), so a longer literal is an error now — waiting for it
371
+ // let a peer make us buffer an encoder stream of any size. Huffman can
372
+ // expand a byte to at most 30 bits, so its bound is 30/8 of that.
373
+ function safeLiteral(prefixBits) {
374
+ if (pos >= combined.length) return null;
375
+ var huffman = (combined[pos] & (1 << prefixBits)) !== 0;
376
+ var len = readPrefixedInt(combined, prefixBits, pos);
377
+ if (!len) return null;
378
+ var limit = huffman ? Math.ceil(maxLiteral * 30 / 8) + 1 : maxLiteral;
379
+ if (len.value > limit) {
380
+ throw new Error('literal of ' + len.value + ' bytes cannot fit a table of ' + maxLiteral);
381
+ }
382
+ if (len.next + len.value > combined.length) { needAbs = len.next + len.value; return null; }
383
+ var r = decodeStringLiteral(combined, prefixBits, pos, true);
384
+ pos = r.next;
385
+ return _text(r.bytes);
386
+ }
387
+
439
388
  while (pos < combined.length) {
440
389
  var startPos = pos;
441
390
  var byte = combined[pos];
@@ -444,25 +393,13 @@ function parseQpackEncoderInstructions(chunks, fromOffset) {
444
393
  // Insert with name reference
445
394
  var fromStatic = (byte & 0x40) !== 0;
446
395
  var nameIdx = safeVarInt(6); if (nameIdx === null) { pos = startPos; break; }
447
- var valH = (combined[pos] & 0x80) !== 0;
448
- var valLen = safeVarInt(7); if (valLen === null) { pos = startPos; break; }
449
- if (pos + valLen > combined.length) { pos = startPos; break; }
450
- var valBytes = combined.slice(pos, pos + valLen); pos += valLen;
451
- var value = valH ? huffDecode(valBytes) : new TextDecoder().decode(valBytes);
396
+ var value = safeLiteral(7); if (value === null) { pos = startPos; break; }
452
397
  instructions.push({ type: 'insert_name_ref', from_static: fromStatic, name_index: nameIdx, value: value });
453
398
 
454
399
  } else if ((byte & 0xc0) === 0x40) {
455
- // Insert without name reference
456
- var nameH = (byte & 0x20) !== 0;
457
- var nameLen = safeVarInt(5); if (nameLen === null) { pos = startPos; break; }
458
- if (pos + nameLen > combined.length) { pos = startPos; break; }
459
- var nameBytes = combined.slice(pos, pos + nameLen); pos += nameLen;
460
- var name = nameH ? huffDecode(nameBytes) : new TextDecoder().decode(nameBytes);
461
- var valH = (combined[pos] & 0x80) !== 0;
462
- var valLen = safeVarInt(7); if (valLen === null) { pos = startPos; break; }
463
- if (pos + valLen > combined.length) { pos = startPos; break; }
464
- var valBytes = combined.slice(pos, pos + valLen); pos += valLen;
465
- var value = valH ? huffDecode(valBytes) : new TextDecoder().decode(valBytes);
400
+ // Insert without name reference: 01 H NNNNN, 5-bit prefix on the name
401
+ var name = safeLiteral(5); if (name === null) { pos = startPos; break; }
402
+ var value = safeLiteral(7); if (value === null) { pos = startPos; break; }
466
403
  instructions.push({ type: 'insert_literal', name: name, value: value });
467
404
 
468
405
  } else if ((byte & 0xe0) === 0x20) {
@@ -471,13 +408,83 @@ function parseQpackEncoderInstructions(chunks, fromOffset) {
471
408
  instructions.push({ type: 'set_capacity', capacity: capacity });
472
409
 
473
410
  } else {
474
- break;
411
+ // Duplicate: 000 xxxxx, relative index (§4.3.4). This used to `break`,
412
+ // which stopped reading the encoder stream for good: every instruction
413
+ // after it went unread, and our table fell permanently out of step with
414
+ // the peer's. Chrome duplicates entries it wants to keep alive.
415
+ var dupIdx = safeVarInt(5); if (dupIdx === null) { pos = startPos; break; }
416
+ instructions.push({ type: 'duplicate', index: dupIdx });
475
417
  }
476
418
  }
477
419
 
478
- return { instructions: instructions, new_from_offset: fromOffset + pos };
420
+ return { instructions: instructions, consumed: pos,
421
+ need: needAbs > pos ? needAbs - pos : 0 };
422
+ }
423
+
424
+
425
+ // ============================================================
426
+ // Error codes (RFC 9114 §8.1, RFC 9204 §6)
427
+ //
428
+ // These live in QUIC's APPLICATION error space and must be sent in a 0x1d
429
+ // CONNECTION_CLOSE — see the note on QUICConnection.close().
430
+ //
431
+ // RFC 9114 splits failures into two kinds, and they are NOT interchangeable:
432
+ //
433
+ // Connection errors — the whole connection is unusable, because the shared
434
+ // state it depends on is now unknown. Every QPACK failure is here: the
435
+ // dynamic table is a running total of the peer's inserts, so one bad
436
+ // header block means every later block on every stream decodes to
437
+ // garbage. There is nothing to salvage per-stream. CONNECTION_CLOSE.
438
+ //
439
+ // Stream errors — one request is malformed but the connection is fine.
440
+ // RESET_STREAM / STOP_SENDING on that stream id, everything else
441
+ // continues. H3_MESSAGE_ERROR is the common case.
442
+ //
443
+ // Both are wired: a stream error is abortStream (STOP_SENDING and
444
+ // RESET_STREAM, both reliable control frames of quic_connection's —
445
+ // resetStream / stopSending, retransmitted until acknowledged) or
446
+ // stopReading (STOP_SENDING alone). H3 resets a stream by asking QUIC, which
447
+ // owns streams; the error code is an opaque varint QUIC never reads. HTTP/2
448
+ // has no layer beneath it to ask — its RST_STREAM is its own frame on one
449
+ // TCP byte stream — so the two share the idea, not the code.
450
+ // ============================================================
451
+
452
+ var H3_ERR = {
453
+ H3_NO_ERROR: 0x0100,
454
+ H3_GENERAL_PROTOCOL_ERROR: 0x0101,
455
+ H3_INTERNAL_ERROR: 0x0102,
456
+ H3_STREAM_CREATION_ERROR: 0x0103,
457
+ H3_CLOSED_CRITICAL_STREAM: 0x0104,
458
+ H3_FRAME_UNEXPECTED: 0x0105,
459
+ H3_FRAME_ERROR: 0x0106,
460
+ H3_EXCESSIVE_LOAD: 0x0107,
461
+ H3_ID_ERROR: 0x0108,
462
+ H3_SETTINGS_ERROR: 0x0109,
463
+ H3_MISSING_SETTINGS: 0x010a,
464
+ H3_REQUEST_REJECTED: 0x010b,
465
+ H3_REQUEST_CANCELLED: 0x010c,
466
+ H3_REQUEST_INCOMPLETE: 0x010d,
467
+ H3_MESSAGE_ERROR: 0x010e,
468
+ H3_CONNECT_ERROR: 0x010f,
469
+ H3_VERSION_FALLBACK: 0x0110,
470
+
471
+ QPACK_DECOMPRESSION_FAILED: 0x0200,
472
+ QPACK_ENCODER_STREAM_ERROR: 0x0201,
473
+ QPACK_DECODER_STREAM_ERROR: 0x0202
474
+ };
475
+
476
+ // An exception from inside this code — a read past an end, a property of
477
+ // undefined — and not one of the protocol's: none of these is thrown on
478
+ // purpose on the paths that read a peer's bytes. It is our failure, and goes
479
+ // as H3_INTERNAL_ERROR (as HTTP/2 sends INTERNAL_ERROR for one), not under a
480
+ // code that blames the peer: H3_FRAME_ERROR, QPACK_DECOMPRESSION_FAILED.
481
+ function isInternalError(err) {
482
+ return err instanceof TypeError || err instanceof RangeError || err instanceof ReferenceError || err instanceof SyntaxError;
479
483
  }
480
484
 
485
+ var H3_ERR_NAME = {};
486
+ for (var _ec in H3_ERR) H3_ERR_NAME[H3_ERR[_ec]] = _ec;
487
+
481
488
 
482
489
  // ============================================================
483
490
  // H3Connection — reactive pattern
@@ -488,6 +495,43 @@ function parseQpackEncoderInstructions(chunks, fromOffset) {
488
495
  // 'http_body' (stream_id, data) — request body chunk
489
496
  //
490
497
 
498
+ // The SETTINGS a connection with these local values sends.
499
+ function localSettings(c) {
500
+ var o = { SETTINGS_MAX_FIELD_SECTION_SIZE: c.local_max_header_size };
501
+ if (c.local_qpack_max_table_capacity > 0) {
502
+ o.SETTINGS_QPACK_MAX_TABLE_CAPACITY = c.local_qpack_max_table_capacity;
503
+ o.SETTINGS_QPACK_BLOCKED_STREAMS = QPACK_BLOCKED_STREAMS;
504
+ }
505
+ if (c.local_datagram_support) {
506
+ o.SETTINGS_H3_DATAGRAM = 1;
507
+ o.SETTINGS_ENABLE_CONNECT_PROTOCOL = 1;
508
+ }
509
+ if (c.local_webtransport) {
510
+ o.SETTINGS_ENABLE_WEBTRANSPORT = 1;
511
+ if (c.local_wt_max_sessions !== null) o.SETTINGS_WT_MAX_SESSIONS = c.local_wt_max_sessions;
512
+ }
513
+ return o;
514
+ }
515
+ function localValues(options) {
516
+ return {
517
+ local_max_header_size: 65536,
518
+ local_qpack_max_table_capacity: QPACK_DYNAMIC_TABLE ? QPACK_TABLE_BYTES : 0,
519
+ local_datagram_support: !!(options.enableWebTransport || options.enableDatagrams),
520
+ local_webtransport: !!options.enableWebTransport,
521
+ local_wt_max_sessions: options.maxWebTransportSessions > 0 ? Math.floor(options.maxWebTransportSessions) : null
522
+ };
523
+ }
524
+
525
+ // 0-RTT (RFC 9114 §7.2.4.2): a client that remembered our SETTINGS may send
526
+ // early data under them — accepted only while they are the ones we send now.
527
+ // What a server built with these H3Connection options sends, as a string for
528
+ // the tickets' early-data context.
529
+ function settingsContext(options) {
530
+ var o = localSettings(localValues(options || {})), keys = Object.keys(o).sort(), parts = [];
531
+ for (var i = 0; i < keys.length; i++) parts.push(keys[i] + '=' + o[keys[i]]);
532
+ return 'h3:' + parts.join(',');
533
+ }
534
+
491
535
  function H3Connection(options) {
492
536
  if (!(this instanceof H3Connection)) return new H3Connection(options);
493
537
  options = options || {};
@@ -495,17 +539,74 @@ function H3Connection(options) {
495
539
  var ev = Emitter();
496
540
  var quic = options.quicConnection;
497
541
 
542
+ // Set once a connection error has been raised. Everything downstream stops:
543
+ // the peer is getting a CONNECTION_CLOSE, and continuing to decode against a
544
+ // dynamic table we no longer trust can only produce more wrong headers.
545
+ var _fatal = false;
546
+
547
+ /**
548
+ * Raise an HTTP/3 connection error: tell the owner, then close the QUIC
549
+ * connection with the application error code.
550
+ *
551
+ * Before this existed, a malformed header block threw out of the QPACK
552
+ * decoder, up through processBidiStream, into the quic.on('stream') listener
553
+ * — where Emitter's catch swallowed it. No error, no close, no RESET_STREAM.
554
+ * The request simply hung until the idle timeout, and the peer was never told
555
+ * why. Silently dropping a stream is the one response RFC 9114 does not allow.
556
+ */
557
+ // Report-only escape hatch for the checks added alongside this error layer
558
+ // (strict Huffman padding, unresolvable table indices, post-base field
559
+ // lines). Every one of them turns something previously ignored into a closed
560
+ // connection — correct per RFC, but it means any latent bug they touch now
561
+ // presents as a hard failure against a real peer. With qpackLenient the
562
+ // condition is logged and the field skipped, restoring the old behaviour, so
563
+ // a regression can be isolated without reverting the whole layer.
564
+ var _lenient = options.qpackLenient === true;
565
+
566
+ function connectionError(code, reason) {
567
+ if (_fatal) return;
568
+ var name = H3_ERR_NAME[code] || ('0x' + code.toString(16));
569
+
570
+ if (_lenient) {
571
+ console.warn('[h3] qpackLenient: ignoring ' + name + ': ' + reason);
572
+ return;
573
+ }
574
+
575
+ _fatal = true;
576
+ // Logged unconditionally, not behind DEBUG: this closes the connection, and
577
+ // the peer only ever sees a generic protocol error. One line here is the
578
+ // difference between a five-minute diagnosis and a packet capture.
579
+ console.error('[h3] connection error ' + name + ': ' + reason);
580
+ var err = new Error('HTTP/3 ' + name + ': ' + reason);
581
+ err.code = name;
582
+ err.errorCode = code;
583
+ ev.emit('error', err);
584
+ // `true` = application error space (0x1d). H3/QPACK codes are meaningless
585
+ // in the transport space.
586
+ try { quic.close(code, name, true); } catch (e) { /* already closing */ }
587
+ }
588
+
498
589
 
499
590
  // ---- Context ----
500
591
 
592
+ var lv = localValues(options);
501
593
  var context = {
502
594
  isServer: options.isServer !== false,
503
595
 
504
- // Local settings (what we advertise)
505
- local_max_header_size: 65536,
506
- local_qpack_max_table_capacity: 0,
507
- local_datagram_support: !!options.enableWebTransport,
508
- local_webtransport: !!options.enableWebTransport,
596
+ // Local settings (what we advertise; localValues, as settingsContext sees them)
597
+ local_max_header_size: lv.local_max_header_size,
598
+ // QPACK_TABLE_BYTES. Measured against a real Chrome header set, a subresource request drops
599
+ // from 412 bytes to 80 once these are in the table. The encoder is the
600
+ // conservative one described in qpack.js, so this costs no risk of
601
+ // head-of-line blocking.
602
+ local_qpack_max_table_capacity: lv.local_qpack_max_table_capacity,
603
+ // SETTINGS_H3_DATAGRAM (RFC 9297): WebTransport needs it, and so does a
604
+ // CONNECT-UDP client (enableDatagrams) that is not WebTransport.
605
+ local_datagram_support: lv.local_datagram_support,
606
+ local_webtransport: lv.local_webtransport,
607
+ // How many WebTransport sessions a peer may have open at once on this
608
+ // connection (SETTINGS_WT_MAX_SESSIONS); null: no limit, and none sent.
609
+ local_wt_max_sessions: lv.local_wt_max_sessions,
509
610
 
510
611
  // Remote settings (from peer's SETTINGS frame)
511
612
  remote_max_header_size: 0,
@@ -516,6 +617,7 @@ function H3Connection(options) {
516
617
  remote_qpack_dynamic_table: [],
517
618
  remote_qpack_table_base_index: 0,
518
619
  remote_qpack_table_capacity: 0,
620
+ remote_qpack_table_size: 0, // bytes, by name + value + 32
519
621
 
520
622
  // Stream tracking
521
623
  streams: {}, // stream_id → { chunks, from_offset, type }
@@ -528,6 +630,61 @@ function H3Connection(options) {
528
630
 
529
631
  // ---- set_context ----
530
632
 
633
+ // ---- QPACK outbound state ----
634
+ //
635
+ // Declared here, above set_context, because set_context opens the table when
636
+ // the peer's SETTINGS arrives and would otherwise read an undefined `var`
637
+ // that is hoisted but not yet assigned — a silent no-op rather than an
638
+ // error, which is how the table stayed closed and nothing compressed.
639
+ //
640
+ // Separate from remote_qpack_dynamic_table, which is the PEER's table for
641
+ // what we receive. The two are independent: each side runs its own with its
642
+ // own capacity, and they are never in step.
643
+ var encoderTable = createQpackTable(0);
644
+ var encoderHistory = createQpackHistory(QPACK_TABLE_BYTES); // insert on second sighting
645
+ var encoderStreamId = null;
646
+ var decoderStreamId = null;
647
+ var qpackKnownReceived = 0; // insertions the peer has acknowledged
648
+ var qpackPendingAcks = 0; // insertions of THEIRS we owe an ack for
649
+
650
+ // ---- SETTINGS remembered for 0-RTT (RFC 9114 §7.2.4.2) ----
651
+ //
652
+ // A client keeps the server's SETTINGS with the sessions it saves; its
653
+ // early requests may then rely on them before this connection's arrive —
654
+ // datagrams, the field-section limit (the QPACK table stays closed until
655
+ // the real ones: using less is always compatible). With 0-RTT accepted,
656
+ // the server's new SETTINGS must not take back what was remembered: a
657
+ // known setting with a non-default value omitted, or one now smaller, is
658
+ // H3_SETTINGS_ERROR. Nothing was kept, and early requests went on the
659
+ // defaults with no check. Known identifiers only, as '0x…' keys.
660
+ function knownSettings(list) {
661
+ var out = {};
662
+ for (var i = 0; i < list.length; i++) if (H3_ID_TO_NAME[list[i].id]) out['0x' + list[i].id.toString(16)] = list[i].value;
663
+ return out;
664
+ }
665
+ var rememberedSettings = null;
666
+ function useRememberedSettings() {
667
+ var app = !context.isServer && quic && quic.earlyDataStatus === 'offered' ? quic.rememberedApplicationSettings : null;
668
+ if (!app || !app.h3 || typeof app.h3 !== 'object') return;
669
+ rememberedSettings = app.h3;
670
+ var u = {};
671
+ if (typeof rememberedSettings['0x6'] === 'number') u.remote_max_header_size = rememberedSettings['0x6'];
672
+ if (typeof rememberedSettings['0x33'] === 'number') u.remote_datagram_support = rememberedSettings['0x33'] > 0;
673
+ set_context(u);
674
+ }
675
+ function checkRememberedSettings(list) {
676
+ if (!rememberedSettings || !quic || quic.earlyDataStatus !== 'accepted') return;
677
+ var now = knownSettings(list);
678
+ for (var k in rememberedSettings) {
679
+ var was = rememberedSettings[k];
680
+ if (typeof was !== 'number' || !H3_ID_TO_NAME[parseInt(k, 16)]) continue;
681
+ if (!(k in now) ? was !== 0 : now[k] < was) {
682
+ throw h3Error(H3_ERR.H3_SETTINGS_ERROR, '0-RTT accepted, and SETTINGS ' + (H3_ID_TO_NAME[parseInt(k, 16)]) +
683
+ (!(k in now) ? ' omitted' : ' reduced') + ' (' + was + ' remembered' + ((k in now) ? ', ' + now[k] + ' now' : '') + ')');
684
+ }
685
+ }
686
+ }
687
+
531
688
  function set_context(updates) {
532
689
  if (!updates || typeof updates !== 'object') return;
533
690
 
@@ -541,6 +698,20 @@ function H3Connection(options) {
541
698
  if ('remote_qpack_max_table_capacity' in updates && updates.remote_qpack_max_table_capacity !== context.remote_qpack_max_table_capacity) {
542
699
  context.remote_qpack_max_table_capacity = updates.remote_qpack_max_table_capacity;
543
700
  changed.remote_qpack_max_table_capacity = true;
701
+
702
+ // Only now may we insert anything. The capacity is the PEER's — it
703
+ // decides how much it is willing to hold for us — and inserting before
704
+ // it has said so would produce references it cannot resolve.
705
+ //
706
+ // The Set Dynamic Table Capacity instruction goes first on the encoder
707
+ // stream, before any insertion (§4.3.1).
708
+ var cap = QPACK_DYNAMIC_TABLE ? Math.min(updates.remote_qpack_max_table_capacity, QPACK_TABLE_BYTES) : 0;
709
+ if (cap > 0 && encoderTable.capacity === 0 && encoderStreamId !== null) {
710
+ encoderTable.setCapacity(cap);
711
+ quic.sendStream(encoderStreamId,
712
+ new Uint8Array(qpackEncodeSetCapacity(cap)), false);
713
+ if (DEBUG) console.log('[h3] qpack encoder table opened at ' + cap);
714
+ }
544
715
  }
545
716
 
546
717
  if ('remote_datagram_support' in updates) {
@@ -548,30 +719,16 @@ function H3Connection(options) {
548
719
  changed.remote_datagram_support = true;
549
720
  }
550
721
 
551
- if ('remote_qpack_table_capacity' in updates) {
552
- context.remote_qpack_table_capacity = updates.remote_qpack_table_capacity;
553
- changed.remote_qpack_table_capacity = true;
554
- }
555
-
556
722
  if ('remote_settings_received' in updates && !context.remote_settings_received) {
557
723
  context.remote_settings_received = true;
558
724
  changed.remote_settings_received = true;
559
725
  }
560
726
 
561
- if ('add_qpack_entries' in updates) {
562
- var entries = updates.add_qpack_entries;
563
- for (var i = 0; i < entries.length; i++) {
564
- context.remote_qpack_dynamic_table.unshift(entries[i]);
565
- context.remote_qpack_table_base_index++;
566
- }
567
- changed.qpack_table = true;
568
- }
569
-
570
727
  // ---- Reactive ----
571
728
 
572
- if (changed.remote_qpack_table_capacity) {
573
- evictQpackTable();
574
- }
729
+ // (The peer's table — insertions, capacity, eviction, acknowledgements —
730
+ // is maintained by processQpackEncoderStream directly, one instruction at
731
+ // a time. It used to go through here in batches; see that function.)
575
732
 
576
733
  if (changed.remote_settings_received) {
577
734
  // Could trigger QPACK table capacity update etc.
@@ -579,40 +736,118 @@ function H3Connection(options) {
579
736
  }
580
737
 
581
738
 
582
- function evictQpackTable() {
583
- var entries = context.remote_qpack_dynamic_table;
584
- var capacity = context.remote_qpack_table_capacity;
585
- var totalSize = 0;
586
- for (var i = 0; i < entries.length; i++) {
587
- totalSize += entries[i][0].length + entries[i][1].length + 32;
739
+ /**
740
+ * The peer's decoder stream: what it has acknowledged.
741
+ *
742
+ * The only instruction that matters to this encoder is Insert Count
743
+ * Increment. Each one raises the watermark below which an entry is safe to
744
+ * reference — and until an entry is below it, we send a literal instead.
745
+ * That single number is what makes this encoder non-blocking.
746
+ *
747
+ * Section acknowledgements and stream cancellations are parsed and skipped:
748
+ * they drive eviction accounting in an encoder that references
749
+ * unacknowledged entries, which this one never does.
750
+ */
751
+ function processQpackDecoderStream(s) {
752
+ if (s.buffered === 0) return;
753
+ // Only the unparsed bytes; at most one instruction's worth (a few bytes)
754
+ // is ever left over between arrivals.
755
+ var buf = Buffer.from(copyBytes(s, s.buffered));
756
+
757
+ var r = qpackParseDecoderInstructions(buf, 0);
758
+ if (r.increments > 0) {
759
+ qpackKnownReceived += r.increments;
760
+ if (DEBUG) console.log('[h3] qpack acked ' + r.increments +
761
+ ' (known=' + qpackKnownReceived + ')');
588
762
  }
589
- while (totalSize > capacity && entries.length > 0) {
763
+ consumeBytes(s, r.consumed);
764
+ }
765
+
766
+ function evictQpackTable(limit) {
767
+ // Oldest first, until what remains fits. Sizes in BYTES: measuring by
768
+ // string length, as this did, undercounts any non-ASCII header value and
769
+ // lets our table drift out of step with the peer's.
770
+ var entries = context.remote_qpack_dynamic_table;
771
+ if (limit === undefined) limit = context.remote_qpack_table_capacity;
772
+ while (context.remote_qpack_table_size > limit && entries.length > 0) {
590
773
  var removed = entries.pop();
591
- totalSize -= removed[0].length + removed[1].length + 32;
774
+ context.remote_qpack_table_size -= qpackEntrySize(removed[0], removed[1]);
775
+ }
776
+ }
777
+
778
+ function qpackEncoderStreamError(msg) {
779
+ var err = new Error('QPACK encoder stream: ' + msg);
780
+ err.h3ErrorCode = H3_ERR.QPACK_ENCODER_STREAM_ERROR;
781
+ return err;
782
+ }
783
+
784
+ // One insertion into the PEER's table, applied immediately — the next
785
+ // instruction on the same stream may refer to it by relative index.
786
+ function remoteTableInsert(name, value) {
787
+ var need = qpackEntrySize(name, value);
788
+ if (need > context.remote_qpack_table_capacity) {
789
+ throw qpackEncoderStreamError('entry of ' + need + ' bytes exceeds capacity ' +
790
+ context.remote_qpack_table_capacity);
592
791
  }
792
+ evictQpackTable(context.remote_qpack_table_capacity - need);
793
+ context.remote_qpack_dynamic_table.unshift([name, value]);
794
+ context.remote_qpack_table_size += need;
795
+ context.remote_qpack_table_base_index++;
796
+ qpackPendingAcks++;
593
797
  }
594
798
 
799
+ // On the encoder stream a relative index counts back from the most recent
800
+ // insertion (§3.2.5): 0 is the newest entry, which is also position 0 of
801
+ // this newest-first array. The old code computed an absolute index here
802
+ // instead, and named the inserted entry after the wrong one.
803
+ function remoteEntryByRelative(rel) {
804
+ var e = context.remote_qpack_dynamic_table[rel];
805
+ if (!e) throw qpackEncoderStreamError('relative index ' + rel + ' unresolvable');
806
+ return e;
807
+ }
808
+
809
+ // Tell the peer what we now hold, so it may reference it.
810
+ function flushQpackAcks() {
811
+ if (qpackPendingAcks > 0 && decoderStreamId !== null) {
812
+ quic.sendStream(decoderStreamId, new Uint8Array(qpackEncodeAck(qpackPendingAcks)), false);
813
+ qpackPendingAcks = 0;
814
+ }
815
+ }
816
+
817
+
595
818
 
596
819
  // ---- Send initial control + QPACK streams ----
597
820
 
821
+ /**
822
+ * Send queued insertions on the encoder stream.
823
+ *
824
+ * Before the header block that will eventually use them, and never for the
825
+ * block being sent now — nothing is referenced until acknowledged, so an
826
+ * insertion is always for a later request.
827
+ */
828
+ function flushQpackInserts(inserts) {
829
+ if (!inserts.length || encoderStreamId === null) return;
830
+ var chunks = [];
831
+ for (var i = 0; i < inserts.length; i++) {
832
+ // Never evict: an entry that does not fit as things stand is not
833
+ // inserted. insert() would make room by dropping old entries, and one of
834
+ // those may still be referenced by a section the peer has not decoded
835
+ // yet — which RFC 9204 §2.1.1 forbids. (It used to do exactly that, and
836
+ // on "did not fit" also emptied our table without telling the peer.)
837
+ if (!encoderTable.fits(inserts[i].name, inserts[i].value)) continue;
838
+ encoderTable.insert(inserts[i].name, inserts[i].value);
839
+ chunks.push(qpackEncodeInsert(inserts[i].name, inserts[i].value));
840
+ }
841
+ if (chunks.length) {
842
+ quic.sendStream(encoderStreamId, new Uint8Array(Buffer.concat(chunks)), false);
843
+ }
844
+ }
845
+
598
846
  function sendControlStreams() {
599
847
  if (context.control_streams_sent) return;
600
848
  context.control_streams_sent = true;
601
849
 
602
- var settingsObj = {
603
- SETTINGS_MAX_FIELD_SECTION_SIZE: context.local_max_header_size
604
- };
605
- if (context.local_qpack_max_table_capacity > 0) {
606
- settingsObj.SETTINGS_QPACK_MAX_TABLE_CAPACITY = context.local_qpack_max_table_capacity;
607
- }
608
- if (context.local_datagram_support) {
609
- settingsObj.SETTINGS_H3_DATAGRAM = 1;
610
- settingsObj.SETTINGS_ENABLE_CONNECT_PROTOCOL = 1;
611
- }
612
- if (context.local_webtransport) {
613
- settingsObj.SETTINGS_ENABLE_WEBTRANSPORT = 1;
614
- }
615
- var settings = buildSettingsPayload(settingsObj);
850
+ var settings = buildSettingsPayload(localSettings(context));
616
851
 
617
852
  var controlFrame = buildH3Frames([{ frame_type: 0x04, payload: settings }]);
618
853
 
@@ -628,9 +863,18 @@ function H3Connection(options) {
628
863
  var ctrlData = new Uint8Array(1 + controlFrame.byteLength);
629
864
  ctrlData[0] = 0x00; // control stream type
630
865
  ctrlData.set(controlFrame, 1);
866
+ // Ahead of every request and WebTransport stream (urgency -1, below
867
+ // RFC 9218's range): SETTINGS, GOAWAY, PRIORITY_UPDATE, and the QPACK
868
+ // instructions the header blocks on request streams depend on — a block
869
+ // that refers to an entry still waiting behind response bytes blocks.
870
+ quic.setStreamPriority(ctrlId, { urgency: -1 });
871
+ quic.setStreamPriority(encId, { urgency: -1 });
872
+ quic.setStreamPriority(decId, { urgency: -1 });
631
873
  quic.sendStream(ctrlId, ctrlData, false);
632
874
  quic.sendStream(encId, new Uint8Array([0x02]), false); // QPACK encoder
633
875
  quic.sendStream(decId, new Uint8Array([0x03]), false); // QPACK decoder
876
+ encoderStreamId = encId;
877
+ decoderStreamId = decId;
634
878
  }
635
879
 
636
880
 
@@ -650,90 +894,548 @@ function H3Connection(options) {
650
894
  var _claimedStreams = new Set(); // streams owned by WebTransport (skip H3 processing)
651
895
  var _wtSessions = new Set(); // active WT session stream IDs
652
896
  var _wtStreams = {}; // streamId → { sessionId, isBidi }
897
+ var _wtLocalUni = {}; // our own WT uni streams: streamId → sessionId
898
+
899
+ // WebTransport streams that arrive before their session is accepted. The
900
+ // client may open them as soon as it has sent the CONNECT (draft-ietf-
901
+ // webtrans-http3 §4.6), and they travel on streams of their own, so one can
902
+ // be read before the CONNECT itself — or before the application has
903
+ // answered it. They used to be read as unknown streams and their bytes
904
+ // dropped, without a word to either side. Now they are held — their bytes
905
+ // in the stream's own buffer, uncredited, so the peer is held to one
906
+ // stream window each — until the session is accepted (then delivered as if
907
+ // just arrived) or turned down, or too many wait (then reset with
908
+ // WEBTRANSPORT_BUFFERED_STREAM_REJECTED, as the draft has it).
909
+ var WT_BUFFERED_STREAM_REJECTED = 0x3994bd84;
910
+ var MAX_HELD_WT_STREAMS = 64;
911
+ var _wtHeld = new Map(); // streamId → the session ID it names
912
+ var _wtConnects = new Set(); // WebTransport CONNECTs read, not yet answered (server)
913
+
914
+ // Could this name a session still to be accepted? A CONNECT read and not
915
+ // answered, or a client stream still open whose request is not read yet.
916
+ function wtSessionMayCome(sessionId) {
917
+ if (!context.isServer || sessionId % 4 !== 0) return false;
918
+ if (_wtConnects.has(sessionId)) return true;
919
+ if (!quic.isReceiving || !quic.isReceiving(sessionId)) return false;
920
+ var cs = context.streams[sessionId];
921
+ return !cs || (cs.type !== 'wt_held' && !cs.final_head_seen);
922
+ }
923
+
924
+ function holdWtStream(s, sessionId) {
925
+ if (_wtHeld.size >= MAX_HELD_WT_STREAMS) { abortStream(s.sid, WT_BUFFERED_STREAM_REJECTED); return; }
926
+ s.type = 'wt_held';
927
+ _wtHeld.set(s.sid, sessionId);
928
+ }
929
+
930
+ // The session is accepted (go on) or not: its held streams, read again
931
+ // from their start, or reset.
932
+ function settleHeldWt(sessionId, accepted) {
933
+ var ids = [];
934
+ _wtHeld.forEach(function (sess, sid) { if (sess === sessionId) ids.push(sid); });
935
+ if (ids.length === 0) return;
936
+ if (!accepted) { for (var i = 0; i < ids.length; i++) abortStream(ids[i], WT_BUFFERED_STREAM_REJECTED); return; }
937
+ // After the caller's accept returns — the layer above finishes setting up
938
+ // the session (and the application its listeners) first.
939
+ queueMicrotask(function () {
940
+ for (var j = 0; j < ids.length; j++) {
941
+ var hs = context.streams[ids[j]];
942
+ if (!hs || _wtHeld.get(ids[j]) !== sessionId) continue;
943
+ _wtHeld.delete(ids[j]);
944
+ hs.type = null;
945
+ onStream(ids[j], new Uint8Array(0), false);
946
+ }
947
+ });
948
+ }
949
+
950
+ // Streams running an extended-CONNECT protocol that is NOT WebTransport —
951
+ // in practice WebSocket (RFC 9220). Their bytes are the inner protocol's,
952
+ // not HTTP/3 frames, so they bypass the frame parser entirely.
953
+ //
954
+ // Kept apart from _wtSessions on purpose. A WebTransport session makes every
955
+ // LATER stream that carries its id a member of that session; a WebSocket
956
+ // does not, and registering one as a session would make unrelated streams
957
+ // look like they belong to it.
958
+ var _tunnelStreams = new Set();
959
+
960
+ // Request bodies whose flow-control credit the application returns as it
961
+ // reads them (holdBody / creditBody / releaseBody). On the server every
962
+ // request stream's credit is held at the QUIC layer from its first byte
963
+ // (setCreditPolicy below); this layer credits what it consumes itself —
964
+ // frame headers, HEADERS, frames it skips — at once, and a body chunk
965
+ // either as it hands it up (the default) or, for a stream in this set, when
966
+ // the application has read it. The peer then sends a body no faster than
967
+ // it is read: a handler that had not read yet — waiting on an
968
+ // authorization check, say — had the whole upload held in memory, as fast
969
+ // as the client could send it.
970
+ var _bodyHeld = new Set();
971
+ // The peer's control and QPACK streams, by type → stream ID.
972
+ var _criticalStreams = {};
973
+ //
974
+ // WebTransport streams the same way: the client's unidirectional streams
975
+ // are held too from their first byte, since what a stream is shows only in
976
+ // those bytes — and our own bidirectional ones, which only WebTransport
977
+ // opens, for what the client sends back on them. Control and QPACK streams (and ones of unknown type) give
978
+ // their credit back the moment they are recognised; a WebTransport stream,
979
+ // bidirectional or not, keeps it held, and h3_server returns it as the
980
+ // application reads. Credited on delivery, a stream nobody read was taken
981
+ // in whole: 40 MB from a browser, all of it in memory.
982
+ //
983
+ // The same on either side — a WebTransport client reads the server's
984
+ // streams as the server reads the client's: every stream the peer can send
985
+ // on, so all but our own unidirectional ones.
986
+ if (quic.setCreditPolicy) {
987
+ var ownUni = context.isServer ? 3 : 2;
988
+ quic.setCreditPolicy(function (sid) { return (sid % 4) !== ownUni; });
989
+ }
990
+ function creditRecv(sid, n) { if (n > 0 && quic.creditStream) quic.creditStream(sid, n); }
991
+ function releaseRecvCredit(sid) { if (quic.releaseStreamCredit) quic.releaseStreamCredit(sid); }
992
+
993
+ // Bytes on a stream we have decided is a tunnel but have not yet accepted.
994
+ //
995
+ // The window is real and unavoidable: the client learns the CONNECT was
996
+ // accepted only when our 2xx reaches it, but nothing stops it sending frames
997
+ // the moment it sent the CONNECT — and an eager client (or a slow network in
998
+ // the other direction) does exactly that. Those bytes are not HTTP/3 frames,
999
+ // so they cannot go to the frame parser; they are held here and replayed, in
1000
+ // order, the instant the tunnel opens.
1001
+ var _tunnelPending = {};
1002
+ var _tunnelPendingBytes = {};
1003
+ var _tunnelPendingIds = new Set();
1004
+ // QUIC returns flow-control credit as it delivers bytes, so bytes held
1005
+ // here are not bounded by it: a client that never waits for the 2xx could
1006
+ // fill memory. Past this, the tunnel is refused.
1007
+ var MAX_TUNNEL_PENDING = 1024 * 1024; // per stream
1008
+ var MAX_TUNNEL_PENDING_TOTAL = 4 * 1024 * 1024; // per connection
1009
+ var _tunnelPendingTotal = 0;
1010
+ var _tunnelPendingFin = new Set();
1011
+
1012
+ // Frames on the control stream are read whole (SETTINGS, GOAWAY); no real
1013
+ // one comes near this. Anything else there is skipped as it arrives.
1014
+ var MAX_CONTROL_FRAME = 16384;
1015
+
1016
+ // ── Stream lifecycle: one exit ────────────────────────────────────────
1017
+ //
1018
+ // releaseStream is the ONLY place per-stream state is dropped, and every
1019
+ // way a stream can end comes through it:
1020
+ //
1021
+ // FIN, on whichever path read the stream (HTTP/3 frames, a tunnel, a
1022
+ // WebTransport stream — including a FIN that arrived with its prefix —,
1023
+ // a claimed or an ignored stream) → the 'stream' handler
1024
+ // RESET_STREAM from the peer → quic 'stream_reset'
1025
+ // cancelled or refused by us → abortStream / stopReading
1026
+ // its WebTransport session ended → endWtSession → abortStream
1027
+ // the connection closed → quic 'close'
1028
+ //
1029
+ // It drops only what this layer holds for the stream's RECEIVING side; a
1030
+ // response still being written is QUIC's, and unaffected. Idempotent. The
1031
+ // state used to be dropped piecemeal at each of those sites, each with its
1032
+ // own subset — which is how entries came to outlive their streams.
1033
+ function releaseStream(sid, keepBody) {
1034
+ sid = Number(sid);
1035
+ // Credit still held for it goes back — unless the stream ended normally
1036
+ // (keepBody) and the application still has its body to read: then it is
1037
+ // returned as that is read, or by releaseBody.
1038
+ if (!(keepBody && _bodyHeld.has(sid))) { _bodyHeld.delete(sid); releaseRecvCredit(sid); }
1039
+ delete context.streams[sid];
1040
+ _blocked.delete(sid);
1041
+ _prioPending.delete(sid);
1042
+ _wtHeld.delete(sid);
1043
+ // A CONNECT that ends unanswered: the streams waiting for it will not
1044
+ // get a session.
1045
+ if (_wtConnects.delete(sid) || !_wtSessions.has(sid)) settleHeldWt(sid, false);
1046
+ _tunnelStreams.delete(sid);
1047
+ _tunnelPendingIds.delete(sid);
1048
+ dropTunnelPending(sid);
1049
+ delete _wtStreams[sid];
1050
+ _claimedStreams.delete(sid);
1051
+ delete _wtLocalUni[sid];
1052
+ endWtSession(sid); // a CONNECT stream's end is its session's end
1053
+ }
653
1054
 
654
- quic.on('stream', function (streamId, data, fin) {
1055
+ // The connection is gone: every stream with it. No frames are sent — there
1056
+ // is no one to send them to — but the layer above still hears of each
1057
+ // What this layer listens to on the QUIC connection, so that it can stop
1058
+ // (detach): a client whose 0-RTT the server refused starts HTTP/3 over on
1059
+ // the same connection, and the layer that spoke early must not hear it.
1060
+ var _quicListeners = [];
1061
+ function listenQuic(name, fn) { _quicListeners.push([name, fn]); quic.on(name, fn); }
1062
+
1063
+ // WebTransport session ending, through the same path as any other end.
1064
+ var _connClosed = false;
1065
+ listenQuic('close', function () {
1066
+ _connClosed = true;
1067
+ var ids = new Set();
1068
+ [context.streams, _wtStreams, _tunnelPending, _wtLocalUni].forEach(function (m) {
1069
+ for (var k in m) ids.add(Number(k));
1070
+ });
1071
+ [_tunnelStreams, _tunnelPendingIds, _claimedStreams, _wtSessions, _bodyHeld].forEach(function (set) {
1072
+ set.forEach(function (k) { ids.add(Number(k)); });
1073
+ });
1074
+ ids.forEach(releaseStream);
1075
+ });
1076
+
1077
+ function dropTunnelPending(sid) {
1078
+ if (_tunnelPendingBytes[sid]) _tunnelPendingTotal -= _tunnelPendingBytes[sid];
1079
+ delete _tunnelPending[sid];
1080
+ delete _tunnelPendingBytes[sid];
1081
+ _tunnelPendingFin.delete(sid);
1082
+ }
1083
+
1084
+ // Bytes held for a tunnel, handed over in order — with the FIN, if it came.
1085
+ function replayTunnelPending(sid) {
1086
+ var pend = _tunnelPending[sid];
1087
+ var fin = _tunnelPendingFin.has(sid);
1088
+ dropTunnelPending(sid);
1089
+ if (pend) for (var i = 0; i < pend.length; i++) ev.emit('tunnel_data', sid, pend[i], false);
1090
+ if (fin) { ev.emit('tunnel_data', sid, new Uint8Array(0), true); releaseStream(sid); }
1091
+ }
1092
+
1093
+ // A WebTransport session is over (its CONNECT stream ended or was reset).
1094
+ // Its streams go with it: reset and stopped with WEBTRANSPORT_SESSION_GONE
1095
+ // (draft-ietf-webtrans-http3 §4.6), and reported so whoever holds them can
1096
+ // destroy them. They used to stay in _wtStreams / _claimedStreams, routed
1097
+ // to a session that no longer existed, until each one happened to end.
1098
+ var WT_SESSION_GONE = 0x170d7b68;
1099
+ function endWtSession(sessionId) {
1100
+ if (!_wtSessions.delete(sessionId)) return;
1101
+ var children = [];
1102
+ for (var k in _wtStreams) {
1103
+ if (_wtStreams[k].sessionId === sessionId) children.push(Number(k));
1104
+ }
1105
+ for (var u in _wtLocalUni) {
1106
+ if (_wtLocalUni[u] === sessionId) children.push(Number(u));
1107
+ }
1108
+ for (var i = 0; i < children.length; i++) {
1109
+ if (_connClosed) releaseStream(children[i]);
1110
+ else abortStream(children[i], WT_SESSION_GONE);
1111
+ }
1112
+ ev.emit('wt_session_closed', sessionId, children);
1113
+ }
1114
+
1115
+ // A request stream announces itself as soon as it is one — its first bytes
1116
+ // read as HTTP/3, not WebTransport — before its HEADERS frame has even
1117
+ // arrived whole: the position of h2's 'stream_open', for the same reasons.
1118
+ // A refusal here costs nothing decoded, and the clock on a request's head
1119
+ // (h3_server's headersTimeout) starts with its first byte, not with a
1120
+ // HEADERS frame that a slow peer may never finish sending. It was announced
1121
+ // at that frame's decode, so a HEADERS trickled a byte at a time was
1122
+ // invisible for as long as it lasted.
1123
+ // Emitted unconditionally: a listenerCount guard once stood here, when
1124
+ // h3's emitter had no such method, and the event never fired at all.
1125
+ function announceRequestStream(s) {
1126
+ if (s.announced) return;
1127
+ s.announced = true;
1128
+ ev.emit('stream_open', Number(s.sid));
1129
+ }
1130
+
1131
+ // A stream WE end because of what the peer sent on it — a stream error
1132
+ // (RFC 9114 §8). The stream is cancelled both ways, and reported: a
1133
+ // request above may already exist for it, and would otherwise wait for
1134
+ // an end that is never coming.
1135
+ function streamError(sid, code, reason) {
1136
+ if (DEBUG) console.log('[h3] stream error on ' + sid + ' (' + (H3_ERR_NAME[code] || code) + '): ' + reason);
1137
+ abortStream(sid, code);
1138
+ ev.emit('stream_error', sid, code, reason);
1139
+ }
1140
+
1141
+ // Streams WE cancelled (abortStream). The peer's replies to that — it MUST
1142
+ // answer our STOP_SENDING with a RESET_STREAM (RFC 9000 §3.5) — are not the
1143
+ // peer cancelling anything, and must not be reported as if it were: a rate
1144
+ // limiter counting cancellations would charge clients for our refusals.
1145
+ // Bounded; a stream's echo arrives within a round trip or two.
1146
+ var _abortedHere = new Set();
1147
+ function rememberAborted(sid) {
1148
+ _abortedHere.add(sid);
1149
+ if (_abortedHere.size > 4096) _abortedHere.delete(_abortedHere.values().next().value);
1150
+ }
1151
+
1152
+ // The peer reset its sending part (RESET_STREAM). For a request stream that
1153
+ // is the client cancelling the request — RFC 9114 §4.1.1 — and whoever is
1154
+ // handling it needs to know. The WebTransport session is passed along for
1155
+ // a WT stream, so it can be routed to its session.
1156
+ listenQuic('stream_reset', function (streamId, code) {
655
1157
  var sid = Number(streamId);
1158
+ var wt = _wtStreams[sid];
1159
+ var wasParsing = sid in context.streams;
1160
+ // A client's request stream reset before its HEADERS came: there is no
1161
+ // request, and no one above to end the response side — it stayed open
1162
+ // for good, its slot of the client's stream limit with it (and what the
1163
+ // transport keeps per stream until both sides end). Ended here, as a
1164
+ // cancelled request (RFC 9114 §4.1.1). Not a WebTransport stream: its
1165
+ // session's.
1166
+ var noRequest = context.isServer && (sid & 3) === 0 && !wt && !_wtHeld.has(sid) &&
1167
+ !(wasParsing && context.streams[sid].headers_seen);
1168
+ releaseStream(sid);
1169
+ if (noRequest && quic.resetStream) quic.resetStream(sid, H3_ERR.H3_REQUEST_CANCELLED);
1170
+ if (_abortedHere.has(sid)) return;
1171
+ if (wasParsing) sendStreamCancel(sid);
1172
+ ev.emit('stream_reset', sid, code, wt ? wt.sessionId : null);
1173
+ });
1174
+
1175
+ // The peer will not read what we send (STOP_SENDING). quic_connection has
1176
+ // already answered with RESET_STREAM, as RFC 9000 §3.5 requires. This is
1177
+ // how a browser cancels a GET: its own side of the stream is already
1178
+ // finished, so there is nothing for it to reset — STOP_SENDING is the
1179
+ // whole cancellation.
1180
+ listenQuic('stop_sending', function (streamId, code) {
1181
+ var sid = Number(streamId);
1182
+ if (_abortedHere.has(sid)) return;
1183
+ ev.emit('stop_sending', sid, code);
1184
+ });
1185
+
1186
+ /**
1187
+ * Cancel a stream, both directions — RFC 9114 §4.1.1: "reset the sending
1188
+ * parts of streams and abort reading on the receiving parts". For a request
1189
+ * the server declines to process, `code` is H3_REQUEST_REJECTED (the
1190
+ * client may safely retry it); for one abandoned part way,
1191
+ * H3_REQUEST_CANCELLED.
1192
+ */
1193
+ function abortStream(streamId, code) {
1194
+ var sid = Number(streamId);
1195
+ rememberAborted(sid);
1196
+ var stopped = quic.stopSending ? quic.stopSending(sid, code) : false;
1197
+ if (quic.resetStream) quic.resetStream(sid, code);
1198
+ if (stopped) sendStreamCancel(sid);
1199
+ releaseStream(sid);
1200
+ }
1201
+
1202
+ // Stop reading a stream, leaving our sending part alone — for a stream
1203
+ // whose response is already written (a refused tunnel: the 4xx/5xx went
1204
+ // out with FIN, and resetting would discard it).
1205
+ function stopReading(streamId, code) {
1206
+ var sid = Number(streamId);
1207
+ rememberAborted(sid);
1208
+ var stopped = quic.stopSending ? quic.stopSending(sid, code) : false;
1209
+ if (stopped) sendStreamCancel(sid);
1210
+ releaseStream(sid);
1211
+ }
1212
+
1213
+ // RFC 9204 §2.2.2.2: reading of a request stream was abandoned (by us, or
1214
+ // by the peer's reset), so the peer's encoder must hear that any section
1215
+ // on it will never be acknowledged — or the entries it references can
1216
+ // never be evicted. Header sections live on client-initiated
1217
+ // bidirectional streams, whichever side we are.
1218
+ function sendStreamCancel(sid) {
1219
+ if ((sid % 4) !== 0) return;
1220
+ if (context.local_qpack_max_table_capacity > 0 && decoderStreamId !== null) {
1221
+ quic.sendStream(decoderStreamId, new Uint8Array(qpackEncodeStreamCancel(sid)), false);
1222
+ }
1223
+ }
1224
+
1225
+ listenQuic('stream', onStream);
1226
+ function onStream(streamId, data, fin) {
1227
+ if (_fatal) return; // connection is closing; decoding further is pointless
1228
+ var sid = Number(streamId);
1229
+
1230
+ // An extended-CONNECT tunnel: after the CONNECT is accepted, everything on
1231
+ // this stream belongs to the protocol running inside it. RFC 9220 §3 —
1232
+ // WebSocket frames, not DATA frames, and reading them as HTTP/3 would fail
1233
+ // on the first opcode.
1234
+ if (_tunnelStreams.has(sid)) {
1235
+ // Bytes that arrived before the application accepted the CONNECT
1236
+ // (normally replayed at accept already). They go first, in order.
1237
+ if (_tunnelPending[sid]) replayTunnelPending(sid);
1238
+ ev.emit('tunnel_data', sid, data, fin);
1239
+ if (fin) releaseStream(sid);
1240
+ return;
1241
+ }
656
1242
 
657
1243
  // Route data for already-detected WT streams
658
1244
  if (sid in _wtStreams) {
659
1245
  var ws = _wtStreams[sid];
660
1246
  ev.emit('wt_data', ws.sessionId, sid, data, fin);
661
- if (fin) delete _wtStreams[sid];
1247
+ // Nothing arrives after a FIN (quic_connection's recv_final), so the
1248
+ // claim is not needed past it. Kept, both grew by one entry for every
1249
+ // WebTransport stream the connection ever carried.
1250
+ if (fin) releaseStream(sid);
1251
+ return;
1252
+ }
1253
+
1254
+ if (_tunnelPendingIds.has(sid)) {
1255
+ // Marked as a tunnel-to-be by the application, not yet accepted.
1256
+ if (!_tunnelPending[sid]) { _tunnelPending[sid] = []; _tunnelPendingBytes[sid] = 0; }
1257
+ if (data && data.byteLength) {
1258
+ _tunnelPendingBytes[sid] += data.byteLength;
1259
+ _tunnelPendingTotal += data.byteLength;
1260
+ if (_tunnelPendingBytes[sid] > MAX_TUNNEL_PENDING || _tunnelPendingTotal > MAX_TUNNEL_PENDING_TOTAL) {
1261
+ streamError(sid, H3_ERR.H3_EXCESSIVE_LOAD, 'tunnel data held before the CONNECT was accepted exceeds the limit');
1262
+ return;
1263
+ }
1264
+ _tunnelPending[sid].push(data);
1265
+ }
1266
+ // The FIN is kept too: nothing more arrives after it, so it has to be
1267
+ // replayed at accept, not on a next delivery that never comes.
1268
+ if (fin) _tunnelPendingFin.add(sid);
662
1269
  return;
663
1270
  }
664
1271
 
665
- if (_claimedStreams.has(sid)) return; // owned by WebTransport client
1272
+ // Owned by the WebTransport client (webtransport.js reads it itself);
1273
+ // released at its FIN like any other — it used to be kept for good.
1274
+ if (_claimedStreams.has(sid)) { if (fin) releaseStream(sid); return; }
666
1275
  if (DEBUG) console.log('[h3] stream ' + streamId + ' data=' + data.byteLength + ' fin=' + fin);
667
1276
  if (!(streamId in context.streams)) {
668
- context.streams[streamId] = { chunks: [], from_offset: 0, type: null, fin_received: false };
1277
+ context.streams[streamId] = { sid: sid, chunks: [], from_offset: 0, type: null, fin_received: false,
1278
+ buffered: 0, // bytes held past from_offset
1279
+ data_left: 0, skip_left: 0,
1280
+ headers_seen: false, need: 0 };
1281
+ if (context.isServer && sid % 4 === 0 && sid > _maxPeerBidi) _maxPeerBidi = sid;
669
1282
  }
670
1283
 
671
1284
  var s = context.streams[streamId];
672
- s.chunks.push(data);
1285
+ if (data && data.byteLength) { s.chunks.push(data); s.buffered += data.byteLength; }
673
1286
  if (fin) s.fin_received = true;
674
1287
 
675
- // Identify stream type
1288
+ // Identify the stream type — from its first bytes, however QUIC splits
1289
+ // them. The type used to be read from the first DELIVERY: one that was
1290
+ // empty (a zero-length STREAM frame) left the stream typeless for good,
1291
+ // holding every byte sent on it; a WebTransport prefix split across two
1292
+ // deliveries was read as HTTP/3 frames. Only the first few bytes are
1293
+ // copied, never the whole buffer.
676
1294
  if (s.type === null) {
677
- var isUni = (Number(streamId) & 0x2) !== 0;
678
-
679
- // ---- WT stream detection ----
680
- if (context.local_webtransport && _wtSessions.size > 0) {
681
- var allData = concatUint8Arrays(s.chunks);
682
- if (DEBUG) console.log('[h3] WT check: stream=' + sid + ' firstByte=0x' + (allData[0] || 0).toString(16) + ' len=' + allData.byteLength + ' sessions=' + Array.from(_wtSessions));
683
- if (allData.byteLength >= 2 && allData[0] === 0x40) {
684
- var typeResult = readVarInt(allData, 0);
685
- if (typeResult) {
686
- var expectedType = isUni ? 0x54 : 0x41;
687
- if (typeResult.value === expectedType) {
688
- var afterType = allData.slice(typeResult.byteLength);
689
- var sidResult = readVarInt(afterType, 0);
690
- if (sidResult && _wtSessions.has(sidResult.value)) {
691
- // WT stream detected
692
- var sessionId = sidResult.value;
693
- var prefixLen = typeResult.byteLength + sidResult.byteLength;
694
- var remaining = allData.byteLength > prefixLen ? allData.slice(prefixLen) : new Uint8Array(0);
695
-
696
- _claimedStreams.add(sid);
697
- _wtStreams[sid] = { sessionId: sessionId, isBidi: !isUni };
698
- delete context.streams[streamId];
699
-
700
- if (DEBUG) console.log('[h3] WT stream detected: stream=' + sid + ' session=' + sessionId + ' bidi=' + !isUni + ' dataLen=' + remaining.byteLength);
701
- ev.emit('wt_stream', sessionId, sid, remaining, fin, !isUni);
702
- if (fin) delete _wtStreams[sid];
703
- return;
704
- }
705
- }
1295
+ var isUni = (sid % 4) >= 2;
1296
+ var head = copyBytes(s, Math.min(s.buffered, 16));
1297
+ var st = readVarInt(head, 0);
1298
+
1299
+ if (!st) {
1300
+ // Not even the first varint yet.
1301
+ if (!s.fin_received) return;
1302
+ if (isUni) { releaseStream(sid); return; } // empty: nothing to do
1303
+ s.type = 'bidi'; // processBidiStream reports what is wrong with it
1304
+ announceRequestStream(s);
1305
+ } else {
1306
+ // ---- WT stream detection ----
1307
+ var wtType = isUni ? 0x54 : 0x41;
1308
+ if (context.local_webtransport && st.value === wtType) {
1309
+ var sidResult = readVarInt(head, st.byteLength);
1310
+ if (!sidResult && !s.fin_received) return; // the session ID is still to come
1311
+ if (sidResult && !_wtSessions.has(sidResult.value)) {
1312
+ // Not a session (yet): held for one that may come, refused
1313
+ // for one that will not.
1314
+ if (wtSessionMayCome(sidResult.value)) holdWtStream(s, sidResult.value);
1315
+ else abortStream(sid, WT_BUFFERED_STREAM_REJECTED);
1316
+ return;
1317
+ }
1318
+ if (sidResult) {
1319
+ var sessionId = sidResult.value;
1320
+ var prefixLen = st.byteLength + sidResult.byteLength;
1321
+ consumeBytes(s, prefixLen);
1322
+ var remaining = s.buffered > 0 ? copyBytes(s, s.buffered) : new Uint8Array(0);
1323
+
1324
+ delete context.streams[sid]; // no longer parsed as HTTP/3: a WT stream now
1325
+ // The prefix was ours, and consumeBytes credited it; the rest is
1326
+ // the application's, credited as it reads it (h3_server).
1327
+ _claimedStreams.add(sid);
1328
+ _wtStreams[sid] = { sessionId: sessionId, isBidi: !isUni };
1329
+
1330
+ if (DEBUG) console.log('[h3] WT stream detected: stream=' + sid + ' session=' + sessionId + ' bidi=' + !isUni + ' dataLen=' + remaining.byteLength);
1331
+ ev.emit('wt_stream', sessionId, sid, remaining, s.fin_received, !isUni);
1332
+ if (s.fin_received) releaseStream(sid); // the FIN came with the prefix
1333
+ return;
706
1334
  }
707
1335
  }
708
- }
709
1336
 
710
- if (isUni) {
711
- if (s.chunks[0].length > 0) {
712
- var first = s.chunks[0][0];
713
- if (first === 0x00) { s.type = 'control'; s.from_offset = 1; }
714
- else if (first === 0x02) { s.type = 'qpack_enc'; s.from_offset = 1; }
715
- else if (first === 0x03) { s.type = 'qpack_dec'; s.from_offset = 1; }
716
- else { s.type = 'unknown'; }
1337
+ if (isUni) {
1338
+ if (st.value === 0x00) s.type = 'control';
1339
+ else if (st.value === 0x02) s.type = 'qpack_enc';
1340
+ else if (st.value === 0x03) s.type = 'qpack_dec';
1341
+ else if (st.value === 0x01) {
1342
+ // A push stream (§4.6, §6.2.2). Only a server pushes: one from a
1343
+ // client is H3_STREAM_CREATION_ERROR. A client allowed none (it
1344
+ // sends no MAX_PUSH_ID): "...receipt of a push stream as a
1345
+ // connection error of type H3_ID_ERROR when no MAX_PUSH_ID frame
1346
+ // has been sent".
1347
+ releaseRecvCredit(sid);
1348
+ if (context.isServer) connectionError(H3_ERR.H3_STREAM_CREATION_ERROR, 'a push stream from the client');
1349
+ else connectionError(H3_ERR.H3_ID_ERROR, 'a push stream, none allowed');
1350
+ return;
1351
+ }
1352
+ else s.type = 'unknown';
1353
+ if (s.type !== 'unknown') consumeBytes(s, st.byteLength);
1354
+ // Not a WebTransport stream: its credit is not the application's
1355
+ // to return (see setCreditPolicy above).
1356
+ releaseRecvCredit(sid);
1357
+ // One of each per peer (RFC 9114 §6.2.1, RFC 9204 §4.2): a second
1358
+ // control or QPACK stream is H3_STREAM_CREATION_ERROR. Read, a
1359
+ // second encoder stream fed the same dynamic table from two
1360
+ // directions (nginx CVE-2026-42530 was a reopened encoder stream).
1361
+ if (s.type !== 'unknown') {
1362
+ if (_criticalStreams[s.type] !== undefined && _criticalStreams[s.type] !== sid) {
1363
+ connectionError(H3_ERR.H3_STREAM_CREATION_ERROR, 'a second ' + s.type + ' stream');
1364
+ return;
1365
+ }
1366
+ _criticalStreams[s.type] = sid;
1367
+ }
1368
+ } else if ((sid % 4) === 1) {
1369
+ // Server-initiated bidirectional: HTTP/3 itself never opens one
1370
+ // (RFC 9114 §6.1) — only an extension such as WebTransport, whose
1371
+ // client side (webtransport.js) reads these streams itself. Parsed
1372
+ // as request frames, a WT payload starting with 0x00 read as DATA
1373
+ // before HEADERS and closed the whole connection.
1374
+ s.type = 'unknown';
1375
+ releaseRecvCredit(sid); // not read: nothing will return its credit
1376
+ } else {
1377
+ s.type = 'bidi';
1378
+ announceRequestStream(s);
717
1379
  }
718
- } else {
719
- s.type = 'bidi';
720
1380
  }
721
1381
  }
722
1382
 
723
- // Process by type
724
- if (s.type === 'control') {
725
- processControlStream(s);
726
-
727
- } else if (s.type === 'qpack_enc') {
728
- processQpackEncoderStream(s);
1383
+ // Process by type.
1384
+ //
1385
+ // Each processor decodes attacker-controlled bytes, so each can throw:
1386
+ // a bad prefixed integer, an invalid Huffman code, a truncated literal.
1387
+ // The catch turns that into the connection error RFC 9114 requires,
1388
+ // instead of letting it unwind into Emitter's swallow.
1389
+ try {
1390
+ if ((s.type === 'control' || s.type === 'qpack_enc' || s.type === 'qpack_dec') && s.fin_received) {
1391
+ // RFC 9114 §6.2.1, RFC 9204 §4.2: these live as long as the connection.
1392
+ throw h3Error(H3_ERR.H3_CLOSED_CRITICAL_STREAM, 'the peer closed its ' + s.type + ' stream');
1393
+ }
1394
+ if (s.type === 'control') {
1395
+ processControlStream(s);
1396
+
1397
+ } else if (s.type === 'qpack_enc') {
1398
+ processQpackEncoderStream(s);
1399
+
1400
+ } else if (s.type === 'qpack_dec') {
1401
+ processQpackDecoderStream(s);
1402
+
1403
+ } else if (s.type === 'bidi') {
1404
+ processBidiStream(streamId, s);
1405
+
1406
+ } else if (s.type === 'unknown') {
1407
+ // A unidirectional stream of a type we do not know — GREASE, or an
1408
+ // extension we do not speak. RFC 9114 §6.2: its data is ignored. It
1409
+ // used to be kept all the same, every byte, for the connection's life.
1410
+ s.chunks.length = 0;
1411
+ s.from_offset = 0;
1412
+ s.buffered = 0;
1413
+ if (s.fin_received) releaseStream(sid);
1414
+ }
729
1415
 
730
- } else if (s.type === 'bidi') {
731
- processBidiStream(streamId, s);
1416
+ // The processors read s.chunks from s.from_offset onward; what lies
1417
+ // before it is done with. Drop those chunks and rebase the offset, so
1418
+ // the array holds only bytes not yet parsed. Kept, every arrival walked
1419
+ // the whole history to find its place and re-copied the unparsed part
1420
+ // of it: O(n^2) over a large upload, and over the connection's life on
1421
+ // the control and QPACK streams, which never end.
1422
+ while (s.chunks.length > 0 && s.from_offset >= s.chunks[0].length) {
1423
+ s.from_offset -= s.chunks[0].length;
1424
+ s.chunks.shift();
1425
+ }
1426
+ } catch (err) {
1427
+ // A code already chosen at the throw site (see decodeHeaders below) wins;
1428
+ // an exception of our own is H3_INTERNAL_ERROR (isInternalError), and
1429
+ // counted as the QUIC layer counts its own; anything else is a framing
1430
+ // failure on whichever stream we were reading.
1431
+ if (!err.h3ErrorCode && isInternalError(err) && quic.countInternalError) quic.countInternalError(err);
1432
+ connectionError(err.h3ErrorCode || (isInternalError(err) ? H3_ERR.H3_INTERNAL_ERROR : H3_ERR.H3_FRAME_ERROR),
1433
+ err.message || 'stream processing failed');
732
1434
  }
733
- });
1435
+ }
734
1436
 
735
1437
 
736
- quic.on('connect', function () {
1438
+ listenQuic('connect', function () {
737
1439
  sendControlStreams();
738
1440
  });
739
1441
 
@@ -745,110 +1447,685 @@ function H3Connection(options) {
745
1447
 
746
1448
  // ---- Stream processors ----
747
1449
 
748
- function processControlStream(s) {
749
- var ext = extractH3Frames(s.chunks, s.from_offset);
750
- if (DEBUG) console.log('[h3] control: extracted ' + ext.frames.length + ' H3 frames');
751
- if (ext.frames.length === 0) return;
752
- s.from_offset = ext.new_from_offset;
753
-
754
- for (var i = 0; i < ext.frames.length; i++) {
755
- if (ext.frames[i].frame_type === 0x04) {
756
- var settings = parseSettingsPayload(ext.frames[i].payload);
757
- var updates = { remote_settings_received: true };
758
-
759
- if ('SETTINGS_QPACK_MAX_TABLE_CAPACITY' in settings) {
760
- updates.remote_qpack_max_table_capacity = settings['SETTINGS_QPACK_MAX_TABLE_CAPACITY'];
761
- }
762
- if ('SETTINGS_MAX_FIELD_SECTION_SIZE' in settings) {
763
- updates.remote_max_header_size = settings['SETTINGS_MAX_FIELD_SECTION_SIZE'];
764
- }
765
- if ('SETTINGS_H3_DATAGRAM' in settings) {
766
- updates.remote_datagram_support = settings['SETTINGS_H3_DATAGRAM'] > 0;
767
- }
1450
+ // The control stream is read frame by frame, like a request stream. It
1451
+ // used to re-join everything unparsed on every arrival and wait for each
1452
+ // frame whole: a frame of an unknown type (legal — RFC 9114 §9 says to
1453
+ // ignore it) announcing a huge length made us buffer all of it, with a full
1454
+ // copy per packet. Now frames we do not read are skipped as they arrive,
1455
+ // and the ones we do read are bounded.
1456
+ function h3Error(code, msg) {
1457
+ var e = new Error(msg);
1458
+ e.h3ErrorCode = code;
1459
+ return e;
1460
+ }
768
1461
 
769
- set_context(updates);
1462
+ // Frame types of HTTP/2 that HTTP/3 reserves (RFC 9114 §7.2.8, §11.2.1):
1463
+ // PRIORITY, PING, WINDOW_UPDATE, CONTINUATION. On any stream:
1464
+ // H3_FRAME_UNEXPECTED.
1465
+ function reservedH2Frame(t) { return t === 0x02 || t === 0x06 || t === 0x08 || t === 0x09; }
1466
+
1467
+ // Server push (RFC 9114 §4.6). Neither side here takes part in it: the
1468
+ // client sends no MAX_PUSH_ID, so no push ID is allowed it; the server
1469
+ // sends no PUSH_PROMISE, so no push ID exists to cancel. What each side
1470
+ // received of the peer's push frames, to hold them to their own rules.
1471
+ var _peerMaxPushId = -1; // server: the highest MAX_PUSH_ID received
1472
+ var _peerGoawayId = -1; // the last GOAWAY's identifier
1473
+
1474
+ // A frame payload that is one varint and nothing else (CANCEL_PUSH,
1475
+ // GOAWAY, MAX_PUSH_ID): its value, or H3_FRAME_ERROR (§7.1: "...contain
1476
+ // additional bytes after the identified fields or a frame payload that
1477
+ // terminates before the end of the identified fields").
1478
+ function oneVarint(payload, what) {
1479
+ var v = readVarInt(payload, 0);
1480
+ if (!v || v.byteLength !== payload.length) throw h3Error(H3_ERR.H3_FRAME_ERROR, what + ' payload is not one integer');
1481
+ return v.value;
1482
+ }
770
1483
 
771
- } else if (ext.frames[i].frame_type === 0x07) {
772
- // GOAWAY (RFC 9114 §5.2): the peer will not process requests/pushes
773
- // with an identifier >= this value. Surface it so consumers (e.g. the
774
- // agent) can stop sending new requests on this connection.
775
- var gp = readVarInt(ext.frames[i].payload, 0);
776
- if (DEBUG) console.log('[h3] ← GOAWAY id=' + (gp ? gp.value : '?'));
777
- ev.emit('goaway', gp ? gp.value : 0);
1484
+ function processControlStream(s) {
1485
+ while (true) {
1486
+ if (s.skip_left > 0) {
1487
+ var skipNow = Math.min(s.skip_left, s.buffered);
1488
+ if (skipNow === 0) return;
1489
+ consumeBytes(s, skipNow);
1490
+ s.skip_left -= skipNow;
1491
+ continue;
778
1492
  }
1493
+ if (s.buffered === 0) return;
1494
+ var head = copyBytes(s, Math.min(s.buffered, 16));
1495
+ var ft = readVarInt(head, 0);
1496
+ if (!ft) return;
1497
+ var flen = readVarInt(head, ft.byteLength);
1498
+ if (!flen) return;
1499
+ var headLen = ft.byteLength + flen.byteLength;
1500
+
1501
+ // RFC 9114 §7.2.1, §7.2.2, §7.2.5: DATA, HEADERS and PUSH_PROMISE on
1502
+ // the control stream are H3_FRAME_UNEXPECTED.
1503
+ if (ft.value === 0x00 || ft.value === 0x01 || ft.value === 0x05 || reservedH2Frame(ft.value)) {
1504
+ throw h3Error(H3_ERR.H3_FRAME_UNEXPECTED, 'frame type 0x' + ft.value.toString(16) + ' on the control stream');
1505
+ }
1506
+ // RFC 9114 §6.2.1, §7.2.4: SETTINGS first, and once.
1507
+ if (!s.settings_seen && ft.value !== 0x04) throw h3Error(H3_ERR.H3_MISSING_SETTINGS, 'the control stream did not begin with SETTINGS');
1508
+ if (s.settings_seen && ft.value === 0x04) throw h3Error(H3_ERR.H3_FRAME_UNEXPECTED, 'a second SETTINGS frame');
1509
+ // MAX_PUSH_ID from a server (§7.2.7): H3_FRAME_UNEXPECTED, on sight.
1510
+ if (ft.value === 0x0d && !context.isServer) {
1511
+ throw h3Error(H3_ERR.H3_FRAME_UNEXPECTED, 'MAX_PUSH_ID from the server');
1512
+ }
1513
+ if (ft.value !== 0x04 && ft.value !== 0x07 && ft.value !== 0x03 && ft.value !== 0x0d &&
1514
+ ft.value !== PRIORITY_UPDATE_REQUEST && ft.value !== PRIORITY_UPDATE_PUSH) {
1515
+ consumeBytes(s, headLen);
1516
+ s.skip_left = flen.value;
1517
+ continue;
1518
+ }
1519
+ if (flen.value > MAX_CONTROL_FRAME) {
1520
+ throw h3Error(H3_ERR.H3_EXCESSIVE_LOAD, 'control frame of ' + flen.value + ' bytes');
1521
+ }
1522
+ if (s.buffered < headLen + flen.value) return;
1523
+ consumeBytes(s, headLen);
1524
+ var payload = copyBytes(s, flen.value);
1525
+ consumeBytes(s, flen.value);
1526
+ if (ft.value === 0x04) s.settings_seen = true;
1527
+ handleControlFrame(ft.value, payload);
1528
+ if (_fatal) return;
1529
+ }
1530
+ }
1531
+
1532
+ function handleControlFrame(frameType, payload) {
1533
+ if (DEBUG) console.log('[h3] control: frame 0x' + frameType.toString(16) + ' len=' + payload.length);
1534
+ if (frameType === 0x04) {
1535
+ var settings = parseSettingsPayload(payload);
1536
+ checkRememberedSettings(settings._ordered || []);
1537
+ // Kept with the sessions saved from now on (a client's).
1538
+ if (!context.isServer && quic && quic.rememberApplicationSettings) quic.rememberApplicationSettings({ h3: knownSettings(settings._ordered || []) });
1539
+ var updates = { remote_settings_received: true };
1540
+
1541
+ if ('SETTINGS_QPACK_MAX_TABLE_CAPACITY' in settings) {
1542
+ updates.remote_qpack_max_table_capacity = settings['SETTINGS_QPACK_MAX_TABLE_CAPACITY'];
1543
+ }
1544
+ if ('SETTINGS_MAX_FIELD_SECTION_SIZE' in settings) {
1545
+ updates.remote_max_header_size = settings['SETTINGS_MAX_FIELD_SECTION_SIZE'];
1546
+ }
1547
+ if ('SETTINGS_H3_DATAGRAM' in settings) {
1548
+ updates.remote_datagram_support = settings['SETTINGS_H3_DATAGRAM'] > 0;
1549
+ }
1550
+
1551
+ set_context(updates);
1552
+
1553
+ // Surfaced separately from the context update: this is an observation
1554
+ // about the peer, not a setting that changes our behaviour.
1555
+ ev.emit('peer_settings', settings._ordered || []);
1556
+
1557
+ } else if (frameType === 0x07) {
1558
+ // GOAWAY (RFC 9114 §5.2): the peer will not process requests/pushes
1559
+ // with an identifier >= this value. Surface it so consumers (e.g. the
1560
+ // agent) can stop sending new requests on this connection.
1561
+ var gid = oneVarint(payload, 'GOAWAY');
1562
+ // From a server, a client-initiated bidirectional stream ID — "...of
1563
+ // any other type as a connection error of type H3_ID_ERROR"; from a
1564
+ // client, a push ID. Either way it may only stay or go down: "...an
1565
+ // identifier greater than that sent in a previous GOAWAY frame ...
1566
+ // MUST treat this as a connection error of type H3_ID_ERROR".
1567
+ if (!context.isServer && gid % 4 !== 0) throw h3Error(H3_ERR.H3_ID_ERROR, 'GOAWAY with stream ' + gid + ', not a request stream');
1568
+ if (_peerGoawayId >= 0 && gid > _peerGoawayId) throw h3Error(H3_ERR.H3_ID_ERROR, 'GOAWAY went up, ' + _peerGoawayId + ' to ' + gid);
1569
+ _peerGoawayId = gid;
1570
+ if (DEBUG) console.log('[h3] ← GOAWAY id=' + gid);
1571
+ ev.emit('goaway', gid);
1572
+ } else if (frameType === 0x03) {
1573
+ // CANCEL_PUSH (§7.2.3). At a server, of a push it never promised — it
1574
+ // promises none: "...a push ID that has not yet been mentioned by a
1575
+ // PUSH_PROMISE frame, this MUST be treated as a connection error of
1576
+ // type H3_ID_ERROR". At a client, of a push ID above the maximum it
1577
+ // allowed — it allowed none: H3_ID_ERROR.
1578
+ var cp = oneVarint(payload, 'CANCEL_PUSH');
1579
+ throw h3Error(H3_ERR.H3_ID_ERROR, 'CANCEL_PUSH for push ' + cp + (context.isServer ? ', never promised' : ', none allowed'));
1580
+ } else if (frameType === 0x0d) {
1581
+ // MAX_PUSH_ID at the server (§7.2.7; the client's case is refused
1582
+ // above): "...a MAX_PUSH_ID frame that contains a smaller value than
1583
+ // previously received MUST be treated as a connection error of type
1584
+ // H3_ID_ERROR." Kept, though no push follows.
1585
+ var mp = oneVarint(payload, 'MAX_PUSH_ID');
1586
+ if (mp < _peerMaxPushId) throw h3Error(H3_ERR.H3_ID_ERROR, 'MAX_PUSH_ID went down, ' + _peerMaxPushId + ' to ' + mp);
1587
+ _peerMaxPushId = mp;
1588
+ } else {
1589
+ handlePriorityUpdate(frameType, payload);
1590
+ }
1591
+ }
1592
+
1593
+ // ---- Priorities (RFC 9218) ────────────────────────────────────────────
1594
+ //
1595
+ // The client's signals, read here and handed to QUIC's scheduler as the
1596
+ // peer's (setStreamPriority's fromPeer): the `priority` header of a
1597
+ // request, and PRIORITY_UPDATE on its control stream, which replaces it.
1598
+ // What the application sets for a response (h3_server's res.setPriority)
1599
+ // is the server's decision, and a signal does not override it. A request
1600
+ // with no signal keeps the scheduler's default: urgency 3, sharing turns.
1601
+ //
1602
+ // A PRIORITY_UPDATE may arrive before its request (§7.1 — they travel on
1603
+ // different streams): held until the request's head is read, then applied
1604
+ // over it. Bounded, oldest out; a client has no reason to run far ahead.
1605
+ var PRIORITY_UPDATE_REQUEST = 0xF0700, PRIORITY_UPDATE_PUSH = 0xF0701;
1606
+ var PRIORITY_PENDING_MAX = 128;
1607
+ var _prioPending = new Map(); // sid -> Priority Field Value
1608
+ var _maxPeerBidi = -1; // highest request stream the client has opened
1609
+
1610
+ function handlePriorityUpdate(frameType, payload) {
1611
+ // §7.2: the server sends none.
1612
+ if (!context.isServer) throw h3Error(H3_ERR.H3_FRAME_UNEXPECTED, 'PRIORITY_UPDATE from the server');
1613
+ var id = readVarInt(payload, 0);
1614
+ if (!id) throw h3Error(H3_ERR.H3_FRAME_ERROR, 'PRIORITY_UPDATE without an element ID');
1615
+ // §7.2: no push was ever allowed (we send no MAX_PUSH_ID), so any push ID
1616
+ // is beyond the limit; a request ID must be a client-initiated
1617
+ // bidirectional stream.
1618
+ if (frameType === PRIORITY_UPDATE_PUSH) throw h3Error(H3_ERR.H3_ID_ERROR, 'PRIORITY_UPDATE for a push');
1619
+ var sid = id.value;
1620
+ if (sid % 4 !== 0) throw h3Error(H3_ERR.H3_ID_ERROR, 'PRIORITY_UPDATE for stream ' + sid + ', not a request stream');
1621
+ var value = Buffer.from(payload.subarray(id.byteLength)).toString('latin1');
1622
+ var s = context.streams[sid];
1623
+ if (sid > _maxPeerBidi || (s && s.type !== 'bidi') || (s && !s.final_head_seen)) {
1624
+ _prioPending.delete(sid);
1625
+ _prioPending.set(sid, value);
1626
+ if (_prioPending.size > PRIORITY_PENDING_MAX) _prioPending.delete(_prioPending.keys().next().value);
1627
+ return;
779
1628
  }
1629
+ quic.setStreamPriority(sid, parsePriority(value), true); // false for a stream already over: nothing to do
1630
+ }
1631
+
1632
+ // A request's head is read: its signals, the header first and any update
1633
+ // that came ahead of it over it.
1634
+ function applyRequestPriority(sid, headers) {
1635
+ if (headers.priority != null) quic.setStreamPriority(sid, parsePriority(headers.priority), true);
1636
+ if (_prioPending.has(sid)) {
1637
+ quic.setStreamPriority(sid, parsePriority(_prioPending.get(sid)), true);
1638
+ _prioPending.delete(sid);
1639
+ }
1640
+ }
1641
+
1642
+ // The client's side: change a request's priority after it was sent (§7).
1643
+ function sendPriorityUpdate(streamId, p) {
1644
+ if (context.isServer) return false;
1645
+ sendControlStreams();
1646
+ var field = new Uint8Array(Buffer.from(formatPriority(p), 'latin1'));
1647
+ var idv = writeVarInt(Number(streamId));
1648
+ var payload = new Uint8Array(idv.byteLength + field.byteLength);
1649
+ payload.set(idv, 0); payload.set(field, idv.byteLength);
1650
+ quic.sendStream(context.isServer ? 3 : 2, buildH3Frames([{ frame_type: PRIORITY_UPDATE_REQUEST, payload: payload }]), false);
1651
+ return true;
780
1652
  }
781
1653
 
782
1654
  function processQpackEncoderStream(s) {
783
- var ext = parseQpackEncoderInstructions(s.chunks, s.from_offset);
1655
+ // An instruction cut short said how many bytes it needs; until they are
1656
+ // here, there is nothing to parse.
1657
+ if (s.buffered === 0 || (s.need > 0 && s.buffered < s.need)) return;
1658
+ var ext;
1659
+ try {
1660
+ // Only the unparsed bytes are copied (once per completed instruction,
1661
+ // given `need`), and no literal larger than our table can hold is
1662
+ // waited for.
1663
+ ext = parseQpackEncoderInstructions(copyBytes(s, s.buffered),
1664
+ context.local_qpack_max_table_capacity);
1665
+ } catch (err) {
1666
+ // RFC 9204 §4.2: a malformed encoder stream is QPACK_ENCODER_STREAM_ERROR,
1667
+ // not a decompression failure — nothing was being decompressed yet.
1668
+ err.h3ErrorCode = H3_ERR.QPACK_ENCODER_STREAM_ERROR;
1669
+ throw err;
1670
+ }
1671
+ consumeBytes(s, ext.consumed);
1672
+ s.need = ext.need;
784
1673
  if (ext.instructions.length === 0) return;
785
- s.from_offset = ext.new_from_offset;
786
1674
 
787
- var inserts = [];
1675
+ // Each instruction is applied before the next is read. They used to be
1676
+ // collected and inserted together, so an instruction naming an entry
1677
+ // inserted earlier in the same batch found the wrong one. And one that
1678
+ // could not be resolved was skipped — leaving our insert count one short
1679
+ // of the peer's, and every later reference pointing at the wrong entry.
1680
+ // Both are errors the peer must hear about, not things to paper over.
788
1681
  for (var i = 0; i < ext.instructions.length; i++) {
789
1682
  var inst = ext.instructions[i];
1683
+
790
1684
  if (inst.type === 'set_capacity') {
791
- set_context({ remote_qpack_table_capacity: inst.capacity });
1685
+ if (inst.capacity > context.local_qpack_max_table_capacity) {
1686
+ throw qpackEncoderStreamError('capacity ' + inst.capacity + ' exceeds the ' +
1687
+ context.local_qpack_max_table_capacity + ' we advertised');
1688
+ }
1689
+ context.remote_qpack_table_capacity = inst.capacity;
1690
+ evictQpackTable();
792
1691
 
793
1692
  } else if (inst.type === 'insert_name_ref') {
794
- var name = null;
795
- if (inst.from_static && inst.name_index < QPACK_STATIC.length) {
796
- name = QPACK_STATIC[inst.name_index][0];
797
- } else if (!inst.from_static) {
798
- var di = context.remote_qpack_table_base_index - 1 - inst.name_index;
799
- if (di >= 0 && di < context.remote_qpack_dynamic_table.length) {
800
- name = context.remote_qpack_dynamic_table[di][0];
1693
+ var name;
1694
+ if (inst.from_static) {
1695
+ if (inst.name_index >= QPACK_STATIC.length) {
1696
+ throw qpackEncoderStreamError('static name index ' + inst.name_index + ' out of range');
801
1697
  }
1698
+ name = QPACK_STATIC[inst.name_index][0];
1699
+ } else {
1700
+ name = remoteEntryByRelative(inst.name_index)[0];
802
1701
  }
803
- if (name) inserts.push([name, inst.value]);
1702
+ remoteTableInsert(name, inst.value);
804
1703
 
805
1704
  } else if (inst.type === 'insert_literal') {
806
- inserts.push([inst.name, inst.value]);
1705
+ remoteTableInsert(inst.name, inst.value);
1706
+
1707
+ } else if (inst.type === 'duplicate') {
1708
+ var dup = remoteEntryByRelative(inst.index);
1709
+ remoteTableInsert(dup[0], dup[1]);
807
1710
  }
808
1711
  }
809
1712
 
810
- if (inserts.length > 0) {
811
- set_context({ add_qpack_entries: inserts });
1713
+ flushQpackAcks();
1714
+ unblockStreams();
1715
+ }
1716
+
1717
+ // ---- Blocked streams (RFC 9204 §2.1.2) ----
1718
+ //
1719
+ // A HEADERS frame whose section needs inserts not yet received waits — its
1720
+ // bytes stay in the stream's buffer, uncredited, and nothing behind it on
1721
+ // the stream is read — until the encoder stream brings them. At most
1722
+ // QPACK_BLOCKED_STREAMS at once; one more is the peer breaking the limit
1723
+ // we set: QPACK_DECOMPRESSION_FAILED (§2.2.1).
1724
+ var _blocked = new Map(); // stream id → the Required Insert Count it waits for
1725
+
1726
+ // The Required Insert Count of a field section, from its prefix.
1727
+ function sectionRequiredInsertCount(payload) {
1728
+ var ricEnc = readPrefixedInt(payload, 8, 0);
1729
+ if (!ricEnc) return 0;
1730
+ return decodeRequiredInsertCount(ricEnc.value, Math.floor(context.local_qpack_max_table_capacity / 32), context.remote_qpack_table_base_index);
1731
+ }
1732
+
1733
+ // Whether stream `sid` must wait for this section; registers it if so.
1734
+ function sectionBlocked(sid, payload) {
1735
+ var ric;
1736
+ try { ric = sectionRequiredInsertCount(payload); }
1737
+ catch (e) { return false; } // malformed: decodeHeaders reports it
1738
+ if (ric <= context.remote_qpack_table_base_index) { _blocked.delete(sid); return false; }
1739
+ if (!_blocked.has(sid) && _blocked.size >= QPACK_BLOCKED_STREAMS) {
1740
+ throw h3Error(H3_ERR.QPACK_DECOMPRESSION_FAILED, 'more than ' + QPACK_BLOCKED_STREAMS + ' blocked streams');
1741
+ }
1742
+ _blocked.set(sid, ric);
1743
+ return true;
1744
+ }
1745
+
1746
+ // Inserts arrived: every stream they unblock is read on, in turn.
1747
+ function unblockStreams() {
1748
+ if (_blocked.size === 0) return;
1749
+ var ready = [];
1750
+ _blocked.forEach(function (ric, sid) { if (ric <= context.remote_qpack_table_base_index) ready.push(sid); });
1751
+ for (var i = 0; i < ready.length; i++) {
1752
+ _blocked.delete(ready[i]);
1753
+ var bs = context.streams[ready[i]];
1754
+ if (bs && bs.type === 'bidi') processBidiStream(ready[i], bs);
1755
+ }
1756
+ }
1757
+
1758
+
1759
+ /**
1760
+ * Decode a QPACK header block, re-tagging any failure as
1761
+ * QPACK_DECOMPRESSION_FAILED (RFC 9204 §2.2.3) rather than the generic
1762
+ * framing error. The distinction matters to whoever reads the peer's logs:
1763
+ * a decompression failure says the two dynamic tables have diverged, which
1764
+ * is a different bug from a malformed frame.
1765
+ */
1766
+ function decodeHeaders(payload, streamId) {
1767
+ var headers;
1768
+ try {
1769
+ headers = parseQpackHeaderBlock(
1770
+ payload,
1771
+ context.remote_qpack_dynamic_table,
1772
+ context.remote_qpack_table_base_index,
1773
+ // MaxEntries from the capacity WE advertised (§4.5.1.1).
1774
+ Math.floor(context.local_qpack_max_table_capacity / 32),
1775
+ context.local_max_header_size
1776
+ );
1777
+ } catch (err) {
1778
+ // Too large is this message's fault, not the tables': the stream goes,
1779
+ // the connection stays (see the caller).
1780
+ if (!err.excessive && !isInternalError(err)) err.h3ErrorCode = H3_ERR.QPACK_DECOMPRESSION_FAILED;
1781
+ throw err;
1782
+ }
1783
+ // §4.4.1: a section that used the dynamic table is acknowledged, so the
1784
+ // peer's encoder knows those references are done with.
1785
+ // Not for a stream we have abandoned: abortStream sent a Stream
1786
+ // Cancellation for it, and an acknowledgement after that names a section
1787
+ // the encoder no longer has — QPACK_DECODER_STREAM_ERROR on its side.
1788
+ if (headers._ric > 0 && decoderStreamId !== null && streamId !== undefined &&
1789
+ !_abortedHere.has(Number(streamId))) {
1790
+ quic.sendStream(decoderStreamId, new Uint8Array(qpackEncodeSectionAck(streamId)), false);
1791
+ }
1792
+ return headers;
1793
+ }
1794
+
1795
+
1796
+ // ---- Reading a request stream ----
1797
+ //
1798
+ // A request stream is a sequence of frames — HEADERS, DATA, perhaps trailing
1799
+ // HEADERS — and the bytes arrive in whatever pieces QUIC delivers, about one
1800
+ // packet each, cut with no regard for where a frame begins or ends.
1801
+ //
1802
+ // HEADERS is decoded whole, so it waits until all of it is here. DATA is not:
1803
+ // its bytes are handed to the application as they arrive, and s.data_left
1804
+ // counts down what is still to come of the frame. It used to wait for the
1805
+ // whole frame like HEADERS, re-joining everything received so far on every
1806
+ // packet — a 10MB DATA frame took some 20 seconds of CPU to parse, while the
1807
+ // application received nothing and the whole body sat in memory. One DATA
1808
+ // frame per write is exactly what h3_client sends.
1809
+ //
1810
+ // Frames of any other type carry nothing for a request stream; their bytes
1811
+ // are skipped the same way (s.skip_left), not buffered. They were ignored
1812
+ // before too, once complete.
1813
+
1814
+ // Bytes buffered on stream `s` past its read position.
1815
+ function bufferedBytes(s) {
1816
+ return s.buffered; // kept current by the push and consumeBytes — O(1)
1817
+ }
1818
+
1819
+ // Move the read position on by `n` bytes, dropping chunks read to the end.
1820
+ // A body chunk (isBody) is credited by the caller; anything else consumed
1821
+ // here is credited at once.
1822
+ function consumeBytes(s, n, isBody) {
1823
+ if (!isBody && s.sid !== undefined) creditRecv(s.sid, n);
1824
+ s.from_offset += n;
1825
+ s.buffered -= n;
1826
+ while (s.chunks.length > 0 && s.from_offset >= s.chunks[0].length) {
1827
+ s.from_offset -= s.chunks[0].length;
1828
+ s.chunks.shift();
812
1829
  }
813
1830
  }
814
1831
 
1832
+ // Copy `n` bytes from the read position; the caller has checked they are here.
1833
+ function copyBytes(s, n) {
1834
+ var out = new Uint8Array(n);
1835
+ var got = 0, skip = s.from_offset;
1836
+ for (var i = 0; got < n; i++) {
1837
+ var c = s.chunks[i];
1838
+ if (skip >= c.length) { skip -= c.length; continue; }
1839
+ var take = Math.min(c.length - skip, n - got);
1840
+ out.set(c.subarray(skip, skip + take), got);
1841
+ got += take;
1842
+ skip = 0;
1843
+ }
1844
+ return out;
1845
+ }
1846
+
1847
+ // The bytes from the read position to the end of the chunk holding it, at
1848
+ // most `max` — a view, not a copy. null when nothing is buffered.
1849
+ function nextPiece(s, max) {
1850
+ var skip = s.from_offset;
1851
+ for (var i = 0; i < s.chunks.length; i++) {
1852
+ var c = s.chunks[i];
1853
+ if (skip >= c.length) { skip -= c.length; continue; }
1854
+ return c.subarray(skip, skip + Math.min(c.length - skip, max));
1855
+ }
1856
+ return null;
1857
+ }
1858
+
815
1859
  function processBidiStream(streamId, s) {
816
- var ext = extractH3Frames(s.chunks, s.from_offset);
817
- if (DEBUG) console.log('[h3] bidi stream ' + streamId + ': extracted ' + ext.frames.length + ' H3 frames, types=' + ext.frames.map(function(f){ return '0x' + f.frame_type.toString(16); }).join(','));
818
-
819
- if (ext.frames.length > 0) {
820
- s.from_offset = ext.new_from_offset;
821
-
822
- for (var i = 0; i < ext.frames.length; i++) {
823
- if (ext.frames[i].frame_type === 1) {
824
- // HEADERS
825
- var headers = parseQpackHeaderBlock(
826
- ext.frames[i].payload,
827
- context.remote_qpack_dynamic_table,
828
- context.remote_qpack_table_base_index
829
- );
830
- ev.emit('http_headers', Number(streamId), headers);
831
-
832
- } else if (ext.frames[i].frame_type === 0) {
833
- // DATA
834
- ev.emit('http_body', Number(streamId), ext.frames[i].payload);
1860
+ while (true) {
1861
+ // Inside a DATA frame: hand over whatever of it is here.
1862
+ if (s.data_left > 0) {
1863
+ var piece = nextPiece(s, s.data_left);
1864
+ if (!piece) break;
1865
+ consumeBytes(s, piece.length, true);
1866
+ if (!_bodyHeld.has(Number(streamId))) creditRecv(Number(streamId), piece.length);
1867
+ s.data_left -= piece.length;
1868
+ if (s.content_length !== null && s.content_length !== undefined) {
1869
+ s.body_received += piece.length;
1870
+ if (s.body_received > s.content_length) {
1871
+ streamError(Number(streamId), H3_ERR.H3_MESSAGE_ERROR, 'body longer than content-length');
1872
+ return;
1873
+ }
1874
+ }
1875
+ ev.emit('http_body', Number(streamId), piece);
1876
+ if (context.streams[streamId] !== s) return;
1877
+ continue;
1878
+ }
1879
+
1880
+ // Inside a frame of another type: skip whatever of it is here.
1881
+ if (s.skip_left > 0) {
1882
+ var skipNow = Math.min(s.skip_left, bufferedBytes(s));
1883
+ if (skipNow === 0) break;
1884
+ consumeBytes(s, skipNow);
1885
+ s.skip_left -= skipNow;
1886
+ continue;
1887
+ }
1888
+
1889
+ // The next frame header: type and length, two varints of at most 8
1890
+ // bytes each.
1891
+ var avail = bufferedBytes(s);
1892
+ if (avail === 0) break;
1893
+ var head = copyBytes(s, Math.min(avail, 16));
1894
+ var ft = readVarInt(head, 0);
1895
+ if (!ft) break;
1896
+ var len = readVarInt(head, ft.byteLength);
1897
+ if (!len) break;
1898
+ var headLen = ft.byteLength + len.byteLength;
1899
+
1900
+ if (ft.value === 0) {
1901
+ // DATA
1902
+ // RFC 9114 §4.1: a message starts with HEADERS; DATA before it is an
1903
+ // invalid frame sequence — H3_FRAME_UNEXPECTED, a connection error.
1904
+ // It used to be delivered, and h3_server built a request around it:
1905
+ // empty method and URL, handler called, and an entry nothing would
1906
+ // ever remove.
1907
+ if (!s.headers_seen) {
1908
+ throw h3Error(H3_ERR.H3_FRAME_UNEXPECTED, 'DATA before HEADERS on stream ' + streamId);
1909
+ }
1910
+ // Nor after the trailers, which end the message (§4.1): taken as more
1911
+ // body, the message read here was not the one framed — what a proxy
1912
+ // in front would have passed on.
1913
+ if (s.trailers_seen) {
1914
+ throw h3Error(H3_ERR.H3_FRAME_UNEXPECTED, 'DATA after trailers on stream ' + streamId);
835
1915
  }
1916
+ consumeBytes(s, headLen);
1917
+ s.data_left = len.value;
1918
+ if (len.value === 0) {
1919
+ // An empty one is still reported, as it always was.
1920
+ ev.emit('http_body', Number(streamId), new Uint8Array(0));
1921
+ if (context.streams[streamId] !== s) return;
1922
+ }
1923
+ continue;
1924
+ }
1925
+ // Frames of the control stream, and HTTP/2's reserved ones, are
1926
+ // H3_FRAME_UNEXPECTED on a request stream (§7.2.3, §7.2.4, §7.2.6,
1927
+ // §7.2.7, §7.2.8; RFC 9218 §7.2 for PRIORITY_UPDATE).
1928
+ if (ft.value === 0x03 || ft.value === 0x04 || ft.value === 0x07 || ft.value === 0x0d ||
1929
+ ft.value === PRIORITY_UPDATE_REQUEST || ft.value === PRIORITY_UPDATE_PUSH || reservedH2Frame(ft.value)) {
1930
+ throw h3Error(H3_ERR.H3_FRAME_UNEXPECTED, 'frame type 0x' + ft.value.toString(16) + ' on request stream ' + streamId);
836
1931
  }
1932
+ // PUSH_PROMISE (§7.2.5): from a client, H3_FRAME_UNEXPECTED; at a
1933
+ // client, a push ID above the maximum it allowed — none — H3_ID_ERROR.
1934
+ if (ft.value === 0x05) {
1935
+ if (context.isServer) throw h3Error(H3_ERR.H3_FRAME_UNEXPECTED, 'PUSH_PROMISE from the client on stream ' + streamId);
1936
+ throw h3Error(H3_ERR.H3_ID_ERROR, 'PUSH_PROMISE on stream ' + streamId + ', no push allowed');
1937
+ }
1938
+ if (ft.value !== 1) {
1939
+ consumeBytes(s, headLen);
1940
+ s.skip_left = len.value;
1941
+ continue;
1942
+ }
1943
+
1944
+ // HEADERS — whole, or wait for the rest. Waiting is bounded: QUIC
1945
+ // returns flow-control credit as it hands bytes up, so it does not stop
1946
+ // a peer from announcing a frame of any length and sending it all. One
1947
+ // larger than the field section size we advertised (SETTINGS_MAX_
1948
+ // FIELD_SECTION_SIZE) is refused on sight, before it is buffered. The
1949
+ // encoded size is the right thing to compare: literals count their
1950
+ // full length plus 32 bytes per field there, more than they take here.
1951
+ // A field section after the trailers: §4.1, H3_FRAME_UNEXPECTED.
1952
+ if (s.trailers_seen) {
1953
+ throw h3Error(H3_ERR.H3_FRAME_UNEXPECTED, 'HEADERS after trailers on stream ' + streamId);
1954
+ }
1955
+ if (len.value > context.local_max_header_size) {
1956
+ streamError(Number(streamId), H3_ERR.H3_EXCESSIVE_LOAD,
1957
+ 'HEADERS of ' + len.value + ' bytes exceeds ' + context.local_max_header_size);
1958
+ return;
1959
+ }
1960
+ if (avail < headLen + len.value) break;
1961
+ // Waits for the encoder stream when it must (see sectionBlocked): the
1962
+ // frame stays in the buffer, read again once unblocked.
1963
+ // Already waiting, and the inserts it needs are not here yet: nothing
1964
+ // to do. The frame was copied and its prefix decoded again on every
1965
+ // packet that arrived on the stream meanwhile.
1966
+ if (_blocked.has(Number(streamId)) &&
1967
+ _blocked.get(Number(streamId)) > context.remote_qpack_table_base_index) return;
1968
+ var whole = copyBytes(s, headLen + len.value);
1969
+ var payload = whole.subarray(headLen);
1970
+ if (sectionBlocked(Number(streamId), payload)) return;
1971
+ consumeBytes(s, headLen + len.value);
1972
+ s.headers_seen = true;
1973
+
1974
+ // HEADERS
1975
+
1976
+ var headers;
1977
+ try { headers = decodeHeaders(payload, Number(streamId)); }
1978
+ catch (e) {
1979
+ if (!e.excessive) throw e;
1980
+ streamError(Number(streamId), H3_ERR.H3_EXCESSIVE_LOAD, e.message);
1981
+ return;
1982
+ }
1983
+
1984
+ // Refused in 'stream_open' (abortStream forgot the stream): stop
1985
+ // here, before the request exists. The block is still decoded — the
1986
+ // QPACK state is shared with the peer and has to see every section —
1987
+ // but nothing above this layer hears of it. The comment above
1988
+ // promised exactly this; the code went on to emit http_headers, and
1989
+ // a refused request reached its handler all the same.
1990
+ if (context.streams[streamId] !== s) return;
1991
+
1992
+ // The section must be well formed (RFC 9114 §4.1.2, §4.2, §4.3), by the
1993
+ // rules HTTP/2 uses too (validateFieldSection). None were checked here:
1994
+ // a value with CR LF, a connection-specific field, a request without
1995
+ // :path reached the handler as it came. Malformed: H3_MESSAGE_ERROR on
1996
+ // this stream. A section after the final head is trailers; a 1xx
1997
+ // response (103 Early Hints) is not the final head.
1998
+ var isTrailers = !!s.final_head_seen;
1999
+ var r;
2000
+ try {
2001
+ r = validateFieldSection(headers._list || [], {
2002
+ isServer: context.isServer,
2003
+ trailers: isTrailers,
2004
+ connectProtocol: !!context.local_datagram_support
2005
+ });
2006
+ } catch (e) {
2007
+ streamError(Number(streamId), H3_ERR.H3_MESSAGE_ERROR, e.message);
2008
+ return;
2009
+ }
2010
+ if (!isTrailers && !(!context.isServer && /^1/.test(r.headers[':status']))) s.final_head_seen = true;
2011
+ if (isTrailers) s.trailers_seen = true;
2012
+
2013
+ // content-length against the body that actually comes (§4.1.2) — for
2014
+ // requests: a response to HEAD, or a 304, carries a length and no body.
2015
+ if (context.isServer) {
2016
+ if (!isTrailers) {
2017
+ try { s.content_length = parseContentLength(r.headers['content-length']); }
2018
+ catch (e) { streamError(Number(streamId), H3_ERR.H3_MESSAGE_ERROR, e.message); return; }
2019
+ s.body_received = 0;
2020
+ } else if (s.content_length !== null && s.content_length !== undefined && s.body_received !== s.content_length) {
2021
+ streamError(Number(streamId), H3_ERR.H3_MESSAGE_ERROR, 'body shorter than content-length');
2022
+ return;
2023
+ }
2024
+ }
2025
+
2026
+ // The pseudo-header order, for the protocol fingerprint — once the
2027
+ // section is known to be well formed. Emitted separately because by the
2028
+ // time the headers reach an application they are an object and the
2029
+ // order is gone; this decode is the only place that sees the sequence.
2030
+ if (r.pseudoOrder.length) ev.emit('pseudo_order', r.pseudoOrder);
2031
+ if (context.isServer && !isTrailers) applyRequestPriority(Number(streamId), r.headers);
2032
+ if (context.isServer && !isTrailers && context.local_webtransport &&
2033
+ r.headers[':method'] === 'CONNECT' && r.headers[':protocol'] === 'webtransport') {
2034
+ // One session more than we said the peer may have: refused before
2035
+ // the application hears of it — H3_REQUEST_REJECTED, so it may try
2036
+ // again later, or on another connection.
2037
+ if (context.local_wt_max_sessions !== null && _wtSessions.size + _wtConnects.size >= context.local_wt_max_sessions) {
2038
+ streamError(Number(streamId), H3_ERR.H3_REQUEST_REJECTED, 'more WebTransport sessions than SETTINGS_WT_MAX_SESSIONS');
2039
+ return;
2040
+ }
2041
+ _wtConnects.add(Number(streamId));
2042
+ }
2043
+
2044
+ // Trailers (a field section after the final head, RFC 9114 §4.1) are
2045
+ // their own event. As 'http_headers' they reached the layers above as a
2046
+ // second head: the client took them for the response's headers — its
2047
+ // status read 0 and 'response' fired twice — and the server merged
2048
+ // them into the request's headers.
2049
+ ev.emit(isTrailers ? 'http_trailers' : 'http_headers', Number(streamId), r.headers);
2050
+ // A handler may cancel the stream the moment it sees the headers;
2051
+ // DATA behind them in this batch must not surface as a body for a
2052
+ // request that no longer exists — h3_server would build a new one.
2053
+ if (context.streams[streamId] !== s) return;
837
2054
  }
838
2055
 
839
2056
  // FIN received → stream complete (emitted only once)
840
2057
  if (s.fin_received && !s.http_end_emitted) {
2058
+ // Every byte of the stream is here once its FIN is: anything left
2059
+ // unread is a frame cut short by the end of the stream. RFC 9114 §7.1:
2060
+ // H3_FRAME_ERROR. Ending the request quietly instead would hand the
2061
+ // application a truncated body as a complete one — the more so now that
2062
+ // DATA is delivered as it arrives.
2063
+ if (s.data_left > 0 || s.skip_left > 0 || bufferedBytes(s) > 0) {
2064
+ var truncated = new Error('stream ' + streamId + ' ended inside a frame');
2065
+ truncated.h3ErrorCode = H3_ERR.H3_FRAME_ERROR;
2066
+ throw truncated;
2067
+ }
2068
+ // A stream that ended without ever carrying HEADERS holds no message:
2069
+ // RFC 9114 §4.1.2, H3_REQUEST_INCOMPLETE. Reporting its end made the
2070
+ // layer above build a request out of nothing.
2071
+ if (!s.headers_seen) {
2072
+ streamError(Number(streamId), H3_ERR.H3_REQUEST_INCOMPLETE, 'stream ended without HEADERS');
2073
+ return;
2074
+ }
2075
+ if (s.content_length !== null && s.content_length !== undefined && s.body_received !== s.content_length) {
2076
+ streamError(Number(streamId), H3_ERR.H3_MESSAGE_ERROR, 'body shorter than content-length');
2077
+ return;
2078
+ }
841
2079
  s.http_end_emitted = true;
842
2080
  ev.emit('http_end', Number(streamId));
2081
+ // Its receiving side is over, and nothing more can arrive on it:
2082
+ // quic_connection discards every frame after the FIN (recv_final). Kept,
2083
+ // the entry held every byte the stream ever carried — a whole upload —
2084
+ // until the connection closed. Only this side is done; responses are
2085
+ // sent without this entry.
2086
+ // If it was a WebTransport CONNECT stream, its FIN ended the session:
2087
+ // a later stream naming it belongs to nothing, and the streams still
2088
+ // open in it are closed with it (releaseStream → endWtSession).
2089
+ if (context.streams[streamId] === s) releaseStream(Number(streamId), true);
843
2090
  }
844
2091
  }
845
2092
 
846
2093
 
847
2094
  // ---- Send methods ----
848
2095
 
2096
+ // One encoder whatever the peer allows. With no dynamic table — the peer
2097
+ // advertised capacity 0, or its SETTINGS have not arrived yet, and
2098
+ // encoderTable's capacity is still 0 — it uses the static table and Huffman
2099
+ // alone, and references and inserts nothing. (A second,
2100
+ // static-only encoder used to serve that case, without Huffman: a typical
2101
+ // page's response headers came out 313 bytes instead of 245.)
849
2102
  function sendHeaders(streamId, headers, fin) {
850
- var payload = buildQpackHeaderBlock(headers);
851
- var frame = buildH3Frames([{ frame_type: 1, payload: payload }]);
2103
+ var list = [];
2104
+ for (var hname in headers) {
2105
+ if (Object.prototype.hasOwnProperty.call(headers, hname)) {
2106
+ // A list: one field line per value (Set-Cookie above all, which may
2107
+ // not be combined), not String(list).
2108
+ var hv = headers[hname];
2109
+ if (Array.isArray(hv)) { for (var hi = 0; hi < hv.length; hi++) list.push([hname, String(hv[hi])]); }
2110
+ else list.push([hname, hv]);
2111
+ }
2112
+ }
2113
+ var enc = qpackEncodeHeaderBlock(list, encoderTable, {
2114
+ knownReceivedCount: qpackKnownReceived,
2115
+ history: encoderHistory,
2116
+ // The Required Insert Count is encoded against the capacity the PEER
2117
+ // advertised, not the smaller one this encoder uses (§4.5.1.1).
2118
+ peerMaxTableCapacity: context.remote_qpack_max_table_capacity || 0,
2119
+ // authorization and cookie carry secrets, and compressed size is
2120
+ // observable — the CRIME attack. They are never inserted.
2121
+ neverIndex: { 'authorization': true, 'cookie': true,
2122
+ 'proxy-authorization': true, 'set-cookie': true }
2123
+ });
2124
+ // Insertions FIRST, on their own stream: they are for the next request,
2125
+ // not this one.
2126
+ flushQpackInserts(enc.inserts);
2127
+
2128
+ var frame = buildH3Frames([{ frame_type: 1, payload: new Uint8Array(enc.block) }]);
852
2129
  if (DEBUG) console.log('[h3] sendHeaders stream=' + streamId + ' len=' + frame.byteLength + ' fin=' + !!fin);
853
2130
  quic.sendStream(streamId, frame, !!fin);
854
2131
  }
@@ -861,14 +2138,24 @@ function H3Connection(options) {
861
2138
  return;
862
2139
  }
863
2140
  if (typeof data === 'string') data = new TextEncoder().encode(data);
864
- var frame = buildH3Frames([{ frame_type: 0, payload: data }]);
865
- quic.sendStream(streamId, frame, !!fin);
2141
+ // The DATA frame header and the payload go down as two pieces: the payload
2142
+ // is queued by reference rather than copied behind a few header bytes.
2143
+ var header = concatUint8Arrays([writeVarInt(0), writeVarInt(data.byteLength)]);
2144
+ quic.sendStream(streamId, [header, data], !!fin);
866
2145
  }
867
2146
 
868
2147
 
869
2148
  // ---- Public API ----
870
2149
 
2150
+ useRememberedSettings();
2151
+
871
2152
  var api = {
2153
+ /** Stop listening to the QUIC connection: this HTTP/3 layer is done
2154
+ * with it, another may take over (a client's refused 0-RTT). */
2155
+ detach: function () {
2156
+ for (var i = 0; i < _quicListeners.length; i++) if (quic.off) quic.off(_quicListeners[i][0], _quicListeners[i][1]);
2157
+ _quicListeners = [];
2158
+ },
872
2159
  context: context,
873
2160
 
874
2161
  on: function (name, fn) { ev.on(name, fn); },
@@ -876,35 +2163,112 @@ function H3Connection(options) {
876
2163
 
877
2164
  sendHeaders: sendHeaders,
878
2165
  sendBody: sendBody,
2166
+ abortStream: abortStream,
879
2167
  sendControlStreams: sendControlStreams,
2168
+ sendPriorityUpdate: sendPriorityUpdate,
880
2169
  sendGoaway: sendGoaway,
881
- claimStream: function (streamId) { _claimedStreams.add(Number(streamId)); },
882
2170
 
883
2171
  // WebTransport server-side API
884
2172
  registerWebTransportSession: function (sessionStreamId) {
885
- _wtSessions.add(Number(sessionStreamId));
2173
+ var id = Number(sessionStreamId);
2174
+ _wtSessions.add(id);
2175
+ _wtConnects.delete(id);
2176
+ settleHeldWt(id, true); // its streams that came early
886
2177
  },
887
2178
 
888
- createWebTransportStream: function (sessionId, isBidi) {
889
- // Allocate next stream ID (server-initiated)
890
- // Server bidi: 1, 5, 9, 13... (type 0x1)
891
- // Server uni: 3, 7, 11, 15... (type 0x3) — but 3,7,11 used by H3
892
- var streamId;
893
- if (isBidi) {
894
- if (!context._nextServerBidiId) context._nextServerBidiId = 1;
895
- streamId = context._nextServerBidiId;
896
- context._nextServerBidiId += 4;
897
- } else {
898
- if (!context._nextServerUniId) context._nextServerUniId = 15;
899
- streamId = context._nextServerUniId;
900
- context._nextServerUniId += 4;
2179
+ // The application answered a WebTransport CONNECT with something other
2180
+ // than 2xx: streams that came early for it are refused.
2181
+ rejectWebTransportSession: function (sessionStreamId) {
2182
+ var id = Number(sessionStreamId);
2183
+ _wtConnects.delete(id);
2184
+ settleHeldWt(id, false);
2185
+ },
2186
+
2187
+ // We closed it (CLOSE_WEBTRANSPORT_SESSION): its streams go now, reset
2188
+ // with WEBTRANSPORT_SESSION_GONE as when the peer ends it.
2189
+ endWebTransportSession: function (sessionStreamId) { endWtSession(Number(sessionStreamId)); },
2190
+
2191
+ /**
2192
+ * Take a stream out of HTTP/3 framing and hand its bytes through raw.
2193
+ *
2194
+ * Called once an extended CONNECT has been accepted with a 2xx. From then
2195
+ * on the stream carries whatever protocol `:protocol` named — WebSocket
2196
+ * frames, for RFC 9220 — and 'tunnel_data' delivers them verbatim.
2197
+ */
2198
+ registerTunnelStream: function (streamId) {
2199
+ var id = Number(streamId);
2200
+ _tunnelStreams.add(id);
2201
+ _tunnelPendingIds.delete(id);
2202
+ // Anything that arrived before the accept is delivered now — after the
2203
+ // caller's accept returns — rather than on the next arrival, which may
2204
+ // never come (the client's FIN may already be here).
2205
+ if (_tunnelPending[id] || _tunnelPendingFin.has(id)) {
2206
+ queueMicrotask(function () { if (_tunnelStreams.has(id)) replayTunnelPending(id); });
901
2207
  }
2208
+ },
2209
+
2210
+ /** Stop reading a stream without resetting what we sent on it. */
2211
+ stopReading: stopReading,
2212
+
2213
+ /** A WebTransport stream we opened has ended on our side. */
2214
+ releaseWebTransportStream: function (streamId) { releaseStream(streamId); },
2215
+
2216
+ /**
2217
+ * Mark a stream as a tunnel-in-progress: stop feeding it to the HTTP/3
2218
+ * frame parser, but hold its bytes until registerTunnelStream() accepts.
2219
+ *
2220
+ * Called the moment `:protocol` is seen, which is before the application
2221
+ * has decided whether to accept. Without it, a client that sends its first
2222
+ * WebSocket frame immediately after the CONNECT has that frame parsed as
2223
+ * an HTTP/3 frame and lost.
2224
+ */
2225
+ beginTunnelStream: function (streamId) {
2226
+ _tunnelPendingIds.add(Number(streamId));
2227
+ // Its bytes are the tunnel's, bounded by MAX_TUNNEL_PENDING until the
2228
+ // accept: credited as delivered from here on.
2229
+ _bodyHeld.delete(Number(streamId));
2230
+ releaseRecvCredit(Number(streamId));
2231
+ },
902
2232
 
903
- // Send WT prefix: VarInt(type) + VarInt(session_id)
2233
+ /**
2234
+ * The application returns a request body's flow-control credit itself, as
2235
+ * it reads (see _bodyHeld): from now on, `creditBody(id, n)` for each n
2236
+ * bytes read, and `releaseBody(id)` when it will read no more.
2237
+ */
2238
+ holdBody: function (streamId) {
2239
+ var id = Number(streamId);
2240
+ if (context.streams[id]) _bodyHeld.add(id);
2241
+ },
2242
+ creditBody: function (streamId, n) { if (_bodyHeld.has(Number(streamId))) creditRecv(Number(streamId), n); },
2243
+ releaseBody: function (streamId) {
2244
+ var id = Number(streamId);
2245
+ if (!_bodyHeld.delete(id)) return;
2246
+ releaseRecvCredit(id);
2247
+ },
2248
+
2249
+ /** Write raw bytes onto a tunnel stream. */
2250
+ sendTunnelData: function (streamId, data, fin) {
2251
+ quic.sendStream(Number(streamId), data, !!fin);
2252
+ },
2253
+
2254
+ // A WebTransport stream of our own, on either side: the next free stream
2255
+ // number (quic's nextLocalStreamId), opened now with its prefix —
2256
+ // VarInt(0x41 or 0x54) + VarInt(session ID) — as a browser does.
2257
+ createWebTransportStream: function (sessionId, isBidi) {
2258
+ var streamId = quic.nextLocalStreamId(!!isBidi);
904
2259
  quic.sendStream(streamId, wt_stream_header(isBidi, sessionId), false);
905
2260
 
906
- _claimedStreams.add(streamId);
907
- _wtStreams[streamId] = { sessionId: sessionId, isBidi: isBidi };
2261
+ // Claimed for what the peer sends back on it — so only a bidirectional
2262
+ // one. Nothing ever arrives on our own unidirectional stream, so no FIN
2263
+ // ever released a claim made for it.
2264
+ if (isBidi) {
2265
+ _claimedStreams.add(streamId);
2266
+ _wtStreams[streamId] = { sessionId: sessionId, isBidi: isBidi };
2267
+ } else {
2268
+ // Tracked only so the session's end can reset it (released by the
2269
+ // writer at its end: releaseWebTransportStream).
2270
+ _wtLocalUni[streamId] = Number(sessionId);
2271
+ }
908
2272
 
909
2273
  return streamId;
910
2274
  }
@@ -917,4 +2281,106 @@ function H3Connection(options) {
917
2281
  }
918
2282
 
919
2283
 
920
- export { H3Connection };
2284
+ // ============================================================
2285
+ // WebTransport stream error codes (draft-ietf-webtrans-http3 §4.4)
2286
+ // ============================================================
2287
+ //
2288
+ // A WebTransport application resets or stops a stream with a 32-bit code of
2289
+ // its own; on the wire it is an HTTP/3 error code, in a range reserved for
2290
+ // it, which skips the codes reserved for greasing (0x1f * N + 0x21). Sent
2291
+ // raw, a WebTransport code collided with HTTP/3's own (H3_NO_ERROR is
2292
+ // 0x100), and the browser read neither.
2293
+ var WT_ERR_FIRST = 0x52e4a40fa8db;
2294
+ var WT_ERR_LAST = 0x52e5ac983162;
2295
+
2296
+ function wtErrorToH3(code) {
2297
+ code = Number(code);
2298
+ if (!(code >= 0 && code <= 0xffffffff) || Math.floor(code) !== code) code = 0;
2299
+ return WT_ERR_FIRST + code + Math.floor(code / 0x1e);
2300
+ }
2301
+
2302
+ // The WebTransport code an HTTP/3 code carries; null if it carries none.
2303
+ function h3ErrorToWt(h3code) {
2304
+ h3code = Number(h3code);
2305
+ if (!(h3code >= WT_ERR_FIRST && h3code <= WT_ERR_LAST)) return null;
2306
+ if ((h3code - 0x21) % 0x1f === 0) return null; // a grease code
2307
+ var shifted = h3code - WT_ERR_FIRST;
2308
+ return shifted - Math.floor(shifted / 0x1f);
2309
+ }
2310
+
2311
+ // ============================================================
2312
+ // WebTransport session capsules (RFC 9297; draft-ietf-webtrans-http3 §5)
2313
+ // ============================================================
2314
+ //
2315
+ // After the 2xx, a session's CONNECT stream carries capsules — type, length,
2316
+ // value — in HTTP/3 DATA frames. Two concern the session:
2317
+ // CLOSE_WEBTRANSPORT_SESSION (0x2843): a 32-bit application code and a
2318
+ // UTF-8 reason of up to 1024 bytes; the sender then ends the stream;
2319
+ // DRAIN_WEBTRANSPORT_SESSION (0x78ae): empty — finish up, no new work.
2320
+ // Any other type is skipped (RFC 9297 §3.2), without being buffered.
2321
+ var CAPSULE_CLOSE_WT = 0x2843;
2322
+ var CAPSULE_DRAIN_WT = 0x78ae;
2323
+ var WT_CLOSE_REASON_MAX = 1024;
2324
+
2325
+ function encodeCapsule(type, value) {
2326
+ return concatUint8Arrays([writeVarInt(type), writeVarInt(value.byteLength), value]);
2327
+ }
2328
+
2329
+ function wtCloseCapsule(code, reason) {
2330
+ code = Number(code);
2331
+ if (!(code >= 0 && code <= 0xffffffff) || Math.floor(code) !== code) code = 0;
2332
+ var r = Buffer.from(String(reason == null ? '' : reason), 'utf8');
2333
+ if (r.length > WT_CLOSE_REASON_MAX) {
2334
+ // Cut on a character boundary: back off continuation bytes (10xxxxxx).
2335
+ var end = WT_CLOSE_REASON_MAX;
2336
+ while (end > 0 && (r[end] & 0xc0) === 0x80) end--;
2337
+ r = r.subarray(0, end);
2338
+ }
2339
+ var v = new Uint8Array(4 + r.length);
2340
+ v[0] = code >>> 24; v[1] = (code >>> 16) & 0xff; v[2] = (code >>> 8) & 0xff; v[3] = code & 0xff;
2341
+ v.set(r, 4);
2342
+ return encodeCapsule(CAPSULE_CLOSE_WT, v);
2343
+ }
2344
+
2345
+ function wtDrainCapsule() { return encodeCapsule(CAPSULE_DRAIN_WT, new Uint8Array(0)); }
2346
+
2347
+ // Reads a CONNECT stream's capsules as they arrive, however they are split:
2348
+ // onClose({ closeCode, reason }) and onDrain(). A CLOSE whose value is
2349
+ // malformed (shorter than its code, or a reason past the limit) is still a
2350
+ // close — with code 0 and no reason.
2351
+ function createCapsuleReader(onClose, onDrain) {
2352
+ var buf = new Uint8Array(0), skip = 0;
2353
+ return function feed(data) {
2354
+ if (skip > 0) {
2355
+ var n = Math.min(skip, data.byteLength);
2356
+ skip -= n; data = data.subarray(n);
2357
+ }
2358
+ if (data.byteLength === 0) return;
2359
+ buf = buf.byteLength ? concatUint8Arrays([buf, data]) : data;
2360
+ for (;;) {
2361
+ var t = readVarInt(buf, 0); if (!t) return;
2362
+ var l = readVarInt(buf, t.byteLength); if (!l) return;
2363
+ var head = t.byteLength + l.byteLength;
2364
+ var known = t.value === CAPSULE_CLOSE_WT || t.value === CAPSULE_DRAIN_WT;
2365
+ if (!known || l.value > 4 + WT_CLOSE_REASON_MAX) {
2366
+ if (known) onClose({ closeCode: 0, reason: '' }); // oversized: a close all the same
2367
+ var have = buf.byteLength - head;
2368
+ if (have >= l.value) { buf = buf.subarray(head + l.value); continue; }
2369
+ skip = l.value - have; buf = new Uint8Array(0); return;
2370
+ }
2371
+ if (buf.byteLength < head + l.value) return; // the rest is still to come
2372
+ var v = buf.subarray(head, head + l.value);
2373
+ buf = buf.subarray(head + l.value);
2374
+ if (t.value === CAPSULE_DRAIN_WT) { onDrain(); continue; }
2375
+ if (v.byteLength < 4) { onClose({ closeCode: 0, reason: '' }); continue; }
2376
+ onClose({ closeCode: ((v[0] << 24) >>> 0) + (v[1] << 16) + (v[2] << 8) + v[3],
2377
+ reason: Buffer.from(v.subarray(4)).toString('utf8') });
2378
+ }
2379
+ };
2380
+ }
2381
+
2382
+ // parseQpackHeaderBlock is exported for the QPACK tests, which check it
2383
+ // against sections from an independent encoder.
2384
+ export { settingsContext as h3SettingsContext };
2385
+ export { H3Connection, H3_ERR, H3_ERR_NAME, parseQpackHeaderBlock, wtErrorToH3, h3ErrorToWt,
2386
+ wtCloseCapsule, wtDrainCapsule, createCapsuleReader };