| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Name | Name | Last commit date | ||
|---|---|---|---|---|
Pure JavaScript implementation of QUIC, HTTP/3, QPACK & WebTransport for Node.js
The entire HTTP/3 stack — QUIC, TLS 1.3, QPACK, congestion control — in JavaScript you can read.
⚠️ Project status: Beta — active development. The core transport is verified against six independent QUIC implementations (see Interoperability), but APIs may still change before v1.0. Please report issues!
QUIC is the future of Internet transport protocols. Created at Google, standardized by the IETF as RFC 9000, QUIC powers HTTP/3 and is already deployed at scale by the world's largest platforms. It was specifically designed to overcome the limitations of TCP and deliver a faster, smoother, and more resilient web.
Key advantages of QUIC include:
Eliminating bottlenecks — With TCP, data must arrive strictly in order. If a single packet is lost or delayed, all subsequent packets are blocked until it arrives — a phenomenon called head-of-line blocking. QUIC removes this bottleneck by running over UDP and multiplexing independent streams: each stream makes progress on its own, and a loss on one never stalls the others.
UDP efficiency — By running over UDP, QUIC bypasses decades of kernel-level TCP constraints, enabling fast connection handling even on constrained devices such as smartphones, IoT hardware, or edge gateways.
Security by default — TLS 1.3 is built directly into the protocol. Every connection is encrypted — no exceptions, no downgrade paths. Even the packet headers are cryptographically protected.
Seamless mobility — Connections are identified by connection IDs rather than IP/port 4-tuples, so a session can survive a device moving across networks (Wi-Fi → 4G/5G) or changing addresses.
Lower latency — QUIC merges the transport and TLS handshakes into a single exchange, significantly reducing connection setup time.
Smarter congestion control — Modern algorithms such as BBR continuously measure bandwidth and round-trip time instead of only reacting to loss, adapting to real-world network conditions in real time.
QUIC combines UDP speed, TCP reliability, mandatory TLS security, and adaptive multiplexing into one transport layer.
HTTP/3 (RFC 9114) is the layer on top: the evolution of the web's application protocol. Instead of riding over TCP, HTTP/3 maps requests and responses directly onto QUIC streams, with QPACK header compression designed to avoid reintroducing head-of-line blocking. The result: faster page loads and real-time applications that scale across the modern web.
Node.js is the backbone of countless modern web applications — yet it has no native QUIC implementation. The browser you're reading this in speaks HTTP/3. The CDN that served this page speaks HTTP/3. Your Node server? Stuck on TCP.
Why? Supporting QUIC inside Node.js requires deep access to TLS internals that Node's existing TLS APIs simply don't expose. TLS-over-TCP hands you an encrypted byte stream; QUIC needs something entirely different — the raw handshake secrets at each encryption level, so it can derive its own packet-protection keys and encrypt each packet individually. node:tls was never built to hand those out. Beyond the crypto, QUIC demands a genuinely complex architecture: interlocking state machines across three packet-number spaces, a packet scheduler with pacing, loss detection timers, and congestion control.
QUICO brings this missing capability directly into the Node.js ecosystem. It is a from-scratch JavaScript implementation of QUIC, HTTP/3 and WebTransport, built without OpenSSL and without a single line of native code. The cryptographic layer is LemonTLS — a pure JavaScript TLS 1.3 library built by the same author specifically to expose what QUIC needs. Together, QUICO and LemonTLS form a complete, fully auditable JavaScript networking stack from the UDP socket all the way up to HTTP/3.
No build tools. No platform-specific binaries. No node-gyp. It runs anywhere Node runs — Linux, macOS, Windows, ARM, Docker, edge — and when something breaks at 3 AM, the stack trace ends in code you can open, not in a .node binary.
| Requirement | Notes |
|---|---|
| Node.js 18+ | ESM only (import). Tested on 18, 20 and 22. |
| No compiler | Pure JavaScript — no native addons. |
npm install quicoQUICO is an ES module. Use import, and make sure your package.json has "type": "module" (or use the .mjs extension).
import quico from 'quico';
import fs from 'node:fs';
const server = quico.createServer({
key: fs.readFileSync('server.key'),
cert: fs.readFileSync('server.crt')
}, (req, res) => {
res.writeHead(200, { 'content-type': 'text/plain' });
res.end('Hello from HTTP/3!');
});
server.listen(4433, () => {
console.log('HTTP/3 server on https://localhost:4433');
});import quico from 'quico';
quico.request('https://www.google.com/', (res) => {
console.log('Status:', res.statusCode); // 200
console.log('Protocol:', res.httpVersion); // "3.0"
res.on('data', (chunk) => process.stdout.write(chunk));
}).end();⚠️ Don't forget .end() — like node:https, the request isn't sent until you call it.
📂 More examples in examples/
Two QUIC implementations can both be fully conformant and still behave completely differently on the wire. RFC 9000 defines what must happen; it deliberately leaves how to the implementer. This section documents where QUICO made a choice — the places where another implementation would reasonably do something else.
This is the foundational choice, and the one that most distinguishes QUICO from other implementations.
How it's normally done. RFC 9002 describes tracking each packet you send along with the frames it contained. When loss detection fires, you look up that packet, recover its frames, and put them back in a retransmission queue. The sender is procedural: an event happens (loss), and you perform a corrective action (re-enqueue). Almost every QUIC stack works this way.
What QUICO does instead. There is no sent_packets table, no stored frame copies, and no retransmission queue — those concepts don't exist in the codebase. Each stream keeps only three things:
{
pending_data: Uint8Array, // the bytes the application wrote
acked_ranges: [from, to, …], // byte ranges the peer has confirmed
in_flight_ranges: { 47: [from, to], 48: [from, to] } // packet number → bytes it carries
}Every send opportunity asks one question, and it's a set operation:
missing = invert(acked_ranges ∪ in_flight_ranges, 0, total_size)Whatever's missing goes out. That's the whole algorithm. The sender doesn't remember what it did — it recomputes what the peer still needs, from scratch, every time. It's a declarative model: continuously reconcile "what the peer has" against "what the peer should have", and emit the difference.
Loss detection, therefore, is one line:
delete st.in_flight_ranges[pn]; // → those bytes are "missing" againDeleting the mapping is the entire recovery action. Nothing is queued, scheduled, or flagged. The bytes simply reappear in the next missing computation and get sent along with everything else.
The consequence: a packet is a disposable container, not an identity. The data that was in lost packet #47 doesn't come back as packet #47's contents. It comes back as bytes that need sending, and the next burst may:
Packet numbers and stream byte-offsets are fully decoupled: PNs are transient bookkeeping for the loss timer, while the byte range is the only durable identity of data. This is possible because QUIC frames carry explicit offsets — the receiver reassembles by offset and genuinely does not care how the bytes were packetized.
Why this design:
What it costs. Computing invert(acked ∪ in_flight) per stream per send opportunity is O(number of ranges) work, where a queue pop would be O(1). With coalesced flat range arrays this stays cheap in practice, but it is real CPU that a queue-based sender doesn't spend — a deliberate trade of cycles for correctness.
The receive side is symmetric. Incoming data goes into chunks keyed by offset plus a ranges array, and delivery walks the contiguous prefix. Same range algebra, mirrored — which is exactly why the receiver must tolerate chunks whose boundaries don't line up with anything it saw before: on the sending side, they never had to.
The choice: most QUIC stacks ship a loss-based controller — quic-go uses Reno with HyStart++, others default to CUBIC. QUICO implements a model-based controller in the BBRv1 family instead.
The difference is philosophical. Loss-based algorithms grow the window until packets drop, which means they require queue overflow to find the limit — and they misread random wireless loss as congestion. BBR builds an explicit model of the path and steers by it:
BDP = BtlBw × RTprop
Three states drive the two output knobs:
| State | Pacing gain | Purpose |
|---|---|---|
| startup | 2.89 (≈ 2/ln 2) | Exponential ramp to discover BtlBw. Exits after 3 rounds without ≥25% growth — the pipe is full. |
| drain | 1/2.89 | One phase to drain the queue startup necessarily built. |
| 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. |
The pacing rate (gain × BtlBw) spreads packets smoothly rather than dumping window-sized bursts into the bottleneck; the congestion window (cwnd_gain × BDP) is the in-flight backstop. Both are clamped to floors and ceilings so a single bad measurement can never wedge the connection.
Watch it live, one line per round-trip:
QUICO_DEBUG_BBR=1 node yourserver.js[bbr] round=5 startup BtlBw=8.96Mbps RTprop=16ms BDP=17920B → cwnd=51789B pace=25.89Mbps [bbr] round=9 drain BtlBw=10.98Mbps RTprop=16ms BDP=21952B → cwnd=63441B pace=3.80Mbps [bbr] round=15 probe_bw BtlBw=10.98Mbps RTprop=16ms BDP=21952B → cwnd=27440B pace=10.98Mbps
That trace is a real run through a 10 Mbps bottleneck with a 25-packet queue: startup finds the link in ~7 rounds, drain empties the queue, probe_bw settles within 5% of line rate.
Honest scope: this is BBR-style, not a certified BBRv2. ProbeRTT — the periodic cwnd collapse that re-measures RTprop on a permanently saturated path — is not implemented, and there is no explicit loss response. The windowed filters bound the damage of stale measurements in the meantime.
The choice: when several streams have data ready and one packet is going out, who gets the space? Implementations differ sharply — many drain streams in priority or creation order, filling each one before moving on.
QUICO interleaves at the byte level, inside a single packet (execute_quic_burst). The scheduler cycles through all active streams, taking a chunk from each until the packet is full, then cycles again — so a 1350-byte packet routinely carries STREAM frames from three different streams:
┌─────────────────────────────── one QUIC packet ───────────────────────────────┐ │ ACK │ STREAM(id=0, 450B) │ STREAM(id=4, 450B) │ STREAM(id=8, 400B) │ └───────────────────────────────────────────────────────────────────────────────┘
The loop repeats while any stream still yields data and space remains (progress flag), so a stream that runs dry doesn't hold a slot — the remaining bandwidth redistributes to the others within the same packet.
Why it matters: three parallel downloads progress at genuinely equal rates instead of finishing sequentially, and no stream can be starved by a large one ahead of it. The cost is fragmentation — more STREAM frame headers per packet (~24 bytes each) than a drain-one-stream-at-a-time scheduler. QUICO accepts that trade for fairness. Priority signalling (RFC 9218) would let applications override the equal split; it's on the roadmap.
The subtle part falls straight out of the reconciliation model: the sender decides what to send by asking which bytes aren't acked or in flight — but chunks selected earlier in the same burst have no packet number yet, so they're in neither set, and the next iteration would select them again. QUICO parks them under a temporary _burst key in the in-flight map, which real packet-number keys replace the moment the packet is encrypted and sent. The set arithmetic stays honest even mid-burst.
Given the missing set, in what order do you drain it? QUICO walks it in two passes: forward from the send cursor first (new data), then backfilling the gaps below it (bytes whose packets were lost).
New data leads because it keeps the pipe full and the ACK clock running — the receiver buffers out-of-order bytes anyway, and the gap gets repaired within the same burst rather than a round-trip later. Both passes read from the same missing computation, so there's no ordering conflict to arbitrate: it's one set, drained in two sweeps.
Flow control interacts here in a non-obvious way. Send-side limits cap the highest offset we may send, so they bind only new bytes. A repair re-sends offsets that were already inside the allowance when first sent — it consumes no budget and must never be blocked. Getting this wrong deadlocks the connection: at the edge of an exhausted window, the missing range itself becomes unsendable, and the peer can't rescue you, because the data it would open the window for is exactly the data that never arrived.
Because recovery itself is just the reconciliation pass, loss "detection" here means only one thing: deciding when to stop counting a packet's bytes as in flight. Two decisions worth calling out:
The PTO itself is srtt + max(4·rttvar, 1ms) + max_ack_delay. That last term is not optional: peers may legitimately sit on an ACK for their full advertised delay (25 ms is common), and a timer without it expires packets moments before their ACKs arrive.
ACK ranges are capped at 32 (RFC 9000 §13.2.4 permits acking a subset). Under loss, packet-number gaps never close — a lost PN is gone forever, its data returns under a new PN — so an uncapped ACK frame grows with the cumulative loss count until it no longer fits in a packet. We measured exactly that against a line-rate sender: ACK packets inflating 325 B → 1017 B second by second, until they crossed the MTU and stopped being sendable at all.
The choice: when do you tell the peer it may send more? The naive trigger is "bytes arrived"; QUICO slides its receive windows based on application consumption — a MAX_STREAM_DATA / MAX_DATA update goes out once the app has actually read a threshold of data, not merely when packets landed.
The difference shows up under backpressure: a slow consumer causes the window to stop advancing, which slows the sender, instead of letting unbounded data accumulate in the receive buffer. Node stream semantics (write() return values, highWaterMark) propagate through H3 into these windows, so ordinary .pipe() backpressure reaches all the way to the peer's congestion controller.
Files map one-to-one onto protocol layers (see Project Structure), state lives in plain objects you can console.log, and the dense parts carry comments explaining why, not just what. Start at src/quic_connection.js and follow a packet in from feedDatagram() or out from sendStream().
Everything above is a claim; this section is the receipt.
QUICO is tested with the QUIC Interop Runner, the standard cross-implementation test harness. Every test runs both directions (QUICO as server and as client) through a network simulator with configurable delay, bandwidth limits and packet loss.
| Peer implementation | Language | handshake | transfer | http3 |
|---|---|---|---|---|
| quic-go | Go | ✅ | ✅ | ✅ |
| ngtcp2 | C | ✅ | ✅ | ✅ |
| quiche (Cloudflare) | Rust | ✅ | ✅ | ✅ |
| picoquic | C | ✅ | ✅ | ✅ |
| aioquic | Python | ✅ | ✅ | ✅ |
| neqo (Firefox) | Rust | ✅ | ✅ | ✅ |
Additionally verified against production servers: Google, Cloudflare, Facebook, Microsoft, LiteSpeed and nginx — including a 12.7 MB download from cloudflaremirrors.com byte-for-byte.
QUICO mirrors the node:https API wherever possible. If a method or property is not documented here, it likely behaves exactly as its node:https counterpart.
This chapter covers the HTTP/3 API first, then framework compatibility, WebTransport, and finally the low-level QUIC primitives for non-HTTP protocols.
Creates an HTTP/3 server. Returns a server object with listen(), close() and on().
Options
| Option | Type | Default | Description |
|---|---|---|---|
| key | Buffer | string | — | Private key in PEM format. Required unless SNICallback is used. |
| cert | Buffer | string | — | Certificate chain in PEM format. |
| ca | Buffer | string | Array | null | Additional trusted CAs. |
| SNICallback | Function | null | (servername, cb) => cb(null, secureContext) for multi-domain setups. |
| http2 | boolean | true | Also listen for HTTP/2 over TCP (fallback for non-QUIC clients). |
| http1 | boolean | true | Also listen for HTTP/1.1 over TCP. |
| maxConnections | number | 10000 | Maximum concurrent QUIC connections. |
| socket | dgram.Socket | null | Use an externally-bound IPv4 UDP socket instead of creating one. Must already be bound. |
| socket6 | dgram.Socket | null | Same, for IPv6. |
Setting http2: false and http1: false makes the server HTTP/3 only — no TCP listener is opened.
Methods
Events
const server = quico.createServer({ key, cert, http2: false, http1: false });
server.on('request', (req, res) => {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ url: req.url, version: req.httpVersion }));
});
server.listen(4433, '0.0.0.0');Issues an HTTP/3 request. Returns a ClientRequest. Accepts a URL string, an options object, or both (options override the URL).
Options
| Option | Type | Default | Description |
|---|---|---|---|
| hostname / host | string | 'localhost' | Target host. |
| port | number | 443 | Target port. |
| path | string | '/' | Request path including query string. |
| method | string | 'GET' | HTTP method. |
| headers | object | {} | Request headers. |
| rejectUnauthorized | boolean | true | Verify the server certificate. Set false for self-signed certs. |
| ca | Buffer | string | Array | null | Trust these CAs in addition to the defaults. |
| agent | Agent | false | globalAgent | Connection pool to use. false disables pooling. |
| http2 | boolean | true | Allow HTTP/2 fallback. |
| http1 | boolean | true | Allow HTTP/1.1 fallback. |
| h3Timeout | number | 3000 | Milliseconds to wait for QUIC before falling back. |
const req = quico.request({
hostname: 'localhost',
port: 4433,
path: '/api/items',
method: 'POST',
headers: { 'content-type': 'application/json' },
rejectUnauthorized: false // self-signed cert in development
}, (res) => {
let body = '';
res.on('data', (c) => body += c);
res.on('end', () => console.log(res.statusCode, body));
});
req.on('error', console.error);
req.write(JSON.stringify({ name: 'test' }));
req.end();Same as request() with method: 'GET', and end() is called automatically.
A Node.js Readable stream. On the server it is the request; on the client, the response.
| Property | Description |
|---|---|
| httpVersion | '3.0', '2.0' or '1.1' depending on the transport actually used |
| headers | Lower-cased header object |
| method | Request method (server side) |
| url | Request path (server side) |
| statusCode | Response status (client side) |
| socket / connection | Compatibility shim for frameworks |
Standard Readable behaviour applies: 'data', 'end', 'error', pipe(), for await (const chunk of req), and setTimeout(ms, cb).
A Node.js Writable stream.
For WebTransport sessions, ServerResponse additionally exposes sendDatagram(), createBidirectionalStream() and createUnidirectionalStream() — see WebTransport.
A Node.js Writable stream.
Connection pool and protocol cache, analogous to https.Agent.
import { Agent } from 'quico';
const agent = new Agent({
http2: true, // allow H2 in the fallback chain
http1: true, // allow H1.1 in the fallback chain
h3Timeout: 3000, // ms before falling back from H3
maxSockets: 256,
timeout: 30000
});
quico.request({ hostname: 'example.com', agent }, handler).end();The agent caches which protocol works per host (including Alt-Svc advertisements with their ma= lifetime), pools live QUIC connections for multiplexing, and coalesces concurrent requests to the same origin onto a single connection.
quico.globalAgent is the default instance used when no agent option is given. Call agent.destroy() to close all pooled connections.
Creates a TLS context for use with SNICallback. Accepts key, cert and ca. Re-exported from LemonTLS.
A core design goal is a familiar API. If you know node:https, QUICO should feel natural — which means existing frameworks work unmodified:
import express from 'express';
import quico from 'quico';
const app = express();
app.get('/', (req, res) => res.json({ protocol: req.httpVersion }));
quico.createServer({ key: KEY, cert: CERT }, app).listen(4433);
// → { "protocol": "3.0" }Express, Fastify and Koa are all supported this way.
Multi-domain setups use SNICallback, exactly as in node:https:
import quico from 'quico';
import tls from 'lemon-tls';
import fs from 'node:fs';
const server = quico.createServer({
SNICallback: (servername, cb) => {
cb(null, tls.createSecureContext({
key: fs.readFileSync(`certs/${servername}.key`),
cert: fs.readFileSync(`certs/${servername}.crt`)
}));
}
}, (req, res) => {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end('Hello World from HTTP/3 on Node.js!');
});
server.listen(4433);import quico from 'quico';
quico.createServer({ key: KEY, cert: CERT }, (req, res) => {
if (req.headers[':protocol'] === 'webtransport') {
res.writeHead(200); // accept the session
req.on('stream', (stream) => { // client-initiated streams
stream.on('data', (chunk) => stream.write(chunk)); // echo
stream.on('end', () => stream.end());
});
req.on('datagram', (data) => res.sendDatagram(data));
const push = res.createBidirectionalStream(); // server-initiated
push.write('server says hello');
push.end();
} else {
res.end('Hello HTTP/3!');
}
}).listen(4433);import { WebTransport } from 'quico';
const wt = new WebTransport('https://localhost:4433/echo');
await wt.ready;
const stream = await wt.createBidirectionalStream();
stream.write('hello');
stream.on('data', (chunk) => console.log('echo:', chunk.toString()));
stream.end();
const uni = await wt.createUnidirectionalStream();
uni.write('fire and forget');
uni.end();
wt.sendDatagram(Buffer.from('ping'));
wt.on('datagram', (data) => console.log('pong:', data.toString()));
wt.close();A QUICO server works with the native browser WebTransport API — no client library needed:
const wt = new WebTransport('https://yourserver.com:4433/live');
await wt.ready;
const writer = wt.datagrams.writable.getWriter();
await writer.write(new TextEncoder().encode('Hello QUIC'));
const reader = wt.datagrams.readable.getReader();
const { value } = await reader.read();
console.log('Received:', new TextDecoder().decode(value));For non-HTTP protocols over QUIC, or full control of the connection.
| Option | Type | Default | Description |
|---|---|---|---|
| key, cert, ca | — | TLS credentials | |
| SNICallback | Function | null | Per-hostname contexts |
| alpn | string[] | ['h3'] | ALPN protocols to accept |
| maxConnections | number | 10000 | Connection cap |
| socket / socket6 | dgram.Socket | null | External, already-bound UDP sockets |
Returns { listen, close, on, handlePacket, hasConnection }. The 'connection' event fires with (quicConnection, peer).
import { createQuicServer } from 'quico';
const qs = createQuicServer({ key, cert, alpn: ['my-protocol'] });
qs.on('connection', (quic, peer) => {
console.log('connection from', peer.address, peer.port);
quic.on('stream', (streamId, data, fin) => {
if (fin) quic.sendStream(streamId, Buffer.from('bye'), true);
});
quic.on('datagram', (data) => quic.sendDatagram(data));
});
qs.listen(4433);handlePacket(msg, rinfo) and hasConnection(rinfo) support external socket mode, where QUICO shares a UDP port with another protocol (STUN/TURN, for example) and you route datagrams yourself.
| Member | Description |
|---|---|
| quic.state | 'idle', 'handshaking', 'connected', 'closing', 'draining', 'closed' |
| quic.sendStream(id, data, fin) | Write to a stream |
| quic.sendDatagram(data) | Send an unreliable datagram |
| quic.maxDatagramSize() | Largest datagram payload that fits the current path |
| quic.close(errorCode, reason) | Send CONNECTION_CLOSE |
| quic.on(event, fn) / off | Event subscription |
Events: 'connect', 'stream' (id, data, fin), 'datagram', 'close', 'error', 'keylog'.
Connection options (when constructing directly): idleTimeout (default 30000), handshakeTimeout (default 10000), keepAlive (true → half the idle timeout, or a number of ms), alpn, hostname, rejectUnauthorized, ca.
These are the values QUICO advertises to peers. They are currently compiled-in defaults; making them configurable is on the roadmap.
| Parameter | Default | Meaning |
|---|---|---|
| initial_max_data | 1 MB | Connection-level receive window |
| initial_max_stream_data_bidi_local/remote | 256 KB | Per-stream receive window |
| initial_max_stream_data_uni | 128 KB | Per-unidirectional-stream window |
| initial_max_streams_bidi | 100 | Concurrent bidirectional streams |
| initial_max_streams_uni | 3 | Concurrent unidirectional streams |
| max_idle_timeout | 30 s | Idle before the connection is dropped |
| max_ack_delay | 25 ms | Maximum ACK delay we advertise |
| max_datagram_frame_size | 65527 | Datagram support (RFC 9221) |
| active_connection_id_limit | 4 | Connection IDs we will track |
Receive windows slide automatically as the application consumes data, so large downloads are not limited by the initial values.
// via the low-level API
new QUICConnection({
idleTimeout: 60000, // default 30000
handshakeTimeout: 10000, // default 10000
keepAlive: true // PING at idleTimeout/2; or a number of ms
});| Variable | Effect |
|---|---|
| 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. |
| QUICO_DEBUG_BBR=1 | One line per round-trip with congestion-control state: BtlBw, RTprop, BDP, cwnd, pacing rate. Cheap enough to leave on. |
QUICO_DEBUG_BBR=1 node server.js
# [bbr] round=12 probe_bw BtlBw=9.84Mbps RTprop=16ms BDP=19671B → cwnd=24589B pace=9.84MbpsQUICO emits TLS secrets in NSS keylog format, so you can read your own encrypted QUIC traffic:
import fs from 'node:fs';
const out = fs.createWriteStream('/tmp/keys.log', { flags: 'a' });
quic.on('keylog', (line) => out.write(line)); // low-level QUICConnectionThen in Wireshark, set Preferences → Protocols → TLS → (Pre)-Master-Secret log filename to that file — or from the command line:
tshark -r capture.pcap -o tls.keylog_file:/tmp/keys.log -Y quicQUIC requires TLS 1.3. Self-signed RSA certificates are often rejected by QUIC clients and browsers; mkcert is the easiest way to get locally-trusted ECDSA certificates.
QUICO supports ALPN "h3" only — not draft variants such as h3-29. Chrome must be told explicitly to use QUIC against a local server:
Chrome
chrome --enable-quic --quic-version=h3 --ignore-certificate-errors \
--origin-to-force-quic-on=localhost:4433curl (needs an HTTP/3-enabled build)
curl --http3 https://localhost:4433 --insecureTest suites
node examples/test_integration.js # HTTP/3 client + server — 19 tests
node examples/test_wt_server.js # WebTransport echo (QUICO ↔ QUICO)
node examples/test_webtransport.js URL # WebTransport client against any serverInterop runner — see interop/ for the Docker endpoint used with the QUIC Interop Runner.
quico/
├── index.js — Public API exports
├── interop/ — QUIC Interop Runner endpoint (Dockerfile + shim)
├── examples/ — Runnable examples and test suites
└── src/
├── server.js — Unified server (drop-in for node:https)
├── client.js — Unified client with H3 → H2 → H1 fallback
├── agent.js — Connection pooling, coalescing, Alt-Svc cache
├── streams.js — IncomingMessage, ServerResponse, ClientRequest
├── h3.js — HTTP/3 framing, QPACK, WebTransport detection
├── h3_server.js — HTTP/3 + WebTransport server
├── h3_client.js — HTTP/3 client (DNS, UDP, QUIC, H3 pipeline)
├── quic_server.js — UDP listener, connection routing, retry/VN
├── quic_socket.js — Client-side UDP socket + connection bootstrap
├── quic_connection.js — QUIC state machine, loss detection, CC, flow control
├── transport.js — Packet parsing, frame encoding/decoding
├── crypto.js — AEAD, header protection, key derivation
├── tls_bridge.js — LemonTLS integration, transport parameters
├── utils.js — VarInt, ACK ranges, binary helpers
└── webtransport.js — WebTransport client + stream classes
Being explicit about what is not implemented yet:
| Area | Status |
|---|---|
| 0-RTT / session resumption | Not implemented — every connection is a full handshake. |
| ChaCha20-Poly1305 | Not implemented. AES-128-GCM and AES-256-GCM only. On hardware without AES acceleration this costs performance. |
| Connection migration | Not implemented; QUICO advertises disable_active_migration. Connections do not survive a client IP change. |
| ECN | Not implemented — Node's dgram does not expose the required socket options. |
| Retry / address validation | Not implemented (client does not consume Retry tokens; server does not issue them). |
| Anti-amplification limit | Not enforced (RFC 9000 §8.1). |
| Stream priority (RFC 9218) | Not implemented. |
| Delayed ACK | Not implemented — an ACK is sent per ack-eliciting packet. Correct, but more packets than necessary. |
| MAX_STREAMS issuance | Streams limits are honoured but not proactively raised, capping very-high-concurrency workloads. |
| TypeScript types | Not shipped yet. |
Performance has not been formally benchmarked. QUICO is pure JavaScript; expect it to be slower than native implementations, especially for bulk transfer.
Community contributions are welcome! Please ⭐ star the repo to follow progress.
Contributions are very welcome — bug reports, reproductions, and PRs alike.
Useful things to know before you start:
QUICO is an evenings-and-weekends project. Support development via GitHub Sponsors — or simply share the project.
For feedback, ideas, or contributions: 📧 support@quicojs.dev
For security-related issues, please see SECURITY.md.
Apache License 2.0
Copyright © 2025 colocohen
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
| Back | FazBrowse Home | New Git URL |