quico 0.1.3 → 0.3.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 ADDED
@@ -0,0 +1,920 @@
1
+ /*
2
+ * quico: HTTP/3 and QUIC implementation for Node.js
3
+ * Copyright 2025 colocohen
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at:
8
+ *
9
+ * https://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * This file is part of the open-source project hosted at:
12
+ * https://github.com/colocohen/quico
13
+ */
14
+
15
+ import {
16
+ DEBUG,
17
+ Emitter,
18
+ concatUint8Arrays,
19
+ writeVarInt,
20
+ readVarInt,
21
+ wt_stream_header
22
+ } from './utils.js';
23
+
24
+
25
+ // ============================================================
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)
83
+ // ============================================================
84
+
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));
154
+ }
155
+
156
+
157
+ // ============================================================
158
+ // QPACK parse/build helpers
159
+ // ============================================================
160
+
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;
176
+ }
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
+
187
+ var headers = {};
188
+ while (pos < buf.length) {
189
+ var byte = buf[pos];
190
+
191
+ 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
+ }
202
+ }
203
+
204
+ } 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) {
215
+ name = QPACK_STATIC[nameIdx.value][0];
216
+ } else if (!fromStatic && dynamicTable) {
217
+ var di = bi - 1 - nameIdx.value;
218
+ if (di >= 0 && di < dynamicTable.length) name = dynamicTable[di][0];
219
+ }
220
+ if (name) headers[name] = value;
221
+
222
+ } 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;
233
+
234
+ } else {
235
+ break;
236
+ }
237
+ }
238
+
239
+ return headers;
240
+ }
241
+
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
+
315
+
316
+ // ============================================================
317
+ // H3 Frame build/parse
318
+ // ============================================================
319
+
320
+ function buildH3Frames(frames) {
321
+ var parts = [];
322
+ for (var i = 0; i < frames.length; i++) {
323
+ parts.push(writeVarInt(frames[i].frame_type));
324
+ parts.push(writeVarInt(frames[i].payload.length));
325
+ parts.push(frames[i].payload);
326
+ }
327
+ return concatUint8Arrays(parts);
328
+ }
329
+
330
+ function extractH3Frames(chunks, fromOffset) {
331
+ if (!chunks || chunks.length === 0) return { frames: [], new_from_offset: fromOffset };
332
+
333
+ var buffers = [];
334
+ var acc = 0;
335
+ for (var i = 0; i < chunks.length; i++) {
336
+ var nextAcc = acc + chunks[i].length;
337
+ if (fromOffset < nextAcc) {
338
+ buffers.push(chunks[i].slice(fromOffset - acc));
339
+ for (var j = i + 1; j < chunks.length; j++) buffers.push(chunks[j]);
340
+ break;
341
+ }
342
+ acc = nextAcc;
343
+ }
344
+
345
+ if (buffers.length === 0) return { frames: [], new_from_offset: fromOffset };
346
+
347
+ var combined = concatUint8Arrays(buffers);
348
+ var offset = 0, frames = [];
349
+
350
+ while (offset < combined.length) {
351
+ var start = offset;
352
+ var ft = readVarInt(combined, offset);
353
+ if (!ft) break;
354
+ offset += ft.byteLength;
355
+ var len = readVarInt(combined, offset);
356
+ if (!len) { offset = start; break; }
357
+ offset += len.byteLength;
358
+ if (offset + len.value > combined.length) { offset = start; break; }
359
+ frames.push({ frame_type: ft.value, payload: combined.slice(offset, offset + len.value) });
360
+ offset += len.value;
361
+ }
362
+
363
+ return { frames: frames, new_from_offset: fromOffset + offset };
364
+ }
365
+
366
+
367
+ // ============================================================
368
+ // H3 Settings
369
+ // ============================================================
370
+
371
+ var H3_SETTINGS_MAP = {
372
+ SETTINGS_QPACK_MAX_TABLE_CAPACITY: 0x01,
373
+ SETTINGS_MAX_FIELD_SECTION_SIZE: 0x06,
374
+ SETTINGS_ENABLE_CONNECT_PROTOCOL: 0x08,
375
+ SETTINGS_H3_DATAGRAM: 0x33,
376
+ SETTINGS_ENABLE_WEBTRANSPORT: 0x2b603742,
377
+ SETTINGS_WT_MAX_SESSIONS: 0x14e9cd29
378
+ };
379
+
380
+ var H3_ID_TO_NAME = {};
381
+ for (var k in H3_SETTINGS_MAP) H3_ID_TO_NAME[H3_SETTINGS_MAP[k]] = k;
382
+
383
+ function buildSettingsPayload(settings) {
384
+ var payload = [];
385
+ for (var name in settings) {
386
+ var id = H3_SETTINGS_MAP[name];
387
+ if (id === undefined) continue;
388
+ payload.push.apply(payload, Array.from(writeVarInt(id)));
389
+ payload.push.apply(payload, Array.from(writeVarInt(settings[name])));
390
+ }
391
+ return new Uint8Array(payload);
392
+ }
393
+
394
+ function parseSettingsPayload(buf) {
395
+ var out = {}, offset = 0;
396
+ while (offset < buf.length) {
397
+ var id = readVarInt(buf, offset); if (!id) break; offset += id.byteLength;
398
+ var val = readVarInt(buf, offset); if (!val) break; offset += val.byteLength;
399
+ var name = H3_ID_TO_NAME[id.value] || ('UNKNOWN_0x' + id.value.toString(16));
400
+ out[name] = val.value;
401
+ }
402
+ return out;
403
+ }
404
+
405
+
406
+ // ============================================================
407
+ // QPACK Encoder Instructions (from peer's encoder stream)
408
+ // ============================================================
409
+
410
+ function parseQpackEncoderInstructions(chunks, fromOffset) {
411
+ if (!chunks || chunks.length === 0) return { instructions: [], new_from_offset: fromOffset };
412
+
413
+ var buffers = [];
414
+ var acc = 0;
415
+ for (var i = 0; i < chunks.length; i++) {
416
+ var nextAcc = acc + chunks[i].length;
417
+ if (fromOffset < nextAcc) {
418
+ buffers.push(chunks[i].slice(fromOffset - acc));
419
+ for (var j = i + 1; j < chunks.length; j++) buffers.push(chunks[j]);
420
+ break;
421
+ }
422
+ acc = nextAcc;
423
+ }
424
+ if (buffers.length === 0) return { instructions: [], new_from_offset: fromOffset };
425
+
426
+ var combined = concatUint8Arrays(buffers);
427
+ var pos = 0;
428
+ 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.
432
+ function safeVarInt(prefixBits) {
433
+ var r = readPrefixedInt(combined, prefixBits, pos);
434
+ if (!r) return null;
435
+ pos = r.next;
436
+ return r.value;
437
+ }
438
+
439
+ while (pos < combined.length) {
440
+ var startPos = pos;
441
+ var byte = combined[pos];
442
+
443
+ if ((byte & 0x80) === 0x80) {
444
+ // Insert with name reference
445
+ var fromStatic = (byte & 0x40) !== 0;
446
+ 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);
452
+ instructions.push({ type: 'insert_name_ref', from_static: fromStatic, name_index: nameIdx, value: value });
453
+
454
+ } 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);
466
+ instructions.push({ type: 'insert_literal', name: name, value: value });
467
+
468
+ } else if ((byte & 0xe0) === 0x20) {
469
+ // Set dynamic table capacity
470
+ var capacity = safeVarInt(5); if (capacity === null) { pos = startPos; break; }
471
+ instructions.push({ type: 'set_capacity', capacity: capacity });
472
+
473
+ } else {
474
+ break;
475
+ }
476
+ }
477
+
478
+ return { instructions: instructions, new_from_offset: fromOffset + pos };
479
+ }
480
+
481
+
482
+ // ============================================================
483
+ // H3Connection — reactive pattern
484
+ // ============================================================
485
+ //
486
+ // Events:
487
+ // 'http_headers' (stream_id, headers) — new HTTP request headers
488
+ // 'http_body' (stream_id, data) — request body chunk
489
+ //
490
+
491
+ function H3Connection(options) {
492
+ if (!(this instanceof H3Connection)) return new H3Connection(options);
493
+ options = options || {};
494
+
495
+ var ev = Emitter();
496
+ var quic = options.quicConnection;
497
+
498
+
499
+ // ---- Context ----
500
+
501
+ var context = {
502
+ isServer: options.isServer !== false,
503
+
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,
509
+
510
+ // Remote settings (from peer's SETTINGS frame)
511
+ remote_max_header_size: 0,
512
+ remote_qpack_max_table_capacity: 0,
513
+ remote_datagram_support: null,
514
+
515
+ // QPACK dynamic table (peer's encoder inserts into this)
516
+ remote_qpack_dynamic_table: [],
517
+ remote_qpack_table_base_index: 0,
518
+ remote_qpack_table_capacity: 0,
519
+
520
+ // Stream tracking
521
+ streams: {}, // stream_id → { chunks, from_offset, type }
522
+
523
+ // Control stream state
524
+ control_streams_sent: false,
525
+ remote_settings_received: false,
526
+ };
527
+
528
+
529
+ // ---- set_context ----
530
+
531
+ function set_context(updates) {
532
+ if (!updates || typeof updates !== 'object') return;
533
+
534
+ var changed = {};
535
+
536
+ if ('remote_max_header_size' in updates && updates.remote_max_header_size !== context.remote_max_header_size) {
537
+ context.remote_max_header_size = updates.remote_max_header_size;
538
+ changed.remote_max_header_size = true;
539
+ }
540
+
541
+ if ('remote_qpack_max_table_capacity' in updates && updates.remote_qpack_max_table_capacity !== context.remote_qpack_max_table_capacity) {
542
+ context.remote_qpack_max_table_capacity = updates.remote_qpack_max_table_capacity;
543
+ changed.remote_qpack_max_table_capacity = true;
544
+ }
545
+
546
+ if ('remote_datagram_support' in updates) {
547
+ context.remote_datagram_support = !!updates.remote_datagram_support;
548
+ changed.remote_datagram_support = true;
549
+ }
550
+
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
+ if ('remote_settings_received' in updates && !context.remote_settings_received) {
557
+ context.remote_settings_received = true;
558
+ changed.remote_settings_received = true;
559
+ }
560
+
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
+ // ---- Reactive ----
571
+
572
+ if (changed.remote_qpack_table_capacity) {
573
+ evictQpackTable();
574
+ }
575
+
576
+ if (changed.remote_settings_received) {
577
+ // Could trigger QPACK table capacity update etc.
578
+ }
579
+ }
580
+
581
+
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;
588
+ }
589
+ while (totalSize > capacity && entries.length > 0) {
590
+ var removed = entries.pop();
591
+ totalSize -= removed[0].length + removed[1].length + 32;
592
+ }
593
+ }
594
+
595
+
596
+ // ---- Send initial control + QPACK streams ----
597
+
598
+ function sendControlStreams() {
599
+ if (context.control_streams_sent) return;
600
+ context.control_streams_sent = true;
601
+
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);
616
+
617
+ var controlFrame = buildH3Frames([{ frame_type: 0x04, payload: settings }]);
618
+
619
+ // Uni stream IDs: server=3,7,11 client=2,6,10
620
+ var ctrlId = context.isServer ? 3 : 2;
621
+ var encId = context.isServer ? 7 : 6;
622
+ var decId = context.isServer ? 11 : 10;
623
+
624
+ if (DEBUG) console.log('[h3] sendControlStreams ctrl=' + ctrlId + ' enc=' + encId + ' dec=' + decId);
625
+ if (DEBUG) console.log('[h3] SETTINGS hex: ' + Array.from(controlFrame).map(function(b){ return b.toString(16).padStart(2,'0'); }).join(' '));
626
+
627
+ // Send type byte + SETTINGS together as one chunk (some implementations need this)
628
+ var ctrlData = new Uint8Array(1 + controlFrame.byteLength);
629
+ ctrlData[0] = 0x00; // control stream type
630
+ ctrlData.set(controlFrame, 1);
631
+ quic.sendStream(ctrlId, ctrlData, false);
632
+ quic.sendStream(encId, new Uint8Array([0x02]), false); // QPACK encoder
633
+ quic.sendStream(decId, new Uint8Array([0x03]), false); // QPACK decoder
634
+ }
635
+
636
+
637
+ // ---- Graceful shutdown: GOAWAY (RFC 9114 §5.2) ----
638
+
639
+ function sendGoaway(id) {
640
+ sendControlStreams(); // idempotent — ensures the control stream + its type byte exist
641
+ var ctrlId = context.isServer ? 3 : 2;
642
+ var frame = buildH3Frames([{ frame_type: 0x07, payload: writeVarInt(id || 0) }]);
643
+ if (DEBUG) console.log('[h3] → GOAWAY id=' + (id || 0));
644
+ quic.sendStream(ctrlId, frame, false);
645
+ }
646
+
647
+
648
+ // ---- Incoming stream data ----
649
+
650
+ var _claimedStreams = new Set(); // streams owned by WebTransport (skip H3 processing)
651
+ var _wtSessions = new Set(); // active WT session stream IDs
652
+ var _wtStreams = {}; // streamId → { sessionId, isBidi }
653
+
654
+ quic.on('stream', function (streamId, data, fin) {
655
+ var sid = Number(streamId);
656
+
657
+ // Route data for already-detected WT streams
658
+ if (sid in _wtStreams) {
659
+ var ws = _wtStreams[sid];
660
+ ev.emit('wt_data', ws.sessionId, sid, data, fin);
661
+ if (fin) delete _wtStreams[sid];
662
+ return;
663
+ }
664
+
665
+ if (_claimedStreams.has(sid)) return; // owned by WebTransport client
666
+ if (DEBUG) console.log('[h3] stream ' + streamId + ' data=' + data.byteLength + ' fin=' + fin);
667
+ if (!(streamId in context.streams)) {
668
+ context.streams[streamId] = { chunks: [], from_offset: 0, type: null, fin_received: false };
669
+ }
670
+
671
+ var s = context.streams[streamId];
672
+ s.chunks.push(data);
673
+ if (fin) s.fin_received = true;
674
+
675
+ // Identify stream type
676
+ 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
+ }
706
+ }
707
+ }
708
+ }
709
+
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'; }
717
+ }
718
+ } else {
719
+ s.type = 'bidi';
720
+ }
721
+ }
722
+
723
+ // Process by type
724
+ if (s.type === 'control') {
725
+ processControlStream(s);
726
+
727
+ } else if (s.type === 'qpack_enc') {
728
+ processQpackEncoderStream(s);
729
+
730
+ } else if (s.type === 'bidi') {
731
+ processBidiStream(streamId, s);
732
+ }
733
+ });
734
+
735
+
736
+ quic.on('connect', function () {
737
+ sendControlStreams();
738
+ });
739
+
740
+ // If QUIC already connected (client creates H3 after connect), send now
741
+ if (quic.state === 'connected') {
742
+ sendControlStreams();
743
+ }
744
+
745
+
746
+ // ---- Stream processors ----
747
+
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
+ }
768
+
769
+ set_context(updates);
770
+
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);
778
+ }
779
+ }
780
+ }
781
+
782
+ function processQpackEncoderStream(s) {
783
+ var ext = parseQpackEncoderInstructions(s.chunks, s.from_offset);
784
+ if (ext.instructions.length === 0) return;
785
+ s.from_offset = ext.new_from_offset;
786
+
787
+ var inserts = [];
788
+ for (var i = 0; i < ext.instructions.length; i++) {
789
+ var inst = ext.instructions[i];
790
+ if (inst.type === 'set_capacity') {
791
+ set_context({ remote_qpack_table_capacity: inst.capacity });
792
+
793
+ } 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];
801
+ }
802
+ }
803
+ if (name) inserts.push([name, inst.value]);
804
+
805
+ } else if (inst.type === 'insert_literal') {
806
+ inserts.push([inst.name, inst.value]);
807
+ }
808
+ }
809
+
810
+ if (inserts.length > 0) {
811
+ set_context({ add_qpack_entries: inserts });
812
+ }
813
+ }
814
+
815
+ 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);
835
+ }
836
+ }
837
+ }
838
+
839
+ // FIN received → stream complete (emitted only once)
840
+ if (s.fin_received && !s.http_end_emitted) {
841
+ s.http_end_emitted = true;
842
+ ev.emit('http_end', Number(streamId));
843
+ }
844
+ }
845
+
846
+
847
+ // ---- Send methods ----
848
+
849
+ function sendHeaders(streamId, headers, fin) {
850
+ var payload = buildQpackHeaderBlock(headers);
851
+ var frame = buildH3Frames([{ frame_type: 1, payload: payload }]);
852
+ if (DEBUG) console.log('[h3] sendHeaders stream=' + streamId + ' len=' + frame.byteLength + ' fin=' + !!fin);
853
+ quic.sendStream(streamId, frame, !!fin);
854
+ }
855
+
856
+ function sendBody(streamId, data, fin) {
857
+ if (DEBUG) console.log('[h3] sendBody stream=' + streamId + ' data=' + (data ? data.byteLength : 'null') + ' fin=' + fin);
858
+ if (data === null || data === undefined) {
859
+ // Just set FIN on the stream (no additional H3 frame needed for empty body)
860
+ quic.sendStream(streamId, new Uint8Array(0), true);
861
+ return;
862
+ }
863
+ if (typeof data === 'string') data = new TextEncoder().encode(data);
864
+ var frame = buildH3Frames([{ frame_type: 0, payload: data }]);
865
+ quic.sendStream(streamId, frame, !!fin);
866
+ }
867
+
868
+
869
+ // ---- Public API ----
870
+
871
+ var api = {
872
+ context: context,
873
+
874
+ on: function (name, fn) { ev.on(name, fn); },
875
+ set_context: set_context,
876
+
877
+ sendHeaders: sendHeaders,
878
+ sendBody: sendBody,
879
+ sendControlStreams: sendControlStreams,
880
+ sendGoaway: sendGoaway,
881
+ claimStream: function (streamId) { _claimedStreams.add(Number(streamId)); },
882
+
883
+ // WebTransport server-side API
884
+ registerWebTransportSession: function (sessionStreamId) {
885
+ _wtSessions.add(Number(sessionStreamId));
886
+ },
887
+
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;
901
+ }
902
+
903
+ // Send WT prefix: VarInt(type) + VarInt(session_id)
904
+ quic.sendStream(streamId, wt_stream_header(isBidi, sessionId), false);
905
+
906
+ _claimedStreams.add(streamId);
907
+ _wtStreams[streamId] = { sessionId: sessionId, isBidi: isBidi };
908
+
909
+ return streamId;
910
+ }
911
+ };
912
+
913
+ for (var k in api) {
914
+ if (Object.prototype.hasOwnProperty.call(api, k)) this[k] = api[k];
915
+ }
916
+ return this;
917
+ }
918
+
919
+
920
+ export { H3Connection };