quico 0.3.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -1,49 +1,71 @@
1
1
  /*
2
- * quico: HTTP/3 and QUIC implementation for Node.js
2
+ * quico: a full HTTP stack in JavaScript
3
3
  * Copyright 2025 colocohen
4
- *
5
- * Licensed under the Apache License, Version 2.0 (the "License");
6
- * you may not use this file except in compliance with the License.
7
- * You may obtain a copy of the License at:
8
- *
9
- * https://www.apache.org/licenses/LICENSE-2.0
10
- *
11
- * This file is part of the open-source project hosted at:
4
+ * Licensed under the Apache License, Version 2.0
12
5
  * https://github.com/colocohen/quico
13
6
  */
14
7
 
8
+ // ============================================================
9
+ // quico no longer imports node:http, node:https, node:http2 or node:tls.
10
+ //
11
+ // Every protocol layer is here: HTTP/1.1 (h1.js), HTTP/2 (h2.js + hpack.js),
12
+ // HTTP/3 (h3.js + QUIC), and WebSocket. TLS comes from lemon-tls, also pure
13
+ // JavaScript. What remains from Node is the transport it alone can provide —
14
+ // node:net for TCP and node:dgram for UDP — plus node:crypto for primitives.
15
+ //
16
+ // The point is not purity for its own sake. One stack, one set of semantics:
17
+ // the same IncomingMessage and ServerResponse reach the application whether a
18
+ // request arrived over QUIC, over TCP with h2, or over TCP with HTTP/1.1, so
19
+ // a framework written against node:http runs over all three without knowing.
20
+ // ============================================================
21
+
15
22
  import { createServer } from './src/server.js';
16
23
  import { request, get } from './src/client.js';
17
24
  import { Agent, globalAgent } from './src/agent.js';
18
25
  import { IncomingMessage, ServerResponse, ClientRequest } from './src/streams.js';
19
26
  import { createSecureContext } from './src/tls_bridge.js';
20
- import { WebTransport } from './src/webtransport.js';
27
+ import lemonTls from 'lemon-tls';
28
+
29
+ // Encrypted Client Hello keys (RFC 9849) — LemonTLS's; for createServer's `ech`.
30
+ const generateEchKey = lemonTls.generateEchKey;
31
+ import { WebTransport, WebTransportError } from './src/webtransport.js';
32
+ import { WebSocket } from './src/ws_client.js';
33
+ import { connectUdp } from './src/connect_udp_client.js';
34
+ import { WebSocketServer, upgradeWebSocket } from './src/ws_server.js';
35
+ import { ja4h } from './src/ja4h.js';
36
+ import { h2Fingerprint, h3Fingerprint, observeH2 } from './src/h2_fingerprint.js';
37
+ import { profileRequest } from './src/client_profile.js';
38
+
39
+ // Transport layers, for building something other than HTTP on top.
21
40
  import { createQuicServer } from './src/quic_server.js';
41
+ import { createQuicClientSocket } from './src/quic_socket.js';
42
+ import { createTcpListener } from './src/tcp_listener.js';
22
43
 
