quico 0.4.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -39,28 +39,37 @@
39
39
  6. [Design Choices](#-design-choices)
40
40
  - [The send model: range reconciliation](#the-send-model-range-reconciliation-not-retransmission)
41
41
  - [Congestion control](#congestion-control-bbr-style-not-loss-based)
42
+ - [The first round trips](#the-first-round-trips)
43
+ - [A returning client: Careful Resume](#a-returning-client-careful-resume)
42
44
  - [Stream scheduling](#stream-scheduling-byte-level-round-robin)
43
45
  - [Loss detection](#loss-detection-timers-and-how-we-treat-late-acks)
46
+ - [The handshake](#the-handshake-coalesced-datagrams-its-own-loss-detection)
44
47
  - [Flow control](#flow-control-consumption-based-windows)
45
48
  7. [Interoperability](#-interoperability)
46
49
  8. [API Reference](#-api-reference)
47
50
  - [`createServer()`](#quicocreateserveroptions-requestlistener)
48
- - [`request()` / `get()`](#quicorequesturl-options-callback)
51
+ - [`request()` / `get()`](#quicorequesturloptions-options-callback)
49
52
  - [`IncomingMessage`](#incomingmessage) · [`ServerResponse`](#serverresponse) · [`ClientRequest`](#clientrequest)
50
53
  - [`Agent`](#agent) · [`createSecureContext()`](#quicocreatesecurecontextoptions)
54
+ - [0-RTT (early data)](#0-rtt-early-data)
51
55
  - [Framework compatibility](#framework-compatibility)
52
- - [WebTransport](#webtransport)
56
+ - [WebTransport](#webtransport-1)
57
+ - [WebSocket](#websocket-1)
58
+ - [CONNECT-UDP (proxying UDP)](#connect-udp-proxying-udp)
59
+ - [Fingerprinting](#fingerprinting-1)
60
+ - [Protocol layers](#protocol-layers)
53
61
  - [Low-level QUIC API](#low-level-quic-api)
54
62
  9. [Configuration & Tuning](#-configuration--tuning)
55
63
  10. [Debugging](#-debugging)
56
64
  11. [Testing](#-testing)
57
65
  12. [Project Structure](#-project-structure)
58
- 13. [Known Limitations](#-known-limitations)
59
- 14. [Roadmap](#-roadmap)
60
- 15. [Contributing](#-contributing)
61
- 16. [Sponsors](#-sponsors)
62
- 17. [Contact](#-contact)
63
- 18. [License](#-license)
66
+ 13. [Security](#-security)
67
+ 14. [Known Limitations](#-known-limitations)
68
+ 15. [Roadmap](#-roadmap)
69
+ 16. [Contributing](#-contributing)
70
+ 17. [Sponsors](#-sponsors)
71
+ 18. [Contact](#-contact)
72
+ 19. [License](#-license)
64
73
 
65
74
  ---
66
75
 
@@ -146,6 +155,7 @@ stack trace ends in code you can open, not in a `.node` binary.
146
155
  |---|---|
147
156
  | **Node.js 18+** | ESM only (`import`). Tested on 18, 20 and 22. |
148
157
  | **No compiler** | Pure JavaScript — no native addons. |
158
+ | **Dependencies** | [`lemon-tls`](https://github.com/colocohen/lemon-tls) (TLS 1.3) and [`flat-ranges`](https://www.npmjs.com/package/flat-ranges) (range arithmetic). Nothing else. |
149
159
 
150
160
  ```bash
151
161
  npm install quico
@@ -189,7 +199,8 @@ quico.request('https://www.google.com/', (res) => {
189
199
  }).end();
190
200
  ```
191
201
 
192
- > ⚠️ Don't forget `.end()` — like `node:https`, the request isn't sent until you call it.
202
+ > GET and HEAD requests are ended for you on the next tick. For any method with a
203
+ > body, call `.end()` — like `node:https`, the request isn't sent until you do.
193
204
 
194
205
  > 📂 More examples in [`examples/`](./examples)
195
206
 
@@ -198,36 +209,106 @@ quico.request('https://www.google.com/', (res) => {
198
209
 
199
210
  ## ✨ Features
200
211
 
201
- ### HTTP/3 Server & Client
202
- - Full request/response cycle over QUIC (RFC 9114)
203
- - QPACK header compression, static and dynamic tables (RFC 9204)
204
- - Node.js-compatible API: `req.headers`, `res.writeHead()`, `res.end()`
205
- - Stream multiplexing — many concurrent requests on one connection
206
- - Connection pooling and coalescing: concurrent requests to the same origin share
207
- a single QUIC connection
208
- - Automatic `Alt-Svc` advertisement for HTTP/3 discovery
209
- - Automatic H3 → H2 → H1.1 fallback when QUIC is unavailable
212
+ ### One HTTP stack, three versions
213
+ QUICO does not import `node:http`, `node:https`, `node:http2` or `node:tls`. HTTP/1.1,
214
+ HTTP/2 and HTTP/3 are all implemented here, and all three hand the application the
215
+ same `IncomingMessage` / `ServerResponse` — so a framework written against `node:http`
216
+ runs over any of them without knowing which. For the network and cryptography, all
217
+ QUICO takes from Node is `node:net` (TCP), `node:dgram` (UDP) and `node:crypto`
218
+ (primitives).
219
+
220
+ - **HTTP/3** (RFC 9114) with QPACK, static and dynamic tables (RFC 9204); up to 100
221
+ blocked streams — a field section that references an entry whose insertion has not
222
+ arrived yet waits for it (`SETTINGS_QPACK_BLOCKED_STREAMS`)
223
+ - **HTTP/2** (RFC 9113) with HPACK, including h2c by connection preface
224
+ - **HTTP/1.1** with keep-alive, chunked transfer and trailers
225
+ - One server listens on UDP and TCP and serves all three; `Alt-Svc` is advertised
226
+ on HTTPS HTTP/1.1 and HTTP/2 responses so browsers discover HTTP/3 (see `altSvc`)
227
+ - Client with automatic H3 → H2 → H1.1 fallback, connection pooling and
228
+ per-origin coalescing
229
+ - `103 Early Hints` (`res.writeEarlyHints()`) on all three versions
230
+ - Extra protocols on the same TCP port through ALPN or byte sniffing
231
+ (`server.addProtocol()`)
210
232
 
211
233
  ### QUIC Transport
212
- - QUIC v1 (RFC 9000) — Initial, Handshake and 1-RTT packet spaces
234
+ - QUIC v1 (RFC 9000) and **QUIC v2** (RFC 9369) — Initial, Handshake and 1-RTT packet
235
+ spaces; everything a version decides (salt, key labels, packet type bits, Retry key)
236
+ in one profile per version (`quic_version.js`), the rest shared
237
+ - Compatible version negotiation (RFC 9368): a client that starts in v1 and offers v2 is
238
+ moved to v2 without a round trip when the server prefers it (`quicVersions`); Version
239
+ Negotiation and a second attempt when there is no compatible version
213
240
  - TLS 1.3 handshake via LemonTLS, no OpenSSL (RFC 9001)
214
241
  - Version Negotiation (RFC 9000 §6)
215
242
  - HelloRetryRequest — X25519, P-256, P-384
216
243
  - AES-128-GCM and AES-256-GCM
217
244
  - Key Update — bidirectional key phase rotation (RFC 9001 §6)
218
- - Connection ID management and packet coalescing
245
+ - Session resumption on the server, with ticket keys shared across connections
246
+ (`ticketKeys`)
247
+ - Retry and address validation, switchable at runtime (`validateAddress`)
248
+ - Handshakes under way counted for the application (`getPendingHandshakes()`,
249
+ `'handshakeLoad'`), and capped per client address (`maxHandshakesPerAddress`)
250
+ - Anti-amplification limit — 3× until the client's address is validated (RFC 9000 §8.1)
251
+ - Connection migration (RFC 9000 §9): a client may move to another network (Wi-Fi to
252
+ cellular) and go on in the same connection — each side issues spare connection IDs
253
+ (NEW_CONNECTION_ID, RETIRE_CONNECTION_ID, Retire Prior To) and the new path is
254
+ addressed by one of its own (§9.5); NAT rebinding is followed too. Each new address is
255
+ validated with PATH_CHALLENGE first, and congestion/RTT state starts over on a new IP
256
+ - NEW_TOKEN (RFC 9000 §8.1.3): a returning client's address needs no proof — no Retry,
257
+ no 3× limit; the agent keeps one token per server and uses it once
258
+ - ACK frequency (draft-ietf-quic-ack-frequency): a receiver follows ACK_FREQUENCY and
259
+ IMMEDIATE_ACK; a sender with many packets in flight asks for an ACK every ~10 packets —
260
+ an 8 MB download, ~340 ACK packets instead of ~3,400
261
+ - `grease_quic_bit` (RFC 9287): the fixed bit sent unpredictable to a peer that allows it
262
+ (not on a UDP port shared with other protocols)
263
+ - Certificate compression (RFC 8879, in LemonTLS): the server's chain compressed for a
264
+ client that offers it (Chrome: brotli), only when it comes out smaller, and once per chain
265
+ (cached). The client offers brotli, zstd and zlib on every version. A client certificate
266
+ goes compressed when the server's CertificateRequest asks. Decompressing stops at the
267
+ declared length and never goes past 128 KB. With an RSA-4096 chain, the server's
268
+ Handshake flight over QUIC went from 9.6 KB to 6.0 KB. The server's algorithms are
269
+ `createServer({ certificateCompression })`; what went on the wire, each way, is
270
+ `socket.getNegotiationResult().certificateCompression` on every version. The chain is
271
+ compressed when the server is made, in the background, not by the first client's
272
+ handshake (brotli at quality 11: ~4 ms for a small chain, ~18 ms for 9 KB)
273
+ - Encrypted Client Hello (RFC 9849, in LemonTLS) — HTTP/3, HTTP/2 and HTTP/1.1 alike: the
274
+ site name and ALPN travel encrypted, to a key published in DNS (`ech`; see
275
+ [Encrypted Client Hello](#encrypted-client-hello-ech))
276
+ - Stateless reset with HMAC-derived tokens (`resetTokenSecret`)
219
277
  - Stream and connection-level flow control with sliding windows
278
+ - Stream limits enforced in both directions, and raised with `MAX_STREAMS` as
279
+ streams close (RFC 9000 §4.6)
220
280
  - Loss detection with PTO and exponential backoff (RFC 9002)
221
281
  - BBR-style congestion control with pacing
282
+ - Path MTU discovery (DPLPMTUD, RFC 8899)
222
283
  - Unreliable datagrams (RFC 9221)
223
- - Stateless reset, idle timeout, keep-alive
224
- - TLS keylog export for Wireshark decryption
284
+ - Idle timeout, keep-alive, TLS keylog export for Wireshark
225
285
 
226
286
  ### WebTransport
227
287
  - Client and server (RFC 9297 + draft-ietf-webtrans-http3)
228
288
  - Bidirectional and unidirectional streams (Node.js Duplex API)
229
289
  - Unreliable datagrams
230
290
  - Natural API: `req.on('stream')`, `res.sendDatagram()`, `res.writeHead(200)` to accept
291
+ - Over HTTP/3 only: a session asked for over HTTP/1.1 or HTTP/2 is answered `501`,
292
+ with no body, before the application sees it
293
+
294
+ ### WebSocket
295
+ - Server over all three versions: HTTP/1.1 Upgrade, HTTP/2 extended CONNECT
296
+ (RFC 8441) and HTTP/3 extended CONNECT (RFC 9220) — one `'upgrade'` event for all
297
+ - `WebSocketServer` in the shape of the `ws` package, and the browser's
298
+ `WebSocket` client (HTTP/1.1), checked against the WPT `websockets/` tests
299
+ - `WebSocketStream` (experimental): the streams version, with backpressure both ways
300
+
301
+ ### CONNECT-UDP
302
+ - A UDP proxy (RFC 9298, MASQUE) over HTTP/3 — QUIC datagrams — and HTTP/2 and
303
+ HTTP/1.1 — capsules: `server.on('connectUdp', (req, tunnel) => tunnel.accept())`
304
+ - Its client: `quico.connectUdp(proxy, { host, port })`, a `dgram.Socket` that goes
305
+ through the proxy
306
+
307
+ ### Fingerprinting
308
+ - JA4H (HTTP request), HTTP/2 and HTTP/3 protocol fingerprints, per request
309
+ - TLS fingerprints (JA4, JA4S, JA5) through LemonTLS
310
+ - `profileRequest()` — scores how well a request's protocol behaviour matches the
311
+ client it claims to be
231
312
 
232
313
  ---
233
314
 
@@ -251,13 +332,14 @@ recover its frames, and put them back in a retransmission queue. The sender is
251
332
 
252
333
  **What QUICO does instead.** There is no `sent_packets` table, no stored frame copies,
253
334
  and no retransmission queue — those concepts don't exist in the codebase. Each stream
254
- keeps only three things:
335
+ keeps only this:
255
336
 
256
337
  ```js
257
338
  {
258
- pending_data: Uint8Array, // the bytes the application wrote
339
+ q: [Uint8Array, …], // the bytes the application wrote, as written
259
340
  acked_ranges: [from, to, …], // byte ranges the peer has confirmed
260
- in_flight_ranges: { 47: [from, to], 48: [from, to] } // packet number → bytes it carries
341
+ in_flight_ranges: { 47: [from, to], 48: [from, to] }, // packet number → bytes it carries
342
+ missing_ranges: [from, to, …] // the answer to the question below, kept (see below)
261
343
  }
262
344
  ```
263
345
 
@@ -268,14 +350,54 @@ missing = invert(acked_ranges ∪ in_flight_ranges, 0, total_size)
268
350
  ```
269
351
 
270
352
  Whatever's missing goes out. That's the whole algorithm. The sender doesn't remember
271
- what it *did* — it recomputes what the peer *still needs*, from scratch, every time.
272
- It's a declarative model: continuously reconcile "what the peer has" against "what the
273
- peer should have", and emit the difference.
353
+ what it *did* — it asks what the peer *still needs*, every time. It's a declarative
354
+ model: continuously reconcile "what the peer has" against "what the peer should have",
355
+ and emit the difference.
274
356
 
275
- **Loss detection, therefore, is one line:**
357
+ **The answer is kept, not recomputed.** `acked_ranges ∪ in_flight_ranges` was computed
358
+ from every packet in flight, three or four times per send pass — with thousands of
359
+ packets in flight on a large transfer, that was a fifth of the CPU. Each stream now holds
360
+ the answer itself, as [flat-ranges](https://github.com/colocohen/flat-ranges)' three
361
+ states of what the peer has:
362
+
363
+ | flat-ranges | QUICO | the peer… |
364
+ |---|---|---|
365
+ | `have` | `acked_ranges` | has these bytes — acknowledged |
366
+ | `not_have` | `missing_ranges` | surely lacks them — never sent, or given up on |
367
+ | `unknown` | `in_flight_ranges` (by packet) | may have them — in flight |
368
+
369
+ `have` and `not_have` never overlap, and `missing_ranges` is what the sender sends from.
370
+ It changes at exactly five points ([`nothingMissing`](./src/quic_connection.js)
371
+ documents them):
372
+
373
+ | Event | `missing_ranges` |
374
+ |---|---|
375
+ | the application writes bytes | added |
376
+ | bytes yielded into a packet (`get_stream_chunks`) | removed |
377
+ | an ACK credits bytes — a late one included | removed |
378
+ | the expiry timer gives a packet up | its bytes added, except any acknowledged meanwhile (`add_not_have`) |
379
+ | a packet that never left (encryption failed) | its bytes added |
380
+
381
+ Adding a packet's bytes back is exact because each byte is in flight in one packet at
382
+ most: bytes are yielded only while missing, and become missing again only when the
383
+ packet that carried them expires. `sendstate_test` checks `missing_ranges` against the
384
+ complement of `acked_ranges ∪ in_flight_ranges`, rebuilt from scratch every few
385
+ milliseconds under loss, reordering and late ACKs, and that it never overlaps
386
+ `acked_ranges`.
387
+
388
+ Congestion control counts something else: **packets, by their size on the wire** — header,
389
+ frames, padding and AEAD tag, whatever they carry, as RFC 9002 counts bytes in flight.
390
+ Each packet the peer must acknowledge enters `pkt_in_flight` when it leaves and goes when
391
+ it is acknowledged or given up on; `app_bytes_in_flight` is their sum, kept as they come
392
+ and go, and an ACK delivers what its packets weighed. The window, the pacing rate and the
393
+ bandwidth BBR measures are one unit. (They used to be STREAM bytes, which left out the
394
+ packets' overhead, packets of control frames, and most of the handshake.)
395
+
396
+ **Loss detection, therefore, is still one step:**
276
397
 
277
398
  ```js
278
- delete st.in_flight_ranges[pn]; // → those bytes are "missing" again
399
+ delete st.in_flight_ranges[pn]; // not in flight any more
400
+ flat_ranges.add_not_have(acked, missing, lostRanges); // → missing again, unless acked
279
401
  ```
280
402
 
281
403
  Deleting the mapping is the entire recovery action. Nothing is queued, scheduled, or
@@ -312,15 +434,20 @@ reassembles by offset and genuinely does not care how the bytes were packetized.
312
434
  isn't a contradiction to reconcile — it just adds a range to `acked_ranges`, and the
313
435
  next pass naturally stops sending those bytes.
314
436
 
315
- **What it costs.** Computing `invert(acked ∪ in_flight)` per stream per send
316
- opportunity is O(number of ranges) work, where a queue pop would be O(1). With
317
- coalesced flat range arrays this stays cheap in practice, but it is real CPU that a
318
- queue-based sender doesn't spend — a deliberate trade of cycles for correctness.
319
-
320
- **The receive side is symmetric.** Incoming data goes into `chunks` keyed by offset
321
- plus a `ranges` array, and delivery walks the contiguous prefix. Same range algebra,
322
- mirrored — which is exactly why the receiver must tolerate chunks whose boundaries
323
- don't line up with anything it saw before: on the sending side, they never had to.
437
+ **What it costs.** A send opportunity reads a stream's few `missing_ranges` — usually
438
+ one or two — and keeping them is a range insert or removal per write, and per packet
439
+ sent, acknowledged or expired. The expiry walk visits a
440
+ stream's packets oldest first and stops at the first one still within the timeout, so
441
+ it costs the packets that expired, not every packet in flight.
442
+
443
+ **The receive side is symmetric.** Incoming data is recorded in a `ranges` array, and
444
+ delivery walks the contiguous prefix. Same range algebra, mirrored — which is exactly
445
+ why the receiver must tolerate chunks whose boundaries don't line up with anything it
446
+ saw before: on the sending side, they never had to. It does so at insertion: only the
447
+ bytes not already held are stored, so stored pieces never overlap, tile `ranges`
448
+ exactly, and delivery is an exact-key walk. A peer cannot make reassembly expensive
449
+ either — the number of gaps per stream is capped (1000, as in quic-go), and pieces are
450
+ compacted into one buffer per contiguous run when they outnumber the gaps.
324
451
 
325
452
  ### Congestion control: BBR-style, not loss-based
326
453
 
@@ -371,21 +498,103 @@ That trace is a real run through a 10 Mbps bottleneck with a 25-packet queue: st
371
498
  finds the link in ~7 rounds, drain empties the queue, probe_bw settles within 5% of
372
499
  line rate.
373
500
 
501
+ #### The first round trips
502
+
503
+ A page's first view is decided before any of this has measured anything: by the window
504
+ the connection starts with, and how fast it grows. QUICO starts at **20 packets (24 KB)**
505
+ — `initialCongestionWindow` — where RFC 9002 suggests 10. That is where TCP effectively
506
+ is when its response goes out: a 10-segment TCP window has grown on the acknowledged
507
+ handshake segments by then, and a QUIC connection at 10 left a 60 KB page a round trip
508
+ behind HTTP/2 on the same path. In `startup` the window then grows by every byte the
509
+ peer acknowledges — the handshake's packets as well as the application's (RFC 9002
510
+ §7.3.1, TCP's slow start). Through an emulated 450 ms, 9 Mbps link, Chromium loading a
511
+ 61 KB page that references a 61 KB script:
512
+
513
+ | | HTML after its first byte | the script | `load` |
514
+ |---|---|---|---|
515
+ | a 10-packet start | 934 ms (2 round trips) | 1384 ms | 2326 ms |
516
+ | 20 packets (default) | 490 ms (1) | 942 ms | 1890 ms |
517
+
518
+ The window's ceiling is **8 MB** — what a long fat path needs at the pacing ceiling
519
+ (14 MB/s × 450 ms ≈ 6.3 MB); a 4 MB download through the same link takes 5.4 s (it took
520
+ 8.7 s under the earlier 300 KB ceiling). The window reaches it only where BBR measured
521
+ that much: on a short path, the BDP keeps it small.
522
+
523
+ #### A returning client: Careful Resume
524
+
525
+ A client back within the hour has already shown this server its path. QUICO keeps what
526
+ BBR measured — the bottleneck bandwidth and the minimum RTT — in the client's session
527
+ ticket, and a connection that resumes with it starts from there, as **RFC 9959 (Careful
528
+ Resume)** lays out, with its BBR appendix — when turned on (`carefulResume: true`; off by
529
+ default, for reasons measured at the end of this section):
530
+
531
+ - **Saved** once BBR has a measurement worth keeping (a capacity of at least four initial
532
+ windows): during Startup at each round it grows by a quarter, then when it moves by a
533
+ quarter either way — in a NewSessionTicket sent after the handshake (lemon-tls
534
+ `sendTicket()`), inside the ticket's encryption: the client carries it, cannot read or
535
+ forge it, and the server keeps nothing. A resumed connection carries it forward.
536
+ - **Not used** past an hour, from another network (the client's IPv4 /24 or IPv6 /48
537
+ changed: a laptop moved from home to a café), or when the first flight shows another
538
+ RTT — at most half, or over ten times, the saved one.
539
+ - **Reconnaissance → jump → validation.** The first flight goes out at the initial window,
540
+ as on any connection. Once it is acknowledged without loss, the first time the sender
541
+ has more than its window allows, the window jumps to **half** the saved capacity, paced
542
+ over one round trip; slow start goes on from what that delivers.
543
+ - **Safe Retreat.** A loss while the jump is out is taken for congestion: the saved state is
544
+ deleted, the window drops to half of what was actually delivered and holds until the jump
545
+ is acknowledged, and BBR goes on in ProbeBW from there.
546
+
547
+ Chromium through the emulated link, the second download on a new connection resuming with
548
+ the first one's ticket:
549
+
550
+ | Path | File | First visit | Returning, Careful Resume | Returning, without |
551
+ |---|---|---|---|---|
552
+ | 450 ms, 9 Mbps | 512 KB | 2365 ms | **1736 ms** | 2384 ms |
553
+ | 450 ms, 9 Mbps | 2 MB | 4107 ms | **3353 ms** | 4152 ms |
554
+ | 100 ms, 50 Mbps | 4 MB | 1876 ms | **1428 ms** | 1634 ms |
555
+ | 450 ms, 9 Mbps, 2 % random loss on the return | 2 MB | 4026 ms | 5357 ms | **4608 ms** |
556
+
557
+ The last row is the RFC's price: a random loss during the jump looks like congestion, and
558
+ the retreat costs more than QUICO's BBR loses without it — it does not react to loss at all.
559
+ On a path that did get slower (the same RTT, a third of the rate), the jump is what the
560
+ retreat is for; measured there, the returning download was still faster with it (6.8 s
561
+ against 7.8 s), the queue having absorbed it.
562
+
563
+ **Off by default.** Those measurements were of one file, downloaded twice — so the first
564
+ visit had measured the path at full rate. A website's first visit is a page: a document and
565
+ its resources, which measure a lower rate, so the jump on the next visit is smaller. Measured
566
+ again that way (Chromium, the second visit on a new connection), and with the handshake
567
+ and loss recovery improved since, which made connections without it faster too:
568
+
569
+ | Path | Returning visit | With Careful Resume | Without |
570
+ |---|---|---|---|
571
+ | 100 ms, 50 Mbps | 4 MB download | 1314 ms | 1320 ms |
572
+ | 450 ms, 9 Mbps | 512 KB download | 2833 ms | 2918 ms |
573
+ | 100 ms, 50 Mbps | page (60 KB + 20 scripts) | 731 ms | **678 ms** |
574
+ | 250 ms, 20 Mbps | page | 1769 ms | **1660 ms** |
575
+ | 250 ms, 20 Mbps, 3 % random loss | page | 3583 ms | **2150 ms** |
576
+
577
+ Almost nothing on clean paths, and a lot lost on a lossy one: with a hundred packets in the
578
+ jump and a few percent of random loss, one is nearly always lost, Safe Retreat takes it for
579
+ congestion, and the connection goes on at a fraction of the link. `carefulResume: true`
580
+ turns it on — for an application whose returning clients download large files over clean
581
+ paths, as in the first table; `getStats().carefulResume` gives a connection's phase.
582
+
374
583
  **Honest scope:** this is BBR-*style*, not a certified BBRv2. ProbeRTT — the periodic
375
584
  cwnd collapse that re-measures RTprop on a permanently saturated path — is not
376
585
  implemented, and there is no explicit loss response. The windowed filters bound the
377
586
  damage of stale measurements in the meantime.
378
587
 
379
- ### Stream scheduling: byte-level round-robin
588
+ ### Stream scheduling: byte-level round-robin, by priority
380
589
 
381
590
  **The choice:** when several streams have data ready and one packet is going out, who
382
591
  gets the space? Implementations differ sharply — many drain streams in priority or
383
592
  creation order, filling each one before moving on.
384
593
 
385
594
  QUICO interleaves at the **byte level, inside a single packet**
386
- ([`execute_quic_burst`](./src/quic_connection.js)). The scheduler cycles through all
595
+ ([`execute_quic_burst`](./src/quic_connection.js)). The scheduler cycles through the
387
596
  active streams, taking a chunk from each until the packet is full, then cycles again —
388
- so a 1350-byte packet routinely carries STREAM frames from three different streams:
597
+ so a 1200-byte packet can carry STREAM frames from three different streams:
389
598
 
390
599
  ```
391
600
  ┌─────────────────────────────── one QUIC packet ───────────────────────────────┐
@@ -397,19 +606,104 @@ The loop repeats while any stream still yields data and space remains (`progress
397
606
  flag), so a stream that runs dry doesn't hold a slot — the remaining bandwidth
398
607
  redistributes to the others within the same packet.
399
608
 
400
- **Why it matters:** three parallel downloads progress at genuinely equal rates instead
401
- of finishing sequentially, and no stream can be starved by a large one ahead of it.
402
- The cost is fragmentation — more STREAM frame headers per packet (~24 bytes each) than
403
- a drain-one-stream-at-a-time scheduler. QUICO accepts that trade for fairness.
404
- Priority signalling (RFC 9218) would let applications override the equal split; it's
405
- on the roadmap.
609
+ **Each packet starts where the last one left off.** A stream takes what it has, up to
610
+ the room left — so a stream with plenty to send fills a whole packet on its own. If
611
+ every packet started from the lowest stream number, that stream would fill every
612
+ packet and the others would wait for it to finish: three 6 MB downloads ran one after
613
+ another. The scheduler remembers who opened the previous packet (`rr_cursor`) and starts
614
+ the next round just after it, so with three busy streams the packets go 0, 4, 8, 0, 4,
615
+ 8… and the downloads progress at equal rates. The cost is fragmentation — more STREAM
616
+ frame headers per packet (~24 bytes each) than a drain-one-stream-at-a-time scheduler.
617
+ QUICO accepts that trade for fairness.
618
+
619
+ **Priorities decide who takes part in a round.** The streams with data are arranged in
620
+ tiers, and a packet takes from the first tier with something to send; only if that
621
+ runs dry does the next tier fill the rest of the same packet:
622
+
623
+ - by **urgency**, most urgent first — RFC 9218's `u=0..7`, default 3; urgency −1 is
624
+ HTTP/3's own control and QPACK streams, ahead of everything (a header block that
625
+ refers to a QPACK entry stuck behind response bytes would block);
626
+ - within an urgency, **non-incremental** streams go one at a time, by stream number,
627
+ each a tier of its own — a stylesheet whole before the next;
628
+ - then the **incremental** ones, taking turns as above;
629
+ - a **group** (any object, given to `setStreamPriority`) takes *one* turn in its round,
630
+ and passes it to its member with the highest `order` that has something to send;
631
+ equal orders take turns. This is WebTransport's `sendGroup` / `sendOrder`, mapped in
632
+ the WebTransport layer; QUIC only knows "streams that share one turn".
633
+
634
+ A stream nobody set a priority for takes turns at urgency 3 — as every stream did
635
+ before. The mechanism lives in QUIC (`setStreamPriority(id, { urgency, incremental,
636
+ group, order })`); the policy comes from above: HTTP/3 reads the client's `priority`
637
+ header and `PRIORITY_UPDATE` frames, the application can override them
638
+ (`res.setPriority`) — the client's later signals do not undo the application's decision
639
+ — and WebTransport maps its streams' `sendGroup` / `sendOrder`. A client asks for a
640
+ priority with the `priority` header on its request (`'u=1, i'`) — QUICO's client: the
641
+ `priority` option, `req.setPriority()`. HTTP/2 has the same
642
+ rules in its own scheduler (`h2.js`), for the DATA that waits for a window or for the
643
+ socket; what the socket already holds leaves in the order it was written. An HTTP/2
644
+ server sends `SETTINGS_NO_RFC7540_PRIORITIES = 1`: it reads RFC 9218's signals, not
645
+ RFC 7540's priority tree.
646
+
647
+ A scheduler can only order what is waiting. A stream fed in small writes with
648
+ backpressure has little queued at any moment, and while its queue is empty the next
649
+ tier sends — so an application that wants a stream strictly first should hand it its
650
+ data ahead of time, not a chunk per `'drain'`. Likewise a stream blocked by the peer's
651
+ flow control lets the next one go, rather than leaving the packet empty.
406
652
 
407
653
  **The subtle part** falls straight out of the reconciliation model: the sender decides
408
654
  what to send by asking which bytes aren't acked or in flight — but chunks selected
409
655
  *earlier in the same burst* have no packet number yet, so they're in neither set, and
410
- the next iteration would select them again. QUICO parks them under a temporary
411
- `_burst` key in the in-flight map, which real packet-number keys replace the moment
412
- the packet is encrypted and sent. The set arithmetic stays honest even mid-burst.
656
+ the next iteration would select them again. `get_stream_chunks` takes what it yields out
657
+ of `missing_ranges` at once, so the next iteration sees it as taken; the packet's number
658
+ records it in `in_flight_ranges` when it is sent. A packet that does not leave hands its
659
+ bytes back to `missing`. One packet can carry two pieces of one stream — new data and a
660
+ repaired gap — so a packet's entry in `in_flight_ranges` is a range list, read whole by
661
+ the ACK, the expiry timer and a stream reset.
662
+
663
+ **Many streams on one connection.** What the sender does per packet and per ACK does not
664
+ walk every stream: each packet in flight records the streams it carries, so an ACK (and
665
+ the expiry timer) visits those; the streams that hit flow control, and those whose FIN
666
+ is still to go, are kept apart the same way. Walked, those loops were most of the CPU of
667
+ a connection with many streams — a thousand concurrent responses on one connection ran
668
+ at 25 Mbps on loopback, two thousand at 10. Now 1000 run at ~60 and 2000 at ~55, against
669
+ ~70 with 200 (one core, client and server in one process). What remains per stream is
670
+ the scheduler's arrangement of the streams with data, once per burst.
671
+
672
+ **Writes go out once per tick.** A write to a stream does not send: it asks for one send
673
+ pass at the end of the current tick, and every write until then rides in it. A response
674
+ written as headers, ten small writes and an end is one datagram, not fourteen. A pass
675
+ for any other reason — an ACK, a timer — still runs at once and takes what is waiting.
676
+
677
+ **Datagrams** (RFC 9221) are never retransmitted, but they go under the same congestion
678
+ control: they wait in a queue, are taken into packets ahead of stream data, and the
679
+ packets that carry them count in flight (with nothing to resend). A datagram sent with
680
+ a priority (WebTransport's `sendOrder` / `sendGroup`) is not first: it takes its place
681
+ in its group, as a member at its order. While the path cannot
682
+ keep up the oldest are dropped — any that have waited 250 ms (configurable), and past
683
+ 1 MB queued — since a late datagram is worth nothing. The peer's
684
+ `max_datagram_frame_size` is honoured.
685
+
686
+ ### The send queue: the application's buffers, not copies
687
+
688
+ What a stream holds for sending is the list of buffers the application wrote, by
689
+ reference, each with the stream offset it starts at. A packet takes a view of one of
690
+ them, or — where it straddles two — a copy of its own ~1200 bytes. Writes of up to
691
+ 4 KB are the exception: they are gathered into a 16 KB buffer of the stream's own, so
692
+ ten thousand one-byte writes are a handful of pieces. A buffer leaves the queue the
693
+ moment the peer has acknowledged all of it.
694
+
695
+ The effect is on memory: `res.end(file)` holds the file once, not once per layer, and
696
+ any number of concurrent responses of the same `Buffer` share it. As with
697
+ `net.Socket`, **a buffer passed to `write()` / `end()` must not be modified afterwards**
698
+ — it is referenced until acknowledged, and a retransmission reads it again.
699
+
700
+ **Backpressure on the sending side** works as on a TCP socket. A write is complete —
701
+ its callback runs — once the stream has *sent* all but `writableHighWaterMark` of what
702
+ was written (bytes in flight are bounded by the congestion window, as the kernel
703
+ bounds them for TCP). So on HTTP/3 `res.write()` returns `false` and `'drain'` follows,
704
+ and `readable.pipe(res)` reads at the client's pace: a 30 MB file piped to a browser
705
+ holds ~5 MB of buffers on the server, not the file. The same applies to WebTransport
706
+ streams and to tunnels (WebSocket over HTTP/3, CONNECT).
413
707
 
414
708
  ### Within a stream: new data leads, gaps follow
415
709
 
@@ -448,10 +742,54 @@ decisions worth calling out:
448
742
  and if those ACKs are then barred from producing RTT samples, the timer never learns
449
743
  that it's too short. The loop is self-sealing. Accepting the sample breaks it.
450
744
 
745
+ - **Optimistic ACKs are caught** (RFC 9000 §21.4). Every 256–1023 packets, at random,
746
+ one 1-RTT packet number is skipped; an ACK that covers it acknowledges a packet that
747
+ was never sent, and closes the connection (PROTOCOL_VIOLATION). A peer acknowledging
748
+ ahead of what arrived would otherwise inflate the congestion window at will. The send
749
+ history keeps a zero-length placeholder for the skipped number, so its index math
750
+ stays contiguous.
751
+
451
752
  The PTO itself is `srtt + max(4·rttvar, 1ms) + max_ack_delay`. That last term is not
452
753
  optional: peers may legitimately sit on an ACK for their full advertised delay (25 ms
453
754
  is common), and a timer without it expires packets moments before their ACKs arrive.
454
755
 
756
+ The timer is the backstop, not the first word. On a new connection rttvar is half the
757
+ first RTT sample, so the timer comes ~3 RTTs after a packet left — and a lost packet in
758
+ a page's first response is exactly where those RTTs are paid. Two things come first:
759
+
760
+ - **Loss by ACK: the time threshold** (RFC 9002 §6.1.2). A packet is given up on once a
761
+ packet sent after it is acknowledged and `max(9/8·RTT, RTT + 5 ms)` has passed since
762
+ it was sent (RTT: the larger of srtt and the latest sample) — not the timer. Then
763
+ exactly the timer's path: resent, parked for a late ACK, counted lost. The RFC's
764
+ packet threshold (three later packets acknowledged) is left out on purpose: a packet
765
+ that arrives out of order is not resent for that, only for being later than the
766
+ threshold; the 5 ms floor keeps that so on short paths, where an eighth of the RTT is
767
+ less than Wi-Fi's own retransmissions reorder by. A 60 KB response through a 200 ms
768
+ path with one packet lost: ~10 ms longer than with none, where it was ~185 ms.
769
+ - **The tail loss probe** (RFC 8985). The last packet of a response — or a request in
770
+ one packet — has no later one to be acknowledged: after `2·srtt + max_ack_delay`
771
+ without an ACK, a copy of its data goes as a new packet. Nothing is given up on (the
772
+ original stays in flight; whichever arrives counts), one probe until an ACK comes,
773
+ only once the handshake is confirmed, and only while the timer is further off.
774
+
775
+ Chromium through 250 ms with 3 % loss (a 60 KB page and 20 scripts): page load p75
776
+ 2.41 s → 2.02 s; through 20 ms with 1 % loss and 5 ms jitter: median 474 → 419 ms.
777
+
778
+ **Our own ACKs are delayed and batched** (RFC 9000 §13.2). In the 1-RTT space an ACK
779
+ goes out once two ack-eliciting packets are owed — decided at the end of the
780
+ event-loop turn, so datagrams that arrived together get one ACK — at once for a
781
+ packet out of order (that is what lets the sender detect loss quickly), and
782
+ otherwise within `max_ack_delay`. Any packet we send anyway carries the ACK that is
783
+ owed, and the ACK Delay field reports how long it was held, so the peer can take it
784
+ out of its RTT. Initial and Handshake packets are acknowledged immediately, as the
785
+ RFC requires — in the handshake's flush, with whatever else those spaces send (see
786
+ [the handshake](#the-handshake-coalesced-datagrams-its-own-loss-detection)). Before this, every ack-eliciting packet got an ACK packet of its own:
787
+ a 20 MB download had the receiver send ~18,000 of them; now ~1,200.
788
+
789
+ The ACK timer runs at `max_ack_delay − 15 ms` (10 ms against the 25 ms we
790
+ advertise): timers fire late by up to their granularity, ~15.6 ms on Windows, and
791
+ the peer's loss timer counts on our ACK arriving within the advertised value.
792
+
455
793
  **ACK ranges are capped at 32** (RFC 9000 §13.2.4 permits acking a subset). Under
456
794
  loss, packet-number gaps never close — a lost PN is gone forever, its data returns
457
795
  under a new PN — so an uncapped ACK frame grows with the cumulative loss count until
@@ -459,18 +797,128 @@ it no longer fits in a packet. We measured exactly that against a line-rate send
459
797
  ACK packets inflating 325 B → 1017 B second by second, until they crossed the MTU and
460
798
  stopped being sendable at all.
461
799
 
800
+
801
+ ### The handshake: coalesced datagrams, its own loss detection
802
+
803
+ The handshake is the one place where every datagram must arrive before anything can
804
+ go on — so it is built to send as few as it can (RFC 9000 §12.2):
805
+
806
+ - **One flush, coalesced datagrams.** Initial and Handshake packets are not sent as
807
+ TLS produces each message. What the two spaces have to send — CRYPTO data neither
808
+ acknowledged nor in flight, an ACK they owe — goes out at the end of the turn, in
809
+ packets as full as the datagram allows, the packets in as few datagrams as they fit.
810
+ The datagram is padded to 1200 bytes, not an Initial packet with zeros ahead of the
811
+ Handshake packets that could have filled it. A server's whole flight (ServerHello,
812
+ EncryptedExtensions, Certificate, CertificateVerify, Finished, and the ACK of the
813
+ ClientHello) is **one datagram** with a small chain, where it was seven; a client's
814
+ answer one, where it was up to eight.
815
+ - **RTT from the first round trip.** ACKs of Initial and Handshake packets give RTT
816
+ samples, as 1-RTT ones do (RFC 9002 §4.1: RTT measurement is "unified across packet
817
+ number spaces"); the client has one when its handshake completes, not only after its
818
+ first 1-RTT packet is acknowledged.
819
+ - **ACK-based loss detection, in these two spaces only.** A packet is lost once a later
820
+ one is acknowledged and three more are, or 9/8 of an RTT has passed (RFC 9002 §6.1);
821
+ its range is sent again at once. The retransmission timer stays as the backstop. The
822
+ 1-RTT space keeps the recovery described above.
823
+ - **The client's anti-deadlock probe** (RFC 9002 §6.2.2.1). Until one of its Handshake
824
+ packets is acknowledged, a client keeps its timer even with nothing in flight — and
825
+ sends a PING when it fires: a server held by its anti-amplification limit has no
826
+ timer of its own, and waits for any datagram from the client.
827
+ - **The first 1-RTT packet rides in the last handshake datagram.** When 1-RTT keys
828
+ exist, the flush's last datagram waits for the end of the turn, and the first 1-RTT
829
+ packet made in it goes in it, last (a short header has no length): the server's
830
+ SETTINGS with its flight, the client's request — with its control streams — with its
831
+ Finished, in room the Initial's padding took anyway. A connection with one request:
832
+ 8 datagrams where it was 13; before the response, one datagram each way after the
833
+ ClientHello. Not where it would cost the server a round trip: a datagram the
834
+ anti-amplification budget would have let through alone is not made too large for it.
835
+ - **The peer's retransmission is news** (RFC 9002 §6.2.3). Handshake data received
836
+ again means the peer has not heard from us: what we sent in answer and is still
837
+ unacknowledged goes again at once, with the ACK the packet is owed — a server seeing
838
+ the ClientHello again, a client seeing the server's flight again — not a
839
+ retransmission timeout (three RTTs on the first sample) later. Twice per connection
840
+ at most. A 1-RTT packet that rode in a datagram found lost this way, or by the
841
+ ACK-based detection above, is lost with it and sent again in the new datagram — the
842
+ request does not wait for the 1-RTT space's own timeout because it travelled with the
843
+ Finished. A late ACK for either still counts.
844
+
845
+ Chromium against a QUICO server through a 250 ms path with 3 % loss: time to first byte
846
+ went from a median of 1182 ms to 516 ms (two round trips — the floor), and the page from
847
+ 2.8 s to 2.0 s. QUICO's client through the same path (100 connections): the 90th
848
+ percentile of time to first byte from ~1150 ms to ~850 ms, mostly the lost
849
+ Finished-and-request datagram answered on the server's retransmission.
850
+
851
+
852
+ ### Path MTU discovery
853
+
854
+ Packets start at what every QUIC path carries — 1200 bytes of frames — and most paths
855
+ carry more: ~1450 on Ethernet. Every byte more in a packet is a packet less, and a packet
856
+ is an encryption, a system call and its bookkeeping — where a JavaScript sender spends its
857
+ time: a 4 MB download goes out in ~16 % fewer packets at 1452.
858
+
859
+ A round trip after the handshake is confirmed — the first responses go first; a probe is
860
+ 1.4 KB of padding a slow link would put ahead of them — the sender probes (DPLPMTUD,
861
+ RFC 8899 / RFC 9000 §14.3), in a send pass of its own if nothing else would run one (a
862
+ client that only downloads may have nothing to send), and after the data of the pass it
863
+ shares:
864
+ a PING padded to a size, sent in the paced flow of the connection, and acknowledged — by
865
+ its own packet number — only if that size crossed the path. Sizes are whole UDP datagrams.
866
+ The first probe is at **the largest datagram the peer has sent on this path** (the limit is
867
+ most often the peer's own link — a PPPoE line, a VPN — and the same both ways), then at the
868
+ ceiling (1452, and the peer's `max_udp_payload_size`), and past a failure a binary search
869
+ down to 16 bytes. A size fails after three unacknowledged probes; the search runs again ten
870
+ minutes after it ends. A probe carries no stream data: a lost one — and a probe too large is
871
+ lost by design — holds nothing back, and is no loss to congestion control (§14.4).
872
+
873
+ **Black holes.** The MTU can shrink mid-connection. Packets above the floor lost in a row,
874
+ with none acknowledged since, are a suspicion, not a verdict — congestion loses packets in
875
+ runs too (Startup's overshoot does, every connection). A probe of the current size settles
876
+ it: acknowledged, it was congestion; lost three times, packets go back to 1200 and the
877
+ search starts again later. A new path starts from 1200, as its congestion state does.
878
+
879
+ | Path (largest datagram) | Found | Probes dropped |
880
+ |---|---|---|
881
+ | Ethernet (1472) | 1452 — the ceiling | 0 |
882
+ | a VPN (1392) | 1387, within the search's 16 bytes | ~9 |
883
+ | 1452, cut to 1300 mid-download | back to 1200 after 3 confirming probes | — |
884
+
462
885
  ### Flow control: consumption-based windows
463
886
 
464
887
  **The choice:** when do you tell the peer it may send more? The naive trigger is
465
- "bytes arrived"; QUICO slides its receive windows based on **application
466
- consumption** — a `MAX_STREAM_DATA` / `MAX_DATA` update goes out once the app has
467
- actually *read* a threshold of data, not merely when packets landed.
468
-
469
- The difference shows up under backpressure: a slow consumer causes the window to stop
470
- advancing, which slows the sender, instead of letting unbounded data accumulate in the
471
- receive buffer. Node stream semantics (`write()` return values, `highWaterMark`)
472
- propagate through H3 into these windows, so ordinary `.pipe()` backpressure reaches
473
- all the way to the peer's congestion controller.
888
+ "bytes arrived"; QUICO slides its receive windows on **consumption** — a
889
+ `MAX_STREAM_DATA` / `MAX_DATA` update goes out once in-order data has been delivered
890
+ up the stack and half a window has been used, never for bytes still sitting out of
891
+ order in the reassembly buffer.
892
+
893
+ **Where "consumed" is measured.** For what the application reads — a request body on
894
+ the server, a response body on the client, a WebTransport stream on either side — it is
895
+ the application's read: the
896
+ bytes that have left the Node stream's buffer, counted after each `read()` (which
897
+ flowing mode, `pipe()` and async iteration all go through). A reader that reads
898
+ nothing holds the peer to one stream window; one that reads slowly sets the peer's
899
+ pace. Everything else — HTTP/3's own streams — is consumed at the hand-off from QUIC to the HTTP/3 layer, and bounded there by its
900
+ own limits (a HEADERS frame by the field-section size we advertise, control frames by
901
+ a fixed cap).
902
+
903
+ These bytes count in the connection's window too (`initial_max_data`, 1 MB by
904
+ default): a few streams that nobody reads can hold the whole connection. An
905
+ application with many parallel streams — MoQT's stream per group — reads or stops
906
+ (`stopSending`) each one, and sizes the windows for it with `transportParams`.
907
+
908
+ **Autotuning.** A window caps the peer at one window a round trip: 256 KB a stream is
909
+ 10 Mbit/s at 200 ms. So, as Chromium and quiche do, a window the application consumed
910
+ half of within two round trips of the last update — the window is what limits, not the
911
+ path — doubles, up to **16 MB** a stream and **24 MB** the connection (a larger
912
+ `transportParams` value is kept as given); the connection's grows with its streams, to
913
+ 1.5× the largest. It grows only with what is consumed, so a reader that falls behind
914
+ keeps the window it has. Measured through an emulated 20 Mbit/s, 200 ms link, a 4 MB
915
+ download by QUICO's client: 7.8 s before, 4.2 s now (Chromium: 3.1 s).
916
+
917
+ **HTTP/2, over TCP.** The client's stream window is **6 MB** (Chrome's), not
918
+ HTTP/2's 64 KB, and the connection's 16 MB. Credit still goes back as the
919
+ application reads, so a response nobody reads stops at 6 MB. The 64 KB default held a
920
+ response to 64 KB a round trip: 16 MB through a 100 ms round trip took 52 s, and takes
921
+ under 1 s now. An application that wants less sets `initialWindowSize` on the request.
474
922
 
475
923
  ### Reading the source
476
924
 
@@ -506,6 +954,11 @@ delay, bandwidth limits and packet loss.
506
954
  - **http3** — three concurrent HTTP/3 requests, which must share **one** QUIC
507
955
  connection
508
956
 
957
+ 0-RTT over TCP is verified with OpenSSL both ways (`s_client -early_data`, `s_server
958
+ -early_data`: taken, refused and sent again, and after a HelloRetryRequest). Over QUIC,
959
+ 0-RTT is verified with Chromium (its second visit's request in its first flight, and its
960
+ retry after a `425`), and with aioquic and quic-go in both directions.
961
+
509
962
  Additionally verified against production servers: **Google**, **Cloudflare**,
510
963
  **Facebook**, **Microsoft**, **LiteSpeed** and **nginx** — including a 12.7 MB
511
964
  download from `cloudflaremirrors.com` byte-for-byte.
@@ -522,37 +975,311 @@ and finally the low-level QUIC primitives for non-HTTP protocols.
522
975
 
523
976
  ### `quico.createServer([options][, requestListener])`
524
977
 
525
- Creates an HTTP/3 server. Returns a server object with `listen()`, `close()` and
526
- `on()`.
978
+ Creates a server for HTTP/3, HTTP/2 and HTTP/1.1. Returns a server object with
979
+ `listen()`, `close()` and `on()`.
527
980
 
528
981
  **Options**
529
982
 
530
983
  | Option | Type | Default | Description |
531
984
  |---|---|---|---|
532
- | `key` | `Buffer \| string` | — | Private key in PEM format. Required unless `SNICallback` is used. |
533
- | `cert` | `Buffer \| string` | — | Certificate chain in PEM format. |
985
+ | `key` | `Buffer \| string \| Array` | — | Private key in PEM format (a string without `-----` is read as a file path). Required unless `SNICallback` is used. An array — `node:tls`'s form, keys or `{ pem, passphrase }` — for several certificates of different kinds, each key paired with the chain in `cert` that carries its public key (see [Post-quantum TLS](#post-quantum-tls)). |
986
+ | `cert` | `Buffer \| string \| Array` | — | Certificate chain in PEM format (or a file path, as for `key`); an array, one chain per key. |
534
987
  | `ca` | `Buffer \| string \| Array` | `null` | Additional trusted CAs. |
535
- | `SNICallback` | `Function` | `null` | `(servername, cb) => cb(null, secureContext)` for multi-domain setups. |
988
+ | `SNICallback` | `Function` | `null` | `(servername, cb, info) => cb(null, secureContext)` for multi-domain setups. `info.transport` is `'tcp'` or `'quic'`. |
989
+ | `http3` | `boolean` | `true` | Listen for HTTP/3 over UDP. |
536
990
  | `http2` | `boolean` | `true` | Also listen for HTTP/2 over TCP (fallback for non-QUIC clients). |
537
991
  | `http1` | `boolean` | `true` | Also listen for HTTP/1.1 over TCP. |
992
+ | `ALPNProtocols` | `string[]` | — | ALPN names to accept, in preference order, as in `node:tls`. Served HTTP versions missing from the list are put first; naming a version that is disabled throws. |
538
993
  | `maxConnections` | `number` | `10000` | Maximum concurrent QUIC connections. |
994
+ | `ticketKeys` | `Buffer` (48 bytes) | random | Session-ticket key, shared by TCP and QUIC. Pass a stable one to keep resumption working across restarts. |
995
+ | `ech` | `{ keys }` | — | Encrypted Client Hello keys, TCP and QUIC alike: `{ keys: [{ config, privateKey, retry }] }`, from `quico.generateEchKey()`. A key that does not match its config throws here. See [Encrypted Client Hello](#encrypted-client-hello-ech). |
996
+ | `earlyData` | `boolean` | `false` | Accept 0-RTT — a returning client's request in its first flight, answered one round trip sooner: over HTTP/3, and over TLS 1.3 on TCP (HTTP/2, HTTP/1.1). Off by default: early data can be replayed. See [0-RTT](#0-rtt-early-data). |
997
+ | `earlyDataMaxAge` | `number` (ms) | `10000` | 0-RTT: how far the age a client states for its ticket may be from what the server measures (RFC 8446 §8.3) — a captured first flight is good for this long at most. 1–600000. |
998
+ | `earlyDataReplay` | `(id, ttl, cb) => void` | in memory | 0-RTT's anti-replay store: `cb(null, true)` if `id` is new (and keep it `ttl` ms), `cb(null, false)` if seen. Share one (Redis, memcached) between servers that share `ticketKeys`. No answer in 100 ms, or an error: no 0-RTT, the handshake goes on. |
999
+ | `earlyDataFilter` | `(req, cb) => void` | `GET`, `HEAD`, `OPTIONS` | 0-RTT: whether a request that came early may be handled before the handshake completes — `cb(null, true)`; otherwise the client gets `425 Too Early` and sends it again after the handshake (RFC 8470). No answer in 100 ms: `425`. |
1000
+ | `sessionTimeout` | `number` | `7200` | Seconds a session ticket stays good, TCP and QUIC alike (`node:tls`'s option; its default is 300). `0`–`604800` (RFC 8446's 7 days); `0` issues tickets that never resume. |
1001
+ | `requestCert`, `rejectUnauthorized` | `boolean` | `false`, `true` | Client certificates, as in `node:tls`, on every version — HTTP/3 included — verified against `ca`. With `rejectUnauthorized` (the default) a client without a valid one is refused; with `false` it gets in, and `req.socket.authorized` says which. `req.socket.getPeerCertificate()` / `authorizationError` as on a `TLSSocket`; on a resumed connection, the certificate it proved on its first. |
1002
+ | `minVersion`, `maxVersion` | `string` | `'TLSv1.2'`, `'TLSv1.3'` | `'TLSv1.2'` or `'TLSv1.3'`. HTTP/3 is TLS 1.3 only: `maxVersion: 'TLSv1.2'` needs `http3: false`. |
1003
+ | `certificateCompression` | `string[]` \| `boolean` | `true` | The algorithms the server's certificate may go compressed in (RFC 8879), in order: `['brotli', 'zstd', 'zlib']` — the default, `true`, is all of them that Node has (zstd from 22.15). It goes compressed only to a client that offered one, and only when that comes out smaller. `false`: always plain. TCP and QUIC alike; a name unknown throws from `createServer`. |
1004
+ | `honorCipherOrder` | `boolean` | `false` | As `node:tls`: the cipher suite by the server's order (`ciphers`). By default the client's — a client that asks for AES-256 first gets it. |
1005
+ | `ciphers`, `ecdhCurve`, `sigalgs` | `string` | — | As `node:tls` takes them (OpenSSL's colon-separated names; `ecdhCurve: 'X25519:P-256'`). The server's default groups: every post-quantum one Node has, then X25519, P-256, P-384 (see [Post-quantum TLS](#post-quantum-tls)). HTTP/3 uses the TLS 1.3 suites of `ciphers` (AES-GCM and ChaCha20-Poly1305); a list without one needs `http3: false`. A value naming nothing usable throws from `createServer`. |
1006
+ | `handshakeTimeout` | `number` | TCP `120000`, QUIC `10000` | Milliseconds a TLS handshake may take. Over QUIC, counted from the connection's first packet, and on a server until the client's Finished is verified — not only until the server's own Finished has gone: a client that sends a ClientHello and nothing more holds the connection for this long, not for the idle timeout. |
1007
+ | `passphrase` | `string` | — | For an encrypted `key`. |
1008
+ | `validateAddress` | `boolean` | `false` | Validate client addresses with a QUIC Retry before any handshake work. |
1009
+ | `handshakeLoadThreshold` | `number` | `500` | HTTP/3 handshakes under way at which `'handshakeLoad'` fires (`high: true`), and again under half of it (`high: false`). `0`: off. |
1010
+ | `maxHandshakesPerAddress` | `number` | `64` | HTTP/3 handshakes under way from one client address; past it an Initial from there makes no connection. `0`: no cap. |
1011
+ | `resetTokenSecret` | `Buffer` (≥ 16 bytes) | random | Secret for QUIC stateless-reset tokens. Keep it stable across restarts so clients of a crashed instance get a reset instead of a timeout. |
1012
+ | `tokenSecret` | `Buffer` (≥ 16 bytes) | random | Secret the HTTP/3 address-validation tokens (Retry, and NEW_TOKEN for a client's next connection) are sealed under — node:quic's name. Servers that share it open each other's tokens: several on one port (a thread each, `SO_REUSEPORT`), or one restarted. |
539
1013
  | `socket` | `dgram.Socket` | `null` | Use an externally-bound IPv4 UDP socket instead of creating one. Must already be bound. |
540
1014
  | `socket6` | `dgram.Socket` | `null` | Same, for IPv6. |
1015
+ | `greaseQuicBit` | `boolean` | `true`; `false` with `socket` / `socket6` | QUIC: invite clients to send the fixed bit unpredictable (RFC 9287), so the bit stays free to change. Off by default on an external socket, whose port may be shared with STUN, TURN or DTLS — told apart from QUIC by that bit (RFC 9443). `true` when that port carries QUIC alone. |
1016
+ | `port` | `number` | `443` | The port advertised in `Alt-Svc` when `listen()` is not used (externally fed server). |
1017
+ | `altSvc` | `string \| false` | — | The `Alt-Svc` header on HTTPS HTTP/1.1 and HTTP/2 responses. By default `h3=":<port>"; ma=86400` when this server runs HTTP/3, and none with `http3: false`. A string is sent as is — for HTTP/3 served by something else (`'h3=":443"; ma=86400'`); `false` sends none. A response's own `alt-svc` header wins. |
1018
+ | `connectUdp` | `object` | — | CONNECT-UDP's policy: `{ template, allowPrivate, maxTunnelsPerConnection, idleTimeout, lookup, proxyName }` — see [CONNECT-UDP](#connect-udp-proxying-udp). |
1019
+ | `shouldUpgradeCallback` | `(req) => boolean` | listens for `'upgrade'`? | As in `node:http`: whether an upgrade request (HTTP/1.1 `Upgrade`, or extended CONNECT over HTTP/2 and HTTP/3) is taken up as `'upgrade'`, or handled as an ordinary `'request'`. |
1020
+ | `headersTimeout` | `number` | `60000` | How long a request's head may take to arrive. HTTP/1.1: `408` and close. HTTP/2: a header block left open (HEADERS without END_HEADERS) blocks the whole connection, so the connection goes (GOAWAY). HTTP/3: the request stream is reset, `H3_REQUEST_INCOMPLETE`. `0` disables it. |
1021
+ | `requestTimeout` | `number` | `300000` | As `node:http`: the whole request, body included, must arrive within it, counted from the end of its head. HTTP/1.1: `408` and close. HTTP/2 and HTTP/3: that request's stream is reset (`CANCEL` / `H3_REQUEST_INCOMPLETE`) and the request gets `'aborted'`; the connection serves on. Not applied to `CONNECT` (WebSocket, WebTransport), whose stream is meant to stay open. The response is not timed. `0` disables it. |
1022
+ | `keepAliveTimeout` | `number` | `5000` | HTTP/1.1 only: an idle connection between requests. |
1023
+ | `streamResetBurst`, `streamResetRate` | `number` | `1000`, `33` | Rapid Reset (CVE-2023-44487) protection, `node:http2`'s options and defaults: a client may cancel `streamResetBurst` requests at once, then `streamResetRate` a second. Past it the connection is closed — HTTP/2 `GOAWAY` `ENHANCE_YOUR_CALM` (a `'sessionError'`, `ERR_HTTP2_STREAM_RESET_RATE`), HTTP/3 `H3_EXCESSIVE_LOAD`. HTTP/3 counts cancelled request streams (`RESET_STREAM` or `STOP_SENDING`, once each), not a WebTransport session's own streams. `Infinity` for either: no limit. The `'stream_reset'` event remains for a policy of your own. |
1024
+ | `maxRequestsPerSocket` | `number` | `0` (no limit) | `node:http`'s: requests one connection may carry. HTTP/1.1: the Nth response says `Connection: close` and the connection ends (a request past it, pipelined, gets `503`). HTTP/2 and HTTP/3: `GOAWAY` after the Nth — requests in flight finish, the client takes the next to a new connection (nginx's `keepalive_requests`). |
1025
+ | `maxHeaderSize` | `number` | `16384` | HTTP/1.1: request line plus headers, in bytes (`node:http`'s option and default). Over it: `431`. |
1026
+ | `maxHeaderCount` | `number` | `2000` | HTTP/1.1: header lines. Over it: `431` (`node:http` drops the extra ones instead). |
1027
+ | `noDelay`, `keepAlive`, `keepAliveInitialDelay` | | `true`, `false`, `0` | TCP socket options. |
1028
+ | `transportParams` | `object` | — | QUIC's windows and stream limits, as sent to every client: `initialMaxData`, `initialMaxStreamDataBidiLocal`, `initialMaxStreamDataBidiRemote`, `initialMaxStreamDataUni`, `initialMaxStreamsBidi`, `initialMaxStreamsUni`, `maxIdleTimeout`. See [Transport parameters](#transport-parameters). |
1029
+ | `initialCongestionWindow` | `number` | `20` | QUIC: the congestion window a connection starts with, in full-size packets (1200 B), `2`–`100`. See [The first round trips](#the-first-round-trips). |
1030
+ | `carefulResume` | `boolean` | `false` | QUIC: a returning client's connection starts from what the server measured on its last one (RFC 9959). Off by default: for page loads it gains almost nothing and costs a lot with random loss. Worth it where returning clients download large files on clean paths. See [Careful Resume](#a-returning-client-careful-resume). |
1031
+ | `startupCap` | `'stall'` \| `'always'` \| `'off'` | `'stall'` | QUIC: Startup's cap on the congestion window, 2 × BDP plus ACK aggregation (BBR v3). `'stall'`: from the first round in which bandwidth stopped growing — fewer drops at a router's queue where the window used to overshoot, no cost to a page's ramp-up. `'always'`: the whole of Startup — fewest drops, a slower ramp on long lossy paths (for bulk transfers). `'off'`: none. Also per connection (`connectionOptions`, `conn.setOptions`), at runtime (`setOptions`), and a client's own option for what it sends. |
1032
+ | `pmtuDiscovery` | `boolean` | `true` (`false` on Windows) | QUIC: path MTU discovery. Off on Windows by default — Node cannot set the Don't Fragment bit there, so a probe may cross fragmented and be taken for a fit. Off, packets stay at 1200 bytes of frames (or what was found before it was turned off). |
1033
+ | `maxUdpPayloadSize` | `number` | `1452` | QUIC: the largest UDP payload sent (1200–65527): where path MTU discovery stops. Lower it for a VPN or a tunnel; raise it for jumbo frames on a LAN. |
1034
+ | `initialRtt` | `number` (ms) | `333` | QUIC: the RTT assumed before there is a sample (RFC 9002's kInitialRtt) — how soon a lost handshake packet is sent again. Lower for clients known to be near (one country, a CDN edge); too low against far clients, handshake packets are resent needlessly. 10–10000. |
1035
+ | `reorderWindow` | `number` (ms) | `5` | QUIC: the least a packet is allowed to arrive out of order before a later one's ACK gives it up (the floor of the time threshold, see [Loss detection](#loss-detection-timers-and-how-we-treat-late-acks)). Raise it on paths that reorder more (some mobile networks, per-packet load balancing) — `getStats().spuriousLosses` counting up says so. 0–1000. |
1036
+ | `quicVersions` | `Array` | `['v1', 'v2']` | QUIC versions accepted, in preference (`'v1'`, `'v2'`, or the numbers). A client that offers both through `version_information` gets the first of these it can be moved to without a round trip (RFC 9368) — v1 by default; `['v2', 'v1']` moves such clients to v2. A client whose version is not listed gets Version Negotiation. `req.socket.quicVersion` says which. |
1037
+ | `connectionOptions` | `(info, callback) => void` | — | QUIC: each connection's own `initialCongestionWindow`, `transportParams`, `carefulResume` and `startupCap`, decided when its ClientHello arrives. See [Per-connection settings](#per-connection-settings-connectionoptions). |
1038
+ | `maxWebTransportSessions` | `number` | no limit | WebTransport sessions a connection may have open at once — sent as `SETTINGS_WT_MAX_SESSIONS`; a CONNECT past it is refused with `H3_REQUEST_REJECTED` before the application hears of it. |
1039
+ | `maxConcurrentStreams`, `headerTableSize`, `initialWindowSize`, `maxFrameSize`, `maxHeaderListSize`, `connectionWindowSize`, `enableConnectProtocol` | | `100`, `4096`, `65535`, `16384`, `131072`, 16 MB, `true` | HTTP/2 settings. |
1040
+ | `maxSessionMemory` | `number` | `10` | HTTP/2: megabytes a connection may have queued for a peer that is not reading (`node:http2`'s option). DATA waits for the socket instead of queueing; past this, the connection is closed. |
1041
+
1042
+ #### Per-connection settings: `connectionOptions`
1043
+
1044
+ The server's settings are everyone's. `connectionOptions` decides them per QUIC connection —
1045
+ from the client's address, the name it asked for, and whether it has been here before:
1046
+
1047
+ ```js
1048
+ createServer({
1049
+ key, cert,
1050
+ connectionOptions(info, callback) {
1051
+ // info: { remoteAddress, remotePort, servername, resumed,
1052
+ // previous: { rtt, bandwidth, age } | null }
1053
+ lookupNetwork(info.remoteAddress, (err, net) => {
1054
+ if (err) return callback(err); // → the server's settings
1055
+ callback(null, {
1056
+ initialCongestionWindow: net.satellite ? 40 : 20, // packets
1057
+ transportParams: { initialMaxData: 32 * 1024 * 1024 },
1058
+ carefulResume: !net.shared,
1059
+ startupCap: net.bulk ? 'always' : 'stall',
1060
+ });
1061
+ });
1062
+ },
1063
+ }, handler);
1064
+ ```
1065
+
1066
+ It is asked once the ClientHello is in and before anything is answered: the handshake waits
1067
+ for it, as it waits for an asynchronous `SNICallback` (whose turn it takes first), and
1068
+ `transportParams` — merged over the server's — go out in that same handshake. `previous` is
1069
+ what the client's last connection from the same network measured, within the hour: what
1070
+ [Careful Resume](#a-returning-client-careful-resume) would start from (`rtt` in ms,
1071
+ `bandwidth` in bits/s, `age` in seconds); `null` on a first visit, from another network, or
1072
+ past its lifetime. Every field of the answer is optional: `transportParams`, and any of
1073
+ the connection options — `initialCongestionWindow`, `carefulResume`, `startupCap`,
1074
+ `pmtuDiscovery`, `maxUdpPayloadSize`, `initialRtt`, `reorderWindow`. What a connection
1075
+ has is the answer's, else what the server had when it was made (`createServer`, or
1076
+ `setOptions` since), else the default; `conn.setOptions` changes it afterwards.
1077
+
1078
+ It cannot hold a connection hostage: no answer within **1 s**, an error, a throw, or an
1079
+ answer that does not check out (checked whole — nothing of it is taken) leaves the
1080
+ connection on the server's settings, and `process.emitWarning` says why (code
1081
+ `QUICO_CONNECTION_OPTIONS`). It runs for every Initial that opens a connection — spoofed
1082
+ ones included — so anything it calls out to should be cached; `validateAddress` screens
1083
+ spoofed addresses before it is asked.
541
1084
 
542
1085
  Setting `http2: false` and `http1: false` makes the server **HTTP/3 only** — no TCP
543
- listener is opened.
1086
+ listener is opened. Without `key`/`cert`/`SNICallback`, the TCP side serves cleartext
1087
+ HTTP/1.1 and h2c.
544
1088
 
545
1089
  **Methods**
546
1090
 
547
- - `server.listen(port[, host][, callback])` — start listening. Binds UDP (and TCP
548
- unless both fallbacks are disabled).
549
- - `server.close([callback])` — stop listening and close all connections.
1091
+ - `server.listen(port[, host][, callback])` — start listening. Binds UDP (unless
1092
+ `http3: false` or external sockets were given) and TCP (unless both fallbacks are
1093
+ disabled). Port `0` picks a free port.
1094
+ - `server.close([callback])` — as `node:http`'s: no new connections, the idle ones
1095
+ closed at once, and each with a request in flight closed once it is answered — HTTP/2
1096
+ and HTTP/3 with `GOAWAY`, HTTP/1.1 at the end of its response. Then `'close'` and the
1097
+ callback. A second `close()`: the callback gets `ERR_SERVER_NOT_RUNNING`.
1098
+ - `server.closeIdleConnections()` / `server.closeAllConnections()` — as `node:http`'s,
1099
+ on all three versions: the connections with no request in flight (HTTP/2 and HTTP/3
1100
+ are sent `GOAWAY` first), or every one. An HTTP/1.1 socket handed to `'upgrade'` is not
1101
+ the server's any more and stays, as in Node; over HTTP/2 and HTTP/3 a WebSocket or
1102
+ WebTransport session lives on the connection, and closes with it.
1103
+ - `server.getConnections(cb)`, `server.listening`, `server.ref()` / `server.unref()`,
1104
+ `await using server = …` (`Symbol.asyncDispose`) — as `node:net`'s; the count is TCP
1105
+ and QUIC connections.
1106
+ - `server.headersTimeout`, `server.requestTimeout`, `server.maxRequestsPerSocket` (all
1107
+ versions), `server.keepAliveTimeout`, `server.maxHeadersCount` (HTTP/1.1) — readable
1108
+ and settable while the server runs, from the next request or connection on.
1109
+ - The server is an `EventEmitter`, as `node:http`'s: every method (`listeners`,
1110
+ `removeAllListeners`, `prependListener`, …), and `instanceof EventEmitter`.
1111
+ - `server.setSecureContext({ key, cert[, ca, passphrase] })` — as `node:tls`'s: the
1112
+ certificate from the next handshake on, over HTTP/1.1, HTTP/2 and HTTP/3 alike;
1113
+ connections already open keep theirs. How a renewed certificate (Let's Encrypt) is taken
1114
+ without a restart. A key that does not match its certificate, or a missing one, throws —
1115
+ and the certificate in use stays.
1116
+ - `server.addContext(hostname, context)` — as `node:tls`'s: a certificate for one name, by
1117
+ the client's SNI (`'*.example.com'` covers one label; the latest added wins), on all
1118
+ three versions. `context`: `{ key, cert }` or `quico.createSecureContext()`. With an
1119
+ `SNICallback` given to `createServer`, it decides instead (as in Node); when it answers
1120
+ with nothing, the `setSecureContext()` certificate is used.
1121
+ - `server.address()`
1122
+ - `server.handleConnection(socket)` — serve a TCP socket accepted elsewhere.
1123
+ - `server.setTimeout([msecs][, callback])` / `server.timeout` — as in `node:http`:
1124
+ a connection idle for `msecs` emits `'timeout'` with its socket, and is destroyed
1125
+ if nothing listens. `0` (default) disables it; changes apply to new connections.
1126
+ TCP connections (HTTP/1.1, HTTP/2) — an HTTP/3 connection has QUIC's idle timeout.
1127
+ - `server.handlePacket(msg, rinfo)` / `server.hasConnection(rinfo[, msg])` — feed UDP
1128
+ datagrams yourself when sharing a port (external socket mode). `hasConnection(rinfo)`
1129
+ asks by the sender's address; `hasConnection(rinfo, msg)` by the connection ID the
1130
+ datagram is addressed to — for servers sharing a port across threads, where a client
1131
+ whose address changed can reach one that does not hold its connection. Ask it before
1132
+ `handlePacket`: given a short-header packet for an ID it does not know, `handlePacket`
1133
+ answers with a Stateless Reset, which would end a connection another server holds.
1134
+ - `server.addProtocol(name, handler, { sniff? })` — serve another protocol on the same
1135
+ TCP port: by ALPN `name`, or for TLS connections without ALPN, by
1136
+ `sniff(firstBytes) → true | false | null`. `handler(socket, protocol)`.
1137
+ - `server.getTicketKeys()` / `setTicketKeys(keys)`,
1138
+ `getEchKeys()` / `setEchKeys(ech)`,
1139
+ `getValidateAddress()` / `setValidateAddress(on)`,
1140
+ `getResetTokenSecret()` / `setResetTokenSecret(secret)`,
1141
+ `getTokenSecret()` / `setTokenSecret(secret)` — change these at runtime.
1142
+ - `server.setOptions(options)` / `server.getOptions()` — every other option that can
1143
+ change while the server runs, in one call, by the names `createServer` takes. See
1144
+ [Changing options while running](#changing-options-while-running).
1145
+ - `server.getPendingHandshakes()` — HTTP/3 handshakes under way: connections made whose
1146
+ handshake is not complete (the client's Finished verified) and has not failed.
1147
+ What load is, and what to do about it, is the application's — see `'handshakeLoad'`:
1148
+
1149
+ ```js
1150
+ // Retry (one round trip more) for new clients only while handshakes pile up.
1151
+ server.on('handshakeLoad', ({ high }) => server.setOptions({ validateAddress: high }));
1152
+ ```
1153
+
1154
+ `maxHandshakesPerAddress` is a cap, not a judgement of load: an address with that
1155
+ many handshakes under way gets no more connections until some end. High enough for
1156
+ many clients behind one address (a carrier NAT); with Retry on, it counts only
1157
+ addresses that came back with a token.
1158
+ - `server.getStats()` — the server's counters since it was made (a copy). Counts only:
1159
+ no addresses, names, headers or payloads. Codes are the protocol's, as hex strings,
1160
+ each mapped to how many times it was seen:
1161
+
1162
+ | Field | Counts |
1163
+ |---|---|
1164
+ | `connections.quic` / `.tcp` | Connections taken: a QUIC Initial that began a handshake; a TCP socket accepted |
1165
+ | `handshakes.quic` / `.tcp` | `{ completed, failed }` — failed: an alert either way, a timeout, the client gone first |
1166
+ | `handshakes.resumed` | Completed with a session ticket |
1167
+ | `protocols` | `{ h3, h2, 'http/1.1', other }` — connections by the protocol they spoke |
1168
+ | `earlyData` | `{ accepted, rejected, tooEarly }` — 0-RTT the clients offered, and requests answered 425 |
1169
+ | `closes.quic` | `{ sent, received, silent }` — QUIC's CONNECTION_CLOSE by code (`'0x12f'`: a TLS alert, illegal_parameter); silent: ended with none (idle timeout, stateless reset) |
1170
+ | `closes.h3` | `{ sent, received }` — HTTP/3's CONNECTION_CLOSE by code (`'0x100'`: H3_NO_ERROR) |
1171
+ | `closes.h2` | `{ sent, received }` — HTTP/2 GOAWAY by code |
1172
+ | `closes.http1` | `{ sent }` — HTTP/1.1 requests refused before the application saw them, by status (`'400'`) |
1173
+ | `streamResets.h3` / `.h2` | `{ sent, received }` — RESET_STREAM / RST_STREAM by code |
1174
+ | `amplificationBlocked` | Datagrams held at the anti-amplification limit (RFC 9000 §8.1) until the client's address was validated |
1175
+ | `internalErrors` | Exceptions of ours caught in the QUIC and HTTP/3 layers — a bug, never the client's |
1176
+ | `since`, `uptime` | When counting began; milliseconds since |
1177
+
1178
+ ```js
1179
+ setInterval(() => {
1180
+ const s = server.getStats();
1181
+ metrics.gauge('tls_failed', s.handshakes.quic.failed + s.handshakes.tcp.failed);
1182
+ metrics.gauge('h3_errors', Object.entries(s.closes.h3.sent).filter(([c]) => c !== '0x100').reduce((n, [, k]) => n + k, 0));
1183
+ }, 10000);
1184
+ ```
1185
+
1186
+ #### Changing options while running
1187
+
1188
+ `server.setOptions(partial)` changes what can change, by `createServer`'s names;
1189
+ `server.getOptions()` returns all of them as they are now (a copy).
1190
+
1191
+ ```js
1192
+ const server = createServer({ key, cert, startupCap: 'stall', initialRtt: 100 }, app);
1193
+
1194
+ server.on('handshakeLoad', ({ high }) => {
1195
+ server.setOptions({ validateAddress: high, maxHandshakesPerAddress: high ? 16 : 64 });
1196
+ });
1197
+
1198
+ server.setOptions({ startupCap: 'always' }); // returns server: chainable
1199
+ const { startupCap, validateAddress } = server.getOptions();
1200
+
1201
+ try {
1202
+ server.setOptions({ startupCap: 'off', initialRtt: -5 });
1203
+ } catch (e) {
1204
+ e.code; // 'ERR_OUT_OF_RANGE' — "initialRtt must be between 10 and 10000 (ms), got -5"
1205
+ server.getOptions().startupCap; // unchanged: one bad value, and nothing changes
1206
+ }
1207
+ server.setOptions({ cert }); // TypeError: "cert cannot be changed with setOptions(): use setSecureContext()"
1208
+ ```
1209
+
1210
+ | Option | Applies |
1211
+ |---|---|
1212
+ | `validateAddress` | from the next new client |
1213
+ | `handshakeLoadThreshold` | at once (the count as it stands) |
1214
+ | `maxHandshakesPerAddress` | from the next Initial |
1215
+ | `earlyData`, `earlyDataMaxAge` | from the next handshake (`earlyData: false` refuses early data on tickets already given out too) |
1216
+ | `startupCap`, `initialCongestionWindow`, `carefulResume`, `pmtuDiscovery`, `maxUdpPayloadSize`, `initialRtt`, `reorderWindow` | to HTTP/3 connections made from then on — those open keep theirs |
1217
+
1218
+ **One connection's.** The same two calls on a connection — the handle of `'connection'`
1219
+ (its first packet) and `'secureConnection'`, and an HTTP/3 request's `req.socket`; on the
1220
+ client, an HTTP/3 response's `res.socket`. Every request on that connection shares it.
1221
+
1222
+ ```js
1223
+ server.on('connection', (conn) => {
1224
+ if (conn.setOptions) conn.setOptions({ initialRtt: 80 }); // QUIC: before the handshake
1225
+ });
1226
+
1227
+ app.get('/downloads/*', (req, res) => {
1228
+ if (req.socket.setOptions) req.socket.setOptions({ startupCap: 'always' }); // HTTP/3 only
1229
+ // …
1230
+ });
1231
+ ```
1232
+
1233
+ Over TCP (HTTP/1.1, HTTP/2) there is no `setOptions`: these options are QUIC's. Some are
1234
+ about how a connection begins, and past that point the call throws `ERR_INVALID_STATE`
1235
+ rather than take a value that would do nothing:
1236
+
1237
+ | Option | On a running connection |
1238
+ |---|---|
1239
+ | `reorderWindow`, `startupCap` | any time (`startupCap` matters only while in Startup) |
1240
+ | `pmtuDiscovery` | any time: off — probes stop, the size found stays; on — the search starts |
1241
+ | `maxUdpPayloadSize` | any time: lower — packets shrink at once; higher — the search may go there |
1242
+ | `initialRtt` | before the first RTT sample |
1243
+ | `initialCongestionWindow` | before the first 1-RTT packet |
1244
+ | `carefulResume` | before the handshake is confirmed |
1245
+ | `transportParams` | not: they went out in the handshake (`connectionOptions` decides them) |
1246
+ | `validateAddress`, `handshakeLoadThreshold`, `maxHandshakesPerAddress` | not: the server's |
1247
+
1248
+ A client sets them in its request options (`request`, `connect`, `WebTransport`,
1249
+ `connectUdp`) for what it sends: `startupCap`, `initialCongestionWindow`, `pmtuDiscovery`,
1250
+ `maxUdpPayloadSize`, `initialRtt`, `reorderWindow`.
550
1251
 
551
1252
  **Events**
552
1253
 
553
- - `'request'` — `(req, res)` — same as the `requestListener` argument
554
- - `'listening'`
555
- - `'error'` — `(err)`
1254
+ | Event | Arguments | |
1255
+ |---|---|---|
1256
+ | `'request'` | `(req, res)` | Same as the `requestListener` argument. |
1257
+ | `'upgrade'` | `(req, socket, head)` | WebSocket and other upgrades, on all three versions — see [WebSocket](#websocket-1). With no listener, an upgrade request arrives as an ordinary `'request'`, as in Node (see `shouldUpgradeCallback`). |
1258
+ | `'connectUdp'` | `(req, tunnel)` | A request to proxy UDP (RFC 9298), on all three versions — see [CONNECT-UDP](#connect-udp-proxying-udp). With no listener, such a request arrives as before (`'upgrade'` or `'request'`). |
1259
+ | `'timeout'` | `(socket)` | A TCP connection was idle for `server.timeout` ms. |
1260
+ | `'connection'` | TCP: the socket, before TLS. QUIC: a connection handle, on the first packet. | Destroy it here to refuse a peer before any crypto is done. |
1261
+ | `'secureConnection'` | TCP: the TLS socket. QUIC: the connection handle. | After the handshake. |
1262
+ | `'stream_open'` | `(conn, streamId)` | HTTP/2 and HTTP/3: a request stream announced itself, before its headers are decoded. |
1263
+ | `'handshakeLoad'` | `({ pending, high })` | HTTP/3 handshakes under way reached `handshakeLoadThreshold` (`high: true`), or are back under half of it (`high: false`). |
1264
+ | `'stream_reset'` | `(conn, streamId, code)` | HTTP/2 RST_STREAM; HTTP/3 RESET_STREAM or STOP_SENDING — once per stream. A default limit applies (`streamResetBurst`, `streamResetRate`); this is for a policy of your own on top, for both versions. |
1265
+ | `'OCSPRequest'` | `(certificate, issuer, callback)` | As `node:tls`: a client asked for the certificate's OCSP status, TCP or QUIC. `certificate` and `issuer` are DER `Buffer`s (`issuer` `null` when the chain has none); `callback(null, response)` staples a DER OCSP response, `callback(null, null)` none, `callback(err)` fails the handshake. The handshake waits for the answer — fetch and cache responses ahead of time. Without a listener nothing is stapled. (Chromium asks over TCP, not over QUIC.) |
1266
+ | `'tlsClientError'` | `(err, socket \| conn)` | A handshake failed, as in `node:tls`: over TCP the TLS socket, over QUIC the connection handle. Either side may have ended it — plain HTTP sent to the TLS port (`ERR_SSL_WRONG_VERSION_NUMBER`), no common ALPN, or a client refusing the certificate (`ERR_TLS_ALERT_RECEIVED`, `err.alert` = 42 for `bad_certificate`). |
1267
+ | `'clientError'` | `(err, socket)` | HTTP/1.1, once the connection is up (parse errors, the socket's errors). TLS handshake failures are `'tlsClientError'`. |
1268
+ | `'sessionError'` | `(err, session)` | HTTP/2: the connection failed (a protocol error — GOAWAY already sent —, the socket's error, a peer not reading past `maxSessionMemory`). `session` is the connection handle of `'stream_open'`. As `node:http2`. |
1269
+ | `'listening'` | | |
1270
+ | `'error'` | `(err)` | |
1271
+
1272
+ The `conn` handle given to the HTTP/2 and HTTP/3 stream events has `remoteAddress`,
1273
+ `remotePort`, `fingerprint()`, `sendRstStream(streamId, 'refused' | 'cancelled' | code)`,
1274
+ `goaway([code])` and `destroy([code])` — the same on both versions, so one rate limiter
1275
+ serves both:
1276
+
1277
+ - `goaway()` stops new requests; those already in flight finish. HTTP/2 sends GOAWAY;
1278
+ HTTP/3 sends GOAWAY too, refuses any later request with `H3_REQUEST_REJECTED` (safe for
1279
+ the client to retry elsewhere), and closes the connection once the last response has
1280
+ been delivered. `goaway('overloaded')` says why: `ENHANCE_YOUR_CALM` / `H3_EXCESSIVE_LOAD`.
1281
+ - `destroy()` cuts the connection now, in-flight requests included (`'overloaded'` or a
1282
+ number sets the HTTP/3 close code).
556
1283
 
557
1284
  ```js
558
1285
  const server = quico.createServer({ key, cert, http2: false, http1: false });
@@ -569,8 +1296,9 @@ server.listen(4433, '0.0.0.0');
569
1296
 
570
1297
  ### `quico.request(url|options[, options], callback)`
571
1298
 
572
- Issues an HTTP/3 request. Returns a [`ClientRequest`](#clientrequest). Accepts a URL
573
- string, an options object, or both (options override the URL).
1299
+ Issues an HTTP request — HTTP/3 first, falling back to HTTP/2 and HTTP/1.1. Returns a
1300
+ [`ClientRequest`](#clientrequest). Accepts a URL string, an options object, or both
1301
+ (options override the URL). Requests are always made over TLS.
574
1302
 
575
1303
  **Options**
576
1304
 
@@ -581,9 +1309,21 @@ string, an options object, or both (options override the URL).
581
1309
  | `path` | `string` | `'/'` | Request path including query string. |
582
1310
  | `method` | `string` | `'GET'` | HTTP method. |
583
1311
  | `headers` | `object` | `{}` | Request headers. |
1312
+ | `priority` | `object` | — | `{ urgency: 0..7, incremental }` — the request's priority (RFC 9218), as the `priority` header; `req.setPriority()` changes it later. |
1313
+ | `ech` | `Buffer \| string` | — | The server's ECH key list (an ECHConfigList; base64 — the `ech=` of its HTTPS record — or bytes): the ClientHello goes encrypted, on every version. See [ECH](#encrypted-client-hello-ech). |
1314
+ | `echGrease` | `boolean` | `true` | Without `ech`: send GREASE ECH, as browsers do. |
1315
+ | `ecdhCurve` | `string` | `'auto'` | The key-exchange groups to offer, in order — `node:tls`'s option, on every version. `'auto'` is what `node:tls` offers and the browsers do: the post-quantum hybrid X25519MLKEM768 first, with an X25519 share beside it (a server without it needs no retry), then X25519, P-256, P-384 — the hybrid where Node has ML-KEM (24.7+), the classical groups alone before that. `'X25519:P-256'` and the like for classical only; `'MLKEM1024'`, `'SecP384r1MLKEM1024'` and the other post-quantum groups by name (see [Post-quantum TLS](#post-quantum-tls)). A name not implemented, or the hybrid in a Node without ML-KEM (it needs 24.7+ on OpenSSL 3.5), throws from `request()`. A request shares only connections opened with the same groups. |
1316
+ | `startupCap` | `'stall'` \| `'always'` \| `'off'` | `'stall'` | HTTP/3: Startup's window cap on what the client sends (an upload) — as the server's option. |
1317
+ | `pmtuDiscovery`, `maxUdpPayloadSize`, `initialRtt`, `reorderWindow`, `initialCongestionWindow` | | | HTTP/3: this client's connection options — as the server's. `res.socket.setOptions()` changes them while it runs. |
584
1318
  | `rejectUnauthorized` | `boolean` | `true` | Verify the server certificate. Set `false` for self-signed certs. |
585
- | `ca` | `Buffer \| string \| Array` | `null` | Trust these CAs in addition to the defaults. |
586
- | `agent` | `Agent \| false` | `globalAgent` | Connection pool to use. `false` disables pooling. |
1319
+ | `ca` | `Buffer \| string \| Array` | `null` | Trust these CAs instead of the platform roots (as in Node). |
1320
+ | `sigalgs` | `string` | lemon-tls's list | The signature schemes to offer, in order — `node:tls`'s option. `'mldsa65:mldsa87:ecdsa_secp256r1_sha256'` asks a server that holds an ML-DSA certificate for it; the default offers ML-DSA after the classical schemes. A request shares only connections opened with the same list. |
1321
+ | `key`, `cert`, `passphrase` | | — | A client certificate, for a server that asks for one — on HTTP/3, HTTP/2 and HTTP/1.1. A request with one shares only connections opened with the same certificate (see `Agent`). |
1322
+ | `earlyData` | `boolean` | `false` | With a session whose ticket allows it, send a `GET`, `HEAD` or `OPTIONS` in the first flight (0-RTT) — on every version. Refused or answered `425`, it is sent again after the handshake by itself; `res.earlyData` says what happened. See [0-RTT](#0-rtt-early-data). |
1323
+ | `session` | `Buffer` | the agent's | A TLS session to resume with, from an earlier `'session'` event (as `https.request`). Without it the agent offers its own (see `Agent`). |
1324
+ | `requestOCSP` | `boolean` | `false` | Ask the server for its stapled OCSP response (`tls.connect`'s option), on every version; it comes as the request's `'OCSPResponse'` (a DER `Buffer`). Only a full handshake carries one — a resumed session has no certificate. |
1325
+ | `quicVersions` | `Array` | `['v1', 'v2']` | HTTP/3: the QUIC version of the first flight, then the others it offers (RFC 9368) — a server that prefers v2 moves the connection there. `['v2']`: v2 from the first packet. |
1326
+ | `agent` | `Agent \| false` | `globalAgent` | Connection pool to use. `false`, as in Node: a connection of this request's own, closed when it is done (over HTTP/2 and HTTP/1.1 it went on a pool shared by every `agent: false` request), and no sessions kept — pass `session` to resume. |
587
1327
  | `http2` | `boolean` | `true` | Allow HTTP/2 fallback. |
588
1328
  | `http1` | `boolean` | `true` | Allow HTTP/1.1 fallback. |
589
1329
  | `h3Timeout` | `number` | `3000` | Milliseconds to wait for QUIC before falling back. |
@@ -609,7 +1349,8 @@ req.end();
609
1349
 
610
1350
  ### `quico.get(url|options[, options], callback)`
611
1351
 
612
- Same as `request()` with `method: 'GET'`, and `end()` is called automatically.
1352
+ Same as `request()` with `method: 'GET'`. (`request()` itself ends GET and HEAD
1353
+ requests on the next tick.)
613
1354
 
614
1355
  ---
615
1356
 
@@ -625,11 +1366,28 @@ response.
625
1366
  | `method` | Request method (server side) |
626
1367
  | `url` | Request path (server side) |
627
1368
  | `statusCode` | Response status (client side) |
1369
+ | `earlyData` | Server: `true` if the request came in 0-RTT (it could be a replay). Client: `'accepted'` (sent early, taken), `'rejected'` (sent again after the handshake), `null` (not sent early). |
628
1370
  | `socket` / `connection` | Compatibility shim for frameworks |
629
1371
 
630
1372
  Standard `Readable` behaviour applies: `'data'`, `'end'`, `'error'`, `pipe()`,
631
1373
  `for await (const chunk of req)`, and `setTimeout(ms, cb)`.
632
1374
 
1375
+ A body cut short — on the server, a request the client cancels or whose connection
1376
+ drops; on the client, a response the server resets or cuts off — gets `aborted = true`,
1377
+ an `'aborted'` event, an `'error'` (`ECONNRESET`, or the cause) only if something listens
1378
+ for one, and `'close'`; never `'end'`, which would say the body arrived whole, and never
1379
+ an unhandled `'error'`, because a browser cancelling a request is ordinary traffic. This
1380
+ is node:http's behaviour, on all three versions. HTTP/2 requests have
1381
+ `req.h2Fingerprint()`, HTTP/3 requests `req.h3Fingerprint()`; over HTTP/2 and HTTP/3,
1382
+ `req.headers` also keeps the pseudo-headers (`:method`, `:path`, `:authority`, …), as
1383
+ `node:http2` does, plus `host` taken from `:authority`.
1384
+
1385
+ A request handler that throws ends only its own exchange — HTTP/1.1 answers `500` and
1386
+ closes the connection, HTTP/2 and HTTP/3 reset that one stream (`INTERNAL_ERROR`,
1387
+ `H3_INTERNAL_ERROR`), and other requests on the connection go on — and the exception is
1388
+ then rethrown on the next tick, so it reaches `process.on('uncaughtException')` (or ends
1389
+ the process) exactly as in Node. A response already ended before the throw is delivered.
1390
+
633
1391
  ---
634
1392
 
635
1393
  ### `ServerResponse`
@@ -640,12 +1398,35 @@ A Node.js `Writable` stream.
640
1398
  - `res.setHeader(name, value)` / `getHeader` / `getHeaders` / `getHeaderNames` /
641
1399
  `hasHeader` / `removeHeader`
642
1400
  - `res.flushHeaders()`
643
- - `res.write(chunk)` / `res.end([chunk])`
1401
+ - `res.write(chunk)` / `res.end([chunk])` — over HTTP/1.1, a body given whole to `end()`
1402
+ (nothing written before it) is sent with `Content-Length`, otherwise chunked, as
1403
+ `node:http` does
644
1404
  - `res.statusCode`, `res.headersSent`
1405
+ - `res.writeEarlyHints(hints[, callback])` — `103 Early Hints`, on all three versions
1406
+ - `res.destroy([err])` / `req.destroy([err])` — give up on the exchange, as in Node: the
1407
+ stream is reset, the connection serves on. HTTP/3: RESET_STREAM and STOP_SENDING with
1408
+ `H3_REQUEST_CANCELLED` (`H3_INTERNAL_ERROR` with an `err`; RFC 9114 §4.1.1); after a
1409
+ finished response, only STOP_SENDING with `H3_NO_ERROR` for a request body still
1410
+ coming. HTTP/2: RST_STREAM `CANCEL` (`INTERNAL_ERROR` with an `err`). HTTP/1.1: the
1411
+ socket. Both went unsent: a destroyed exchange held its stream until the idle timeout.
1412
+ - `res.addTrailers({ … })` before `end()` — response trailers, on all three versions
1413
+ (HTTP/1.1 chunked; HTTP/2 and HTTP/3 a HEADERS frame after the body). What gRPC
1414
+ answers with: `grpc-status` / `grpc-message` go only there. A request's trailers are in
1415
+ `req.trailers` / `req.rawTrailers` by `'end'`; on the client, `res.trailers`
1416
+ - `res.setPriority({ urgency, incremental })` — HTTP/2 and HTTP/3 (over HTTP/1.1, one
1417
+ response at a time, it does nothing — the same handler runs on all three): the response's
1418
+ priority (RFC 9218: urgency 0..7, 0 first, default 3; incremental: shares the
1419
+ connection with others of its urgency rather than going whole, one at a time). Without
1420
+ it, the client's `priority` header and `PRIORITY_UPDATE` frames decide; set, it is the
1421
+ server's decision and later signals from the client do not override it. The client's
1422
+ header is in `req.headers.priority`. See
1423
+ [Stream scheduling](#stream-scheduling-byte-level-round-robin-by-priority).
645
1424
 
646
1425
  For WebTransport sessions, `ServerResponse` additionally exposes
647
- `sendDatagram()`, `createBidirectionalStream()` and `createUnidirectionalStream()` —
648
- see [WebTransport](#-webtransport).
1426
+ `sendDatagram()`, `maxDatagramSize()`, `datagrams`, `getStats()`, `createBidirectionalStream()`,
1427
+ `createUnidirectionalStream()`, `canOpenStream()`, `createSendGroup()`, `setWebTransportProtocol()`,
1428
+ `closeSession()` and `drainSession()` — see
1429
+ [WebTransport](#webtransport-1).
649
1430
 
650
1431
  ---
651
1432
 
@@ -653,10 +1434,45 @@ see [WebTransport](#-webtransport).
653
1434
 
654
1435
  A Node.js `Writable` stream.
655
1436
 
656
- - `req.write(chunk)` / `req.end([chunk])`
657
- - `req.abort()` / `req.destroy()`
1437
+ - `req.write(chunk)` / `req.end([chunk])` — the body streams, as `node:http`'s does: written
1438
+ while the connection is being made it is held (past 64 KB `write()` returns `false`, then
1439
+ `'drain'`), and from then on it goes as it is written, with the transport's backpressure —
1440
+ the QUIC stream's, HTTP/2's window, the socket's. Over HTTP/1.1 a body not yet complete goes
1441
+ chunked, unless a `content-length` was set: the bytes must then add up to it (too many or too
1442
+ few fails the request with `ERR_HTTP_CONTENT_LENGTH_MISMATCH`; on a pooled connection the
1443
+ difference would be read as another request). The first protocol to be ready takes the
1444
+ request, and only it sends: a request whose answer takes HTTP/3 longer than `h3Timeout` is not
1445
+ sent over TCP as well. The first 64 KB of a body are kept, so that a request refused
1446
+ unprocessed (`REFUSED_STREAM`, `H3_REQUEST_REJECTED`, past a GOAWAY), or whose protocol failed
1447
+ before it was sent, goes again whole; a larger one that has started cannot, and fails.
1448
+ - `req.addTrailers({ … })` before `end()` — request trailers (`node:http`'s), on all three
1449
+ versions: a HEADERS frame after the body over HTTP/2 and HTTP/3, after the last chunk over
1450
+ HTTP/1.1 (so only chunked, as in `node:http`). Connection-specific and forbidden fields are
1451
+ dropped, as for responses.
1452
+ - `req.setPriority({ urgency, incremental })`, or the `priority` option of `request()` — the
1453
+ request's priority (RFC 9218: urgency 0..7, 0 first, default 3; incremental: its response
1454
+ may share the connection with others of its urgency). Before the request goes it is the
1455
+ `priority` header (`'u=1, i'`; the default sends none), on all three versions; after, a
1456
+ `PRIORITY_UPDATE` frame over HTTP/2 and HTTP/3 (HTTP/1.1 has none — nothing is sent). It
1457
+ is the client's signal: a server whose application set the response's priority
1458
+ (`res.setPriority`) keeps its own.
1459
+ - `req.abort()` / `req.destroy([err])` — stop the exchange where it is carried: the
1460
+ request's stream over HTTP/3 and HTTP/2 (a pooled connection serves on), the socket over
1461
+ HTTP/1.1. `res.destroy()` on an unfinished response does the same. A request the server
1462
+ reset after it was sent — a non-idempotent one (RFC 9110 §9.2.2) — fails with the reset,
1463
+ and is not sent again over another version; one it refused is. As in Node, the response then sees `'aborted'` and `'close'` (an `'error'` only
1464
+ if it listens for one), never `'end'`; `destroy()` before any response fails the request
1465
+ with `ECONNRESET` (`socket hang up`), `abort()` emits `'abort'` and no error.
658
1466
  - `req.protocol` — which transport was actually used: `'h3'`, `'h2'` or `'https'`
659
- - Events: `'response'`, `'error'`
1467
+ - Events: `'response'`, `'error'`, `'timeout'`, `'session'` (a `Buffer`: a TLS session the
1468
+ server sent, to resume with — as a `TLSSocket`'s, once per ticket, on the request that
1469
+ opened the connection), `'OCSPResponse'` (with `requestOCSP`: the server's stapled OCSP
1470
+ response, DER, on the request that opened the connection)
1471
+ - A request the server did not process — refused (HTTP/2 `REFUSED_STREAM`, HTTP/3
1472
+ `H3_REQUEST_REJECTED`) or past its `GOAWAY` (a server that closes its connections after
1473
+ `maxRequestsPerSocket` requests does that to the ones in flight) — is sent again once,
1474
+ on a new connection (RFC 9113 §8.7, RFC 9114 §4.1.1, §5.2). When every protocol fails,
1475
+ the `'error'` has the last one's reason as its `cause`.
660
1476
 
661
1477
  ---
662
1478
 
@@ -668,32 +1484,269 @@ Connection pool and protocol cache, analogous to `https.Agent`.
668
1484
  import { Agent } from 'quico';
669
1485
 
670
1486
  const agent = new Agent({
671
- http2: true, // allow H2 in the fallback chain
672
- http1: true, // allow H1.1 in the fallback chain
673
- h3Timeout: 3000, // ms before falling back from H3
674
- maxSockets: 256,
675
- timeout: 30000
1487
+ http2: true, // allow H2 in the fallback chain
1488
+ http1: true, // allow H1.1 in the fallback chain
1489
+ h3Timeout: 3000, // ms before falling back from H3
1490
+ maxSockets: 256, // HTTP/1.1 sockets per origin; more requests wait
1491
+ maxCachedSessions: 100, // TLS sessions kept to resume with
1492
+ ecdhCurve: 'X25519:P-256' // its requests' key exchange (default 'auto': post-quantum first), unless one names its own
676
1493
  });
677
1494
 
678
1495
  quico.request({ hostname: 'example.com', agent }, handler).end();
679
1496
  ```
680
1497
 
681
- The agent caches which protocol works per host (including `Alt-Svc` advertisements
682
- with their `ma=` lifetime), pools live QUIC connections for multiplexing, and
683
- coalesces concurrent requests to the same origin onto a single connection.
1498
+ HTTP/2 and HTTP/3 multiplex every request to an origin over one connection, which
1499
+ the agent keeps and shares. HTTP/1.1 needs a socket per request, and for those the
1500
+ agent takes `node:http.Agent`'s options, with Node's meanings:
1501
+
1502
+ | Option | Default | |
1503
+ |---|---|---|
1504
+ | `keepAlive` | `true` | Keep a socket for the next request (Node's `globalAgent` default; a plain `new http.Agent()` defaults to `false`). |
1505
+ | `keepAliveMsecs` | `1000` | TCP keep-alive initial delay on kept sockets. |
1506
+ | `maxSockets` | `256` | Sockets per origin at once. Requests beyond it wait for a socket. |
1507
+ | `maxFreeSockets` | `256` | Idle sockets kept per origin. |
1508
+ | `timeout` | `5000` | How long an idle socket is kept. |
1509
+
1510
+ **Protocol discovery.** The agent remembers which protocol worked for each origin,
1511
+ and separately what each origin advertises in `Alt-Svc` (RFC 7838), for its `ma=`
1512
+ lifetime. When an origin advertises HTTP/3 — on its own port or another — the next
1513
+ request tries HTTP/3 there first, and falls back to the origin over TCP (or its own
1514
+ HTTP/3) if that fails; a failed alternative is left alone for five minutes, as
1515
+ browsers do. Concurrent requests to an origin share one connection.
1516
+
1517
+ **Which connections a request shares.** As `https.Agent` names its connections
1518
+ (`agent.getName(options)`), a pooled connection is keyed by its origin and its TLS
1519
+ identity — `ca`, `cert` / `key`, `rejectUnauthorized`, `servername` (hashed). A
1520
+ connection opened with `rejectUnauthorized: false` never carries a request that
1521
+ verifies, nor one verified against a private `ca` a request that trusts the public
1522
+ roots; before, any connection to the origin was reused. The same goes for the
1523
+ standalone `h1Request` / `h2Request` pools.
1524
+
1525
+ **Session resumption.** As `https.Agent`, the agent keeps the TLS sessions each
1526
+ origin's server sent — up to 4 per origin, newest last; `maxCachedSessions` (default
1527
+ `100`, the oldest dropped; `0` keeps none) bounds the origins — and a new connection
1528
+ resumes with one — no certificate exchange — over HTTP/3, HTTP/2 and HTTP/1.1, and for
1529
+ `WebSocket` (`wss://`) and `connectUdp` too. Each session is used **once** (RFC 8446
1530
+ §C.4): two connections opened at once take two, so they cannot be linked, and both can
1531
+ send 0-RTT — with one kept per origin, the second's early data was always refused as a
1532
+ replay. A QUICO server sends two tickets per full handshake (one per resumption), as
1533
+ OpenSSL and BoringSSL do. An expired session is not offered (lemon-tls `sessionInfo`).
1534
+ TCP sessions are kept per ALPN (`h2`, `http/1.1`): a ticket carries 0-RTT only for the
1535
+ protocol it was issued under. Before, every connection was a full handshake, and a QUICO server
1536
+ never saw its own ticket again: what it saves there about the path (Careful Resume,
1537
+ above) is only used by a client that resumes. A session is offered only where it was
1538
+ made: the same transport (a TCP session is not offered to QUIC), origin, server name and
1539
+ verification — one from a connection with `rejectUnauthorized: false`, or another `ca`, is
1540
+ not offered to one that verifies. A session the handshake fails with is dropped. A
1541
+ resumed connection reports the server's certificate and `authorized` from the session, as
1542
+ Node does. For QUIC it also keeps the server's NEW_TOKEN token (RFC 9000 §8.1.3) — used
1543
+ once, by the next connection, which then needs no proof of address.
684
1544
 
685
1545
  `quico.globalAgent` is the default instance used when no `agent` option is given.
686
- Call `agent.destroy()` to close all pooled connections.
1546
+ Call `agent.destroy()` to close everything it pools.
687
1547
 
688
1548
  ---
689
1549
 
690
1550
  ### `quico.createSecureContext(options)`
691
1551
 
692
- Creates a TLS context for use with `SNICallback`. Accepts `key`, `cert` and `ca`.
1552
+ Creates a TLS context for `SNICallback`, `server.setSecureContext()` and `server.addContext()`. Accepts `key`, `cert`, `ca` and `passphrase`.
693
1553
  Re-exported from LemonTLS.
694
1554
 
695
1555
  ---
696
1556
 
1557
+ ### 0-RTT (early data)
1558
+
1559
+ A returning visitor's request can go in its first flight, with the ClientHello, under a
1560
+ key from its last session — the answer comes one round trip sooner. On a phone at
1561
+ 150 ms RTT, that is 150 ms off the first byte of every page that opens a new connection.
1562
+ It works the same on every version: over HTTP/3 in QUIC's 0-RTT packets, and over TLS 1.3 on
1563
+ TCP (HTTP/2, HTTP/1.1) as TLS early data — with Encrypted Client Hello as well (the early
1564
+ data is the inner hello's, RFC 9849 §6.1). Chromium and Firefox send 0-RTT over HTTP/3.
1565
+
1566
+ The price: early data has no protection against replay. Someone who captured the first
1567
+ flight can send it again, and the server would run the request again. So it is **off by
1568
+ default**, and turned on it is held to four rules:
1569
+
1570
+ 1. **A ticket carries early data once.** The server remembers each ticket's early data
1571
+ (`earlyDataReplay`, in memory by default) for twice `earlyDataMaxAge`; the second time,
1572
+ the handshake goes on without it.
1573
+ 2. **Only for a short while.** The age the client states for its ticket must be within
1574
+ `earlyDataMaxAge` (10 s) of what the server measures — a captured flight goes stale.
1575
+ 3. **Only under the same settings.** A ticket from before a change of transport
1576
+ parameters or of the HTTP/3 SETTINGS this server sends (or a restart with other ones),
1577
+ or one a connection with lower limits (`connectionOptions`) would not honour, resumes
1578
+ the session without early data: a client sends its early data under what it remembered
1579
+ (RFC 9000 §7.4.1, RFC 9114 §7.2.4.2).
1580
+ 4. **Only safe requests.** `earlyDataFilter` decides which requests may run before the
1581
+ handshake completes — by default `GET`, `HEAD` and `OPTIONS`; the others get
1582
+ `425 Too Early` and the client sends them again once the handshake is done (RFC 8470),
1583
+ by itself — a browser does, and so does `quico.request`.
1584
+
1585
+ Refused early data costs nothing but the round trip saved: the request is served as it
1586
+ would have been without it.
1587
+
1588
+ ```js
1589
+ const server = quico.createServer({
1590
+ key, cert,
1591
+ ticketKeys, // stable keys, or tickets die with the process
1592
+ earlyData: true,
1593
+ earlyDataFilter(req, cb) { // asynchronous: a database, a rule engine
1594
+ if (req.url.startsWith('/api/')) return cb(null, false); // → 425, after the handshake
1595
+ cb(null, true);
1596
+ }
1597
+ }, (req, res) => {
1598
+ // req.earlyData: this request may be a replay — never act on it (a payment,
1599
+ // a login) without the handshake; the filter is where to say so.
1600
+ res.end('hello');
1601
+ });
1602
+ ```
1603
+
1604
+ **A restart.** The default store is in memory and starts empty: a ticket issued before a
1605
+ restart (with `ticketKeys` kept) gets no early data for twice `earlyDataMaxAge` after it —
1606
+ a flight captured just before the restart cannot be sent again just after it. One 1-RTT
1607
+ handshake for such a client, and its new ticket is fine.
1608
+
1609
+ **More than one server.** Each server remembers in its own memory, so a flight captured
1610
+ at one could be replayed at another that shares `ticketKeys`. Give them one store:
1611
+
1612
+ ```js
1613
+ const server = quico.createServer({
1614
+ key, cert, ticketKeys, earlyData: true,
1615
+ // id: the ticket (a SHA-256, hex); ttl: how long to remember it, ms.
1616
+ earlyDataReplay(id, ttl, cb) {
1617
+ redis.set('0rtt:' + id, '1', 'PX', ttl, 'NX', (err, ok) => cb(err, ok === 'OK'));
1618
+ }
1619
+ });
1620
+ ```
1621
+
1622
+ `server.setOptions({ earlyData: false })` turns it off at once — tickets already given out
1623
+ included.
1624
+
1625
+ **What the client remembers.** Each session keeps, with the ticket, the server's transport
1626
+ parameters and its HTTP/3 SETTINGS; early data goes within them (RFC 9000 §7.4.1, RFC 9114
1627
+ §7.2.4.2). A server that accepts the 0-RTT and then gives less — a transport parameter
1628
+ lower than remembered, a setting omitted or lower — breaks the connection it took the
1629
+ early data on: the client closes it, `PROTOCOL_VIOLATION` or `H3_SETTINGS_ERROR`, naming
1630
+ what changed.
1631
+
1632
+ **The client.** `earlyData: true` on a request with a session (its own, or the agent's):
1633
+
1634
+ ```js
1635
+ const req = quico.request('https://example.com/', { earlyData: true }, (res) => {
1636
+ console.log(res.earlyData); // 'accepted', 'rejected' or null
1637
+ });
1638
+ req.end();
1639
+ ```
1640
+
1641
+ Only `GET`, `HEAD` and `OPTIONS` are sent early; anything else waits for the
1642
+ handshake. Refused — by the server's TLS, or with a `425` — the request is sent again after
1643
+ the handshake, and the response is the one to that.
1644
+
1645
+ **Over TCP.** The same options, the same checks (one use per ticket, the age, the context —
1646
+ a ticket from the HTTP/3 side carries no early data here, nor the other way round), the same
1647
+ `earlyDataFilter` and `425`. A ticket allows up to 16 KB of early data — room for a request,
1648
+ not an upload; what does not fit goes after the handshake. The server reads the early
1649
+ request as it comes and may answer as soon as its own handshake flight is out, so the
1650
+ client has the answer one round trip after its first flight.
1651
+
1652
+ ---
1653
+
1654
+ ### Encrypted Client Hello (ECH)
1655
+
1656
+ Without ECH, the first message of every TLS connection — the ClientHello, with the
1657
+ site's name (SNI) and the ALPN list — crosses the network in the clear: anyone on the
1658
+ path sees which site is being visited, HTTPS or not. With ECH (RFC 9849) the browser
1659
+ sends the real ClientHello encrypted, inside an outer one that names only a shared
1660
+ *public name*, to a key the server publishes in its DNS HTTPS record. The server
1661
+ decrypts it and the handshake goes on as usual — `SNICallback` gets the real name.
1662
+
1663
+ ECH hides names that **share an address**: when many sites sit behind one IP and one
1664
+ public name (a CDN, a host with many domains), the network sees only which group. A
1665
+ single site on its own IP is still known by the IP.
1666
+
1667
+ **1. A key, kept across restarts** — like DNSSEC keys:
1668
+
1669
+ ```js
1670
+ import quico from 'quico';
1671
+ const k = quico.generateEchKey({ publicName: 'ech.example.com' });
1672
+ // save k.config and k.privateKey (bytes); publish k.configListBase64 in DNS
1673
+ ```
1674
+
1675
+ **2. The DNS record** — the `ech` key of the domain's HTTPS record (RFC 9460), e.g.
1676
+ with [dnssec-server](https://github.com/colocohen/dnssec-server):
1677
+
1678
+ ```
1679
+ www IN HTTPS 1 . alpn="h3,h2" ech="AEb+DQBC…"
1680
+ ```
1681
+
1682
+ **3. The server** — with a certificate for the public name as well, served when a
1683
+ client's key could not be used:
1684
+
1685
+ ```js
1686
+ const server = quico.createServer({
1687
+ SNICallback: (name, cb) => cb(null, contexts[name]), // incl. ech.example.com
1688
+ ech: { keys: [{ config: saved.config, privateKey: saved.privateKey }] },
1689
+ }, (req, res) => {
1690
+ res.end(req.socket.servername + ' ' + req.socket.echStatus);
1691
+ });
1692
+ ```
1693
+
1694
+ `req.socket.echStatus` is `'accepted'` (the hidden name was used), `'rejected'` (the
1695
+ client's key was not one of ours: the handshake ran as the public name, and the client
1696
+ was sent our current keys — `retry_configs` — to connect again with), or `null` (no ECH
1697
+ offered). `req.socket.echAccepted` is the boolean.
1698
+
1699
+ **Rotation** — new key in DNS first, then on the server, the old one kept with
1700
+ `retry: false` until the old record's TTL has passed:
1701
+
1702
+ ```js
1703
+ server.setEchKeys({ keys: [
1704
+ { config: next.config, privateKey: next.privateKey },
1705
+ { config: prev.config, privateKey: prev.privateKey, retry: false },
1706
+ ] });
1707
+ ```
1708
+
1709
+ The key itself is not certified by anyone: its trust comes from DNS (DNSSEC, and
1710
+ DNS-over-HTTPS on the client). A substituted key could reveal which name was asked
1711
+ for, never let anyone impersonate the site — the certificate is still checked against
1712
+ that name. Tested against Go's `crypto/tls` client (accept, retry, HelloRetryRequest,
1713
+ resumption); DTLS is not covered.
1714
+
1715
+ #### The client
1716
+
1717
+ `quico.request`, `WebTransport` and `connectUdp` take the server's key list as `ech` —
1718
+ the `ech=` value of its HTTPS record (base64), or the bytes. The real ClientHello goes
1719
+ encrypted, over HTTP/3, HTTP/2 and HTTP/1.1 alike:
1720
+
1721
+ ```js
1722
+ const req = quico.request({ hostname: 'www.example.com', path: '/', ech: 'AEb+DQBC…' }, (res) => {
1723
+ console.log(res.socket.echStatus); // 'accepted'
1724
+ });
1725
+ req.end();
1726
+ ```
1727
+
1728
+ `res.socket.echStatus` (and `echAccepted`) on every version: `'accepted'`, `'grease'`, or
1729
+ `null`. When the server cannot use the key (it rotated its keys, say), it proves itself
1730
+ for the public name and sends its current keys; the request is tried once more with
1731
+ them, automatically. The agent keeps those keys (RFC 9849 §6.1.8) and uses them first on
1732
+ the next requests given the same `ech` — `quico.request` and `connectUdp` — so a server
1733
+ that rotated its keys costs one retry, not one per connection until your DNS has the new
1734
+ ones. They are kept as TLS sessions are: per origin and TLS settings, a day at most, none
1735
+ with `agent: false` or `maxCachedSessions: 0`. Rejected in their turn, they are forgotten
1736
+ and your `ech` is tried again. If it sends none — its ECH is off — the request fails with
1737
+ `ERR_ECH_REJECTED` (`err.retryConfigs` null) instead of falling back to a ClientHello
1738
+ in the clear: going on without ECH is the caller's decision.
1739
+
1740
+ Without `ech`, the client sends **GREASE ECH** (RFC 9849 §6.2), as Chrome and Firefox
1741
+ do: an ECH extension no server can decrypt, so that clients that do use ECH do not stand
1742
+ out. `echGrease: false` turns it off. QUICO does not look up HTTPS records itself (no
1743
+ DNS-over-HTTPS here): the application passes the key list it resolved.
1744
+
1745
+ ECH is TLS 1.3 only. Tested against Go's `crypto/tls` server (accept, retry,
1746
+ HelloRetryRequest, GREASE) and QUICO's own server, over QUIC and TCP.
1747
+
1748
+ ---
1749
+
697
1750
  ### Framework compatibility
698
1751
 
699
1752
  A core design goal is a **familiar API**. If you know `node:https`, QUICO should feel
@@ -710,7 +1763,15 @@ quico.createServer({ key: KEY, cert: CERT }, app).listen(4433);
710
1763
  // → { "protocol": "3.0" }
711
1764
  ```
712
1765
 
713
- Express, Fastify and Koa are all supported this way.
1766
+ Express (4 and 5), Fastify (`serverFactory`), Koa and socket.io (`new Server(server)`,
1767
+ polling and WebSocket) run on it unchanged, over all three versions — checked against
1768
+ their npm releases.
1769
+
1770
+ `req` and `res` are `node:http`'s, down to the details frameworks rely on:
1771
+ `res.writeHead(status, [name, value, …])` and `[[name, value], …]`, `appendHeader`,
1772
+ `setHeaders(Headers | Map)`, `getRawHeaderNames`, `getHeaders()` (no prototype),
1773
+ `sendDate`, `strictContentLength`, `writeProcessing()` (102), `end(cb)` after the
1774
+ `'finish'` listeners; `req.headersDistinct`, `req.trailersDistinct`, `req.upgrade`.
714
1775
 
715
1776
  Multi-domain setups use `SNICallback`, exactly as in `node:https`:
716
1777
 
@@ -763,29 +1824,294 @@ quico.createServer({ key: KEY, cert: CERT }, (req, res) => {
763
1824
  }).listen(4433);
764
1825
  ```
765
1826
 
1827
+ **Only over HTTP/3.** A WebTransport session is an extended CONNECT over HTTP/3 (RFC 9220).
1828
+ When one is asked for over HTTP/1.1 or HTTP/2, the server answers it itself: **`501` with no
1829
+ body** (`content-length: 0`, `cache-control: no-store`). The request never reaches the handler,
1830
+ `'request'` or `'upgrade'`. Two signs mark such a request, and either one is enough:
1831
+
1832
+ - the browser's Fetch destination, `Sec-Fetch-Dest: webtransport`;
1833
+ - an extended CONNECT for `:protocol: webtransport` over HTTP/2.
1834
+
1835
+ A browser that cannot reach HTTP/3 may still send its session request over TCP. Firefox 158 does
1836
+ this when HTTP/3 has been excluded for an origin: it sends a plain `GET` with
1837
+ `Sec-Fetch-Dest: webtransport` on a new HTTP/1.1 connection. If that request were answered as a
1838
+ page, the body would land in the browser's WebTransport session, and Firefox would abort the whole
1839
+ browser. With the `501`, the session fails cleanly (`wt.ready` rejects in the page), and the page
1840
+ can fall back to WebSocket.
1841
+
1842
+ **Datagrams** (`res.sendDatagram(data)`) are never retransmitted, but they are sent under
1843
+ the connection's congestion control (RFC 9221 §5.4): they wait for room in a packet and
1844
+ go ahead of stream data. While the path cannot keep up, the oldest are dropped — any that
1845
+ have waited 250 ms, and past 1 MB queued. `sendDatagram` returns `true` when the datagram
1846
+ is queued, `false` when it cannot be sent at all (larger than `res.maxDatagramSize()`, or
1847
+ the peer takes no datagrams). Datagrams sent right after accepting the session are held
1848
+ until the `200` has left: a browser drops datagrams for a session it does not know yet.
1849
+
1850
+ `res.datagrams` is the browser's datagrams object too — the client's `wt.datagrams`,
1851
+ the same code: `readable`, `createWritable({ sendOrder, sendGroup })`, `maxDatagramSize`,
1852
+ `incomingMaxAge`, `outgoingMaxAge`, `incomingMaxBufferedDatagrams` and
1853
+ `outgoingMaxBufferedDatagrams`, as the current W3C spec has them. Each writable has its own
1854
+ queue and backpressure: `writer.ready` waits while `outgoingMaxBufferedDatagrams` of its
1855
+ datagrams wait to go. A write that is not an `ArrayBuffer` or a view onto one, or is
1856
+ empty, rejects with a `TypeError`; one larger than `maxDatagramSize` is dropped. The
1857
+ setters take values as the browser's do: an age of `0` is no limit (`null`), a negative
1858
+ one is a `RangeError`; a buffer count below 1 is 1. Its `outgoingMaxAge` is the session's
1859
+ own: sessions on one connection share its queue, each with its own age limit and its own
1860
+ count of datagrams dropped. The names from before the spec changed still work, for code
1861
+ written then: `datagrams.writable` (one writable, made on first use) and
1862
+ `incomingHighWaterMark` / `outgoingHighWaterMark` (the same settings as
1863
+ `…MaxBufferedDatagrams`).
1864
+
1865
+ **Statistics** — `res.getStats()`, as the client's `wt.getStats()` (the same function):
1866
+ a Promise of the browser's `WebTransportConnectionStats` — `bytesSent` (the application's
1867
+ bytes: new stream data and datagram payloads, without framing or retransmissions),
1868
+ `bytesSentOverhead` (what the packets carried besides), `bytesAcknowledged` (the payload the
1869
+ peer acknowledged), `packetsSent`, `packetsLost` and `bytesLost` (packets of stream data or
1870
+ datagrams declared lost, and their size), `bytesReceived`, `packetsReceived` (whole
1871
+ packets), `smoothedRtt`,
1872
+ `rttVariation`, `minRtt` (ms), `estimatedSendRate` (bits/s, the congestion control's
1873
+ bandwidth estimate; `null` until measured), `atSendCapacity` (the congestion window is
1874
+ full) — with QUICO's `congestionWindow` and `bytesInFlight` besides, and `datagrams`:
1875
+ `{ droppedIncoming, expiredIncoming, expiredOutgoing, lostOutgoing }`, this session's.
1876
+ The connection's figures are shared by every session on it, as in the browser. A sender
1877
+ that has to choose what to drop for a slow receiver reads `atSendCapacity`, or
1878
+ `bytesInFlight` against `congestionWindow`: the congestion is the connection's, not one
1879
+ stream's.
1880
+
1881
+ **Keying material** — `res.exportKeyingMaterial(label, context, outputLength)`, as the
1882
+ client's `wt.exportKeyingMaterial()`: a Promise of `outputLength` bytes (1 to 4096) from the
1883
+ TLS exporter, this session's own (draft-ietf-webtrans-http3: the label
1884
+ `EXPORTER-WebTransport`, the session ID, the application's label and context, each at most
1885
+ 255 bytes). Both ends get the same bytes for the same label and context, which lets a token
1886
+ be bound to the session it was sent on.
1887
+
1888
+ **Priorities** are the browser's: a stream's `sendOrder` and `sendGroup`
1889
+ (`stream.sendGroup = res.createSendGroup()`), and `res.sendDatagram(data, { sendOrder,
1890
+ sendGroup })` as the browser's `datagrams.createWritable({ sendOrder, sendGroup })`. The
1891
+ groups of a session share its sending equally; within a group the highest `sendOrder`
1892
+ with something to send goes first, equal orders taking turns; a stream in no group is in
1893
+ the session's own. Datagrams sent without them go ahead of every stream. The session's
1894
+ urgency — the CONNECT request's `priority`, or `res.setPriority()` — is its streams'.
1895
+
1896
+ **Streams** are Node streams: a `Duplex` for a bidirectional stream, a `Readable` for the
1897
+ client's unidirectional one (`'unidirectionalStream'`), a `Writable` for ours. What the
1898
+ client sends is credited back to it as the application reads, so a stream nobody reads
1899
+ holds one stream window (256 KB bidirectional, 128 KB unidirectional, or as set with
1900
+ `transportParams`), not the whole upload. Each direction can be given up with an
1901
+ application error code (32-bit, mapped onto HTTP/3's codes as draft-ietf-webtrans-http3
1902
+ §4.4 defines), on its own — as in the browser, the two directions of a bidirectional
1903
+ stream are independent:
1904
+
1905
+ | | |
1906
+ |---|---|
1907
+ | `stream.resetStream(code[, { reliableSize }])` | Stop sending: what was written and not yet sent is dropped. A browser's reader fails with `streamErrorCode`. With `reliableSize`, the first that many bytes written still arrive whole before the reset (RESET_STREAM_AT, draft-ietf-quic-reliable-stream-reset) — a MOQT subgroup's header, say; the stream's own WebTransport header always does (draft-ietf-webtrans-http3 §4.4), so the peer knows its session. Needs a peer that takes RESET_STREAM_AT (`stream.reliableResetSupported`; QUICO advertises it); otherwise a RESET_STREAM. Also after `end()`, until all of it is acknowledged (RFC 9000 §3.1) — a MOQT subgroup past its delivery timeout is dropped, not sent to the end. Returns `true` when a reset went, `false` when there was nothing left to reset. |
1908
+ | `stream.stopSending(code)` | Stop reading (STOP_SENDING): the peer stops. A browser's writer fails with `streamErrorCode`. |
1909
+ | `stream.destroy()` | Both, with code 0, for whatever direction is unfinished. |
1910
+ | `'peerReset'` (`code`) | The peer stopped sending (RESET_STREAM): our reading is over — what was read stands, the readable side ends, and its WHATWG `readable` fails with the code. Our writing goes on. |
1911
+ | `'peerStopSending'` (`code`) | The peer stopped reading (STOP_SENDING): our writing is over — writes from now on are dropped, the WHATWG `writable` fails. Our reading goes on. |
1912
+ | `stream.streamErrorCode` | The peer's code from either (`null` if it sent none). |
1913
+ | `'committed'` | Our sending part is "all data committed" (draft-ietf-webtrans-overview §4.3; "Data Recvd", RFC 9000 §3.1): every byte written and the FIN acknowledged by the peer. Once; never for a stream reset first. `'finish'` (and `end()`'s callback) only says the bytes were handed to QUIC — a delivery deadline (MOQT's SUBGROUP_DELIVERY_TIMEOUT) runs from the FIN to here, and `resetStream()` before it drops what is left. `sendState` is then `'committed'`. |
1914
+ | `stream.id`, `stream.sessionId` | Its QUIC stream ID, and its session's — read-only. |
1915
+ | `stream.sendState`, `stream.recvState` | Each direction, read-only: `'open'`, `'finished'` (the FIN sent / received), `'committed'` (ours: all of it and the FIN acknowledged), `'reset'` (we gave up sending / the peer did), `'stopped'` (the peer asked us to stop / we stopped reading); `null` for the direction a unidirectional stream does not have. A `'drain'` says the writer may write; `sendState` says whether what it writes still goes anywhere. |
1916
+ | `'close'` | Once both directions are over — by FIN, reset or stop, from either side, in any order. When the peer's reset or stop is what ends the last one, an `Error` (`ERR_WEBTRANSPORT_STREAM_RESET` / `ERR_WEBTRANSPORT_STOP_SENDING`) goes to an `'error'` listener — only if there is one. `pipe()` adds a listener of its own that re-throws, as it does for a `net.Socket`: a stream piped with `pipe()` needs an `'error'` listener of yours, or use `stream.pipeline()`. The session's end is not such a cancel: when the peer closes the session it resets the streams with `WT_SESSION_GONE`, and they close without an `'error'`. |
1917
+
1918
+ **Opening streams** is the browser's: `res.createBidirectionalStream(options[, callback])`
1919
+ and `res.createUnidirectionalStream(options[, callback])` take `{ sendOrder, sendGroup,
1920
+ waitUntilAvailable }` and return the stream at once, in Node's way (`net.connect`'s).
1921
+ Past the client's `MAX_STREAMS` without `waitUntilAvailable` they return `null`
1922
+ (`res.canOpenStream({ bidirectional })` says beforehand); with it, the stream is opened
1923
+ and its bytes wait for the client to allow more. Until then `stream.pending` is `true`;
1924
+ when it is open, `'ready'` and `callback(null, stream)` — or `callback(err)` (code
1925
+ `ERR_WEBTRANSPORT_STREAM_NOT_OPEN`) if it was given up first, its session closed.
1926
+
1927
+ ```js
1928
+ const st = res.createUnidirectionalStream({ waitUntilAvailable: true, sendOrder: 5 }, (err) => {
1929
+ if (err) return; // never opened: the session went first
1930
+ // open: the client has allowed it
1931
+ });
1932
+ st.write(object); // may be written at once; it waits with the stream
1933
+ ```
1934
+
1935
+ **The subprotocol** — the browser's `new WebTransport(url, { protocols: [...] })`: the
1936
+ client's offer is `req.webTransportProtocols`, in its order of preference (parsed from
1937
+ WT-Available-Protocols), and `res.setWebTransportProtocol(name)` before `writeHead(200)`
1938
+ picks one — sent as WT-Protocol, what the client's `wt.protocol` then says. A name the
1939
+ client did not offer throws: the client would fail the session over it.
1940
+
1941
+ ```js
1942
+ const chosen = req.webTransportProtocols.find(p => SUPPORTED.includes(p));
1943
+ if (chosen) res.setWebTransportProtocol(chosen);
1944
+ res.writeHead(200);
1945
+ ```
1946
+
1947
+ **Streams that arrive early** — opened by the client right after its CONNECT, before
1948
+ the application accepts the session, or even read before the CONNECT itself — are
1949
+ held (one stream window each, up to 64 per connection) and delivered as
1950
+ `'stream'` / `'unidirectionalStream'` once `res.writeHead(200)` accepts it. If the
1951
+ application answers otherwise, or the CONNECT never comes, they are reset with
1952
+ `WEBTRANSPORT_BUFFERED_STREAM_REJECTED`.
1953
+
1954
+ **The session** ends with `'close'` on `req`. `res.closeSession(code, reason)` closes it with
1955
+ CLOSE_WEBTRANSPORT_SESSION — a 32-bit code and a reason of up to 1024 bytes, what a
1956
+ browser's `wt.closed` resolves with — and resets its streams. A client's close arrives the
1957
+ same way: `req.closeInfo` is `{ closeCode, reason }` when it sent one. `res.drainSession()`
1958
+ sends DRAIN_WEBTRANSPORT_SESSION (finish up, start nothing new); a client's DRAIN is
1959
+ `'draining'` on `req`.
1960
+
766
1961
  #### Client (Node.js)
767
1962
 
1963
+ QUICO's `WebTransport` is the browser's API — the same class, promises, streams and
1964
+ options — so a client written for the browser runs in Node unchanged:
1965
+
768
1966
  ```js
769
- import { WebTransport } from 'quico';
1967
+ import { WebTransport, WebTransportError } from 'quico'; // in a browser: globals
770
1968
 
771
- const wt = new WebTransport('https://localhost:4433/echo');
1969
+ const wt = new WebTransport('https://example.com:4433/live', {
1970
+ serverCertificateHashes: [{ algorithm: 'sha-256', value: certHash }], // or a CA-signed cert
1971
+ });
772
1972
  await wt.ready;
773
1973
 
774
- const stream = await wt.createBidirectionalStream();
775
- stream.write('hello');
776
- stream.on('data', (chunk) => console.log('echo:', chunk.toString()));
777
- stream.end();
1974
+ const { readable, writable } = await wt.createBidirectionalStream();
1975
+ const writer = writable.getWriter();
1976
+ await writer.write(new TextEncoder().encode('hello'));
1977
+ await writer.close();
1978
+ for (const reader = readable.getReader();;) { const { value, done } = await reader.read(); if (done) break; /* … */ }
1979
+
1980
+ const uni = await wt.createUnidirectionalStream(); // a WritableStream
1981
+ await uni.getWriter().abort(new WebTransportError({ streamErrorCode: 7 })); // RESET_STREAM, code 7
778
1982
 
779
- const uni = await wt.createUnidirectionalStream();
780
- uni.write('fire and forget');
781
- uni.end();
1983
+ const incoming = wt.incomingUnidirectionalStreams.getReader(); // ReadableStream of ReadableStreams
1984
+ const dgWriter = wt.datagrams.createWritable().getWriter(); // and wt.datagrams.readable
782
1985
 
783
- wt.sendDatagram(Buffer.from('ping'));
784
- wt.on('datagram', (data) => console.log('pong:', data.toString()));
1986
+ wt.close({ closeCode: 0, reason: 'done' });
1987
+ const { closeCode, reason } = await wt.closed; // the server's, if it closed first
1988
+ ```
1989
+
1990
+ The whole browser API is there: `ready`, `closed` (see [How the session ends](#how-the-session-ends) below),
1991
+ `draining`, `close()`, `createBidirectionalStream()` / `createUnidirectionalStream()` with
1992
+ `sendOrder`, `sendGroup` and `waitUntilAvailable`, `incomingBidirectionalStreams`,
1993
+ `incomingUnidirectionalStreams`, `datagrams` (`readable`, `createWritable()`,
1994
+ `maxDatagramSize`, `incomingMaxAge`, `outgoingMaxAge`, `incomingMaxBufferedDatagrams`,
1995
+ `outgoingMaxBufferedDatagrams` — see the server's `res.datagrams` above), `createSendGroup()`, `getStats()`, `reliability`,
1996
+ `congestionControl`, `protocol`, `responseHeaders`, `exportKeyingMaterial()`,
1997
+ `WebTransport.supportsReliableOnly` (`false`: HTTP/3 only), and stream `getStats()`; the options
1998
+ `serverCertificateHashes` (the leaf's SHA-256, a certificate valid for at most 14 days, as
1999
+ the browser requires), `protocols`, `requireUnreliable`, `congestionControl`,
2000
+ `transportParams` (QUICO's own: the windows and stream limits, as the server's),
2001
+ `key` / `cert` / `passphrase` (a client certificate, as `https.request` takes it),
2002
+ `quicVersions` and `ecdhCurve` (as `quico.request`'s),
2003
+ `session` (a TLS session from an earlier `'session'` event, to resume — not with
2004
+ `serverCertificateHashes`: a resumed handshake has no certificate to pin),
2005
+ `initialCongestionWindow` (as the server's),
2006
+ `allowPooling`, the `anticipatedConcurrent…` hints and `datagramsReadableType`. A test runs the same
2007
+ client function in Chromium and in Node against a QUICO server and checks both see the same.
2008
+ `sendOrder` and `sendGroup` order what the client sends, as on the server (see above).
2009
+
2010
+ **The browser's interfaces** are exported, for `instanceof` as in the browser:
2011
+ `WebTransportBidirectionalStream`, `WebTransportSendStream`, `WebTransportReceiveStream`,
2012
+ `WebTransportWriter`, `WebTransportSendGroup`, `WebTransportDatagramsWritable`,
2013
+ `WebTransportDatagramDuplexStream`. As in the browser, none of them can be constructed by
2014
+ the application. `WebTransportError` is a `DOMException` (its `name` is
2015
+ `'WebTransportError'`, with `source` and `streamErrorCode`) and takes the browser's
2016
+ `{ message, source, streamErrorCode }` form as well as the spec's `(message, options)`. Where
2017
+ QUICO gives a Node code too (`ERR_WEBTRANSPORT_SESSION_LOST`, say), it is in `err.code`, in
2018
+ place of the `DOMException`'s number.
2019
+
2020
+ | | |
2021
+ |---|---|
2022
+ | `wt.responseHeaders` | The server's answer to the CONNECT: a `Headers` that cannot be changed, `null` until `ready`. No pseudo-headers and no `wt-protocol` (that is `wt.protocol`). `set-cookie` is there (`getSetCookie()`): a browser keeps it from its page, but in Node the program is the user agent, as with Node's `fetch`. |
2023
+ | `writer.atomicWrite(chunk)` | `getWriter()` of a send stream gives a `WebTransportWriter`. An atomic write goes only if all of it fits the flow-control window when its turn comes; otherwise none of it is sent and it rejects with an `AbortError`, as does any atomic write queued behind it, and the stream goes on. A plain write queued behind it fails the stream, as the spec has it. |
2024
+ | `writer.commit()` | What was written so far is delivered even if the stream is aborted later: `abort()` then sends RESET_STREAM_AT with that many bytes reliable (a MOQT subgroup's header, say) — `stream.resetStream(code, { reliableSize })` in the Node API. A peer that does not take RESET_STREAM_AT gets a plain RESET_STREAM. |
2025
+ | `readable.getReader({ mode: 'byob' })` | Streams are byte streams, as the browser's: a BYOB reader reads into its own buffer, and what does not fit stays for the next read. Datagrams too, with `datagramsReadableType: 'bytes'`: each datagram whole into the reader's buffer, a `RangeError` if it does not fit. |
2026
+
2027
+ It behaves as the browser's does where the web-platform-tests check it:
2028
+
2029
+ - **The constructor checks its arguments.** A URL that does not parse, is not `https:`, or
2030
+ has a fragment is a `SyntaxError` (a `DOMException`, as Node's own `WebSocket` throws), and
2031
+ so is a protocol in `protocols` that is empty, longer than 512, not printable ASCII, or
2032
+ given twice. A malformed `serverCertificateHashes` entry, a `congestionControl` that is
2033
+ not one of the three, or an `anticipatedConcurrent…` hint outside 0–65535 is a
2034
+ `TypeError`. The hints are attributes that can be set later, with the same checks.
2035
+ - **Streams and datagrams can be used before `ready`.** `create…Stream()` waits for the
2036
+ session and then resolves; a datagram written meanwhile goes once the session is up.
2037
+ - **The Promises reject as the browser's do.** `create…Stream()` on a session that is over
2038
+ rejects with an `InvalidStateError`, a send group of another session's with a
2039
+ `TypeError`, and a stream past the server's limit without `waitUntilAvailable` with a
2040
+ `QuotaExceededError`. `getStats()` on a session that failed rejects with an
2041
+ `InvalidStateError`.
2042
+ - **A stream fails as soon as its direction is given up on.** The server's `STOP_SENDING`
2043
+ rejects `writer.closed`, its `RESET_STREAM` rejects `reader.closed`, and the session's
2044
+ end fails both, without waiting for the next read or write. A stream write that is not
2045
+ an `ArrayBuffer` or a view onto one, or is empty, rejects with a `TypeError`.
2046
+ - **The anticipated-streams hints take effect.** Given to the constructor, they raise the
2047
+ stream limits the client grants the server from the start, unless `transportParams`
2048
+ sets those limits itself.
2049
+
2050
+ The same object also has a Node-style API, for Node code:
2051
+
2052
+ | | |
2053
+ |---|---|
2054
+ | `wt.on('stream', duplex)`, `wt.on('unidirectionalStream', readable)` | The server's streams as Node streams. A stream goes to a Node listener if there is one, otherwise to `incoming…Streams` once that is read, and waits for either. |
2055
+ | `wt.on('datagram', buf)`, `wt.sendDatagram(buf[, { sendOrder, sendGroup }])` → `true`/`false`, `wt.maxDatagramSize()` | Datagrams; with a `sendOrder` / `sendGroup` one takes its place among the streams, as the server's `res.sendDatagram`. |
2056
+ | `'ready'`, `'draining'`, `'close'` | The session's events. |
2057
+ | `'session'` (`Buffer`) | A TLS session the server sent, to pass back as `session` next time and resume. |
2058
+ | `stream.write()`, `stream.on('data')`, `stream.pipe()` | Every stream is a Node stream too: `createBidirectionalStream()` gives a `Duplex` that has the browser's `readable` / `writable`; `createUnidirectionalStream()` gives the browser's `WritableStream` that is Node's `Writable` too — `write()` / `'drain'`, `end()`, `resetStream()`, its events, `writableLength` and the rest of its state, and `readable.pipe()` into it. |
2059
+ | `stream.resetStream(code)`, `stream.stopSending(code)`, `stream.streamErrorCode` | Give up on a direction with a code; the peer's code when it gave up. |
2060
+ | `stream.id`, `stream.sessionId`, `stream.sendState`, `stream.recvState`, `stream.pending` | As on the server. `create…Stream()`'s Promise is settled once the stream is open — at once, or with `waitUntilAvailable` when the server allows it — as the browser's is. |
2061
+ | `wt.canOpenStream({ bidirectional })`, `wt.getStats()` | As the server's `res.canOpenStream()` and `res.getStats()`. |
2062
+ | `'peerReset'` / `'peerStopSending'` (`code`), `'close'` | As on the server: the direction the peer gave up on ends, the other goes on; `'close'` once both are over. |
2063
+ | `stream.sendOrder`, `stream.sendGroup` | Its place among the session's streams (see [Priorities](#webtransport-1) on the server — the same rules). |
2064
+
2065
+ The client's streams are the server's kind: the server's are recognised by their
2066
+ WebTransport prefix, not guessed from stream numbers, and what the server sends is credited
2067
+ back as the application reads — a stream nobody reads holds one stream window.
2068
+
2069
+ Besides the browser's options, the second argument takes `ca` and `rejectUnauthorized`
2070
+ (default `true`), as in `https.request`: the server's certificate is verified against the
2071
+ platform roots unless `ca` is given or `serverCertificateHashes` pins it. If the session
2072
+ cannot be established, `ready` and `closed` reject with a `WebTransportError` (source
2073
+ `'session'`, keeping node:tls's `err.code`, e.g. `DEPTH_ZERO_SELF_SIGNED_CERT`, when TLS is
2074
+ the reason), `state` becomes `'failed'` and the connection is closed, as in the browser.
2075
+ `'error'` is emitted too, but only when a listener is attached. Any `2xx` answer to the
2076
+ CONNECT establishes the session, as in the browser (draft-ietf-webtrans-http3 §3.3), not
2077
+ only `200`.
2078
+
2079
+ **Fields on the CONNECT request.** A browser always sends its page's `Origin`, and servers
2080
+ check it (the web-platform-tests WebTransport server refuses a session without one). Node has
2081
+ no page, so the client takes `origin`, and `headers` for other fields, as QUICO's WebSocket
2082
+ client does:
785
2083
 
786
- wt.close();
2084
+ ```js
2085
+ new WebTransport(url, { origin: 'https://app.example', headers: { authorization: 'Bearer …' } });
787
2086
  ```
788
2087
 
2088
+ `headers` is the browser's `HeadersInit`: a `Headers`, a list of `[name, value]` pairs, or
2089
+ an object. The fields go after the pseudo-headers and are treated as the browser's
2090
+ `Headers` treats them: names are lower-cased, leading and trailing whitespace is removed
2091
+ from values, and a name given twice is sent once with its values joined by `, `.
2092
+
2093
+ | Field | What happens |
2094
+ |---|---|
2095
+ | A pseudo-header, `wt-available-protocols` (that is `protocols`), a name or value that is not valid | The constructor throws a `TypeError`, as the browser's does |
2096
+ | `host`, `te`, `content-length`, `trailer`, connection-specific fields (`connection`, `keep-alive`, `transfer-encoding`, `upgrade`, …) | Dropped, as the browser drops them: they cannot go on an HTTP/3 request (the authority is `:authority`) |
2097
+ | `cookie`, `referer`, `sec-*` and the rest | Sent. A browser keeps them from a page for the page's safety; in Node the program is the user agent, as with Node's `fetch` |
2098
+
2099
+ ##### How the session ends
2100
+
2101
+ | | `closed` | `'close'` |
2102
+ |---|---|---|
2103
+ | Either side sends `CLOSE_WEBTRANSPORT_SESSION` (`wt.close({ closeCode, reason })`, `res.closeSession(code, reason)`) | resolves with `{ closeCode, reason }` | emitted |
2104
+ | The server ends the CONNECT stream with a FIN and no capsule | resolves with `{ closeCode: 0, reason: '' }`, at once (draft-ietf-webtrans-http3 §5) | emitted |
2105
+ | The session is lost: the server resets the CONNECT stream, or the connection goes (an error, the idle timeout, the server gone) | **rejects** with a `WebTransportError` (source `'session'`, `code` `'ERR_WEBTRANSPORT_SESSION_LOST'`, and `quicError` `{ code, application, reason }` when the peer closed the connection), as soon as the server's CONNECTION_CLOSE arrives, not after QUIC's draining period; `state` is `'failed'` | emitted |
2106
+ | The session could not be established, or `wt.close()` was called before `ready` | rejects, with the same error as `ready` | emitted |
2107
+
2108
+ As in the browser, `{ closeCode: 0 }` means a close someone asked for; a session that was
2109
+ lost rejects instead. The session's streams, its incoming-stream queues and its datagrams
2110
+ fail with it. When the session was lost or never established, they fail with the very
2111
+ error object `closed` rejects with. After our own `close()` they fail with an
2112
+ `AbortError`, and after the server's with a `WebTransportError`. A clean close closes the
2113
+ incoming-stream queues and the datagrams' `readable` instead of failing them.
2114
+
789
2115
  #### Client (Browser)
790
2116
 
791
2117
  A QUICO server works with the **native browser WebTransport API** — no client library
@@ -795,7 +2121,7 @@ needed:
795
2121
  const wt = new WebTransport('https://yourserver.com:4433/live');
796
2122
  await wt.ready;
797
2123
 
798
- const writer = wt.datagrams.writable.getWriter();
2124
+ const writer = wt.datagrams.createWritable().getWriter();
799
2125
  await writer.write(new TextEncoder().encode('Hello QUIC'));
800
2126
 
801
2127
  const reader = wt.datagrams.readable.getReader();
@@ -805,6 +2131,308 @@ console.log('Received:', new TextDecoder().decode(value));
805
2131
 
806
2132
  ---
807
2133
 
2134
+ ### WebSocket
2135
+
2136
+ #### Server
2137
+
2138
+ One handler serves WebSocket over HTTP/1.1, HTTP/2 (RFC 8441) and HTTP/3 (RFC 9220):
2139
+ all three arrive as the server's `'upgrade'` event.
2140
+
2141
+ ```js
2142
+ import quico, { WebSocketServer } from 'quico';
2143
+
2144
+ const server = quico.createServer({ key: KEY, cert: CERT });
2145
+ const wss = new WebSocketServer({ server, path: '/chat', pingInterval: 30000 });
2146
+
2147
+ wss.on('connection', (ws, req) => {
2148
+ ws.on('message', (data, isBinary) => ws.send(data, { binary: isBinary }));
2149
+ });
2150
+
2151
+ server.listen(4433);
2152
+ ```
2153
+
2154
+ `WebSocketServer(options)` takes `server` (or `noServer: true` and call
2155
+ `wss.handleUpgrade(req, socket, head, cb)` yourself), `path`, `pingInterval` (clients
2156
+ that miss a pong are dropped) and the options of `upgradeWebSocket`:
2157
+
2158
+ - `origin` — a string, an array, or `fn(origin, req)`; a mismatch is answered with 403
2159
+ - `verifyClient(req)` — return `false` (401) or `{ code, message, headers }`
2160
+ - `protocol` (string or `fn(offered, req)`) / `protocols` (preference list)
2161
+ - `headers` — extra response headers
2162
+ - `maxPayload` — default 100 MB
2163
+
2164
+ `wss.clients` is the set of open connections; `wss.close([code][, reason])` closes them.
2165
+ For a single upgrade without a server object, `upgradeWebSocket(req, socket, head,
2166
+ options)` returns the socket, or `null` after answering the client itself.
2167
+
2168
+ Sockets have both surfaces: WHATWG (`onmessage`, `addEventListener`, `binaryType`) and
2169
+ the `ws` package's (`on('message', (data, isBinary))`, `on('close', (code, reason))`,
2170
+ `ping()`, `on('pong')`). Pings are answered automatically.
2171
+
2172
+ #### Client
2173
+
2174
+ ```js
2175
+ import { WebSocket } from 'quico';
2176
+
2177
+ const ws = new WebSocket('wss://localhost:4433/chat', [], { rejectUnauthorized: false });
2178
+ ws.onopen = () => ws.send('hello');
2179
+ ws.onmessage = (e) => console.log(e.data);
2180
+ ```
2181
+
2182
+ The client connects over HTTP/1.1. Options beyond the WHATWG constructor: `origin`,
2183
+ `headers`, `ca`, `rejectUnauthorized`, `maxPayload`, `agent` (whose TLS sessions a
2184
+ `wss://` connection resumes with and keeps — `globalAgent` by default) and `session` (one
2185
+ to resume with, from an earlier `'session'`). `ws.on('session', buf)`: each one the server
2186
+ sends. The second argument may also be the browser's options bag: `new WebSocket(url, { protocols: ['chat'] })`.
2187
+
2188
+ It behaves as a browser's does (WHATWG, WPT `websockets/`):
2189
+
2190
+ - Messages go in the order `send()` was called — a `Blob` included, which is read first.
2191
+ - `close(code, reason)` checks as the browser: `null` is 0 and a code is clamped to 16 bits, so
2192
+ anything but 1000 or 3000–4999 is an `InvalidAccessError`; a reason over 123 bytes, a
2193
+ `SyntaxError`. `close()` while connecting abandons the handshake: `'error'`, then `'close'`
2194
+ 1006, both on a later turn.
2195
+ - A URL with a fragment (even an empty `#`), or protocols repeated in another case
2196
+ (`['chat', 'CHAT']`): `SyntaxError`.
2197
+ - A port browsers refuse (Fetch's bad ports: 22, 25, 6000, …): never connected — `'error'`,
2198
+ then `'close'` 1006.
2199
+ - User and password in the URL (`wss://user:pass@host/`): sent as `Authorization: Basic`,
2200
+ unless `headers` has one.
2201
+ - `MessageEvent.origin` is the URL's origin; `send()` after close adds to `bufferedAmount`.
2202
+
2203
+ #### WebSocketStream (experimental)
2204
+
2205
+ The browser's streams-based WebSocket ([explainer](https://github.com/ricea/websocketstream-explainer);
2206
+ Chrome ships it, not yet a standard — the API may still change). The same connection, but a
2207
+ `ReadableStream` and a `WritableStream` in place of events, so both directions have backpressure:
2208
+
2209
+ ```js
2210
+ import { WebSocketStream, WebSocketError } from 'quico';
2211
+
2212
+ const wss = new WebSocketStream('wss://localhost:4433/chat',
2213
+ { protocols: ['chat'], signal: AbortSignal.timeout(5000), rejectUnauthorized: false });
2214
+ const { readable, writable, protocol } = await wss.opened;
2215
+
2216
+ const writer = writable.getWriter();
2217
+ await writer.write('hello'); // resolves once the frame is sent
2218
+ for await (const message of readable) { // a string, or a Uint8Array
2219
+ console.log(message);
2220
+ }
2221
+ const { closeCode, reason } = await wss.closed;
2222
+ ```
2223
+
2224
+ - **Receiving.** A message nobody has read holds the socket: it is paused, TCP fills up, and the
2225
+ server's sends wait. With `WebSocket`, every message arrives as an event, however fast, into memory.
2226
+ - **Sending.** `write()` resolves when the frame has gone to the socket — a writer that awaits it
2227
+ goes as fast as the connection, with nothing piling up. `WebSocket`'s `send()` returns at once.
2228
+ - `close({ closeCode, reason })` — the checks of `WebSocket.close()`; a reason with no code is 1000.
2229
+ `closed` resolves with the code and reason the peer closed with.
2230
+ - A connection lost, or a close that left writes unsent: `closed` rejects with a `WebSocketError`
2231
+ (a `DOMException` with `closeCode` — 1006 when there was no closing handshake — and `reason`),
2232
+ and the readable and writable error with it.
2233
+ - `readable.cancel(err)` / `writable.abort(err)` close with the code and reason of a `WebSocketError`
2234
+ given: `new WebSocketError('', { closeCode: 4000, reason: 'bye' })`.
2235
+ - `signal` aborted before the connection is open: it is abandoned, `opened` and `closed` reject with
2236
+ the signal's reason; once open, it does nothing.
2237
+ - Options besides `protocols` and `signal` are `WebSocket`'s (`origin`, `headers`, `ca`, …).
2238
+
2239
+ ---
2240
+
2241
+ ### CONNECT-UDP (proxying UDP)
2242
+
2243
+ A QUICO server can be a UDP proxy (RFC 9298, the MASQUE working group's): a client
2244
+ asks it for a UDP "socket" to one target, a host and a port, and from then on sends
2245
+ the target UDP payloads through it and receives the target's. This is what a browser's
2246
+ or an OS's privacy relay does for QUIC traffic.
2247
+
2248
+ ```js
2249
+ const server = quico.createServer({ key: KEY, cert: CERT });
2250
+
2251
+ server.on('connectUdp', (req, tunnel) => {
2252
+ if (req.headers.authorization !== 'Bearer ' + TOKEN) return tunnel.reject(407);
2253
+ tunnel.accept(); // to tunnel.target, from the request
2254
+ tunnel.on('close', (stats) => console.log(tunnel.target, stats.bytesToClient, tunnel.closeReason));
2255
+ });
2256
+
2257
+ server.listen(443);
2258
+ ```
2259
+
2260
+ **How it travels.** The request names the target in its path, by a URI template the
2261
+ clients are configured with: by default `/.well-known/masque/udp/{target_host}/{target_port}/`
2262
+ (an IPv6 address with its colons percent-encoded). Over HTTP/3 it is an extended CONNECT
2263
+ (`:protocol: connect-udp`), and each UDP payload is an HTTP Datagram (RFC 9297) in a QUIC
2264
+ DATAGRAM frame — unreliable, as UDP is. A client that did not negotiate datagrams,
2265
+ HTTP/2 (extended CONNECT) and HTTP/1.1 (`Upgrade: connect-udp`, answered 101) carry them
2266
+ as DATAGRAM capsules on the request stream. The proxy relays from a UDP socket of its own,
2267
+ connected to the target, so only the target's packets come back through it. A payload
2268
+ larger than a QUIC datagram can carry is dropped, not moved to the stream (§5: a
2269
+ reliable, ordered copy of a UDP packet is not one).
2270
+
2271
+ **What the application decides** — in `'connectUdp'`, now or later (the client may
2272
+ send before the answer; the first 32 payloads wait):
2273
+
2274
+ | | |
2275
+ |---|---|
2276
+ | `tunnel.accept()` | Relay to `tunnel.target`. The host is resolved (`lookup`), the socket connected, then the 2xx goes. |
2277
+ | `tunnel.accept({ target: { host, port } })` | Relay to another target. |
2278
+ | `tunnel.accept({ socket })` | Relay through the application's own `dgram` socket (bound where it wants, say). Packets from senders other than the target are not relayed; the socket is not closed with the tunnel. |
2279
+ | `tunnel.accept({ relay: false })` | No relay: the application gets each payload from the client as `'message'` (a `Buffer`) and answers with `tunnel.send(buf)`. |
2280
+ | `tunnel.accept({ headers, allowPrivate })` | Extra response fields; the private-address rule for this tunnel. |
2281
+ | `tunnel.reject(status[, headers])` | Refuse it. |
2282
+ | `tunnel.send(buf)` | A payload to the client, in any mode. `false`: not sent (too large, or the stream is full — dropped, as UDP drops). |
2283
+ | `tunnel.close()` | End it (the request stream is ended). |
2284
+
2285
+ `tunnel.req` is the request (an `IncomingMessage`), `tunnel.target` `{ host, port }`,
2286
+ `tunnel.state` `'pending'` / `'open'` / `'closed'`. Events: `'open'`, `'message'` (with
2287
+ `relay: false`), `'close'` (with the stats; `tunnel.closeReason` says why: `'client'`,
2288
+ `'local'`, `'idle'`, `'aborted'` — the stream was reset or the connection went —,
2289
+ `'socket'` — the target's socket failed, an ICMP unreachable, say — or `'rejected'`;
2290
+ `tunnel.error` holds the error when there was one).
2291
+
2292
+ **Figures.** `tunnel.stats`: `{ target, address, port, httpVersion, state, transport
2293
+ ('datagram' | 'capsule'), openedAt, closedAt, durationMs, packetsFromClient,
2294
+ bytesFromClient, packetsToClient, bytesToClient, droppedFromClient, droppedToClient }`.
2295
+ `server.getConnectUdpStats()`: `{ active, accepted, rejected, closed }` and the same
2296
+ counters summed over every tunnel, open ones included.
2297
+
2298
+ **Refused on its own** — before `'connectUdp'`, or at `accept()`:
2299
+
2300
+ | Case | Answer |
2301
+ |---|---|
2302
+ | A path that is not the template's | 404 |
2303
+ | An empty host, a port outside 1..65535 | 400 |
2304
+ | HTTP/1.1, not a GET | 400 |
2305
+ | More tunnels on one connection than `maxTunnelsPerConnection` | 429 |
2306
+ | The host does not resolve | 502, `Proxy-Status: quico; error=dns_error` |
2307
+ | The host resolves only to private addresses | 403, `Proxy-Status: quico; error=destination_ip_prohibited` |
2308
+ | No `'connectUdp'` listener | Not a tunnel: the request goes to `'upgrade'` or `'request'`, as before |
2309
+
2310
+ **Options** (`createServer({ connectUdp: { … } })`):
2311
+
2312
+ | Option | Default | |
2313
+ |---|---|---|
2314
+ | `template` | `/.well-known/masque/udp/{target_host}/{target_port}/` | The URI template — variables in the path, or a query (`/masque{?target_host,target_port}`). |
2315
+ | `allowPrivate` | `false` | Allow targets in private, loopback, link-local, CGNAT, multicast and reserved ranges (IPv4 and IPv6, IPv4-mapped included). Off, a proxy cannot be used to reach its own network (RFC 9298 §7). |
2316
+ | `maxTunnelsPerConnection` | `16` | |
2317
+ | `idleTimeout` | `120000` | ms with nothing either way before a tunnel is closed; never under two minutes (RFC 9298 §3.1), `0` for none. |
2318
+ | `lookup` | `dns.lookup` | `(host, { all: true }, cb)`, as `dns.lookup`. |
2319
+ | `proxyName` | `'quico'` | The name in `Proxy-Status`. |
2320
+
2321
+ RFC 9298 asks that a UDP proxy serve authenticated users only — the check is the
2322
+ application's, in `'connectUdp'`.
2323
+
2324
+ #### Client: `quico.connectUdp(proxy, target[, options][, callback])`
2325
+
2326
+ A UDP socket to `target` (`{ host, port }`) through a proxy. What it returns behaves as a
2327
+ connected `dgram.Socket`, so code written for `dgram` moves to a proxied path by changing
2328
+ the line that makes the socket:
2329
+
2330
+ ```js
2331
+ import { connectUdp } from 'quico';
2332
+
2333
+ const sock = connectUdp('https://proxy.example', { host: '1.1.1.1', port: 53 },
2334
+ { headers: { 'proxy-authorization': 'Bearer ' + TOKEN } });
2335
+ sock.on('connect', () => sock.send(dnsQuery));
2336
+ sock.on('message', (msg, rinfo) => { /* rinfo: the target, as dgram gives it */ });
2337
+ sock.on('error', console.error);
2338
+ ```
2339
+
2340
+ `proxy` is the proxy's URL — and, if it contains `{target_host}`, the URI template too
2341
+ (`'https://proxy.example/masque{?target_host,target_port}'`); without one, the default
2342
+ template's path — or `{ hostname, port, template }`.
2343
+
2344
+ HTTP/3 first, each payload a QUIC datagram. If QUIC has not connected within `h3Timeout`
2345
+ (UDP blocked on the way, a proxy without HTTP/3), TCP: HTTP/2 extended CONNECT or HTTP/1.1
2346
+ Upgrade, as ALPN decides, the payloads in capsules.
2347
+
2348
+ Over HTTP/3 the tunnels to one proxy share a connection (RFC 9298's tunnels are requests;
2349
+ their datagrams are told apart by Quarter Stream ID): a tunnel joins the connection opened
2350
+ with the same TLS settings, or waits for the one being made — no handshake of its own, one
2351
+ congestion controller, one UDP port — and the connection closes with its last tunnel. A
2352
+ tunnel the proxy refuses, or one closed, leaves the others as they are; a connection lost
2353
+ ends each tunnel on it with `ERR_CONNECT_UDP_CLOSED`. `shareConnection: false` gives a
2354
+ tunnel its own. Over TCP each tunnel has its own connection.
2355
+
2356
+ | | |
2357
+ |---|---|
2358
+ | `send(msg[, offset, length][, callback])` | A connected socket's forms: a `Buffer`, a typed array, a string, or an array of them (one payload). Before `'connect'`, up to 64 are held and go when the tunnel opens. Larger than `maxPayloadSize`: `EMSGSIZE`. When the stream already holds too much (capsules), dropped as UDP drops — no error. With a port and address: `ERR_SOCKET_DGRAM_IS_CONNECTED`. |
2359
+ | `close([callback])` | Ends the tunnel: the request stream's end, and the connection's when it was the last on it. |
2360
+ | `remoteAddress()` | The target `{ address, family, port }`. |
2361
+ | `address()` | The local end of the connection to the proxy. |
2362
+ | `maxPayloadSize` | The largest payload `send()` takes now: over HTTP/3, what a QUIC datagram carries on this path (~1195 bytes at the start; more after path MTU discovery); over capsules, 65527. |
2363
+ | `protocol` | `'h3'`, `'h2'` or `'http/1.1'`, once open. |
2364
+ | `statusCode`, `responseHeaders` | The proxy's answer. |
2365
+ | `closeReason` | `'local'`, `'proxy'` (it ended the tunnel) or `'error'`. |
2366
+ | `stats` | `{ protocol, transport ('datagram' \| 'capsule'), target, openedAt, closedAt, durationMs, packetsSent, bytesSent, packetsReceived, bytesReceived, dropped }` |
2367
+
2368
+ TLS sessions to the proxy are kept and resumed with as `quico.request`'s are: the
2369
+ `agent` option's (`globalAgent` by default; `false`: none), each used once, or `session`;
2370
+ each the proxy sends is the socket's `'session'` event.
2371
+
2372
+ Events: `'connect'`, `'message'` `(msg, rinfo)`, `'error'`, `'close'`, `'session'`. A refusal is an
2373
+ `'error'` with `code: 'ERR_CONNECT_UDP_REFUSED'`, `statusCode` and `proxyStatus` (the
2374
+ proxy's `Proxy-Status`), then `'close'`.
2375
+
2376
+ | Option | Default | |
2377
+ |---|---|---|
2378
+ | `protocol` | `'auto'` | `'h3'` (no fallback), `'h2'` or `'http/1.1'` to use only that. |
2379
+ | `h3Timeout` | `3000` | ms to wait for QUIC before TCP. |
2380
+ | `fallback` | `true` | `false`: HTTP/3 only, even with `'auto'`. |
2381
+ | `shareConnection` | `true` | `false`: an HTTP/3 connection of its own, not shared with the proxy's other tunnels. |
2382
+ | `headers` | — | Fields for the request (authorization). |
2383
+ | `ech`, `echGrease` | | Encrypted Client Hello to the proxy, as `quico.request`'s. |
2384
+ | `ecdhCurve` | | The key-exchange groups to offer the proxy, as `quico.request`'s. |
2385
+ | `template` | the default | When the proxy URL carries none. |
2386
+ | `rejectUnauthorized`, `ca`, `key`, `cert`, `passphrase`, `servername`, `quicVersions` | | As `quico.request`. |
2387
+
2388
+ Tested against quic-go's masque-go in both directions — its client through QUICO's proxy
2389
+ (an HTTP/3 request inside the tunnel: QUIC in QUIC), QUICO's client through its proxy.
2390
+
2391
+ ---
2392
+
2393
+ ### Fingerprinting
2394
+
2395
+ Who is really on the other end? A browser and a script sending the same User-Agent
2396
+ still differ in how they speak the protocols underneath. QUICO exposes those
2397
+ differences, on every layer it implements.
2398
+
2399
+ | Function | Input | Returns |
2400
+ |---|---|---|
2401
+ | `ja4h(req)` | a request | JA4H: `{ hash, raw, a, b, c, d }` — method, version, header names and order, cookie names (never values) |
2402
+ | `req.h2Fingerprint()` | HTTP/2 request | `{ hash, raw, … }` from the client's SETTINGS, WINDOW_UPDATE, PRIORITY and pseudo-header order |
2403
+ | `req.h3Fingerprint()` | HTTP/3 request | `{ hash, raw, … }` from the client's QUIC transport parameters, HTTP/3 SETTINGS and pseudo-header order |
2404
+ | `h2Fingerprint(observed)` / `h3Fingerprint(observed)` | raw observations | the same, computed from your own data |
2405
+ | `req.socket.getJA4()` / `getJA4S()` / `getJA5()` | TLS | TLS fingerprints, from LemonTLS — `null` when the installed LemonTLS does not provide them |
2406
+ | `req.socket.getNegotiationResult()` / `res.socket.getNegotiationResult()` | TLS | What the handshake settled, on every version: `{ versionName, cipherName, groupName, alpn, resumed, helloRetried, certificateCompression: { sent, received }, … }` — `sent` and `received` the algorithm each side's certificate went in (`'brotli'`, `'zlib'`, `'zstd'`), `null` where it went plain or not at all (a resumed connection) |
2407
+ | `profileRequest(req)` | a request | `{ claimed, protocol, fingerprints: { ja4, ja4s, h2, h2raw, h3, h3raw }, signals, suspicion }` — signals where the request's TLS and HTTP/2 behaviour contradicts the client it claims to be, and a 0–100 score. HTTP/3 fingerprints are reported, not yet checked against browsers. |
2408
+
2409
+ ```js
2410
+ server.on('request', (req, res) => {
2411
+ const p = quico.profileRequest(req);
2412
+ if (p.suspicion >= 60) { res.writeHead(403); return res.end(); }
2413
+ // …
2414
+ });
2415
+ ```
2416
+
2417
+ ---
2418
+
2419
+ ### Protocol layers
2420
+
2421
+ The unified server and client are built from layers you can use on their own — for a
2422
+ custom transport, a proxy, or protocols other than HTTP.
2423
+
2424
+ | Export | What it is |
2425
+ |---|---|
2426
+ | `createH1Server(options, handler)` | HTTP/1.1 server on its own |
2427
+ | `createH2Server(options, handler)` | HTTP/2 server on its own |
2428
+ | `createH3Server(options, handler)` | HTTP/3 + WebTransport server on its own (over `createQuicServer`) |
2429
+ | `h1Request(options, cb)` / `h2Request(options, cb)` / `h3Request(options, cb)` | Single-protocol clients, no fallback. Each takes `session` and emits `'session'`, as `https.request`. `h2Request` streams its body: `write()` sends as it is called and returns `false` while the stream's window or the socket is full, then `'drain'` — an upload is never held whole |
2430
+ | `createTcpListener(options)` | TCP accept + TLS + ALPN dispatch: `register(alpn, handler, { sniff })`, `listen()` |
2431
+ | `createQuicServer(options)` | QUIC transport server — see below |
2432
+ | `createQuicClientSocket(opts)` | A UDP socket driving one client `QUICConnection` (`remoteIp`, `remotePort`, `hostname`, `alpn`, `ecdhCurve`, `onConnect(quic)`, `onSocket(quic)` before the handshake — the place to attach `'keylog'`) |
2433
+
2434
+ ---
2435
+
808
2436
  ### Low-level QUIC API
809
2437
 
810
2438
  For non-HTTP protocols over QUIC, or full control of the connection.
@@ -817,9 +2445,24 @@ For non-HTTP protocols over QUIC, or full control of the connection.
817
2445
  | `SNICallback` | `Function` | `null` | Per-hostname contexts |
818
2446
  | `alpn` | `string[]` | `['h3']` | ALPN protocols to accept |
819
2447
  | `maxConnections` | `number` | `10000` | Connection cap |
2448
+ | `ticketKeys` | `Buffer` | random | Session-ticket key |
2449
+ | `sessionTimeout` | `number` | `7200` | Seconds a session ticket stays good |
2450
+ | `requestCert`, `rejectUnauthorized`, `ciphers`, `ecdhCurve`, `sigalgs`, `handshakeTimeout`, `passphrase`, `certificateCompression` | | | As `createServer` (TLS 1.3) |
2451
+ | `initialCongestionWindow` | `number` | `20` | Initial congestion window, in packets |
2452
+ | `carefulResume` | `boolean` | `false` | Careful Resume (RFC 9959) for returning clients |
2453
+ | `startupCap` | `string` | `'stall'` | Startup's window cap: `'stall'`, `'always'`, `'off'` (as `createServer`) |
2454
+ | `quicVersions` | `Array` | `['v1', 'v2']` | QUIC versions accepted, in preference (RFC 9368, RFC 9369) |
2455
+ | `connectionOptions` | `Function` | — | Per-connection settings (see above) |
2456
+ | `validateAddress` | `boolean` | `false` | Address validation with Retry |
2457
+ | `earlyData`, `earlyDataMaxAge`, `earlyDataReplay` | | `false`, `10000`, in memory | 0-RTT, as `createServer` (`quic.isEarlyStream(id)`: whether a stream came early) |
2458
+ | `handshakeLoadThreshold`, `maxHandshakesPerAddress` | `number` | `500`, `64` | As `createServer`; `getPendingHandshakes()`, `setOptions()` / `getOptions()` too |
2459
+ | `pmtuDiscovery`, `maxUdpPayloadSize`, `initialRtt`, `reorderWindow` | | | As `createServer` |
2460
+ | `resetTokenSecret` | `Buffer` | random | Stateless-reset secret |
2461
+ | `tokenSecret` | `Buffer` (≥ 16 bytes) | random | Address-validation token secret (Retry, NEW_TOKEN), as `createServer` |
820
2462
  | `socket` / `socket6` | `dgram.Socket` | `null` | External, already-bound UDP sockets |
821
2463
 
822
- Returns `{ listen, close, on, handlePacket, hasConnection }`. The `'connection'`
2464
+ Returns `{ listen, close, on, handlePacket, hasConnection }` plus the runtime getters and
2465
+ setters for ticket keys, address validation, the reset secret and the token secret. The `'connection'`
823
2466
  event fires with `(quicConnection, peer)`.
824
2467
 
825
2468
  ```js
@@ -840,7 +2483,7 @@ qs.on('connection', (quic, peer) => {
840
2483
  qs.listen(4433);
841
2484
  ```
842
2485
 
843
- `handlePacket(msg, rinfo)` and `hasConnection(rinfo)` support **external socket
2486
+ `handlePacket(msg, rinfo)` and `hasConnection(rinfo[, msg])` support **external socket
844
2487
  mode**, where QUICO shares a UDP port with another protocol (STUN/TURN, for example)
845
2488
  and you route datagrams yourself.
846
2489
 
@@ -849,22 +2492,37 @@ and you route datagrams yourself.
849
2492
  | Member | Description |
850
2493
  |---|---|
851
2494
  | `quic.state` | `'idle'`, `'handshaking'`, `'connected'`, `'closing'`, `'draining'`, `'closed'` |
852
- | `quic.sendStream(id, data, fin)` | Write to a stream |
853
- | `quic.sendDatagram(data)` | Send an unreliable datagram |
854
- | `quic.maxDatagramSize()` | Largest datagram payload that fits the current path |
855
- | `quic.close(errorCode, reason)` | Send CONNECTION_CLOSE |
2495
+ | `quic.sendStream(id, data, fin)` | Write to a stream. A new stream beyond the peer's `MAX_STREAMS` waits until the peer raises the limit. |
2496
+ | `quic.whenStreamCommitted(id, cb)` | `cb()` once every byte and the FIN of the stream are acknowledged ("Data Recvd"); never if it is reset first |
2497
+ | `quic.resetStream(id, code[, reliableSize])` | Abandon the sending part of a stream (RESET_STREAM); with `reliableSize`, its first bytes still delivered (RESET_STREAM_AT, when the peer takes it: `quic.resetStreamAtSupported`) |
2498
+ | `quic.stopSending(id, code)` | Ask the peer to stop sending on a stream (STOP_SENDING) |
2499
+ | `quic.sendDatagram(data[, prio])` | Send an unreliable datagram; `false` if not connected or too large. With `prio` (`{ urgency, group, order }`) it is scheduled among the streams instead of ahead of them |
2500
+ | `quic.setStreamPriority(id, { urgency, incremental, group, order }[, fromPeer])` | A stream's place in the scheduler — see [Stream scheduling](#stream-scheduling-byte-level-round-robin-by-priority). `fromPeer`: the peer's signal, which does not replace what the application set |
2501
+ | `quic.getStreamPriority(id)` | What is set for a stream, or `null` |
2502
+ | `quic.canOpenStream(bidi)` | Whether a stream of ours could be opened now, within the peer's `MAX_STREAMS` |
2503
+ | `quic.isReceiving(id)` | Whether a stream's receiving part is open (opened, and neither finished nor reset) |
2504
+ | `quic.maxDatagramSize()` | Largest datagram payload that fits one packet (a local estimate) |
2505
+ | `quic.close(errorCode, reason[, application])` | Send CONNECTION_CLOSE; `application: true` for application error codes |
2506
+ | `quic.remoteAddress` / `remotePort` | The peer's current address — follows NAT rebinding |
2507
+ | `quic.earlyDataStatus` | 0-RTT: `null` (none), `'offered'` (a client's, undecided), `'accepted'`, `'rejected'` |
2508
+ | `quic.isEarlyStream(id)` | Server: whether the stream was opened by 0-RTT data |
2509
+ | `quic.whenConfirmed(cb)` | `cb()` once the handshake is confirmed (RFC 9001 §4.1.2), at once if it is |
2510
+ | `quic.alpnProtocol`, `servername`, `getCertificate()`, `getTicketInfo()`, `isSessionReused()` | What the handshake decided, with `TLSSocket`'s names |
856
2511
  | `quic.on(event, fn)` / `off` | Event subscription |
857
2512
 
858
- **Events:** `'connect'`, `'stream'` (`id, data, fin`), `'datagram'`, `'close'`,
859
- `'error'`, `'keylog'`.
2513
+ **Events:** `'connect'`, `'stream'` (`id, data, fin`), `'stream_reset'` (`id, code,
2514
+ finalSize`), `'stop_sending'` (`id, code`), `'datagram'`, `'path'` (`address, port`),
2515
+ `'peer_transport_params'`, `'close'`, `'error'`, `'keylog'`; a client's 0-RTT: `'early'` (it may
2516
+ send application data now), `'early_accepted'`, `'early_rejected'` (what went early is forgotten — send it again).
2517
+ `'closing'` (`peerClose` — `{ error, application, reason }` when the peer's CONNECTION_CLOSE
2518
+ ended it, `null` when we did) fires once, the moment the connection is over for the
2519
+ application; `'close'` follows when the closing or draining period ends (RFC 9000 §10.2, up to
2520
+ 3 s), which is packet housekeeping. Code that reacts to the end belongs on `'closing'`.
860
2521
 
861
2522
  **Connection options** (when constructing directly): `idleTimeout` (default `30000`),
862
2523
  `handshakeTimeout` (default `10000`), `keepAlive` (`true` → half the idle timeout, or
863
2524
  a number of ms), `alpn`, `hostname`, `rejectUnauthorized`, `ca`.
864
2525
 
865
- ---
866
-
867
-
868
2526
 
869
2527
  ---
870
2528
 
@@ -872,35 +2530,49 @@ a number of ms), `alpn`, `hostname`, `rejectUnauthorized`, `ca`.
872
2530
 
873
2531
  ### Transport parameters
874
2532
 
875
- These are the values QUICO advertises to peers. They are currently compiled-in
876
- defaults; making them configurable is on the roadmap.
2533
+ These are the values QUICO advertises to peers. The windows, stream limits and idle timeout can be
2534
+ set — `createServer({ transportParams })`, `new WebTransport(url, { transportParams })`
2535
+ — by RFC 9000's names in camelCase; a value out of range throws when the server or
2536
+ client is made.
877
2537
 
878
- | Parameter | Default | Meaning |
879
- |---|---|---|
880
- | `initial_max_data` | 1 MB | Connection-level receive window |
881
- | `initial_max_stream_data_bidi_local/remote` | 256 KB | Per-stream receive window |
882
- | `initial_max_stream_data_uni` | 128 KB | Per-unidirectional-stream window |
883
- | `initial_max_streams_bidi` | 100 | Concurrent bidirectional streams |
884
- | `initial_max_streams_uni` | 3 | Concurrent unidirectional streams |
885
- | `max_idle_timeout` | 30 s | Idle before the connection is dropped |
886
- | `max_ack_delay` | 25 ms | Maximum ACK delay we advertise |
887
- | `max_datagram_frame_size` | 65527 | Datagram support (RFC 9221) |
888
- | `active_connection_id_limit` | 4 | Connection IDs we will track |
889
-
890
- Receive windows slide automatically as the application consumes data, so large
891
- downloads are not limited by the initial values.
2538
+ | Parameter | Option | Default | Meaning |
2539
+ |---|---|---|---|
2540
+ | `initial_max_data` | `initialMaxData` | 1 MB | Connection-level receive window |
2541
+ | `initial_max_stream_data_bidi_local` | `initialMaxStreamDataBidiLocal` | 256 KB | Receive window of a bidirectional stream we open |
2542
+ | `initial_max_stream_data_bidi_remote` | `initialMaxStreamDataBidiRemote` | 256 KB | Receive window of one the peer opens |
2543
+ | `initial_max_stream_data_uni` | `initialMaxStreamDataUni` | 128 KB | Receive window of a unidirectional stream the peer opens |
2544
+ | `initial_max_streams_bidi` | `initialMaxStreamsBidi` | 100 | Bidirectional streams the peer may have open at once |
2545
+ | `initial_max_streams_uni` | `initialMaxStreamsUni` | 100 | Unidirectional streams the peer may have open at once (HTTP/3 keeps three for itself; at least 3) |
2546
+ | `max_idle_timeout` | `maxIdleTimeout` | 30 s | Milliseconds without a packet from the peer before the connection is closed — silently, with no CONNECTION_CLOSE (RFC 9000 §10.1); `0`: no limit. The one in force is the **smaller** of the two sides' (RFC 9000 §10.1), and never under three PTOs; `keepAlive: true` pings at half of it |
2547
+ | `disable_active_migration` | `disableActiveMigration` | not sent | `true`: clients may not move to another address (connection migration, RFC 9000 §9). Unset, they may — a phone from Wi-Fi to cellular goes on in the same connection |
2548
+ | `max_ack_delay` | | 25 ms | Maximum ACK delay we advertise |
2549
+ | `max_datagram_frame_size` | | 65527 | Datagram support (RFC 9221) |
2550
+ | `active_connection_id_limit` | | 4 | Connection IDs we will track (and issue to the peer, up to its own) |
2551
+ | `version_information` | `quicVersions` | v1, v2 | The versions offered or chosen (RFC 9368) |
2552
+ | `grease_quic_bit` | | sent | The peer may send the fixed bit as 0 (RFC 9287); not on a shared UDP port |
2553
+ | `reset_stream_at` | | sent | RESET_STREAM_AT is taken (draft-ietf-quic-reliable-stream-reset): a peer's reset can keep its stream's first bytes delivered |
2554
+ | `min_ack_delay` | | 1 ms (16 ms on Windows) | ACK_FREQUENCY and IMMEDIATE_ACK are taken (draft-ietf-quic-ack-frequency) |
2555
+
2556
+ Receive windows slide automatically as data is consumed, and grow (autotuning) while
2557
+ the application keeps up, so large downloads are not limited by the initial values. Stream limits work the same way: as
2558
+ the peer's streams close, `MAX_STREAMS` gives them back, keeping the number open at
2559
+ once, so a long-lived connection is not capped at 100 requests.
892
2560
 
893
2561
  ### Per-connection timers
894
2562
 
895
2563
  ```js
896
2564
  // via the low-level API
897
2565
  new QUICConnection({
898
- idleTimeout: 60000, // default 30000
2566
+ idleTimeout: 60000, // default 30000 — what we advertise (transportParams.maxIdleTimeout)
899
2567
  handshakeTimeout: 10000, // default 10000
900
- keepAlive: true // PING at idleTimeout/2; or a number of ms
2568
+ keepAlive: true // PING at half the idle timeout in force; or a number of ms
901
2569
  });
902
2570
  ```
903
2571
 
2572
+ The idle timeout in force is the smaller of ours and the peer's, as RFC 9000 §10.1 has
2573
+ it — the peer's arrives with its transport parameters; `createQuicClientSocket` takes
2574
+ `idleTimeout` and `keepAlive` too.
2575
+
904
2576
  ---
905
2577
 
906
2578
  ## 🐛 Debugging
@@ -911,6 +2583,7 @@ new QUICConnection({
911
2583
  |---|---|
912
2584
  | `QUICO_DEBUG=1` | Full per-packet trace: every datagram, packet, frame, stream write, and TLS transition. **Very verbose** — it can become the bottleneck it observes, so don't use it for throughput measurements. |
913
2585
  | `QUICO_DEBUG_BBR=1` | One line per round-trip with congestion-control state: `BtlBw`, `RTprop`, `BDP`, `cwnd`, pacing rate. Cheap enough to leave on. |
2586
+ | `QUICO_TRACE_ACK=1` | One line per packet received and per ACK sent — enough to line up with a peer's log when it rejects an acknowledgement. |
914
2587
 
915
2588
  ```bash
916
2589
  QUICO_DEBUG_BBR=1 node server.js
@@ -978,41 +2651,190 @@ quico/
978
2651
  ├── interop/ — QUIC Interop Runner endpoint (Dockerfile + shim)
979
2652
  ├── examples/ — Runnable examples and test suites
980
2653
  └── src/
981
- ├── server.js — Unified server (drop-in for node:https)
2654
+ ├── server.js — Unified server: HTTP/3 + HTTP/2 + HTTP/1.1 (drop-in for node:https)
2655
+ ├── server_stats.js — The server's counters (server.getStats())
982
2656
  ├── client.js — Unified client with H3 → H2 → H1 fallback
983
2657
  ├── agent.js — Connection pooling, coalescing, Alt-Svc cache
984
2658
  ├── streams.js — IncomingMessage, ServerResponse, ClientRequest
2659
+ ├── http_common.js — Shared header and message helpers
2660
+ │
985
2661
  ├── h3.js — HTTP/3 framing, QPACK, WebTransport detection
986
2662
  ├── h3_server.js — HTTP/3 + WebTransport server
987
- ├── h3_client.js — HTTP/3 client (DNS, UDP, QUIC, H3 pipeline)
988
- ├── quic_server.js — UDP listener, connection routing, retry/VN
2663
+ ├── h3_client.js — HTTP/3 client
2664
+ ├── qpack.js — QPACK encoder and decoder tables
2665
+ ├── quic_version.js — QUIC versions: v1 and v2 (RFC 9369), one profile each
2666
+ ├── webtransport.js — WebTransport client
2667
+ ├── wt_stream.js — WebTransport streams (both sides)
2668
+ ├── connect_udp.js — CONNECT-UDP proxy (RFC 9298): templates, capsules, the relay
2669
+ ├── connect_udp_client.js — quico.connectUdp(): CONNECT-UDP's client
2670
+ │
2671
+ ├── h2.js — HTTP/2 connection: frames, streams, flow control
2672
+ ├── h2_frames.js — HTTP/2 frame encoding/decoding
2673
+ ├── h2_server.js — HTTP/2 server
2674
+ ├── h2_client.js — HTTP/2 client and session pool
2675
+ ├── hpack.js — HPACK
2676
+ ├── hpack_primitives.js — Prefixed integers, string literals, Huffman (shared by HPACK and QPACK)
2677
+ │
2678
+ ├── h1.js — HTTP/1.1 parser and serializer
2679
+ ├── h1_server.js — HTTP/1.1 server
2680
+ ├── h1_client.js — HTTP/1.1 client and socket pool
2681
+ ├── tcp_listener.js — TCP accept, TLS, ALPN dispatch and sniffing
2682
+ │
2683
+ ├── ws.js — WebSocket framing, and the WHATWG and ws-style event surfaces
2684
+ ├── ws_server.js — WebSocketServer, upgradeWebSocket
2685
+ ├── ws_client.js — WebSocket client
2686
+ ├── ws_stream.js — WebSocketStream, WebSocketError (experimental)
2687
+ │
2688
+ ├── ja4h.js — JA4H request fingerprint
2689
+ ├── h2_fingerprint.js — HTTP/2 and HTTP/3 fingerprints
2690
+ ├── client_profile.js — profileRequest()
2691
+ │
2692
+ ├── quic_server.js — UDP listener, connection routing, Retry, stateless reset
989
2693
  ├── quic_socket.js — Client-side UDP socket + connection bootstrap
990
2694
  ├── quic_connection.js — QUIC state machine, loss detection, CC, flow control
991
2695
  ├── transport.js — Packet parsing, frame encoding/decoding
992
2696
  ├── crypto.js — AEAD, header protection, key derivation
993
2697
  ├── tls_bridge.js — LemonTLS integration, transport parameters
994
- ├── utils.js — VarInt, ACK ranges, binary helpers
995
- └── webtransport.js — WebTransport client + stream classes
2698
+ └── utils.js — VarInt, binary helpers, debug flags
996
2699
  ```
997
2700
 
998
2701
  ---
999
2702
 
2703
+ ## 🔐 Security
2704
+
2705
+ ### Post-quantum TLS
2706
+
2707
+ With Node 24.7+ (ML-KEM and ML-DSA in `node:crypto`, OpenSSL 3.5), on every version —
2708
+ HTTP/3, HTTP/2, HTTP/1.1 — and checked against OpenSSL 3.5 in both directions:
2709
+
2710
+ - **Key exchange** — the hybrids X25519MLKEM768, SecP256r1MLKEM768 and SecP384r1MLKEM1024
2711
+ (draft-ietf-tls-ecdhe-mlkem), and ML-KEM alone: MLKEM512, MLKEM768, MLKEM1024
2712
+ (draft-ietf-tls-mlkem; MLKEM1024 is CNSA 2.0's). A server takes any of them, post-quantum
2713
+ before classical: the client's first post-quantum group, by the client's order — a
2714
+ HelloRetryRequest for it when the client sent no key share for it (a browser sends one
2715
+ for X25519MLKEM768, its first: one round trip; a CNSA 2.0 client listing MLKEM1024 first
2716
+ gets MLKEM1024). The client
2717
+ offers X25519MLKEM768 by default, as `node:tls` and the browsers do; the others by
2718
+ `ecdhCurve`. Each costs 1–1.7 KB in a ClientHello, so they are not offered unasked.
2719
+ - **Authentication** — ML-DSA-44, -65 and -87 certificates (FIPS 204,
2720
+ draft-ietf-tls-mldsa), and chains signed with ML-DSA, verified and presented. A server
2721
+ can hold an ML-DSA and a classical certificate at once — `key` and `cert` arrays, as in
2722
+ `node:tls` — and presents the one the client's `signature_algorithms` ask for first:
2723
+ a client that puts ML-DSA first gets ML-DSA, a browser (which offers none) gets ECDSA or
2724
+ RSA. The client offers ML-DSA after the classical schemes (an ML-DSA chain is several
2725
+ KB more in every handshake); `sigalgs: 'mldsa65:…'` asks for it first.
2726
+
2727
+ ```js
2728
+ createServer({
2729
+ key: [fs.readFileSync('ecdsa.key'), fs.readFileSync('mldsa.key')],
2730
+ cert: [fs.readFileSync('ecdsa.pem'), fs.readFileSync('mldsa.pem')], // in any order
2731
+ minVersion: 'TLSv1.3', // TLS 1.2 has no post-quantum key exchange
2732
+ }, app);
2733
+ ```
2734
+ - **What was negotiated** — `socket.getNegotiationResult()`: `groupName`
2735
+ (`'MLKEM1024'`, …) and `signatureAlgorithmName` (`'mldsa65'`, …).
2736
+ - **TLS 1.2** has no post-quantum key exchange; a server that still accepts it is open to
2737
+ a client pushed onto it. `minVersion: 'TLSv1.3'` closes it (CNSA 2.0 admits TLS 1.3
2738
+ alone). The default stays `node:tls`'s, TLS 1.2 and up; TLS 1.3's downgrade sentinel
2739
+ (RFC 8446 §4.1.3) is sent and checked either way.
2740
+ - **The cipher suite** is the client's choice among ours, as in `node:tls` — AES-256 for a
2741
+ client that asks for it first; `honorCipherOrder: true` for the server's.
2742
+ - An **empty `key_share`** (RFC 8446 §4.2.8 — the client asks the server to choose; how
2743
+ scanners enumerate a server's groups) is answered with a HelloRetryRequest.
2744
+
2745
+ QUICO parses everything a peer sends, so every buffer a peer can make it hold has a
2746
+ bound — and when a peer breaks the rules, the connection (or the stream) is closed
2747
+ with the error the RFC names, not left to grow.
2748
+
2749
+ - **Stream limits are enforced** before any state is allocated: a stream ID beyond the
2750
+ advertised `MAX_STREAMS` is `STREAM_LIMIT_ERROR`; a frame for a stream that cannot
2751
+ exist is `STREAM_STATE_ERROR`; a closed stream cannot be reopened.
2752
+ - **Every way a stream ends** — FIN, RESET_STREAM, STOP_SENDING, a WebTransport
2753
+ session closing, the connection closing — goes through one release path per layer,
2754
+ so nothing a stream allocated outlives it.
2755
+ - **Reassembly is bounded**: gaps per stream are capped, and out-of-order data costs
2756
+ linear, not quadratic, work.
2757
+ - **The handshake's CRYPTO data** is held to 64 KB past what was read and 128 pieces
2758
+ ahead of a gap (`CRYPTO_BUFFER_EXCEEDED`, RFC 9000 §7.5), each byte copied once; pieces
2759
+ sent again in other boundaries, overlapping, join as they should. A ClientHello split
2760
+ over several Initial packets — a post-quantum one is — is joined the same way. Over TCP,
2761
+ a handshake message declaring more than 128 KB is refused on sight (`illegal_parameter`),
2762
+ before its bytes are kept.
2763
+ - **Transport parameters are checked** before any is used (RFC 9000 §7.4, §18.2): out of
2764
+ range (`ack_delay_exponent` above 20, `max_ack_delay` of 2^14 or more,
2765
+ `active_connection_id_limit` below 2, `max_udp_payload_size` below 1200, a stream
2766
+ count above 2^60), malformed (truncated, an integer that does not fill its value, a
2767
+ connection ID over 20 bytes, a reset token not 16 bytes), sent twice, or — from a client
2768
+ — one only a server sends: `TRANSPORT_PARAMETER_ERROR`, on both sides.
2769
+ - **QUIC frames**: an unknown frame type, or a frame cut short, is
2770
+ `FRAME_ENCODING_ERROR`; a packet with no frames, a frame its packet type may not
2771
+ carry, or a DATAGRAM larger than we allowed, `PROTOCOL_VIOLATION` (§12.4, RFC 9221 §3).
2772
+ - **Connection IDs are bounded**: a peer's retired IDs are not kept, the
2773
+ `RETIRE_CONNECTION_ID`s it has not acknowledged are capped at twice our
2774
+ `active_connection_id_limit` (`CONNECTION_ID_LIMIT_ERROR`, §5.1.2), as are its active IDs; a
2775
+ server issues at most 32 IDs per connection, and every route to a connection goes with it.
2776
+ - **HTTP/3 and QPACK buffers are bounded**: a HEADERS frame larger than the field
2777
+ section size we advertise is refused before it is buffered; unknown frames are
2778
+ skipped as they arrive; a QPACK capacity or literal larger than we advertised is
2779
+ `QPACK_ENCODER_STREAM_ERROR`; bytes already parsed are dropped, not re-scanned.
2780
+ - **0-RTT is off by default**, and on, held to one use per ticket, a freshness window,
2781
+ the same transport parameters, and safe requests only — see [0-RTT](#0-rtt-early-data).
2782
+ - **HTTP/3 framing rules** are enforced: DATA before HEADERS is
2783
+ `H3_FRAME_UNEXPECTED`, a request stream without HEADERS is `H3_REQUEST_INCOMPLETE`;
2784
+ a control-stream frame on a request stream, or one of HTTP/2's reserved frame types
2785
+ anywhere, is `H3_FRAME_UNEXPECTED`; a payload that is not exactly its fields,
2786
+ `H3_FRAME_ERROR`.
2787
+ - **HTTP/3's direction rules** (RFC 9114 §4.6, §5.2, §7.2): neither side takes part in
2788
+ server push, and each holds the other to that — at the server, `PUSH_PROMISE` is
2789
+ `H3_FRAME_UNEXPECTED`, a push stream `H3_STREAM_CREATION_ERROR`, `CANCEL_PUSH`
2790
+ `H3_ID_ERROR`; at the client, `MAX_PUSH_ID` is `H3_FRAME_UNEXPECTED`, a push stream,
2791
+ `PUSH_PROMISE` or `CANCEL_PUSH` `H3_ID_ERROR`. `MAX_PUSH_ID` that goes down, `GOAWAY`
2792
+ that goes up, or a server's `GOAWAY` naming anything but a request stream:
2793
+ `H3_ID_ERROR`. `SETTINGS` with one of HTTP/2's identifiers, one twice, or
2794
+ `SETTINGS_H3_DATAGRAM` / `SETTINGS_ENABLE_CONNECT_PROTOCOL` other than 0 or 1:
2795
+ `H3_SETTINGS_ERROR`.
2796
+ - **HTTP/1.1 line endings**: a request must end its lines with CRLF — a bare LF is `400`,
2797
+ since a server that took one where a hop in front did not would read another message
2798
+ (request smuggling). A response may end them with LF alone, as RFC 9112 §2.2 allows and
2799
+ browsers and curl accept: the client is its last reader, with no one behind it to
2800
+ disagree. A bare CR is refused either way.
2801
+ - **Key shares** (RFC 8446 §4.2.8): one of the wrong length, an ML-KEM key that is not
2802
+ one, a share for a group the client did not list, two for one group, or `key_share`
2803
+ without `supported_groups` — a fatal alert, never a handshake in a group the client did
2804
+ not agree to.
2805
+ - **Our own failures are ours**: an exception inside the HTTP/3 code closes with
2806
+ `H3_INTERNAL_ERROR`, not a code that blames the peer; one inside the QUIC packet path
2807
+ drops that packet and is counted (`quic.context.internal_errors`).
2808
+ - **Fuzzed**: every parser in front of a peer's bytes — QUIC frames, transport
2809
+ parameters, packet headers, HTTP/1.1, WebSocket, HPACK and HTTP/2 connections, QPACK,
2810
+ capsules — and HTTP/3 and QUIC frames on live connections, with mutated valid input and
2811
+ noise; none ends in an exception of its own, a hang, or an internal-error close.
2812
+ - **Closing says so**: `server.close()` lets requests in flight finish, then sends each
2813
+ connection its `CONNECTION_CLOSE` and closes the socket only once those have left, so
2814
+ clients learn at once rather than by their idle timeout.
2815
+
2816
+ To report a vulnerability, please see [SECURITY.md](./SECURITY.md) — or write to
2817
+ **support@quicojs.dev** — rather than opening a public issue.
2818
+
2819
+ ---
2820
+
1000
2821
  ## ⚠️ Known Limitations
1001
2822
 
1002
2823
  Being explicit about what is **not** implemented yet:
1003
2824
 
1004
2825
  | Area | Status |
1005
2826
  |---|---|
1006
- | **0-RTT / session resumption** | Not implemented — every connection is a full handshake. |
1007
- | **ChaCha20-Poly1305** | Not implemented. AES-128-GCM and AES-256-GCM only. On hardware without AES acceleration this costs performance. |
1008
- | **Connection migration** | Not implemented; QUICO advertises `disable_active_migration`. Connections do not survive a client IP change. |
2827
+ | **0-RTT** | HTTP/3, HTTP/2 and HTTP/1.1 (TLS 1.3); not DTLS. The client sends early only `GET`, `HEAD` and `OPTIONS` requests. Over HTTP/3, what it sends early assumes the default SETTINGS (no QPACK dynamic table), not the ones it was given last time — the safe choice, at the cost of a few header bytes. |
2828
+ | **ECH** | Server and client (see [Encrypted Client Hello](#encrypted-client-hello-ech)). The client takes the key list from the application — it does not resolve HTTPS records itself. TLS only, not DTLS. |
2829
+ | **Client-initiated migration** | The server takes a client that moves; the QUICO client itself has no API to move to another local address. |
1009
2830
  | **ECN** | Not implemented — Node's `dgram` does not expose the required socket options. |
1010
- | **Retry / address validation** | Not implemented (client does not consume Retry tokens; server does not issue them). |
1011
- | **Anti-amplification limit** | Not enforced (RFC 9000 §8.1). |
1012
- | **Stream priority** (RFC 9218) | Not implemented. |
1013
- | **Delayed ACK** | Not implemented — an ACK is sent per ack-eliciting packet. Correct, but more packets than necessary. |
1014
- | **`MAX_STREAMS` issuance** | Streams limits are honoured but not proactively raised, capping very-high-concurrency workloads. |
1015
- | **TypeScript types** | Not shipped yet. |
2831
+ | **Path MTU discovery** | DPLPMTUD up to 1452-byte datagrams (see [Path MTU discovery](#path-mtu-discovery)). Its probes rely on the Don't Fragment bit, which Node's `dgram` cannot set: Linux sets it on UDP by default, Windows does not — there a probe can cross fragmented and be taken for a fit. |
2832
+ | **Request bodies (`quico.request`)** | Streamed; only the first 64 KB are kept for sending again, so a larger body that started over one protocol cannot move to another. |
2833
+ | **Server push** | Not implemented, on either side: the server sends no `PUSH_PROMISE`, the client allows none (it sends no `MAX_PUSH_ID`). Browsers have dropped it too. |
2834
+ | **Post-quantum TLS** | Needs ML-KEM and ML-DSA in `node:crypto` (Node 24.7+, OpenSSL 3.5). Before that, classical only — `node:tls` there offers the hybrid through OpenSSL, which lemon-tls cannot reach. The hybrid ClientHello is ~1.4 KB: two QUIC Initial packets rather than one. Public CAs do not issue ML-DSA certificates yet. |
2835
+ | **WebSocket** | No permessage-deflate. The client connects over HTTP/1.1 only. |
2836
+ | **CONNECT-UDP** | Context ID 0 only (UDP payloads) — no CONNECT-IP, no extensions that register contexts. The client opens a connection per socket (no pooling of tunnels on one connection). |
2837
+ | **Other transport parameters** | `max_ack_delay` and the rest are compiled in (see [Configuration](#-configuration--tuning)); the windows, stream limits, idle timeout and `disableActiveMigration` can be set. |
1016
2838
 
1017
2839
  Performance has not been formally benchmarked. QUICO is pure JavaScript; expect it to
1018
2840
  be slower than native implementations, especially for bulk transfer.
@@ -1029,28 +2851,33 @@ be slower than native implementations, especially for bulk transfer.
1029
2851
  - Loss detection, PTO with exponential backoff (RFC 9002)
1030
2852
  - BBR-style congestion control with pacing
1031
2853
  - Bidirectional flow control with sliding windows
2854
+ - Stream limits enforced in both directions, raised with `MAX_STREAMS`
2855
+ - Delayed and batched ACKs, piggybacked on outgoing packets (RFC 9000 §13.2)
2856
+ - Retry / address validation and the anti-amplification limit
2857
+ - NAT rebinding with path validation
2858
+ - Stateless reset
2859
+ - Session resumption, server and client
2860
+ - 0-RTT, server and client, over QUIC (RFC 9001 §4.6) and TLS 1.3 on TCP (RFC 8446 §4.2.10), with RFC 8470's 425; off by default
1032
2861
  - HTTP/3 server and client (RFC 9114)
1033
2862
  - QPACK with static and dynamic tables (RFC 9204)
2863
+ - HTTP/2 and HTTP/1.1, implemented in QUICO — one stack for all three versions
1034
2864
  - WebTransport client and server — streams + datagrams (RFC 9297)
2865
+ - WebSocket over HTTP/1.1, HTTP/2 (RFC 8441) and HTTP/3 (RFC 9220)
1035
2866
  - Unreliable datagrams (RFC 9221)
2867
+ - CONNECT-UDP proxy and client (RFC 9298)
2868
+ - Stream priorities (RFC 9218) for HTTP/2 and HTTP/3, and WebTransport's `sendOrder` / `sendGroup`
2869
+ - GOAWAY, 103 Early Hints
2870
+ - JA4H, HTTP/2 and HTTP/3 fingerprints, `profileRequest()`
1036
2871
  - `node:https`-style API — `createServer`, `request`, `get`, `Agent`
1037
2872
  - Express / Fastify / Koa support
1038
2873
  - Connection pooling, coalescing and multiplexing
1039
2874
  - TLS keylog export
1040
2875
  - **Interop: 6/6 implementations green** (handshake, transfer, http3, both directions)
1041
2876
 
1042
- ### 🔄 In Progress
1043
- - Delayed ACK and ACK piggybacking
1044
- - 0-RTT and session resumption
1045
- - Configurable transport parameters
1046
-
1047
2877
  ### ⏳ Planned
1048
- - Connection migration (PATH_CHALLENGE / PATH_RESPONSE)
1049
2878
  - ChaCha20-Poly1305 (0x1303)
1050
- - Retry / address validation + anti-amplification limit
1051
- - GOAWAY and graceful shutdown
1052
- - Stream priority (RFC 9218)
1053
- - TypeScript type definitions
2879
+ - Backpressure from a slow application reader into QUIC flow control
2880
+ - TypeScript type definitions, kept in step with the API
1054
2881
  - Performance benchmarks
1055
2882
  - Fuzz testing
1056
2883