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/qpack.js ADDED
@@ -0,0 +1,555 @@
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
+ QPACK_STATIC,
17
+ buildStaticLookup,
18
+ encodeStringLiteral,
19
+ encodePrefixedInt,
20
+ readPrefixedInt,
21
+ entrySize,
22
+ ENTRY_OVERHEAD
23
+ } from './hpack_primitives.js';
24
+
25
+
26
+ // ============================================================
27
+ // QPACK dynamic table (RFC 9204).
28
+ //
29
+ // HPACK's table and this one solve the same problem and are not the same
30
+ // thing, because the transport underneath is different. In HTTP/2 both sides
31
+ // process header blocks in order, so a shared table stays in step for free.
32
+ // Over QUIC there is no order between streams: a request that references an
33
+ // entry can arrive before the instruction that created it.
34
+ //
35
+ // QPACK's answer is to move insertions onto their own unidirectional stream
36
+ // and to make every reference explicit about what it depends on. That is
37
+ // where the complexity comes from, and it is also where the risk comes from:
38
+ // a reference to an entry that has not arrived yet BLOCKS the decoder — the
39
+ // precise thing QUIC exists to avoid.
40
+ //
41
+ // THIS ENCODER TAKES NO SUCH RISK.
42
+ //
43
+ // RFC 9204 §2.1.2 names both strategies: an encoder may reference entries
44
+ // that are still in flight and compress better, or it may reference only
45
+ // entries the peer has acknowledged and never block. This one does the
46
+ // second. Concretely:
47
+ //
48
+ // - We advertise SETTINGS_QPACK_BLOCKED_STREAMS = 0, promising never to
49
+ // send a reference the peer cannot resolve immediately.
50
+ // - An entry becomes usable only after the peer acknowledges its insertion
51
+ // on the decoder stream.
52
+ // - Until then the field goes out as a literal, exactly as before.
53
+ //
54
+ // What that costs is the first request on a connection, which is no better
55
+ // than it is today. What it buys is every request after it: measured against
56
+ // a real Chrome header set, a subresource request drops from 412 bytes to 80
57
+ // — the repeated user-agent, accept and sec-ch-ua fields are most of a
58
+ // request and none of them are in the static table.
59
+ //
60
+ // It also sidesteps a second hazard for free. RFC 9204 §2.1.3 warns about
61
+ // flow-control deadlock: the encoder stream is flow controlled, so an
62
+ // insertion can stall, and a request that depends on it stalls with it.
63
+ // Here a stalled insertion is simply an unacknowledged one, and an
64
+ // unacknowledged entry is never referenced.
65
+ // ============================================================
66
+
67
+
68
+ // buildStaticLookup returns { full, name } — two separate maps, not one flat
69
+ // object. `full` is keyed by "name\0value" and `name` by the name alone, and
70
+ // treating the result as a single map makes every lookup return undefined:
71
+ // no crash, no error, just an encoder that silently never finds anything and
72
+ // emits literals for fields that are one byte in the static table.
73
+ var STATIC = buildStaticLookup(QPACK_STATIC, 0);
74
+ var STATIC_FULL = STATIC.full;
75
+ var STATIC_NAME = STATIC.name;
76
+
77
+ // entrySize and ENTRY_OVERHEAD come from hpack_primitives: RFC 7541 §4.1 and
78
+ // RFC 9204 §3.2.1 define them identically, and two copies of a constant both
79
+ // peers must agree on is exactly the kind of thing that drifts.
80
+
81
+
82
+ /**
83
+ * createQpackTable(capacity)
84
+ *
85
+ * Entries are held newest-first, which matches how they are addressed: QPACK
86
+ * numbers entries by ABSOLUTE index — a counter of every insertion ever made
87
+ * on the connection — rather than by position. That indirection is what lets
88
+ * a header block name an entry without knowing how many have been evicted
89
+ * since, and it is why `insertCount` below is never reset.
90
+ */
91
+ function createQpackTable(capacity) {
92
+ var entries = []; // newest first
93
+ var size = 0; // bytes, by the formula above
94
+ var insertCount = 0; // absolute, monotonic, never reset
95
+
96
+ function evictTo(limit) {
97
+ while (size > limit && entries.length > 0) {
98
+ var e = entries.pop();
99
+ size -= entrySize(e.name, e.value);
100
+ }
101
+ }
102
+
103
+ return {
104
+ /**
105
+ * Insert, returning the new absolute index, or null if it did not fit.
106
+ *
107
+ * An entry larger than the whole table is not an error: §3.2.2 says to
108
+ * empty the table and not insert it. Treating it as a failure would be
109
+ * wrong, and treating it as a success would leave both sides disagreeing
110
+ * about what the table holds.
111
+ */
112
+ insert: function (name, value) {
113
+ var need = entrySize(name, value);
114
+ if (need > capacity) { evictTo(0); return null; }
115
+ evictTo(capacity - need);
116
+ entries.unshift({ name: name, value: value });
117
+ size += need;
118
+ return ++insertCount;
119
+ },
120
+
121
+ /** Absolute index -> entry, or null once it has been evicted. */
122
+ get: function (absoluteIndex) {
123
+ var pos = insertCount - absoluteIndex;
124
+ if (pos < 0 || pos >= entries.length) return null;
125
+ return entries[pos];
126
+ },
127
+
128
+ /**
129
+ * Find a name/value pair, or just the name.
130
+ *
131
+ * Returns { index, full }, where `full` distinguishes a complete match
132
+ * from a name-only one — the difference between emitting one byte and
133
+ * emitting a name reference plus a literal value.
134
+ */
135
+ find: function (name, value) {
136
+ var nameHit = null;
137
+ for (var i = 0; i < entries.length; i++) {
138
+ if (entries[i].name !== name) continue;
139
+ var absolute = insertCount - i;
140
+ if (entries[i].value === value) return { index: absolute, full: true };
141
+ if (nameHit === null) nameHit = { index: absolute, full: false };
142
+ }
143
+ return nameHit;
144
+ },
145
+
146
+ // Whether an entry fits WITHOUT evicting anything.
147
+ //
148
+ // The encoder only ever inserts when this holds. Evicting is where QPACK
149
+ // gets dangerous: RFC 9204 §2.1.1 forbids evicting an entry that a field
150
+ // section still in flight refers to, and knowing that means tracking every
151
+ // unacknowledged section. Never evicting sidesteps it entirely — once the
152
+ // table is full, new fields simply go out as literals. 4096 bytes holds
153
+ // every header that actually repeats.
154
+ fits: function (name, value) {
155
+ return size + entrySize(name, value) <= capacity;
156
+ },
157
+
158
+ setCapacity: function (n) { capacity = n; evictTo(capacity); },
159
+
160
+ get insertCount() { return insertCount; },
161
+ get size() { return size; },
162
+ get capacity() { return capacity; },
163
+ get length() { return entries.length; }
164
+ };
165
+ }
166
+
167
+
168
+ // ============================================================
169
+ // Encoder insertion history
170
+ // ============================================================
171
+ //
172
+ // Which fields this encoder has sent recently, so that a field is inserted
173
+ // into the dynamic table on its SECOND sighting rather than its first. The
174
+ // table is never evicted (see fits above), so whatever enters it stays for
175
+ // the connection's life — and inserting on first sight filled it with values
176
+ // that never come back: on a connection of 300 API responses, 74 of the 79
177
+ // entries were one response's etag or x-request-id, the table was full after
178
+ // 37 responses, and from then on nothing new could be learned. A field seen
179
+ // twice is one that repeats.
180
+ //
181
+ // Bounded the way the table is, by bytes (entrySize, RFC 9204 §3.2.1), and
182
+ // forgets oldest first: what repeats does so from one response to the next,
183
+ // well within it.
184
+ function createQpackHistory(byteLimit) {
185
+ var seen = new Map(); // name\0value -> its size; insertion order is age
186
+ var size = 0;
187
+ return {
188
+ /** Whether the field was seen before; records it either way. */
189
+ seenBefore: function (name, value) {
190
+ var key = name + '\0' + value;
191
+ if (seen.has(key)) return true;
192
+ var need = entrySize(name, value);
193
+ if (need > byteLimit) return false; // could never be inserted anyway
194
+ seen.set(key, need);
195
+ size += need;
196
+ while (size > byteLimit) {
197
+ var oldest = seen.keys().next().value;
198
+ size -= seen.get(oldest);
199
+ seen.delete(oldest);
200
+ }
201
+ return false;
202
+ }
203
+ };
204
+ }
205
+
206
+
207
+ // ============================================================
208
+ // Encoder-stream instructions (RFC 9204 §4.3)
209
+ // ============================================================
210
+
211
+ /**
212
+ * "Insert with Name Reference" (1Txxxxxx) or "Insert with Literal Name"
213
+ * (01Nxxxxx), whichever the static table allows.
214
+ *
215
+ * T=1 means the name index refers to the STATIC table. Only static references
216
+ * are used here: a dynamic name reference would create a dependency between
217
+ * two insertions, and while that is legal it earns very little and adds a
218
+ * second ordering constraint to reason about.
219
+ */
220
+ function encodeInsert(name, value) {
221
+ var staticIndex = STATIC_NAME[name];
222
+
223
+ // encodePrefixedInt and encodeStringLiteral both return plain arrays, not
224
+ // Buffers — they are shared with HPACK, where the caller accumulates into an
225
+ // array and converts once at the end. Wrapping each one here rather than
226
+ // assuming.
227
+ if (staticIndex !== undefined) {
228
+ var prefix = encodePrefixedInt(staticIndex, 6);
229
+ prefix[0] |= 0xc0; // 1 1 xxxxxx : insert, static name
230
+ return Buffer.concat([Buffer.from(prefix),
231
+ Buffer.from(encodeStringLiteral(value, 7))]);
232
+ }
233
+
234
+ // Insert with Literal Name: 01Nxxxxx, N=0 (this entry may be indexed).
235
+ // The name uses a 5-bit prefix here, not the 7-bit one used for values —
236
+ // the two are different fields and encoding them alike is a silent
237
+ // corruption that only shows on long names.
238
+ var nameBytes = encodeStringLiteral(name, 5);
239
+ nameBytes[0] |= 0x40;
240
+ return Buffer.concat([Buffer.from(nameBytes),
241
+ Buffer.from(encodeStringLiteral(value, 7))]);
242
+ }
243
+
244
+ /** "Set Dynamic Table Capacity" (001xxxxx). */
245
+ function encodeSetCapacity(capacity) {
246
+ var b = encodePrefixedInt(capacity, 5);
247
+ b[0] |= 0x20;
248
+ return Buffer.from(b);
249
+ }
250
+
251
+
252
+ // ============================================================
253
+ // Decoder-stream instructions (RFC 9204 §4.4)
254
+ // ============================================================
255
+
256
+ var DECODER_SECTION_ACK = 0x80; // 1xxxxxxx
257
+ var DECODER_STREAM_CANCEL = 0x40; // 01xxxxxx
258
+ var DECODER_INSERT_COUNT = 0x00; // 00xxxxxx
259
+
260
+ /**
261
+ * Read the peer's decoder stream and report what it acknowledged.
262
+ *
263
+ * Only "Insert Count Increment" carries what this encoder needs: how many of
264
+ * our insertions the peer has processed. Section acknowledgements and stream
265
+ * cancellations matter for eviction accounting in a fuller implementation;
266
+ * here they are parsed and skipped, because entries are never evicted while
267
+ * referenced — this encoder only references acknowledged entries, and the
268
+ * table is small enough that eviction pressure comes from capacity alone.
269
+ */
270
+ function parseDecoderInstructions(buf, offset) {
271
+ var increments = 0;
272
+ var i = offset || 0;
273
+
274
+ // readPrefixedInt(buf, prefixBits, pos) — prefix width SECOND, position
275
+ // THIRD, and it returns { value, next } rather than a byte length. Getting
276
+ // that order wrong is not a crash: it reads a plausible integer from the
277
+ // wrong bits and advances by the wrong amount, so the stream desynchronises
278
+ // and every instruction after the first is misread.
279
+ while (i < buf.length) {
280
+ var b = buf[i];
281
+ var r;
282
+
283
+ if (b & DECODER_SECTION_ACK) {
284
+ r = readPrefixedInt(buf, 7, i);
285
+ if (!r) break;
286
+ i = r.next;
287
+ } else if (b & DECODER_STREAM_CANCEL) {
288
+ r = readPrefixedInt(buf, 6, i);
289
+ if (!r) break;
290
+ i = r.next;
291
+ } else {
292
+ r = readPrefixedInt(buf, 6, i);
293
+ if (!r) break;
294
+ increments += r.value;
295
+ i = r.next;
296
+ }
297
+ }
298
+
299
+ return { increments: increments, consumed: i - (offset || 0) };
300
+ }
301
+
302
+
303
+ // ============================================================
304
+ // Header block encoding (RFC 9204 §4.5)
305
+ // ============================================================
306
+
307
+ /**
308
+ * Encode one header list.
309
+ *
310
+ * Returns { block, inserts } — the field section for the request stream, and
311
+ * any insertions that must go out on the encoder stream. The caller sends the
312
+ * insertions FIRST; they describe entries this block does not yet use, since
313
+ * nothing is referenced before it is acknowledged.
314
+ *
315
+ * `options.knownReceivedCount` is the acknowledgement watermark: entries at or
316
+ * below it are safe to reference, entries above it are not. It is the single
317
+ * value that makes this encoder non-blocking.
318
+ */
319
+ function encodeHeaderBlock(headerList, table, options) {
320
+ options = options || {};
321
+ // Base: the acknowledged insert count. Everything referenced is at or below
322
+ // it, which is the whole non-blocking guarantee.
323
+ var known = options.knownReceivedCount || 0;
324
+ // MaxEntries for the Required Insert Count encoding comes from the DECODER's
325
+ // advertised SETTINGS_QPACK_MAX_TABLE_CAPACITY — the peer's — not from the
326
+ // capacity this encoder chose to use (RFC 9204 §4.5.1.1).
327
+ var maxEntries = Math.floor((options.peerMaxTableCapacity || 0) / ENTRY_OVERHEAD);
328
+ var neverIndex = options.neverIndex || {};
329
+
330
+ var out = [];
331
+ var inserts = [];
332
+ var queued = {}; // name\0value already queued in this block
333
+ var queuedSize = 0; // bytes those will take
334
+ var maxRef = 0; // highest absolute index referenced (1-based)
335
+
336
+ for (var i = 0; i < headerList.length; i++) {
337
+ var name = String(headerList[i][0]).toLowerCase();
338
+ var value = String(headerList[i][1]);
339
+
340
+ // ---- Static table, full match: one byte ----
341
+ var staticFull = STATIC_FULL[name + '\0' + value];
342
+ if (staticFull !== undefined) {
343
+ var idx = encodePrefixedInt(staticFull, 6);
344
+ idx[0] |= 0xc0; // 1 T=1 xxxxxx : indexed, static
345
+ push(out, idx);
346
+ continue;
347
+ }
348
+
349
+ // ---- Dynamic table, but only if acknowledged ----
350
+ //
351
+ // `hit.index <= known` and `rel >= 0` below are the same condition: rel
352
+ // is (known - index). Both are kept because they say different things —
353
+ // the first is the non-blocking policy, the second a bounds check on the
354
+ // wire encoding.
355
+ var neverIdx = neverIndex[name] === true || headerList[i][2] === true;
356
+ if (!neverIdx && table.capacity > 0 && maxEntries > 0) {
357
+ var hit = table.find(name, value);
358
+ if (hit && hit.full && hit.index <= known) {
359
+ // Indexed, dynamic: 10xxxxxx, relative to Base (§4.5.2). Absolute
360
+ // indices are zero-based in the RFC and one-based in this table, so
361
+ // relative = Base - 1 - (index - 1) = known - index.
362
+ var rel = known - hit.index;
363
+ if (rel >= 0) {
364
+ var dyn = encodePrefixedInt(rel, 6);
365
+ dyn[0] |= 0x80;
366
+ push(out, dyn);
367
+ if (hit.index > maxRef) maxRef = hit.index;
368
+ continue;
369
+ }
370
+ }
371
+ }
372
+
373
+ var staticName = STATIC_NAME[name];
374
+
375
+ if (neverIdx) {
376
+ // N=1: an intermediary must not add this to a table either. Compressed
377
+ // size is observable, so a secret in a table can be recovered one byte
378
+ // at a time — the CRIME attack, which is what the N bit exists for.
379
+ if (staticName !== undefined) {
380
+ var nl = encodePrefixedInt(staticName, 4);
381
+ nl[0] |= 0x70; // 01 N=1 T=1 xxxx : literal, static name
382
+ push(out, nl);
383
+ } else {
384
+ // 001 N=1 H xxx. The H bit (0x08) is set by encodeStringLiteral when
385
+ // it actually used Huffman; forcing it here — as 0x38 did — marked a
386
+ // plain name as Huffman-coded, and the peer decoded garbage.
387
+ var nn = encodeStringLiteral(name, 3);
388
+ nn[0] |= 0x30;
389
+ push(out, nn);
390
+ }
391
+ push(out, encodeStringLiteral(value, 7));
392
+ continue;
393
+ }
394
+
395
+ if (staticName !== undefined) {
396
+ var ln = encodePrefixedInt(staticName, 4);
397
+ ln[0] |= 0x50; // 01 N=0 T=1 xxxx : literal, static name
398
+ push(out, ln);
399
+ push(out, encodeStringLiteral(value, 7));
400
+ } else {
401
+ var lit = encodeStringLiteral(name, 3);
402
+ lit[0] |= 0x20; // 001 N=0 H xxx : literal, literal name
403
+ push(out, lit);
404
+ push(out, encodeStringLiteral(value, 7));
405
+ }
406
+
407
+ // Queue it so a LATER request can reference it — only if it is not in the
408
+ // table already, not queued twice in one block, fits without evicting,
409
+ // and (given a history) has been sent before: see createQpackHistory.
410
+ var key = name + '\0' + value;
411
+ if (table.capacity > 0 && maxEntries > 0 && !queued[key] &&
412
+ !(table.find(name, value) || {}).full &&
413
+ (!options.history || options.history.seenBefore(name, value))) {
414
+ var need = entrySize(name, value);
415
+ if (table.size + queuedSize + need <= table.capacity) {
416
+ queued[key] = true;
417
+ queuedSize += need;
418
+ inserts.push({ name: name, value: value });
419
+ }
420
+ }
421
+ }
422
+
423
+ // Field Section Prefix (§4.5.1).
424
+ //
425
+ // Required Insert Count is the highest entry THIS section references, plus
426
+ // one — not everything acknowledged so far. A section that references
427
+ // nothing declares 0, and depends on nothing.
428
+ //
429
+ // Base stays at the acknowledged count, which is at or above it, so the
430
+ // Delta Base is (Base - RIC) with the sign bit clear.
431
+ var prefix = [];
432
+ if (maxRef === 0) {
433
+ push(prefix, encodePrefixedInt(0, 8));
434
+ push(prefix, encodePrefixedInt(0, 7));
435
+ } else {
436
+ push(prefix, encodeRequiredInsertCount(maxRef, maxEntries));
437
+ push(prefix, encodePrefixedInt(known - maxRef, 7)); // S=0
438
+ }
439
+
440
+ return {
441
+ block: Buffer.from(prefix.concat(out)),
442
+ inserts: inserts
443
+ };
444
+ }
445
+
446
+ /**
447
+ * Required Insert Count, encoded modulo 2 * MaxEntries (§4.5.1.1).
448
+ *
449
+ * The wrapping exists so the field stays small on long-lived connections
450
+ * where the absolute count grows without bound. With capacity 0 there are no
451
+ * dynamic references at all and the value is 0.
452
+ */
453
+ function encodeRequiredInsertCount(ric, maxEntries) {
454
+ // RFC 9204 §4.5.1.1: 0 stays 0; otherwise (RIC mod 2*MaxEntries) + 1.
455
+ //
456
+ // This sent the count raw for a while, to agree with quico's own decoder,
457
+ // which read it raw. The two agreed with each other and with nothing else:
458
+ // Chrome and ls-qpack decode the wrapped form, as the RFC requires.
459
+ if (ric === 0 || maxEntries === 0) return encodePrefixedInt(0, 8);
460
+ return encodePrefixedInt((ric % (2 * maxEntries)) + 1, 8);
461
+ }
462
+
463
+ /**
464
+ * The inverse, for the decoder: the Required Insert Count a field section
465
+ * actually declares (RFC 9204 §4.5.1.1).
466
+ *
467
+ * `totalInserts` is how many entries the decoder has received so far and
468
+ * `maxEntries` is floor(the capacity WE advertised / 32). Throws on an
469
+ * encoding no conforming encoder could have produced.
470
+ */
471
+ function decodeRequiredInsertCount(encoded, maxEntries, totalInserts) {
472
+ if (encoded === 0) return 0;
473
+ var fullRange = 2 * maxEntries;
474
+ if (maxEntries === 0 || encoded > fullRange) {
475
+ throw new Error('QPACK: invalid Required Insert Count encoding ' + encoded);
476
+ }
477
+ var maxValue = totalInserts + maxEntries;
478
+ var maxWrapped = Math.floor(maxValue / fullRange) * fullRange;
479
+ var ric = maxWrapped + encoded - 1;
480
+ if (ric > maxValue) {
481
+ if (ric <= fullRange) throw new Error('QPACK: invalid Required Insert Count ' + ric);
482
+ ric -= fullRange;
483
+ }
484
+ if (ric === 0) throw new Error('QPACK: Required Insert Count decoded to 0');
485
+ return ric;
486
+ }
487
+
488
+ /** "Section Acknowledgment" (1xxxxxxx) for the decoder stream (§4.4.1). */
489
+ function encodeSectionAck(streamId) {
490
+ var b = encodePrefixedInt(streamId, 7);
491
+ b[0] |= 0x80;
492
+ return Buffer.from(b);
493
+ }
494
+
495
+
496
+ /**
497
+ * The Base this encoder uses, and what the decoder must agree it is.
498
+ *
499
+ * Kept equal to the acknowledged insert count, which is also the Required
500
+ * Insert Count above. Everything referenced is at or below it by construction,
501
+ * so no post-base indices are ever emitted and the Delta Base is always zero.
502
+ */
503
+
504
+ /** Delta Base, sign bit clear: Base equals Required Insert Count. */
505
+ function encodeBase(delta) {
506
+ var b = encodePrefixedInt(delta, 7);
507
+ return b; // S=0
508
+ }
509
+
510
+ function push(arr, bytes) {
511
+ for (var i = 0; i < bytes.length; i++) arr.push(bytes[i]);
512
+ }
513
+
514
+
515
+ /**
516
+ * "Insert Count Increment" (00xxxxxx) for the decoder stream.
517
+ *
518
+ * Sent after processing insertions from the peer's encoder stream. Without
519
+ * it a conservative peer — one that only references acknowledged entries,
520
+ * like this implementation — never advances past literals, and the dynamic
521
+ * table on both sides fills up while compressing nothing. The two halves are
522
+ * useless apart.
523
+ */
524
+ /**
525
+ * "Stream Cancellation" (01xxxxxx) for the decoder stream (RFC 9204 §4.4.2):
526
+ * reading of a stream was abandoned, and any section on it will never be
527
+ * acknowledged.
528
+ */
529
+ function encodeStreamCancellation(streamId) {
530
+ var b = encodePrefixedInt(streamId, 6);
531
+ b[0] |= 0x40;
532
+ return Buffer.from(b);
533
+ }
534
+
535
+ function encodeInsertCountIncrement(count) {
536
+ var b = encodePrefixedInt(count, 6);
537
+ b[0] &= 0x3f; // 00xxxxxx
538
+ return Buffer.from(b);
539
+ }
540
+
541
+
542
+ export {
543
+ createQpackHistory,
544
+ encodeStreamCancellation,
545
+ encodeInsertCountIncrement,
546
+ decodeRequiredInsertCount,
547
+ encodeSectionAck,
548
+ createQpackTable,
549
+ encodeHeaderBlock,
550
+ encodeInsert,
551
+ encodeSetCapacity,
552
+ parseDecoderInstructions,
553
+ entrySize,
554
+ ENTRY_OVERHEAD
555
+ };