23
- export {
24
- createServer,
25
- request,
26
- get,
27
- Agent,
28
- globalAgent,
29
- IncomingMessage,
30
- ServerResponse,
31
- ClientRequest,
32
- createSecureContext,
33
- WebTransport,
34
- createQuicServer
44
+ // Protocol layers, for consumers that own their own sockets.
45
+ import { createServer as createH1Server } from './src/h1_server.js';
46
+ import { createServer as createH2Server } from './src/h2_server.js';
47
+ import { createServer as createH3Server } from './src/h3_server.js';
48
+ import { request as h1Request } from './src/h1_client.js';
49
+ import { request as h2Request } from './src/h2_client.js';
50
+ import { request as h3Request } from './src/h3_client.js';
51
+
52
+ const api = {
53
+ createServer, request, get, Agent, globalAgent,
54
+ IncomingMessage, ServerResponse, ClientRequest,
55
+ connectUdp, createSecureContext, generateEchKey, WebTransport, WebTransportError, WebSocket, WebSocketServer, upgradeWebSocket,
56
+ ja4h, h2Fingerprint, h3Fingerprint, observeH2, profileRequest,
57
+ createQuicServer, createQuicClientSocket, createTcpListener,
58
+ createH1Server, createH2Server, createH3Server,
59
+ h1Request, h2Request, h3Request
35
60
  };
36
61
 
37
- export default {
38
- createServer,
39
- request,
40
- get,
41
- Agent,
42
- globalAgent,
43
- IncomingMessage,
44
- ServerResponse,
45
- ClientRequest,
46
- createSecureContext,
47
- WebTransport,
48
- createQuicServer
62
+ export {
63
+ createServer, request, get, Agent, globalAgent,
64
+ IncomingMessage, ServerResponse, ClientRequest,
65
+ connectUdp, createSecureContext, generateEchKey, WebTransport, WebTransportError, WebSocket, WebSocketServer, upgradeWebSocket,
66
+ ja4h, h2Fingerprint, h3Fingerprint, observeH2, profileRequest,
67
+ createQuicServer, createQuicClientSocket, createTcpListener,
68
+ createH1Server, createH2Server, createH3Server,
69
+ h1Request, h2Request, h3Request
49
70
  };
71
+ export default api;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quico",
3
- "version": "0.3.0",
3
+ "version": "0.7.0",
4
4
  "description": "A pure JavaScript implementation of QUIC, HTTP/3, QPACK, and WebTransport for Node.js",
5
5
  "type": "module",
6
6
  "main": "index.cjs",
@@ -77,7 +77,7 @@
77
77
  }
78
78
  ],
79
79
  "dependencies": {
80
- "flat-ranges": "^2.0.0",
81
- "lemon-tls": "^0.4.1"
80
+ "flat-ranges": "^2.2.0",
81
+ "lemon-tls": "^0.9.0"
82
82
  }
83
83
  }
package/src/agent.js CHANGED
@@ -13,6 +13,61 @@
13
13
  */
14
14
 
15
15
  import { EventEmitter } from 'node:events';
16
+ import { createHash } from 'node:crypto';
17
+ import lemonTls from 'lemon-tls';
18
+
19
+
20
+ // ============================================================
21
+ // Which connections a request may share — node:https.Agent's getName
22
+ //
23
+ // A pooled connection was looked up by host and port alone. One opened with
24
+ // rejectUnauthorized: false — to a server whose certificate was never
25
+ // checked — then carried the next request to that origin that does check,
26
+ // and that request went over an unverified connection without a word; one
27
+ // verified against a private `ca` served a request that trusts only the
28
+ // public roots. node:https puts what decides a connection's TLS into the
29
+ // pool's name (getName): ca, cert, key, rejectUnauthorized, servername.
30
+ // So does this, for every pool — the agent's HTTP/3 connections, the HTTP/2
31
+ // sessions and HTTP/1.1 sockets, and their TLS sessions — the values
32
+ // hashed, not spelled out, so a key or a CA bundle is not in a map key.
33
+ //
34
+ // A request with a client certificate shared no connection at all, since
35
+ // the name could not tell one certificate from another; now it shares those
36
+ // opened with the same one, as in Node.
37
+ // ============================================================
38
+
39
+ function digestOf(v) {
40
+ if (v === undefined || v === null || v === '') return '';
41
+ var h = createHash('sha256');
42
+ (Array.isArray(v) ? v : [v]).forEach(function (x) {
43
+ h.update(typeof x === 'string' || Buffer.isBuffer(x) || x instanceof Uint8Array ? x : String(x));
44
+ h.update('\0');
45
+ });
46
+ return h.digest('hex').slice(0, 16);
47
+ }
48
+
49
+ /** The TLS identity part of a pool name: '' for a cleartext connection. */
50
+ // Sessions kept per origin (see _takeSession).
51
+ var ECH_RETRY_TTL = 24 * 3600 * 1000; // ms: how long a server's retry_configs are used for
52
+ var TICKETS_PER_ORIGIN = 4;
53
+
54
+ function tlsIdentity(options) {
55
+ options = options || {};
56
+ // With ECH, its own connections: a request asking for its name to be
57
+ // hidden does not ride one opened without (or with another server's keys).
58
+ // With other key-exchange groups (ecdhCurve, as node:https names it), its
59
+ // own connections too: one asking for post-quantum does not ride a
60
+ // classical one.
61
+ var curve = options.ecdhCurve == null ? '' : String(options.ecdhCurve).trim().toUpperCase().replace(/[\s,]+/g, ':');
62
+ // And with other signature schemes (sigalgs): the certificate a server
63
+ // presents can depend on them (an ML-DSA one, asked for first).
64
+ var sigalgs = options.sigalgs == null ? '' : String(options.sigalgs).trim().toUpperCase().replace(/[\s,]+/g, ':');
65
+ return [options.servername || '', options.rejectUnauthorized === false ? 'noverify' : 'verify',
66
+ digestOf(options.ca), digestOf(options.cert), digestOf(options.key)].join(':') +
67
+ (options.ech ? ':ech:' + digestOf(options.ech) : '') +
68
+ (sigalgs ? ':sigalgs:' + sigalgs : '') +
69
+ (curve && curve !== 'AUTO' ? ':curve:' + curve : '');
70
+ }
16
71
 
