quico 0.2.0 → 0.4.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 +854 -125
- package/index.js +8 -2
- package/package.json +2 -2
- package/src/agent.js +67 -1
- package/src/client.js +40 -6
- package/src/crypto.js +37 -5
- package/src/h3.js +52 -43
- package/src/h3_client.js +103 -79
- package/src/h3_server.js +52 -183
- package/src/quic_connection.js +1397 -123
- package/src/quic_server.js +372 -0
- package/src/quic_socket.js +127 -0
- package/src/server.js +30 -22
- package/src/streams.js +15 -1
- package/src/tls_bridge.js +116 -14
- package/src/transport.js +143 -34
- package/src/utils.js +59 -4
- package/src/webtransport.js +25 -78
package/README.md
CHANGED
|
@@ -7,82 +7,157 @@
|
|
|
7
7
|
<em>Pure JavaScript implementation of QUIC, HTTP/3, QPACK & WebTransport for Node.js</em>
|
|
8
8
|
</p>
|
|
9
9
|
|
|
10
|
+
<p align="center">
|
|
11
|
+
<strong>The entire HTTP/3 stack — QUIC, TLS 1.3, QPACK, congestion control — in JavaScript you can read.</strong>
|
|
12
|
+
</p>
|
|
13
|
+
|
|
10
14
|
<p align="center">
|
|
11
15
|
<a href="https://www.npmjs.com/package/quico">
|
|
12
16
|
<img src="https://img.shields.io/npm/v/quico?color=blue" alt="npm">
|
|
13
17
|
</a>
|
|
14
|
-
<img src="https://img.shields.io/badge/status-
|
|
18
|
+
<img src="https://img.shields.io/badge/status-beta-yellow" alt="status">
|
|
19
|
+
<img src="https://img.shields.io/badge/interop-6%2F6%20implementations-brightgreen" alt="interop">
|
|
15
20
|
<img src="https://img.shields.io/github/license/colocohen/quico?color=brightgreen" alt="license">
|
|
16
21
|
</p>
|
|
17
22
|
|
|
18
23
|
---
|
|
19
24
|
|
|
20
|
-
> **⚠️ Project status:
|
|
21
|
-
>
|
|
25
|
+
> **⚠️ Project status: Beta — active development.**
|
|
26
|
+
> The core transport is verified against six independent QUIC implementations
|
|
27
|
+
> (see [Interoperability](#-interoperability)), but APIs may still change before
|
|
28
|
+
> v1.0. Please report issues!
|
|
22
29
|
|
|
23
30
|
---
|
|
24
31
|
|
|
25
|
-
|
|
26
32
|
## Table of Contents
|
|
33
|
+
|
|
27
34
|
1. [What is QUIC / HTTP/3?](#-what-is-quic--http3)
|
|
28
35
|
2. [Why QUICO?](#-why-quico)
|
|
29
|
-
3. [
|
|
30
|
-
4. [
|
|
31
|
-
5. [
|
|
32
|
-
6. [
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
3. [Requirements & Installation](#-requirements--installation)
|
|
37
|
+
4. [Quick Start](#-quick-start)
|
|
38
|
+
5. [Features](#-features)
|
|
39
|
+
6. [Design Choices](#-design-choices)
|
|
40
|
+
- [The send model: range reconciliation](#the-send-model-range-reconciliation-not-retransmission)
|
|
41
|
+
- [Congestion control](#congestion-control-bbr-style-not-loss-based)
|
|
42
|
+
- [Stream scheduling](#stream-scheduling-byte-level-round-robin)
|
|
43
|
+
- [Loss detection](#loss-detection-timers-and-how-we-treat-late-acks)
|
|
44
|
+
- [Flow control](#flow-control-consumption-based-windows)
|
|
45
|
+
7. [Interoperability](#-interoperability)
|
|
46
|
+
8. [API Reference](#-api-reference)
|
|
47
|
+
- [`createServer()`](#quicocreateserveroptions-requestlistener)
|
|
48
|
+
- [`request()` / `get()`](#quicorequesturl-options-callback)
|
|
49
|
+
- [`IncomingMessage`](#incomingmessage) · [`ServerResponse`](#serverresponse) · [`ClientRequest`](#clientrequest)
|
|
50
|
+
- [`Agent`](#agent) · [`createSecureContext()`](#quicocreatesecurecontextoptions)
|
|
51
|
+
- [Framework compatibility](#framework-compatibility)
|
|
52
|
+
- [WebTransport](#webtransport)
|
|
53
|
+
- [Low-level QUIC API](#low-level-quic-api)
|
|
54
|
+
9. [Configuration & Tuning](#-configuration--tuning)
|
|
55
|
+
10. [Debugging](#-debugging)
|
|
56
|
+
11. [Testing](#-testing)
|
|
57
|
+
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)
|
|
39
64
|
|
|
65
|
+
---
|
|
40
66
|
|
|
41
67
|
## ⚡ What is QUIC / HTTP/3?
|
|
42
68
|
|
|
43
|
-
**QUIC
|
|
69
|
+
**QUIC is the future of Internet transport protocols.** Created at Google,
|
|
70
|
+
standardized by the IETF as [RFC 9000](https://www.rfc-editor.org/rfc/rfc9000), QUIC
|
|
71
|
+
powers **HTTP/3** and is already deployed at scale by the world's largest platforms.
|
|
72
|
+
It was specifically designed to overcome the limitations of TCP and deliver a faster,
|
|
73
|
+
smoother, and more resilient web.
|
|
44
74
|
|
|
45
75
|
Key advantages of QUIC include:
|
|
46
76
|
|
|
47
|
-
- **Eliminating bottlenecks
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
77
|
+
- **Eliminating bottlenecks** — With TCP, data must arrive strictly in order. If a
|
|
78
|
+
single packet is lost or delayed, *all* subsequent packets are blocked until it
|
|
79
|
+
arrives — a phenomenon called **head-of-line blocking**. QUIC removes this
|
|
80
|
+
bottleneck by running over UDP and multiplexing independent streams: each stream
|
|
81
|
+
makes progress on its own, and a loss on one never stalls the others.
|
|
52
82
|
|
|
53
|
-
- **
|
|
83
|
+
- **UDP efficiency** — By running over UDP, QUIC bypasses decades of kernel-level TCP
|
|
84
|
+
constraints, enabling fast connection handling even on constrained devices such as
|
|
85
|
+
smartphones, IoT hardware, or edge gateways.
|
|
54
86
|
|
|
55
|
-
- **
|
|
87
|
+
- **Security by default** — TLS 1.3 is built directly into the protocol. Every
|
|
88
|
+
connection is encrypted — no exceptions, no downgrade paths. Even the packet
|
|
89
|
+
headers are cryptographically protected.
|
|
56
90
|
|
|
57
|
-
- **
|
|
91
|
+
- **Seamless mobility** — Connections are identified by connection IDs rather than
|
|
92
|
+
IP/port 4-tuples, so a session can survive a device moving across networks
|
|
93
|
+
(Wi-Fi → 4G/5G) or changing addresses.
|
|
58
94
|
|
|
95
|
+
- **Lower latency** — QUIC merges the transport and TLS handshakes into a single
|
|
96
|
+
exchange, significantly reducing connection setup time.
|
|
59
97
|
|
|
60
|
-
|
|
98
|
+
- **Smarter congestion control** — Modern algorithms such as BBR continuously measure
|
|
99
|
+
bandwidth and round-trip time instead of only reacting to loss, adapting to
|
|
100
|
+
real-world network conditions in real time.
|
|
61
101
|
|
|
62
|
-
|
|
63
|
-
|
|
102
|
+
QUIC combines **UDP speed, TCP reliability, mandatory TLS security, and adaptive
|
|
103
|
+
multiplexing** into one transport layer.
|
|
64
104
|
|
|
105
|
+
**HTTP/3** ([RFC 9114](https://www.rfc-editor.org/rfc/rfc9114)) is the layer on top:
|
|
106
|
+
the evolution of the web's application protocol. Instead of riding over TCP, HTTP/3
|
|
107
|
+
maps requests and responses directly onto QUIC streams, with
|
|
108
|
+
[QPACK](https://www.rfc-editor.org/rfc/rfc9204) header compression designed to avoid
|
|
109
|
+
reintroducing head-of-line blocking. The result: faster page loads and real-time
|
|
110
|
+
applications that scale across the modern web.
|
|
65
111
|
|
|
112
|
+
---
|
|
66
113
|
|
|
67
114
|
## 🧠 Why QUICO?
|
|
68
115
|
|
|
69
|
-
Node.js is the backbone of countless modern web applications
|
|
70
|
-
|
|
71
|
-
|
|
116
|
+
Node.js is the backbone of countless modern web applications — yet it has **no native
|
|
117
|
+
QUIC implementation**. The browser you're reading this in speaks HTTP/3. The CDN that
|
|
118
|
+
served this page speaks HTTP/3. Your Node server? Stuck on TCP.
|
|
119
|
+
|
|
120
|
+
Why? Supporting QUIC inside Node.js requires **deep access to TLS internals** that
|
|
121
|
+
Node's existing TLS APIs simply don't expose. TLS-over-TCP hands you an encrypted
|
|
122
|
+
*byte stream*; QUIC needs something entirely different — the raw **handshake secrets**
|
|
123
|
+
at each encryption level, so it can derive its own packet-protection keys and encrypt
|
|
124
|
+
each packet individually. `node:tls` was never built to hand those out. Beyond the
|
|
125
|
+
crypto, QUIC demands a genuinely complex architecture: interlocking state machines
|
|
126
|
+
across three packet-number spaces, a packet scheduler with pacing, loss detection
|
|
127
|
+
timers, and congestion control.
|
|
128
|
+
|
|
129
|
+
**QUICO brings this missing capability directly into the Node.js ecosystem.** It is a
|
|
130
|
+
from-scratch JavaScript implementation of QUIC, HTTP/3 and WebTransport, built without
|
|
131
|
+
OpenSSL and without a single line of native code. The cryptographic layer is
|
|
132
|
+
[LemonTLS](https://github.com/colocohen/lemon-tls) — a pure JavaScript TLS 1.3 library
|
|
133
|
+
built by the same author specifically to expose what QUIC needs. Together, QUICO and
|
|
134
|
+
LemonTLS form a complete, fully auditable JavaScript networking stack from the UDP
|
|
135
|
+
socket all the way up to HTTP/3.
|
|
136
|
+
|
|
137
|
+
No build tools. No platform-specific binaries. No node-gyp. It runs anywhere Node runs
|
|
138
|
+
— Linux, macOS, Windows, ARM, Docker, edge — and when something breaks at 3 AM, the
|
|
139
|
+
stack trace ends in code you can open, not in a `.node` binary.
|
|
72
140
|
|
|
73
|
-
|
|
141
|
+
---
|
|
74
142
|
|
|
75
|
-
|
|
76
|
-
- **Works everywhere Node.js runs** — Linux, macOS, Windows, ARM, Docker, edge
|
|
77
|
-
- **Debuggable top to bottom** — every packet, every frame, every byte is JavaScript you can step through
|
|
143
|
+
## 📋 Requirements & Installation
|
|
78
144
|
|
|
79
|
-
|
|
80
|
-
|
|
145
|
+
| Requirement | Notes |
|
|
146
|
+
|---|---|
|
|
147
|
+
| **Node.js 18+** | ESM only (`import`). Tested on 18, 20 and 22. |
|
|
148
|
+
| **No compiler** | Pure JavaScript — no native addons. |
|
|
81
149
|
|
|
82
150
|
```bash
|
|
83
151
|
npm install quico
|
|
84
152
|
```
|
|
85
153
|
|
|
154
|
+
QUICO is an **ES module**. Use `import`, and make sure your `package.json` has
|
|
155
|
+
`"type": "module"` (or use the `.mjs` extension).
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## 📦 Quick Start
|
|
160
|
+
|
|
86
161
|
### Server
|
|
87
162
|
|
|
88
163
|
```js
|
|
@@ -90,7 +165,7 @@ import quico from 'quico';
|
|
|
90
165
|
import fs from 'node:fs';
|
|
91
166
|
|
|
92
167
|
const server = quico.createServer({
|
|
93
|
-
key:
|
|
168
|
+
key: fs.readFileSync('server.key'),
|
|
94
169
|
cert: fs.readFileSync('server.crt')
|
|
95
170
|
}, (req, res) => {
|
|
96
171
|
res.writeHead(200, { 'content-type': 'text/plain' });
|
|
@@ -108,48 +183,521 @@ server.listen(4433, () => {
|
|
|
108
183
|
import quico from 'quico';
|
|
109
184
|
|
|
110
185
|
quico.request('https://www.google.com/', (res) => {
|
|
111
|
-
console.log('Status:',
|
|
112
|
-
console.log('Protocol:', res.httpVersion);
|
|
186
|
+
console.log('Status:', res.statusCode); // 200
|
|
187
|
+
console.log('Protocol:', res.httpVersion); // "3.0"
|
|
113
188
|
res.on('data', (chunk) => process.stdout.write(chunk));
|
|
114
|
-
});
|
|
189
|
+
}).end();
|
|
115
190
|
```
|
|
116
191
|
|
|
117
|
-
>
|
|
192
|
+
> ⚠️ Don't forget `.end()` — like `node:https`, the request isn't sent until you call it.
|
|
193
|
+
|
|
194
|
+
> 📂 More examples in [`examples/`](./examples)
|
|
195
|
+
|
|
196
|
+
---
|
|
118
197
|
|
|
119
198
|
|
|
120
199
|
## ✨ Features
|
|
121
200
|
|
|
122
201
|
### HTTP/3 Server & Client
|
|
123
|
-
- Full request/response cycle over QUIC
|
|
124
|
-
- QPACK header compression
|
|
125
|
-
-
|
|
126
|
-
-
|
|
127
|
-
-
|
|
128
|
-
|
|
129
|
-
-
|
|
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
|
|
210
|
+
|
|
211
|
+
### QUIC Transport
|
|
212
|
+
- QUIC v1 (RFC 9000) — Initial, Handshake and 1-RTT packet spaces
|
|
213
|
+
- TLS 1.3 handshake via LemonTLS, no OpenSSL (RFC 9001)
|
|
214
|
+
- Version Negotiation (RFC 9000 §6)
|
|
215
|
+
- HelloRetryRequest — X25519, P-256, P-384
|
|
216
|
+
- AES-128-GCM and AES-256-GCM
|
|
217
|
+
- Key Update — bidirectional key phase rotation (RFC 9001 §6)
|
|
218
|
+
- Connection ID management and packet coalescing
|
|
219
|
+
- Stream and connection-level flow control with sliding windows
|
|
220
|
+
- Loss detection with PTO and exponential backoff (RFC 9002)
|
|
221
|
+
- BBR-style congestion control with pacing
|
|
222
|
+
- Unreliable datagrams (RFC 9221)
|
|
223
|
+
- Stateless reset, idle timeout, keep-alive
|
|
224
|
+
- TLS keylog export for Wireshark decryption
|
|
130
225
|
|
|
131
226
|
### WebTransport
|
|
132
|
-
- Client and server
|
|
133
|
-
- Bidirectional and unidirectional streams (Node.js Duplex
|
|
134
|
-
- Unreliable datagrams
|
|
227
|
+
- Client and server (RFC 9297 + draft-ietf-webtrans-http3)
|
|
228
|
+
- Bidirectional and unidirectional streams (Node.js Duplex API)
|
|
229
|
+
- Unreliable datagrams
|
|
135
230
|
- Natural API: `req.on('stream')`, `res.sendDatagram()`, `res.writeHead(200)` to accept
|
|
136
231
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## 🧬 Design Choices
|
|
235
|
+
|
|
236
|
+
Two QUIC implementations can both be fully conformant and still behave completely
|
|
237
|
+
differently on the wire. RFC 9000 defines *what* must happen; it deliberately leaves
|
|
238
|
+
*how* to the implementer. This section documents where QUICO made a choice — the
|
|
239
|
+
places where another implementation would reasonably do something else.
|
|
240
|
+
|
|
241
|
+
### The send model: range reconciliation, not retransmission
|
|
242
|
+
|
|
243
|
+
This is the foundational choice, and the one that most distinguishes QUICO from other
|
|
244
|
+
implementations.
|
|
245
|
+
|
|
246
|
+
**How it's normally done.** RFC 9002 describes tracking each packet you send along
|
|
247
|
+
with the frames it contained. When loss detection fires, you look up that packet,
|
|
248
|
+
recover its frames, and put them back in a retransmission queue. The sender is
|
|
249
|
+
*procedural*: an event happens (loss), and you perform a corrective action
|
|
250
|
+
(re-enqueue). Almost every QUIC stack works this way.
|
|
251
|
+
|
|
252
|
+
**What QUICO does instead.** There is no `sent_packets` table, no stored frame copies,
|
|
253
|
+
and no retransmission queue — those concepts don't exist in the codebase. Each stream
|
|
254
|
+
keeps only three things:
|
|
255
|
+
|
|
256
|
+
```js
|
|
257
|
+
{
|
|
258
|
+
pending_data: Uint8Array, // the bytes the application wrote
|
|
259
|
+
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
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Every send opportunity asks one question, and it's a set operation:
|
|
265
|
+
|
|
266
|
+
```js
|
|
267
|
+
missing = invert(acked_ranges ∪ in_flight_ranges, 0, total_size)
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
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.
|
|
274
|
+
|
|
275
|
+
**Loss detection, therefore, is one line:**
|
|
276
|
+
|
|
277
|
+
```js
|
|
278
|
+
delete st.in_flight_ranges[pn]; // → those bytes are "missing" again
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Deleting the mapping is the entire recovery action. Nothing is queued, scheduled, or
|
|
282
|
+
flagged. The bytes simply reappear in the next `missing` computation and get sent
|
|
283
|
+
along with everything else.
|
|
284
|
+
|
|
285
|
+
**The consequence: a packet is a disposable container, not an identity.** The data
|
|
286
|
+
that was in lost packet #47 doesn't come back *as* packet #47's contents. It comes
|
|
287
|
+
back as *bytes that need sending*, and the next burst may:
|
|
288
|
+
|
|
289
|
+
- **re-slice them differently** — the chunk boundaries depend on that burst's byte
|
|
290
|
+
budget, so a lost 1200-byte range might go out as 400 bytes appended to one packet
|
|
291
|
+
and 800 in the next;
|
|
292
|
+
- **coalesce them with new data** from the same stream, in a single STREAM frame;
|
|
293
|
+
- **pack them alongside other streams'** data in the same packet, via the round-robin
|
|
294
|
+
scheduler below.
|
|
295
|
+
|
|
296
|
+
Packet numbers and stream byte-offsets are fully decoupled: PNs are transient
|
|
297
|
+
bookkeeping for the loss timer, while the byte range is the only durable identity of
|
|
298
|
+
data. This is possible because QUIC frames carry explicit offsets — the receiver
|
|
299
|
+
reassembles by offset and genuinely does not care how the bytes were packetized.
|
|
300
|
+
|
|
301
|
+
**Why this design:**
|
|
302
|
+
|
|
303
|
+
- **State can't drift.** A retransmission queue is mutable state maintained
|
|
304
|
+
incrementally, and every incremental structure can desynchronize from reality —
|
|
305
|
+
entries duplicated, entries dropped, a range acked while a stale copy still sits in
|
|
306
|
+
the queue. Range arithmetic derives the truth fresh each pass, so a byte range can
|
|
307
|
+
neither be forgotten nor sent twice by bookkeeping error.
|
|
308
|
+
- **Recovery is free.** No priority juggling between "retransmit queue" and "new data
|
|
309
|
+
queue"; repairs and new data are the same thing, drawn from one computation and
|
|
310
|
+
paced by the same congestion controller.
|
|
311
|
+
- **Late information is harmless.** An ACK arriving after the loss timer already fired
|
|
312
|
+
isn't a contradiction to reconcile — it just adds a range to `acked_ranges`, and the
|
|
313
|
+
next pass naturally stops sending those bytes.
|
|
314
|
+
|
|
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.
|
|
324
|
+
|
|
325
|
+
### Congestion control: BBR-style, not loss-based
|
|
326
|
+
|
|
327
|
+
**The choice:** most QUIC stacks ship a loss-based controller — quic-go uses Reno with
|
|
328
|
+
HyStart++, others default to CUBIC. QUICO implements a **model-based controller in the
|
|
329
|
+
BBRv1 family** instead.
|
|
330
|
+
|
|
331
|
+
The difference is philosophical. Loss-based algorithms grow the window until packets
|
|
332
|
+
drop, which means they *require* queue overflow to find the limit — and they misread
|
|
333
|
+
random wireless loss as congestion. BBR builds an explicit model of the path and
|
|
334
|
+
steers by it:
|
|
335
|
+
|
|
336
|
+
```
|
|
337
|
+
BDP = BtlBw × RTprop
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
- **BtlBw** (bottleneck bandwidth) — a **windowed maximum** of per-round delivery rate
|
|
341
|
+
over the last 10 rounds. Sampling happens once per round-trip, not per ACK: under
|
|
342
|
+
ACK aggregation, per-ACK samples spike and a windowed-max latches onto the spike.
|
|
343
|
+
- **RTprop** (round-trip propagation) — a **windowed minimum** of RTT over 10 seconds,
|
|
344
|
+
so queue-induced inflation decays out of the model.
|
|
345
|
+
|
|
346
|
+
Three states drive the two output knobs:
|
|
347
|
+
|
|
348
|
+
| State | Pacing gain | Purpose |
|
|
349
|
+
|---|---|---|
|
|
350
|
+
| `startup` | 2.89 (≈ 2/ln 2) | Exponential ramp to discover BtlBw. Exits after 3 rounds without ≥25% growth — the pipe is full. |
|
|
351
|
+
| `drain` | 1/2.89 | One phase to drain the queue startup necessarily built. |
|
|
352
|
+
| `probe_bw` | `[1.25, 0.75, 1, 1, 1, 1, 1, 1]` | Steady state: probe for more bandwidth, then yield to drain what the probe queued. |
|
|
353
|
+
|
|
354
|
+
The **pacing rate** (`gain × BtlBw`) spreads packets smoothly rather than dumping
|
|
355
|
+
window-sized bursts into the bottleneck; the **congestion window** (`cwnd_gain × BDP`)
|
|
356
|
+
is the in-flight backstop. Both are clamped to floors and ceilings so a single bad
|
|
357
|
+
measurement can never wedge the connection.
|
|
358
|
+
|
|
359
|
+
Watch it live, one line per round-trip:
|
|
360
|
+
|
|
361
|
+
```bash
|
|
362
|
+
QUICO_DEBUG_BBR=1 node yourserver.js
|
|
363
|
+
```
|
|
364
|
+
```
|
|
365
|
+
[bbr] round=5 startup BtlBw=8.96Mbps RTprop=16ms BDP=17920B → cwnd=51789B pace=25.89Mbps
|
|
366
|
+
[bbr] round=9 drain BtlBw=10.98Mbps RTprop=16ms BDP=21952B → cwnd=63441B pace=3.80Mbps
|
|
367
|
+
[bbr] round=15 probe_bw BtlBw=10.98Mbps RTprop=16ms BDP=21952B → cwnd=27440B pace=10.98Mbps
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
That trace is a real run through a 10 Mbps bottleneck with a 25-packet queue: startup
|
|
371
|
+
finds the link in ~7 rounds, drain empties the queue, probe_bw settles within 5% of
|
|
372
|
+
line rate.
|
|
373
|
+
|
|
374
|
+
**Honest scope:** this is BBR-*style*, not a certified BBRv2. ProbeRTT — the periodic
|
|
375
|
+
cwnd collapse that re-measures RTprop on a permanently saturated path — is not
|
|
376
|
+
implemented, and there is no explicit loss response. The windowed filters bound the
|
|
377
|
+
damage of stale measurements in the meantime.
|
|
378
|
+
|
|
379
|
+
### Stream scheduling: byte-level round-robin
|
|
380
|
+
|
|
381
|
+
**The choice:** when several streams have data ready and one packet is going out, who
|
|
382
|
+
gets the space? Implementations differ sharply — many drain streams in priority or
|
|
383
|
+
creation order, filling each one before moving on.
|
|
384
|
+
|
|
385
|
+
QUICO interleaves at the **byte level, inside a single packet**
|
|
386
|
+
([`execute_quic_burst`](./src/quic_connection.js)). The scheduler cycles through all
|
|
387
|
+
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:
|
|
389
|
+
|
|
390
|
+
```
|
|
391
|
+
┌─────────────────────────────── one QUIC packet ───────────────────────────────┐
|
|
392
|
+
│ ACK │ STREAM(id=0, 450B) │ STREAM(id=4, 450B) │ STREAM(id=8, 400B) │
|
|
393
|
+
└───────────────────────────────────────────────────────────────────────────────┘
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
The loop repeats while any stream still yields data and space remains (`progress`
|
|
397
|
+
flag), so a stream that runs dry doesn't hold a slot — the remaining bandwidth
|
|
398
|
+
redistributes to the others within the same packet.
|
|
399
|
+
|
|
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.
|
|
406
|
+
|
|
407
|
+
**The subtle part** falls straight out of the reconciliation model: the sender decides
|
|
408
|
+
what to send by asking which bytes aren't acked or in flight — but chunks selected
|
|
409
|
+
*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.
|
|
413
|
+
|
|
414
|
+
### Within a stream: new data leads, gaps follow
|
|
415
|
+
|
|
416
|
+
Given the `missing` set, in what order do you drain it? QUICO walks it in two passes:
|
|
417
|
+
forward from the send cursor first (new data), then backfilling the gaps below it
|
|
418
|
+
(bytes whose packets were lost).
|
|
419
|
+
|
|
420
|
+
New data leads because it keeps the pipe full and the ACK clock running — the receiver
|
|
421
|
+
buffers out-of-order bytes anyway, and the gap gets repaired within the same burst
|
|
422
|
+
rather than a round-trip later. Both passes read from the same `missing` computation,
|
|
423
|
+
so there's no ordering conflict to arbitrate: it's one set, drained in two sweeps.
|
|
424
|
+
|
|
425
|
+
**Flow control interacts here in a non-obvious way.** Send-side limits cap the
|
|
426
|
+
*highest offset* we may send, so they bind only **new** bytes. A repair re-sends
|
|
427
|
+
offsets that were already inside the allowance when first sent — it consumes no budget
|
|
428
|
+
and must never be blocked. Getting this wrong deadlocks the connection: at the edge of
|
|
429
|
+
an exhausted window, the missing range itself becomes unsendable, and the peer can't
|
|
430
|
+
rescue you, because the data it would open the window for is exactly the data that
|
|
431
|
+
never arrived.
|
|
432
|
+
|
|
433
|
+
### Loss detection: timers, and how we treat late ACKs
|
|
434
|
+
|
|
435
|
+
Because recovery itself is just the reconciliation pass, loss "detection" here means
|
|
436
|
+
only one thing: deciding when to stop counting a packet's bytes as in flight. Two
|
|
437
|
+
decisions worth calling out:
|
|
438
|
+
|
|
439
|
+
- **Late ACKs are honoured, not discarded.** When the timer expires a packet, its
|
|
440
|
+
stream ranges are parked in a *limbo* table rather than forgotten. If the ACK then
|
|
441
|
+
arrives late — routine on a queued path — the bytes are credited to the delivery
|
|
442
|
+
rate model, the pending retransmission is cancelled, and the loss counter is
|
|
443
|
+
corrected. QUIC never reuses packet numbers, so unlike TCP there's no
|
|
444
|
+
retransmission ambiguity: a late ACK is *unambiguous evidence* the data arrived, and
|
|
445
|
+
discarding it distorts BtlBw exactly when the model needs accuracy most.
|
|
446
|
+
- **RTT samples are taken from those packets too** — same reasoning, plus a subtle
|
|
447
|
+
failure mode it prevents: a too-short timer expires packets before their ACKs land,
|
|
448
|
+
and if those ACKs are then barred from producing RTT samples, the timer never learns
|
|
449
|
+
that it's too short. The loop is self-sealing. Accepting the sample breaks it.
|
|
450
|
+
|
|
451
|
+
The PTO itself is `srtt + max(4·rttvar, 1ms) + max_ack_delay`. That last term is not
|
|
452
|
+
optional: peers may legitimately sit on an ACK for their full advertised delay (25 ms
|
|
453
|
+
is common), and a timer without it expires packets moments before their ACKs arrive.
|
|
454
|
+
|
|
455
|
+
**ACK ranges are capped at 32** (RFC 9000 §13.2.4 permits acking a subset). Under
|
|
456
|
+
loss, packet-number gaps never close — a lost PN is gone forever, its data returns
|
|
457
|
+
under a new PN — so an uncapped ACK frame grows with the cumulative loss count until
|
|
458
|
+
it no longer fits in a packet. We measured exactly that against a line-rate sender:
|
|
459
|
+
ACK packets inflating 325 B → 1017 B second by second, until they crossed the MTU and
|
|
460
|
+
stopped being sendable at all.
|
|
461
|
+
|
|
462
|
+
### Flow control: consumption-based windows
|
|
463
|
+
|
|
464
|
+
**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.
|
|
474
|
+
|
|
475
|
+
### Reading the source
|
|
476
|
+
|
|
477
|
+
Files map one-to-one onto protocol layers (see
|
|
478
|
+
[Project Structure](#-project-structure)), state lives in plain objects you can
|
|
479
|
+
`console.log`, and the dense parts carry comments explaining *why*, not just what.
|
|
480
|
+
Start at [`src/quic_connection.js`](./src/quic_connection.js) and follow a packet in
|
|
481
|
+
from `feedDatagram()` or out from `sendStream()`.
|
|
482
|
+
|
|
483
|
+
---
|
|
484
|
+
|
|
485
|
+
## 🔬 Interoperability
|
|
486
|
+
|
|
487
|
+
Everything above is a claim; this section is the receipt.
|
|
488
|
+
|
|
489
|
+
QUICO is tested with the [QUIC Interop Runner](https://github.com/quic-interop/quic-interop-runner),
|
|
490
|
+
the standard cross-implementation test harness. Every test runs both directions
|
|
491
|
+
(QUICO as server *and* as client) through a network simulator with configurable
|
|
492
|
+
delay, bandwidth limits and packet loss.
|
|
493
|
+
|
|
494
|
+
| Peer implementation | Language | `handshake` | `transfer` | `http3` |
|
|
495
|
+
|---|---|:---:|:---:|:---:|
|
|
496
|
+
| [quic-go](https://github.com/quic-go/quic-go) | Go | ✅ | ✅ | ✅ |
|
|
497
|
+
| [ngtcp2](https://github.com/ngtcp2/ngtcp2) | C | ✅ | ✅ | ✅ |
|
|
498
|
+
| [quiche](https://github.com/cloudflare/quiche) (Cloudflare) | Rust | ✅ | ✅ | ✅ |
|
|
499
|
+
| [picoquic](https://github.com/private-octopus/picoquic) | C | ✅ | ✅ | ✅ |
|
|
500
|
+
| [aioquic](https://github.com/aiortc/aioquic) | Python | ✅ | ✅ | ✅ |
|
|
501
|
+
| [neqo](https://github.com/mozilla/neqo) (Firefox) | Rust | ✅ | ✅ | ✅ |
|
|
502
|
+
|
|
503
|
+
- **handshake** — TLS 1.3 handshake over QUIC completes
|
|
504
|
+
- **transfer** — 10 MB across multiple concurrent streams, through a 10 Mbps
|
|
505
|
+
bottleneck with a shallow queue (i.e. with real packet loss)
|
|
506
|
+
- **http3** — three concurrent HTTP/3 requests, which must share **one** QUIC
|
|
507
|
+
connection
|
|
508
|
+
|
|
509
|
+
Additionally verified against production servers: **Google**, **Cloudflare**,
|
|
510
|
+
**Facebook**, **Microsoft**, **LiteSpeed** and **nginx** — including a 12.7 MB
|
|
511
|
+
download from `cloudflaremirrors.com` byte-for-byte.
|
|
512
|
+
|
|
513
|
+
---
|
|
514
|
+
|
|
515
|
+
## 📚 API Reference
|
|
516
|
+
|
|
517
|
+
QUICO mirrors the `node:https` API wherever possible. If a method or property is not
|
|
518
|
+
documented here, it likely behaves exactly as its `node:https` counterpart.
|
|
519
|
+
|
|
520
|
+
This chapter covers the HTTP/3 API first, then framework compatibility, WebTransport,
|
|
521
|
+
and finally the low-level QUIC primitives for non-HTTP protocols.
|
|
522
|
+
|
|
523
|
+
### `quico.createServer([options][, requestListener])`
|
|
524
|
+
|
|
525
|
+
Creates an HTTP/3 server. Returns a server object with `listen()`, `close()` and
|
|
526
|
+
`on()`.
|
|
527
|
+
|
|
528
|
+
**Options**
|
|
529
|
+
|
|
530
|
+
| Option | Type | Default | Description |
|
|
531
|
+
|---|---|---|---|
|
|
532
|
+
| `key` | `Buffer \| string` | — | Private key in PEM format. Required unless `SNICallback` is used. |
|
|
533
|
+
| `cert` | `Buffer \| string` | — | Certificate chain in PEM format. |
|
|
534
|
+
| `ca` | `Buffer \| string \| Array` | `null` | Additional trusted CAs. |
|
|
535
|
+
| `SNICallback` | `Function` | `null` | `(servername, cb) => cb(null, secureContext)` for multi-domain setups. |
|
|
536
|
+
| `http2` | `boolean` | `true` | Also listen for HTTP/2 over TCP (fallback for non-QUIC clients). |
|
|
537
|
+
| `http1` | `boolean` | `true` | Also listen for HTTP/1.1 over TCP. |
|
|
538
|
+
| `maxConnections` | `number` | `10000` | Maximum concurrent QUIC connections. |
|
|
539
|
+
| `socket` | `dgram.Socket` | `null` | Use an externally-bound IPv4 UDP socket instead of creating one. Must already be bound. |
|
|
540
|
+
| `socket6` | `dgram.Socket` | `null` | Same, for IPv6. |
|
|
541
|
+
|
|
542
|
+
Setting `http2: false` and `http1: false` makes the server **HTTP/3 only** — no TCP
|
|
543
|
+
listener is opened.
|
|
544
|
+
|
|
545
|
+
**Methods**
|
|
546
|
+
|
|
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.
|
|
550
|
+
|
|
551
|
+
**Events**
|
|
552
|
+
|
|
553
|
+
- `'request'` — `(req, res)` — same as the `requestListener` argument
|
|
554
|
+
- `'listening'`
|
|
555
|
+
- `'error'` — `(err)`
|
|
556
|
+
|
|
557
|
+
```js
|
|
558
|
+
const server = quico.createServer({ key, cert, http2: false, http1: false });
|
|
559
|
+
|
|
560
|
+
server.on('request', (req, res) => {
|
|
561
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
562
|
+
res.end(JSON.stringify({ url: req.url, version: req.httpVersion }));
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
server.listen(4433, '0.0.0.0');
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
---
|
|
569
|
+
|
|
570
|
+
### `quico.request(url|options[, options], callback)`
|
|
571
|
+
|
|
572
|
+
Issues an HTTP/3 request. Returns a [`ClientRequest`](#clientrequest). Accepts a URL
|
|
573
|
+
string, an options object, or both (options override the URL).
|
|
574
|
+
|
|
575
|
+
**Options**
|
|
576
|
+
|
|
577
|
+
| Option | Type | Default | Description |
|
|
578
|
+
|---|---|---|---|
|
|
579
|
+
| `hostname` / `host` | `string` | `'localhost'` | Target host. |
|
|
580
|
+
| `port` | `number` | `443` | Target port. |
|
|
581
|
+
| `path` | `string` | `'/'` | Request path including query string. |
|
|
582
|
+
| `method` | `string` | `'GET'` | HTTP method. |
|
|
583
|
+
| `headers` | `object` | `{}` | Request headers. |
|
|
584
|
+
| `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. |
|
|
587
|
+
| `http2` | `boolean` | `true` | Allow HTTP/2 fallback. |
|
|
588
|
+
| `http1` | `boolean` | `true` | Allow HTTP/1.1 fallback. |
|
|
589
|
+
| `h3Timeout` | `number` | `3000` | Milliseconds to wait for QUIC before falling back. |
|
|
590
|
+
|
|
591
|
+
```js
|
|
592
|
+
const req = quico.request({
|
|
593
|
+
hostname: 'localhost',
|
|
594
|
+
port: 4433,
|
|
595
|
+
path: '/api/items',
|
|
596
|
+
method: 'POST',
|
|
597
|
+
headers: { 'content-type': 'application/json' },
|
|
598
|
+
rejectUnauthorized: false // self-signed cert in development
|
|
599
|
+
}, (res) => {
|
|
600
|
+
let body = '';
|
|
601
|
+
res.on('data', (c) => body += c);
|
|
602
|
+
res.on('end', () => console.log(res.statusCode, body));
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
req.on('error', console.error);
|
|
606
|
+
req.write(JSON.stringify({ name: 'test' }));
|
|
607
|
+
req.end();
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
### `quico.get(url|options[, options], callback)`
|
|
611
|
+
|
|
612
|
+
Same as `request()` with `method: 'GET'`, and `end()` is called automatically.
|
|
613
|
+
|
|
614
|
+
---
|
|
615
|
+
|
|
616
|
+
### `IncomingMessage`
|
|
617
|
+
|
|
618
|
+
A Node.js `Readable` stream. On the server it is the request; on the client, the
|
|
619
|
+
response.
|
|
620
|
+
|
|
621
|
+
| Property | Description |
|
|
622
|
+
|---|---|
|
|
623
|
+
| `httpVersion` | `'3.0'`, `'2.0'` or `'1.1'` depending on the transport actually used |
|
|
624
|
+
| `headers` | Lower-cased header object |
|
|
625
|
+
| `method` | Request method (server side) |
|
|
626
|
+
| `url` | Request path (server side) |
|
|
627
|
+
| `statusCode` | Response status (client side) |
|
|
628
|
+
| `socket` / `connection` | Compatibility shim for frameworks |
|
|
629
|
+
|
|
630
|
+
Standard `Readable` behaviour applies: `'data'`, `'end'`, `'error'`, `pipe()`,
|
|
631
|
+
`for await (const chunk of req)`, and `setTimeout(ms, cb)`.
|
|
146
632
|
|
|
633
|
+
---
|
|
634
|
+
|
|
635
|
+
### `ServerResponse`
|
|
636
|
+
|
|
637
|
+
A Node.js `Writable` stream.
|
|
638
|
+
|
|
639
|
+
- `res.writeHead(statusCode[, statusMessage][, headers])`
|
|
640
|
+
- `res.setHeader(name, value)` / `getHeader` / `getHeaders` / `getHeaderNames` /
|
|
641
|
+
`hasHeader` / `removeHeader`
|
|
642
|
+
- `res.flushHeaders()`
|
|
643
|
+
- `res.write(chunk)` / `res.end([chunk])`
|
|
644
|
+
- `res.statusCode`, `res.headersSent`
|
|
147
645
|
|
|
148
|
-
|
|
646
|
+
For WebTransport sessions, `ServerResponse` additionally exposes
|
|
647
|
+
`sendDatagram()`, `createBidirectionalStream()` and `createUnidirectionalStream()` —
|
|
648
|
+
see [WebTransport](#-webtransport).
|
|
149
649
|
|
|
150
|
-
|
|
650
|
+
---
|
|
651
|
+
|
|
652
|
+
### `ClientRequest`
|
|
151
653
|
|
|
152
|
-
|
|
654
|
+
A Node.js `Writable` stream.
|
|
655
|
+
|
|
656
|
+
- `req.write(chunk)` / `req.end([chunk])`
|
|
657
|
+
- `req.abort()` / `req.destroy()`
|
|
658
|
+
- `req.protocol` — which transport was actually used: `'h3'`, `'h2'` or `'https'`
|
|
659
|
+
- Events: `'response'`, `'error'`
|
|
660
|
+
|
|
661
|
+
---
|
|
662
|
+
|
|
663
|
+
### `Agent`
|
|
664
|
+
|
|
665
|
+
Connection pool and protocol cache, analogous to `https.Agent`.
|
|
666
|
+
|
|
667
|
+
```js
|
|
668
|
+
import { Agent } from 'quico';
|
|
669
|
+
|
|
670
|
+
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
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
quico.request({ hostname: 'example.com', agent }, handler).end();
|
|
679
|
+
```
|
|
680
|
+
|
|
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.
|
|
684
|
+
|
|
685
|
+
`quico.globalAgent` is the default instance used when no `agent` option is given.
|
|
686
|
+
Call `agent.destroy()` to close all pooled connections.
|
|
687
|
+
|
|
688
|
+
---
|
|
689
|
+
|
|
690
|
+
### `quico.createSecureContext(options)`
|
|
691
|
+
|
|
692
|
+
Creates a TLS context for use with `SNICallback`. Accepts `key`, `cert` and `ca`.
|
|
693
|
+
Re-exported from LemonTLS.
|
|
694
|
+
|
|
695
|
+
---
|
|
696
|
+
|
|
697
|
+
### Framework compatibility
|
|
698
|
+
|
|
699
|
+
A core design goal is a **familiar API**. If you know `node:https`, QUICO should feel
|
|
700
|
+
natural — which means existing frameworks work unmodified:
|
|
153
701
|
|
|
154
702
|
```js
|
|
155
703
|
import express from 'express';
|
|
@@ -162,17 +710,20 @@ quico.createServer({ key: KEY, cert: CERT }, app).listen(4433);
|
|
|
162
710
|
// → { "protocol": "3.0" }
|
|
163
711
|
```
|
|
164
712
|
|
|
165
|
-
|
|
713
|
+
Express, Fastify and Koa are all supported this way.
|
|
714
|
+
|
|
715
|
+
Multi-domain setups use `SNICallback`, exactly as in `node:https`:
|
|
166
716
|
|
|
167
717
|
```js
|
|
168
718
|
import quico from 'quico';
|
|
169
719
|
import tls from 'lemon-tls';
|
|
720
|
+
import fs from 'node:fs';
|
|
170
721
|
|
|
171
722
|
const server = quico.createServer({
|
|
172
|
-
SNICallback:
|
|
723
|
+
SNICallback: (servername, cb) => {
|
|
173
724
|
cb(null, tls.createSecureContext({
|
|
174
|
-
key:
|
|
175
|
-
cert: fs.readFileSync(
|
|
725
|
+
key: fs.readFileSync(`certs/${servername}.key`),
|
|
726
|
+
cert: fs.readFileSync(`certs/${servername}.crt`)
|
|
176
727
|
}));
|
|
177
728
|
}
|
|
178
729
|
}, (req, res) => {
|
|
@@ -180,49 +731,39 @@ const server = quico.createServer({
|
|
|
180
731
|
res.end('Hello World from HTTP/3 on Node.js!');
|
|
181
732
|
});
|
|
182
733
|
|
|
183
|
-
server.listen(4433
|
|
184
|
-
console.log('🚀 QUIC server running on https://localhost:4433');
|
|
185
|
-
});
|
|
734
|
+
server.listen(4433);
|
|
186
735
|
```
|
|
187
736
|
|
|
737
|
+
---
|
|
188
738
|
|
|
189
|
-
|
|
739
|
+
### WebTransport
|
|
190
740
|
|
|
191
|
-
|
|
741
|
+
#### Server
|
|
192
742
|
|
|
193
743
|
```js
|
|
194
744
|
import quico from 'quico';
|
|
195
745
|
|
|
196
746
|
quico.createServer({ key: KEY, cert: CERT }, (req, res) => {
|
|
197
747
|
if (req.headers[':protocol'] === 'webtransport') {
|
|
198
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
req.on('stream', (stream) => {
|
|
203
|
-
stream.on('data', (chunk) => {
|
|
204
|
-
stream.write(chunk); // echo back
|
|
205
|
-
});
|
|
748
|
+
res.writeHead(200); // accept the session
|
|
749
|
+
|
|
750
|
+
req.on('stream', (stream) => { // client-initiated streams
|
|
751
|
+
stream.on('data', (chunk) => stream.write(chunk)); // echo
|
|
206
752
|
stream.on('end', () => stream.end());
|
|
207
753
|
});
|
|
208
754
|
|
|
209
|
-
|
|
210
|
-
req.on('datagram', (data) => {
|
|
211
|
-
res.sendDatagram(data); // echo back
|
|
212
|
-
});
|
|
755
|
+
req.on('datagram', (data) => res.sendDatagram(data));
|
|
213
756
|
|
|
214
|
-
|
|
215
|
-
const push = res.createBidirectionalStream();
|
|
757
|
+
const push = res.createBidirectionalStream(); // server-initiated
|
|
216
758
|
push.write('server says hello');
|
|
217
759
|
push.end();
|
|
218
760
|
} else {
|
|
219
|
-
// Regular HTTP/3 request
|
|
220
761
|
res.end('Hello HTTP/3!');
|
|
221
762
|
}
|
|
222
763
|
}).listen(4433);
|
|
223
764
|
```
|
|
224
765
|
|
|
225
|
-
|
|
766
|
+
#### Client (Node.js)
|
|
226
767
|
|
|
227
768
|
```js
|
|
228
769
|
import { WebTransport } from 'quico';
|
|
@@ -230,111 +771,283 @@ import { WebTransport } from 'quico';
|
|
|
230
771
|
const wt = new WebTransport('https://localhost:4433/echo');
|
|
231
772
|
await wt.ready;
|
|
232
773
|
|
|
233
|
-
// Bidirectional stream
|
|
234
774
|
const stream = await wt.createBidirectionalStream();
|
|
235
775
|
stream.write('hello');
|
|
236
776
|
stream.on('data', (chunk) => console.log('echo:', chunk.toString()));
|
|
237
777
|
stream.end();
|
|
238
778
|
|
|
239
|
-
// Unidirectional stream
|
|
240
779
|
const uni = await wt.createUnidirectionalStream();
|
|
241
780
|
uni.write('fire and forget');
|
|
242
781
|
uni.end();
|
|
243
782
|
|
|
244
|
-
// Datagrams (unreliable)
|
|
245
783
|
wt.sendDatagram(Buffer.from('ping'));
|
|
246
784
|
wt.on('datagram', (data) => console.log('pong:', data.toString()));
|
|
247
785
|
|
|
248
786
|
wt.close();
|
|
249
787
|
```
|
|
250
788
|
|
|
251
|
-
|
|
789
|
+
#### Client (Browser)
|
|
790
|
+
|
|
791
|
+
A QUICO server works with the **native browser WebTransport API** — no client library
|
|
792
|
+
needed:
|
|
252
793
|
|
|
253
794
|
```js
|
|
254
795
|
const wt = new WebTransport('https://yourserver.com:4433/live');
|
|
255
796
|
await wt.ready;
|
|
256
797
|
|
|
257
|
-
// Send via WHATWG Streams API
|
|
258
798
|
const writer = wt.datagrams.writable.getWriter();
|
|
259
799
|
await writer.write(new TextEncoder().encode('Hello QUIC'));
|
|
260
800
|
|
|
261
|
-
// Receive
|
|
262
801
|
const reader = wt.datagrams.readable.getReader();
|
|
263
802
|
const { value } = await reader.read();
|
|
264
803
|
console.log('Received:', new TextDecoder().decode(value));
|
|
265
804
|
```
|
|
266
805
|
|
|
806
|
+
---
|
|
807
|
+
|
|
808
|
+
### Low-level QUIC API
|
|
809
|
+
|
|
810
|
+
For non-HTTP protocols over QUIC, or full control of the connection.
|
|
811
|
+
|
|
812
|
+
#### `createQuicServer(options)`
|
|
813
|
+
|
|
814
|
+
| Option | Type | Default | Description |
|
|
815
|
+
|---|---|---|---|
|
|
816
|
+
| `key`, `cert`, `ca` | | — | TLS credentials |
|
|
817
|
+
| `SNICallback` | `Function` | `null` | Per-hostname contexts |
|
|
818
|
+
| `alpn` | `string[]` | `['h3']` | ALPN protocols to accept |
|
|
819
|
+
| `maxConnections` | `number` | `10000` | Connection cap |
|
|
820
|
+
| `socket` / `socket6` | `dgram.Socket` | `null` | External, already-bound UDP sockets |
|
|
821
|
+
|
|
822
|
+
Returns `{ listen, close, on, handlePacket, hasConnection }`. The `'connection'`
|
|
823
|
+
event fires with `(quicConnection, peer)`.
|
|
824
|
+
|
|
825
|
+
```js
|
|
826
|
+
import { createQuicServer } from 'quico';
|
|
827
|
+
|
|
828
|
+
const qs = createQuicServer({ key, cert, alpn: ['my-protocol'] });
|
|
829
|
+
|
|
830
|
+
qs.on('connection', (quic, peer) => {
|
|
831
|
+
console.log('connection from', peer.address, peer.port);
|
|
832
|
+
|
|
833
|
+
quic.on('stream', (streamId, data, fin) => {
|
|
834
|
+
if (fin) quic.sendStream(streamId, Buffer.from('bye'), true);
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
quic.on('datagram', (data) => quic.sendDatagram(data));
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
qs.listen(4433);
|
|
841
|
+
```
|
|
842
|
+
|
|
843
|
+
`handlePacket(msg, rinfo)` and `hasConnection(rinfo)` support **external socket
|
|
844
|
+
mode**, where QUICO shares a UDP port with another protocol (STUN/TURN, for example)
|
|
845
|
+
and you route datagrams yourself.
|
|
846
|
+
|
|
847
|
+
#### `QUICConnection`
|
|
848
|
+
|
|
849
|
+
| Member | Description |
|
|
850
|
+
|---|---|
|
|
851
|
+
| `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 |
|
|
856
|
+
| `quic.on(event, fn)` / `off` | Event subscription |
|
|
857
|
+
|
|
858
|
+
**Events:** `'connect'`, `'stream'` (`id, data, fin`), `'datagram'`, `'close'`,
|
|
859
|
+
`'error'`, `'keylog'`.
|
|
860
|
+
|
|
861
|
+
**Connection options** (when constructing directly): `idleTimeout` (default `30000`),
|
|
862
|
+
`handshakeTimeout` (default `10000`), `keepAlive` (`true` → half the idle timeout, or
|
|
863
|
+
a number of ms), `alpn`, `hostname`, `rejectUnauthorized`, `ca`.
|
|
864
|
+
|
|
865
|
+
---
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
---
|
|
870
|
+
|
|
871
|
+
## ⚙️ Configuration & Tuning
|
|
872
|
+
|
|
873
|
+
### Transport parameters
|
|
874
|
+
|
|
875
|
+
These are the values QUICO advertises to peers. They are currently compiled-in
|
|
876
|
+
defaults; making them configurable is on the roadmap.
|
|
877
|
+
|
|
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.
|
|
892
|
+
|
|
893
|
+
### Per-connection timers
|
|
894
|
+
|
|
895
|
+
```js
|
|
896
|
+
// via the low-level API
|
|
897
|
+
new QUICConnection({
|
|
898
|
+
idleTimeout: 60000, // default 30000
|
|
899
|
+
handshakeTimeout: 10000, // default 10000
|
|
900
|
+
keepAlive: true // PING at idleTimeout/2; or a number of ms
|
|
901
|
+
});
|
|
902
|
+
```
|
|
903
|
+
|
|
904
|
+
---
|
|
905
|
+
|
|
906
|
+
## 🐛 Debugging
|
|
907
|
+
|
|
908
|
+
### Environment variables
|
|
909
|
+
|
|
910
|
+
| Variable | Effect |
|
|
911
|
+
|---|---|
|
|
912
|
+
| `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
|
+
| `QUICO_DEBUG_BBR=1` | One line per round-trip with congestion-control state: `BtlBw`, `RTprop`, `BDP`, `cwnd`, pacing rate. Cheap enough to leave on. |
|
|
914
|
+
|
|
915
|
+
```bash
|
|
916
|
+
QUICO_DEBUG_BBR=1 node server.js
|
|
917
|
+
# [bbr] round=12 probe_bw BtlBw=9.84Mbps RTprop=16ms BDP=19671B → cwnd=24589B pace=9.84Mbps
|
|
918
|
+
```
|
|
919
|
+
|
|
920
|
+
### Decrypting traffic in Wireshark
|
|
921
|
+
|
|
922
|
+
QUICO emits TLS secrets in NSS keylog format, so you can read your own encrypted
|
|
923
|
+
QUIC traffic:
|
|
924
|
+
|
|
925
|
+
```js
|
|
926
|
+
import fs from 'node:fs';
|
|
927
|
+
|
|
928
|
+
const out = fs.createWriteStream('/tmp/keys.log', { flags: 'a' });
|
|
929
|
+
quic.on('keylog', (line) => out.write(line)); // low-level QUICConnection
|
|
930
|
+
```
|
|
931
|
+
|
|
932
|
+
Then in Wireshark, set *Preferences → Protocols → TLS → (Pre)-Master-Secret log
|
|
933
|
+
filename* to that file — or from the command line:
|
|
934
|
+
|
|
935
|
+
```bash
|
|
936
|
+
tshark -r capture.pcap -o tls.keylog_file:/tmp/keys.log -Y quic
|
|
937
|
+
```
|
|
938
|
+
|
|
939
|
+
---
|
|
267
940
|
|
|
268
941
|
## 🧪 Testing
|
|
269
942
|
|
|
270
|
-
QUIC requires TLS 1.3. Self-signed RSA certificates
|
|
943
|
+
QUIC requires TLS 1.3. Self-signed RSA certificates are often rejected by QUIC clients
|
|
944
|
+
and browsers; [mkcert](https://github.com/FiloSottile/mkcert) is the easiest way to get
|
|
945
|
+
locally-trusted ECDSA certificates.
|
|
271
946
|
|
|
272
|
-
|
|
947
|
+
QUICO supports ALPN `"h3"` only — not draft variants such as `h3-29`. Chrome must be
|
|
948
|
+
told explicitly to use QUIC against a local server:
|
|
273
949
|
|
|
274
|
-
**Chrome
|
|
950
|
+
**Chrome**
|
|
275
951
|
```bash
|
|
276
|
-
chrome --enable-quic --quic-version=h3 --ignore-certificate-errors
|
|
952
|
+
chrome --enable-quic --quic-version=h3 --ignore-certificate-errors \
|
|
953
|
+
--origin-to-force-quic-on=localhost:4433
|
|
277
954
|
```
|
|
278
955
|
|
|
279
|
-
**curl
|
|
956
|
+
**curl** (needs an HTTP/3-enabled build)
|
|
280
957
|
```bash
|
|
281
958
|
curl --http3 https://localhost:4433 --insecure
|
|
282
959
|
```
|
|
283
960
|
|
|
284
|
-
**
|
|
961
|
+
**Test suites**
|
|
285
962
|
```bash
|
|
286
|
-
node examples/test_integration.js
|
|
287
|
-
node examples/test_wt_server.js
|
|
288
|
-
node examples/test_webtransport.js URL
|
|
963
|
+
node examples/test_integration.js # HTTP/3 client + server — 19 tests
|
|
964
|
+
node examples/test_wt_server.js # WebTransport echo (QUICO ↔ QUICO)
|
|
965
|
+
node examples/test_webtransport.js URL # WebTransport client against any server
|
|
289
966
|
```
|
|
290
967
|
|
|
968
|
+
**Interop runner** — see [`interop/`](./interop) for the Docker endpoint used with the
|
|
969
|
+
[QUIC Interop Runner](https://github.com/quic-interop/quic-interop-runner).
|
|
970
|
+
|
|
971
|
+
---
|
|
291
972
|
|
|
292
973
|
## 📁 Project Structure
|
|
293
974
|
|
|
294
975
|
```
|
|
295
976
|
quico/
|
|
296
977
|
├── index.js — Public API exports
|
|
978
|
+
├── interop/ — QUIC Interop Runner endpoint (Dockerfile + shim)
|
|
979
|
+
├── examples/ — Runnable examples and test suites
|
|
297
980
|
└── src/
|
|
298
981
|
├── server.js — Unified server (drop-in for node:https)
|
|
299
982
|
├── client.js — Unified client with H3 → H2 → H1 fallback
|
|
300
|
-
├── agent.js — Connection pooling
|
|
983
|
+
├── agent.js — Connection pooling, coalescing, Alt-Svc cache
|
|
301
984
|
├── streams.js — IncomingMessage, ServerResponse, ClientRequest
|
|
302
985
|
├── h3.js — HTTP/3 framing, QPACK, WebTransport detection
|
|
303
986
|
├── h3_server.js — HTTP/3 + WebTransport server
|
|
304
987
|
├── h3_client.js — HTTP/3 client (DNS, UDP, QUIC, H3 pipeline)
|
|
305
|
-
├──
|
|
988
|
+
├── quic_server.js — UDP listener, connection routing, retry/VN
|
|
989
|
+
├── quic_socket.js — Client-side UDP socket + connection bootstrap
|
|
990
|
+
├── quic_connection.js — QUIC state machine, loss detection, CC, flow control
|
|
306
991
|
├── transport.js — Packet parsing, frame encoding/decoding
|
|
307
|
-
├── crypto.js — AEAD
|
|
992
|
+
├── crypto.js — AEAD, header protection, key derivation
|
|
308
993
|
├── tls_bridge.js — LemonTLS integration, transport parameters
|
|
994
|
+
├── utils.js — VarInt, ACK ranges, binary helpers
|
|
309
995
|
└── webtransport.js — WebTransport client + stream classes
|
|
310
996
|
```
|
|
311
997
|
|
|
998
|
+
---
|
|
999
|
+
|
|
1000
|
+
## ⚠️ Known Limitations
|
|
1001
|
+
|
|
1002
|
+
Being explicit about what is **not** implemented yet:
|
|
1003
|
+
|
|
1004
|
+
| Area | Status |
|
|
1005
|
+
|---|---|
|
|
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. |
|
|
1009
|
+
| **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. |
|
|
1016
|
+
|
|
1017
|
+
Performance has not been formally benchmarked. QUICO is pure JavaScript; expect it to
|
|
1018
|
+
be slower than native implementations, especially for bulk transfer.
|
|
1019
|
+
|
|
1020
|
+
---
|
|
312
1021
|
|
|
313
1022
|
## 🛣 Roadmap
|
|
314
1023
|
|
|
315
1024
|
### ✅ Done
|
|
316
1025
|
- QUIC v1 transport (RFC 9000) — Initial, Handshake, 1-RTT
|
|
317
1026
|
- TLS 1.3 via LemonTLS — X25519, P-256, P-384, AES-128/256-GCM
|
|
1027
|
+
- Version Negotiation, HelloRetryRequest
|
|
1028
|
+
- Key Update — bidirectional key phase rotation (RFC 9001 §6)
|
|
1029
|
+
- Loss detection, PTO with exponential backoff (RFC 9002)
|
|
1030
|
+
- BBR-style congestion control with pacing
|
|
1031
|
+
- Bidirectional flow control with sliding windows
|
|
318
1032
|
- HTTP/3 server and client (RFC 9114)
|
|
319
|
-
- QPACK
|
|
320
|
-
- WebTransport client and server —
|
|
321
|
-
-
|
|
322
|
-
-
|
|
323
|
-
-
|
|
324
|
-
-
|
|
325
|
-
-
|
|
326
|
-
- Interop
|
|
327
|
-
- Handshake timeout
|
|
1033
|
+
- QPACK with static and dynamic tables (RFC 9204)
|
|
1034
|
+
- WebTransport client and server — streams + datagrams (RFC 9297)
|
|
1035
|
+
- Unreliable datagrams (RFC 9221)
|
|
1036
|
+
- `node:https`-style API — `createServer`, `request`, `get`, `Agent`
|
|
1037
|
+
- Express / Fastify / Koa support
|
|
1038
|
+
- Connection pooling, coalescing and multiplexing
|
|
1039
|
+
- TLS keylog export
|
|
1040
|
+
- **Interop: 6/6 implementations green** (handshake, transfer, http3, both directions)
|
|
328
1041
|
|
|
329
1042
|
### 🔄 In Progress
|
|
330
|
-
-
|
|
331
|
-
- Congestion control (BBR / CUBIC)
|
|
1043
|
+
- Delayed ACK and ACK piggybacking
|
|
332
1044
|
- 0-RTT and session resumption
|
|
333
|
-
-
|
|
1045
|
+
- Configurable transport parameters
|
|
334
1046
|
|
|
335
1047
|
### ⏳ Planned
|
|
336
1048
|
- Connection migration (PATH_CHALLENGE / PATH_RESPONSE)
|
|
337
|
-
- ChaCha20-Poly1305
|
|
1049
|
+
- ChaCha20-Poly1305 (0x1303)
|
|
1050
|
+
- Retry / address validation + anti-amplification limit
|
|
338
1051
|
- GOAWAY and graceful shutdown
|
|
339
1052
|
- Stream priority (RFC 9218)
|
|
340
1053
|
- TypeScript type definitions
|
|
@@ -343,23 +1056,39 @@ quico/
|
|
|
343
1056
|
|
|
344
1057
|
_Community contributions are welcome! Please ⭐ star the repo to follow progress._
|
|
345
1058
|
|
|
1059
|
+
---
|
|
346
1060
|
|
|
347
|
-
##
|
|
1061
|
+
## 🤝 Contributing
|
|
1062
|
+
|
|
1063
|
+
Contributions are very welcome — bug reports, reproductions, and PRs alike.
|
|
1064
|
+
|
|
1065
|
+
Useful things to know before you start:
|
|
1066
|
+
|
|
1067
|
+
- Run `node examples/test_integration.js` before and after your change (19 tests).
|
|
1068
|
+
- `QUICO_DEBUG=1` gives a full packet trace; `QUICO_DEBUG_BBR=1` gives a congestion
|
|
1069
|
+
control timeline. Include the relevant excerpt in bug reports.
|
|
1070
|
+
- If you can capture a `.pcap` plus a keylog file, that makes protocol bugs enormously
|
|
1071
|
+
easier to diagnose — see [Debugging](#-debugging).
|
|
1072
|
+
- For interop-level changes, the [`interop/`](./interop) directory contains the Docker
|
|
1073
|
+
endpoint used with the QUIC Interop Runner.
|
|
348
1074
|
|
|
349
|
-
|
|
350
|
-
|
|
1075
|
+
---
|
|
1076
|
+
|
|
1077
|
+
## 🙏 Sponsors
|
|
351
1078
|
|
|
1079
|
+
QUICO is an evenings-and-weekends project.
|
|
1080
|
+
Support development via **GitHub Sponsors** — or simply share the project.
|
|
352
1081
|
|
|
1082
|
+
---
|
|
353
1083
|
|
|
354
1084
|
## 💬 Contact
|
|
355
1085
|
|
|
356
|
-
For feedback, ideas, or contributions
|
|
357
|
-
contact directly at:
|
|
1086
|
+
For feedback, ideas, or contributions:
|
|
358
1087
|
📧 **support@quicojs.dev**
|
|
359
1088
|
|
|
360
1089
|
For security-related issues, please see [SECURITY.md](./SECURITY.md).
|
|
361
1090
|
|
|
362
|
-
|
|
1091
|
+
---
|
|
363
1092
|
|
|
364
1093
|
## 📜 License
|
|
365
1094
|
|
|
@@ -379,4 +1108,4 @@ distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
379
1108
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
380
1109
|
See the License for the specific language governing permissions and
|
|
381
1110
|
limitations under the License.
|
|
382
|
-
```
|
|
1111
|
+
```
|