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