17
72
 
18
73
  // ============================================================
@@ -26,6 +81,19 @@ import { EventEmitter } from 'node:events';
26
81
  // http2: true (default) — allow HTTP/2 in fallback chain
27
82
  // http1: true (default) — allow HTTP/1.1 in fallback chain
28
83
  // h3Timeout: 3000 — ms before falling back from H3
84
+ //
85
+ // and node:http.Agent's, with Node's meanings. They govern HTTP/1.1, the one
86
+ // version that needs a socket per request; HTTP/2 and HTTP/3 multiplex every
87
+ // request to an origin over one connection, which the agent always keeps.
88
+ // keepAlive: true — keep a socket for the next request (Node's
89
+ // globalAgent default; `new http.Agent()` is false)
90
+ // keepAliveMsecs: 1000 — TCP keep-alive initial delay on kept sockets
91
+ // maxSockets: 256 — sockets per origin at once; more requests wait
92
+ // maxFreeSockets: 256 — idle sockets kept per origin
93
+ // timeout — how long an idle socket is kept (5000 when unset,
94
+ // as node:https.globalAgent)
95
+ // maxCachedSessions: 100 — TLS sessions kept to resume with, as
96
+ // node:https.Agent's; 0 keeps none
29
97
  // ============================================================
30
98
 
31
99
  class Agent extends EventEmitter {
@@ -38,8 +106,8 @@ class Agent extends EventEmitter {
38
106
  this.keepAlive = options.keepAlive !== false;
39
107
  this.keepAliveMsecs = options.keepAliveMsecs || 1000;
40
108
  this.maxSockets = options.maxSockets || 256;
41
- this.maxFreeSockets = options.maxFreeSockets || 32;
42
- this.timeout = options.timeout || 30000;
109
+ this.maxFreeSockets = options.maxFreeSockets != null ? options.maxFreeSockets : 256;
110
+ this.timeout = options.timeout; // unset: idle sockets kept 5 s (see agentH1Pool)
43
111
 
44
112
  // Protocol flags (defaults for all requests through this agent)
45
113
  this.http2 = options.http2 !== false;
@@ -48,14 +116,47 @@ class Agent extends EventEmitter {
48
116
  // H3 connect timeout before falling back (ms)
49
117
  this.h3Timeout = options.h3Timeout || 3000;
50
118
 
119
+ // The key-exchange groups its requests offer, unless a request names its
120
+ // own (tls.connect's ecdhCurve; 'X25519MLKEM768:X25519' for the
121
+ // post-quantum hybrid). Checked here, as tls.connect checks it.
122
+ if (options.ecdhCurve != null) lemonTls.parseEcdhCurve(options.ecdhCurve);
123
+ this.ecdhCurve = options.ecdhCurve != null ? options.ecdhCurve : undefined;
124
+ // The signature schemes they offer (tls.connect's sigalgs; 'mldsa65:…'
125
+ // first asks a server holding an ML-DSA certificate for it).
126
+ if (options.sigalgs != null) lemonTls.parseSigalgs(options.sigalgs);
127
+ this.sigalgs = options.sigalgs != null ? options.sigalgs : undefined;
128
+
129
+ // TLS sessions to resume with, as node:https.Agent keeps them: the
130
+ // latest per origin, the oldest dropped past maxCachedSessions.
131
+ this.maxCachedSessions = options.maxCachedSessions != null ? options.maxCachedSessions : 100;
132
+ if (!(Number.isInteger(this.maxCachedSessions) && this.maxCachedSessions >= 0))
133
+ throw new TypeError('maxCachedSessions must be a non-negative integer, got ' + options.maxCachedSessions);
134
+ this._sessionCache = new Map();
135
+ this._tokenCache = new Map(); // NEW_TOKEN tokens (_takeToken)
136
+ this._echCache = new Map(); // ECH keys a server sent on rejecting (_getEchRetry)
137
+
51
138
  // Protocol cache: "host:port" → { protocol, ts, altSvcMaxAge }
52
139
  // protocol: 'h3' | 'h2' | 'https'
53
140
  this._protocolCache = {};
54
141
 
142
+ // Alt-Svc advertisements: "host:port" (the origin) → { port, expires, brokenUntil }
143
+ this._altSvc = {};
144
+
55
145
  // Active QUIC connections: "host:port" → QUICConnection
56
146
  // Allows multiplexing multiple requests over one QUIC connection
57
147
  this._h3Pool = {};
58
148
 
149
+ // Connections still being ESTABLISHED: "host:port" → [waiter callbacks].
150
+ // Without this third state the pool only knows "have one" / "don't", so N
151
+ // requests issued in the same tick each see an empty pool and open N
152
+ // separate QUIC connections to the same origin — N handshakes, N
153
+ // congestion controllers, zero multiplexing (the QUIC interop runner's
154
+ // http3 test flags exactly this: "Expected exactly 1 handshake. Got: 3").
155
+ // The first request to miss the pool becomes the lead and creates the
156
+ // entry; requests arriving before it finishes park a callback here and
157
+ // are replayed once the lead settles.
158
+ this._h3Pending = {};
159
+
59
160
  // Active HTTP/2 sessions: "host:port" → http2.ClientHttp2Session
60
161
  this._h2Pool = {};
61
162
 
@@ -104,45 +205,209 @@ class Agent extends EventEmitter {
104
205
 
105
206
 
106
207
  // ============================================================
107
- // Alt-Svc header parsing
208
+ // Alt-Svc (RFC 7838): where else an origin can be reached
209
+ //
210
+ // An HTTP/1.1 or HTTP/2 response saying `Alt-Svc: h3=":443"; ma=86400`
211
+ // tells us the same origin is also served over HTTP/3, on that port, for
212
+ // the next `ma` seconds. That is what lets a client move to HTTP/3 at all.
213
+ //
214
+ // Kept apart from the protocol cache on purpose. The two answer different
215
+ // questions — "what worked here last time" and "what does the origin
216
+ // advertise" — and when they shared one entry, the request that carried
217
+ // the Alt-Svc header recorded its own protocol ('h2') a line later,
218
+ // overwriting the advertisement: a client never moved to HTTP/3 through
219
+ // Alt-Svc. An advertisement for another port was stored under that port,
220
+ // where no lookup for the origin ever looked.
221
+ //
222
+ // Keyed by origin. Only alternatives on the same host are used: one on
223
+ // another host would need the connection to be authenticated for the
224
+ // origin, not for the host it goes to (RFC 7838 §2.1), which the HTTP/3
225
+ // client does not do.
108
226
  // ============================================================
109
227
 
110
228
  /**
111
- * Parse Alt-Svc header from an HTTP/1.1 or HTTP/2 response.
112
- * If it advertises h3, cache it for future requests.
113
- *
114
- * Examples:
229
+ * Record what an origin advertises. `value` is the Alt-Svc header.
115
230
  * Alt-Svc: h3=":443"; ma=86400
116
- * Alt-Svc: h3=":443", h3-29=":443"
231
+ * Alt-Svc: h3=":8443", h3-29=":8443"
232
+ * Alt-Svc: clear
117
233
  */
118
- parseAltSvc(host, port, altSvcValue) {
119
- if (!altSvcValue || altSvcValue === 'clear') {
120
- var key = host + ':' + (port || 443);
121
- delete this._protocolCache[key];
234
+ parseAltSvc(host, port, value) {
235
+ var key = host + ':' + (port || 443);
236
+ if (!value) return;
237
+ if (String(value).trim() === 'clear') { delete this._altSvc[key]; return; }
238
+
239
+ var entries = String(value).split(',');
240
+ for (var i = 0; i < entries.length; i++) {
241
+ var match = entries[i].trim().match(/^h3="([^"]*)"(.*)$/); // RFC 9114 h3 only, not drafts
242
+ if (!match) continue;
243
+ var authority = match[1];
244
+ var colonIdx = authority.lastIndexOf(':');
245
+ if (colonIdx < 0) continue;
246
+ var altHost = authority.substring(0, colonIdx);
247
+ var altPort = parseInt(authority.substring(colonIdx + 1), 10);
248
+ if (!(altPort > 0 && altPort < 65536)) continue;
249
+ if (altHost && altHost.toLowerCase() !== String(host).toLowerCase()) continue;
250
+ var maMatch = (match[2] || '').match(/(?:^|;)\s*ma=(\d+)/);
251
+ var maxAge = maMatch ? parseInt(maMatch[1], 10) * 1000 : 86400000; // default 24h (§3.1)
252
+ var prev = this._altSvc[key];
253
+ this._altSvc[key] = {
254
+ port: altPort,
255
+ expires: Date.now() + maxAge,
256
+ // A fresh advertisement does not clear a recent failure of the same
257
+ // alternative: the origin keeps advertising it on every response.
258
+ brokenUntil: prev && prev.port === altPort ? prev.brokenUntil : 0
259
+ };
122
260
  return;
123
261
  }
262
+ }
124
263
 
125
- var entries = altSvcValue.split(',');
126
- for (var i = 0; i < entries.length; i++) {
127
- var entry = entries[i].trim();
128
- var match = entry.match(/^h3(?:-\d+)?="([^"]*)"(.*)$/);
129
- if (match) {
130
- var authority = match[1];
131
- var params = match[2] || '';
132
-
133
- var maMatch = params.match(/ma=(\d+)/);
134
- var maxAge = maMatch ? parseInt(maMatch[1]) * 1000 : 86400000;
135
-
136
- var h3Port = port;
137
- var colonIdx = authority.lastIndexOf(':');
138
- if (colonIdx >= 0) {
139
- var parsed = parseInt(authority.substring(colonIdx + 1));
140
- if (!isNaN(parsed)) h3Port = parsed;
141
- }
142
-
143
- this.setProtocol(host, h3Port, 'h3', maxAge);
144
- return;
145
- }
264
+ /** The HTTP/3 alternative to use for an origin now: { port }, or null. */
265
+ getAltSvc(host, port) {
266
+ var key = host + ':' + (port || 443);
267
+ var alt = this._altSvc[key];
268
+ if (!alt) return null;
269
+ var now = Date.now();
270
+ if (now > alt.expires) { delete this._altSvc[key]; return null; }
271
+ if (now < alt.brokenUntil) return null;
272
+ return { port: alt.port };
273
+ }
274
+
275
+ /**
276
+ * The alternative failed: don't try it again for a while (RFC 7838 §2.4
277
+ * lets a client mark an alternative broken). Five minutes, as browsers do
278
+ * for a first failure; requests in the meantime go straight to the
279
+ * origin's own protocol.
280
+ */
281
+ markAltSvcBroken(host, port) {
282
+ var alt = this._altSvc[host + ':' + (port || 443)];
283
+ if (alt) alt.brokenUntil = Date.now() + 5 * 60 * 1000;
284
+ }
285
+
286
+
287
+ // ============================================================
288
+ // TLS sessions (node:https.Agent's _getSession / _cacheSession /
289
+ // _evictSession)
290
+ //
291
+ // Every connection used to start with a full handshake — certificate
292
+ // chain, signature, its verification — though the server had sent a
293
+ // ticket to skip all that next time; node:https.Agent resumes. And the
294
+ // ticket is where a QUICO server keeps what it learned about the path
295
+ // (Careful Resume, quic_connection.js): without resumption a returning
296
+ // client was a stranger again, its transfer starting from the initial
297
+ // window.
298
+ //
299
+ // The key says which connections a session may serve: the transport (a
300
+ // ticket from TLS over TCP is not offered to QUIC, RFC 9001 §4.6.1 keeps
301
+ // them apart), and the pool name (tlsIdentity above) — a session from a
302
+ // connection that did not verify the server, or verified it against
303
+ // another `ca`, is not offered to one that does (lemon-tls checks that
304
+ // too, from the session).
305
+ // ============================================================
306
+
307
+ _sessionKey(transport, host, port, options) {
308
+ return transport + ':' + host + ':' + (port || 443) + ':' + tlsIdentity(options);
309
+ }
310
+
311
+ /** node:https.Agent's getName: what the agent keys a connection by. */
312
+ getName(options) {
313
+ options = options || {};
314
+ return (options.hostname || options.host || 'localhost') + ':' + (options.port || 443) + ':' + tlsIdentity(options);
315
+ }
316
+
317
+ // Each origin's sessions — the tickets its server sent, up to
318
+ // TICKETS_PER_ORIGIN, newest last — and each used ONCE (RFC 8446 §C.4: a
319
+ // ticket offered twice links the two connections, and its 0-RTT is
320
+ // refused as a replay). The latest alone was kept and offered to every
321
+ // connection: two at once resumed with the same ticket, and the second's
322
+ // early data was always refused. A QUICO server sends two per full
323
+ // handshake, as OpenSSL and BoringSSL do. Expired ones are not offered
324
+ // (lemon-tls sessionInfo). maxCachedSessions bounds the origins.
325
+
326
+ /** The newest session for `key`, left in the cache (to look at). */
327
+ _getSession(key) {
328
+ var list = this._sessionCache.get(key);
329
+ return list && list.length ? list[list.length - 1] : undefined;
330
+ }
331
+
332
+ /** The newest unexpired session for `key`, taken: a new connection's. */
333
+ _takeSession(key) {
334
+ var list = this._sessionCache.get(key);
335
+ var now = Date.now();
336
+ while (list && list.length) {
337
+ var s = list.pop();
338
+ var info = lemonTls.sessionInfo ? lemonTls.sessionInfo(s) : null;
339
+ if (info && info.expiresAt != null && info.expiresAt <= now) continue;
340
+ if (!list.length) this._sessionCache.delete(key);
341
+ return s;
342
+ }
343
+ this._sessionCache.delete(key);
344
+ return undefined;
345
+ }
346
+
347
+ _cacheSession(key, session) {
348
+ if (this.maxCachedSessions === 0 || !session) return;
349
+ var list = this._sessionCache.get(key) || [];
350
+ this._sessionCache.delete(key); // the newest origin goes last
351
+ list.push(session);
352
+ while (list.length > TICKETS_PER_ORIGIN) list.shift();
353
+ this._sessionCache.set(key, list);
354
+ while (this._sessionCache.size > this.maxCachedSessions) {
355
+ this._sessionCache.delete(this._sessionCache.keys().next().value);
356
+ }
357
+ }
358
+
359
+ _evictSession(key) {
360
+ this._sessionCache.delete(key);
361
+ }
362
+
363
+ // ECH keys a server sent when it rejected the ones the caller had
364
+ // (retry_configs, RFC 9849 §6.1.6), kept for the next connections (§6.1.8:
365
+ // "Clients MAY use information learned from a rejected ECH for future
366
+ // connections"): a server that rotated its keys was asked with the old
367
+ // ones on every connection until the caller's DNS caught up — rejected,
368
+ // retried, a round trip lost each time. Kept under what PSKs are (§6.1.8:
369
+ // "the same lifetime and scope restrictions"): the key is the session key
370
+ // of the config the caller gave (the same source), the origin, the TLS
371
+ // identity; a day at most (ECH_RETRY_TTL); none with maxCachedSessions 0;
372
+ // bounded by it. A caller that gives another config — its DNS updated —
373
+ // finds nothing kept for it.
374
+ _getEchRetry(key) {
375
+ var e = this._echCache.get(key);
376
+ if (!e) return null;
377
+ if (Date.now() - e.at > ECH_RETRY_TTL) { this._echCache.delete(key); return null; }
378
+ return e.configs;
379
+ }
380
+
381
+ _cacheEchRetry(key, configs) {
382
+ if (this.maxCachedSessions === 0 || !configs) return;
383
+ this._echCache.delete(key);
384
+ this._echCache.set(key, { configs: configs, at: Date.now() });
385
+ while (this._echCache.size > this.maxCachedSessions) {
386
+ this._echCache.delete(this._echCache.keys().next().value);
387
+ }
388
+ }
389
+
390
+ _evictEchRetry(key) {
391
+ this._echCache.delete(key);
392
+ }
393
+
394
+ // NEW_TOKEN tokens (RFC 9000 §8.1.3), per server: the latest one a QUIC
395
+ // connection to it was given, for the next connection's Initial — which
396
+ // then needs no proof of address, so the server's whole first flight goes
397
+ // out in one round trip. Each is used once (§8.1.3: a reused token links
398
+ // the connections that carry it), and the server gives another each time.
399
+ _takeToken(key) {
400
+ var t = this._tokenCache.get(key);
401
+ this._tokenCache.delete(key);
402
+ return t;
403
+ }
404
+
405
+ _cacheToken(key, token) {
406
+ if (this.maxCachedSessions === 0 || !token) return;
407
+ this._tokenCache.delete(key);
408
+ this._tokenCache.set(key, token);
409
+ while (this._tokenCache.size > this.maxCachedSessions) {
410
+ this._tokenCache.delete(this._tokenCache.keys().next().value);
146
411
  }
147
412
  }
148
413
 
@@ -158,13 +423,51 @@ class Agent extends EventEmitter {
158
423
  var conn = this._h3Pool[key];
159
424
  if (!conn) return null;
160
425
  var state = conn.quic ? conn.quic.state : 'closed';
161
- if (state === 'closed' || state === 'draining' || state === 'closing') {
426
+ // GOAWAY'd (RFC 9114 §5.2) — the event below can come before the
427
+ // connection is pooled, so asked here too.
428
+ if (state === 'closed' || state === 'draining' || state === 'closing' || conn.goingAway) {
162
429
  delete this._h3Pool[key];
163
430
  return null;
164
431
  }
165
432
  return conn;
166
433
  }
167
434
 
435
+ /**
436
+ * Coalesce concurrent connection attempts to one origin.
437
+ *
438
+ * Returns false if the caller is the LEAD: no connection exists and nobody
439
+ * else is establishing one. The lead MUST eventually call
440
+ * resolveH3Pending(host, port, connOrNull) — on success, failure, or
441
+ * abandonment — or every later request to this origin parks forever.
442
+ *
443
+ * Returns true if the caller was parked as a follower; `cb` fires (with no
444
+ * arguments) once the lead settles, and the caller should simply retry its
445
+ * pool lookup: on success the connection is already registered, and on
446
+ * failure the retry becomes the next lead — serial retry, no thundering
447
+ * herd.
448
+ */
449
+ joinH3Pending(host, port, cb) {
450
+ var key = host + ':' + (port || 443);
451
+ if (this._h3Pending[key]) {
452
+ this._h3Pending[key].push(cb);
453
+ return true;
454
+ }
455
+ this._h3Pending[key] = [];
456
+ return false;
457
+ }
458
+
459
+ /** Lead's settlement. Registers the connection (if any) BEFORE waking the
460
+ * waiters, so their retried pool lookups succeed synchronously. */
461
+ resolveH3Pending(host, port, conn) {
462
+ var key = host + ':' + (port || 443);
463
+ var waiters = this._h3Pending[key] || [];
464
+ delete this._h3Pending[key];
465
+ if (conn) this.setH3Connection(host, port, conn);
466
+ for (var i = 0; i < waiters.length; i++) {
467
+ try { waiters[i](); } catch (e) { /* a waiter's failure is its own */ }
468
+ }
469
+ }
470
+
168
471
  setH3Connection(host, port, conn) {
169
472
  var key = host + ':' + (port || 443);
170
473
  this._h3Pool[key] = conn;
@@ -247,6 +550,10 @@ class Agent extends EventEmitter {
247
550
  if (now - entry.ts > maxAge) delete this._protocolCache[key];
248
551
  }
249
552
 
553
+ for (var key in this._altSvc) {
554
+ if (now > this._altSvc[key].expires) delete this._altSvc[key];
555
+ }
556
+
250
557
  for (var key in this._h3Pool) {
251
558
  var conn = this._h3Pool[key];
252
559
  var state = conn.quic ? conn.quic.state : 'closed';
@@ -262,8 +569,20 @@ class Agent extends EventEmitter {
262
569
  destroy() {
263
570
  if (this._sweepTimer) { clearInterval(this._sweepTimer); this._sweepTimer = null; }
264
571
 
572
+ // Wake anyone parked on an in-flight connection attempt; their retried
573
+ // pool lookup will find nothing and proceed (or fail) on its own.
574
+ for (var key in this._h3Pending) {
575
+ var waiters = this._h3Pending[key];
576
+ delete this._h3Pending[key];
577
+ for (var i = 0; i < waiters.length; i++) {
578
+ try { waiters[i](); } catch (e) {}
579
+ }
580
+ }
581
+ this._h3Pending = {};
582
+
265
583
  for (var key in this._h3Pool) {
266
- try { this._h3Pool[key].quic.close(0, 'agent destroy'); } catch (e) {}
584
+ // Pooled connections are HTTP/3 — close with H3_NO_ERROR (RFC 9114 §5.2).
585
+ try { this._h3Pool[key].quic.close(0x0100, 'agent destroy', true); } catch (e) {}
267
586
  }
268
587
  this._h3Pool = {};
269
588
 
@@ -272,12 +591,21 @@ class Agent extends EventEmitter {
272
591
  }
273
592
  this._h2Pool = {};
274
593
 
594
+ // The pools client.js keeps on the agent for HTTP/2 sessions and HTTP/1.1
595
+ // sockets. destroy() left them open: a destroyed agent kept its sockets.
596
+ if (this._h2ClientPool) { try { this._h2ClientPool.destroy(); } catch (e) {} this._h2ClientPool = null; }
597
+ if (this._h1ClientPool) { try { this._h1ClientPool.destroy(); } catch (e) {} this._h1ClientPool = null; }
598
+
275
599
  this._protocolCache = {};
600
+ this._altSvc = {};
276
601
  this._activeCounts = {};
602
+ this._sessionCache.clear();
603
+ this._tokenCache.clear();
604
+ this._echCache.clear();
277
605
  }
278
606
  }
279
607
 
280
608
 
281
609
  var globalAgent = new Agent();
282
610
 
283
- export { Agent, globalAgent };
611
+ export { Agent, globalAgent, tlsIdentity };