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.
@@ -0,0 +1,372 @@
1
+ /*
2
+ * quico: HTTP/3 and QUIC implementation for Node.js
3
+ * Copyright 2025 colocohen
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at:
8
+ *
9
+ * https://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * This file is part of the open-source project hosted at:
12
+ * https://github.com/colocohen/quico
13
+ */
14
+
15
+ import dgram from 'node:dgram';
16
+ import { DEBUG, Emitter } from './utils.js';
17
+ import {
18
+ parse_quic_datagram,
19
+ is_supported_version,
20
+ build_version_negotiation
21
+ } from './transport.js';
22
+ import { QUICConnection } from './quic_connection.js';
23
+ import { createSecureContext } from './tls_bridge.js';
24
+
25
+
26
+ // ============================================================
27
+ // createQuicServer(options)
28
+ //
29
+ // Transport-only QUIC server. Owns the UDP socket(s), demultiplexes
30
+ // incoming datagrams to per-peer QUICConnection objects, and emits a
31
+ // 'connection' event carrying the raw QUICConnection. It knows nothing
32
+ // about HTTP/3 — application protocols (HTTP/3, DNS-over-QUIC, ...) are
33
+ // layered on top by listening for 'connection' and then using the
34
+ // connection's own stream/datagram API.
35
+ //
36
+ // This is the connection-management harness that used to live inside
37
+ // h3_server.js. h3_server now builds on it (wrapping each connection in
38
+ // an H3Connection); other consumers (e.g. a DoQ server) can use it
39
+ // directly.
40
+ //
41
+ // Events:
42
+ // 'connection' (quic, peer) — new QUICConnection created for a peer.
43
+ // peer = { address, port }. Handlers
44
+ // attach quic.on('connect'|'stream'|
45
+ // 'datagram'|'close') as needed.
46
+ // 'error' (err) — UDP socket / send error.
47
+ //
48
+ // Options:
49
+ // alpn — ALPN protocol(s) to advertise (default 'h3').
50
+ // DoQ servers pass 'doq'.
51
+ // SNICallback — TLS SNI callback returning a SecureContext.
52
+ // key/cert/ca — convenience: build a default SecureContext when no
53
+ // SNICallback is given.
54
+ // maxConnections — capacity cap (default 10000).
55
+ // socket/socket6 — externally-owned, already-bound UDP sockets
56
+ // (shared-port mode, RFC 9443). When provided, the
57
+ // caller feeds datagrams via handlePacket(msg, rinfo).
58
+ // ============================================================
59
+
60
+ function createQuicServer(options) {
61
+ options = options || {};
62
+
63
+ var ev = Emitter();
64
+ var udp4 = null;
65
+ var udp6 = null;
66
+ var port = null;
67
+
68
+ // ALPN to advertise on every accepted connection. Default ['h3'].
69
+ var alpn = options.alpn || ['h3'];
70
+
71
+ // ── External socket mode (shared UDP port) ──
72
+ // When socket/socket6 are provided, the UDP layer is owned externally.
73
+ // The caller demuxes packets and feeds them via handlePacket(msg, rinfo).
74
+ // See RFC 9443 for the shared-port multiplexing scheme.
75
+ var externalSocket4 = options.socket || null;
76
+ var externalSocket6 = options.socket6 || null;
77
+
78
+ if (externalSocket4) {
79
+ try { externalSocket4.address(); }
80
+ catch (e) { throw new Error('options.socket must be bound before createQuicServer()'); }
81
+ }
82
+ if (externalSocket6) {
83
+ try { externalSocket6.address(); }
84
+ catch (e) { throw new Error('options.socket6 must be bound before createQuicServer()'); }
85
+ }
86
+
87
+ var isExternal = !!(externalSocket4 || externalSocket6);
88
+
89
+ // Build SNICallback
90
+ var sniCallback = options.SNICallback || null;
91
+
92
+ if (!sniCallback && options.key && options.cert) {
93
+ var defaultCtx = createSecureContext({ key: options.key, cert: options.cert, ca: options.ca });
94
+ sniCallback = function (servername, cb) { cb(null, defaultCtx); };
95
+ }
96
+
97
+ // ---- Connection tracking ----
98
+ var connections = {}; // connId → { quic, peer }
99
+ var addressMap = {}; // "ip:port" → connId
100
+ var maxConnections = options.maxConnections || 10000;
101
+ var sweepTimer = null;
102
+
103
+
104
+ // ============================================================
105
+ // UDP → connection routing
106
+ // ============================================================
107
+
108
+ function onUdpMessage(msg, rinfo) {
109
+ var data = new Uint8Array(msg);
110
+ var fromIp = rinfo.address;
111
+ var fromPort = rinfo.port;
112
+ if (DEBUG) console.log('[qserver] UDP from ' + fromIp + ':' + fromPort + ' len=' + data.length);
113
+
114
+ var addressKey = fromIp + ':' + fromPort;
115
+ var packets = parse_quic_datagram(data);
116
+ if (packets.length === 0) return;
117
+
118
+ // Route the whole datagram by its first packet. Coalesced packets in one
119
+ // UDP datagram belong to the same connection (RFC 9000 §12.2), so a single
120
+ // routing decision covers them all — and the datagram is parsed only once
121
+ // here, handing the parsed packets straight to the connection (previously
122
+ // each packet's raw bytes were re-parsed inside feedDatagram).
123
+ // ── Version Negotiation (RFC 9000 §6) ──
124
+ // Must happen before any connection is created. A long-header packet
125
+ // carrying a version we don't speak gets a Version Negotiation packet in
126
+ // reply — that is how a client discovers what we support, and how a prober
127
+ // checks we are alive without completing a handshake.
128
+ //
129
+ // Getting this wrong is silent and confusing: without it, findConnectionId
130
+ // below happily creates a connection for the unknown-version Initial, maps
131
+ // the address to it, and every subsequent probe is routed into a
132
+ // connection that cannot do anything with it. The peer sees no reply at
133
+ // all and concludes the server is down.
134
+ var firstPkt = packets[0];
135
+ if (firstPkt.form === 'long' &&
136
+ firstPkt.type !== 'version_negotiation' &&
137
+ !is_supported_version(firstPkt.version)) {
138
+
139
+ // Anti-amplification (RFC 9000 §14.1): only answer datagrams that are
140
+ // at least 1200 bytes, so we can't be used to reflect traffic at a
141
+ // spoofed source. A genuine Initial is always padded to that size.
142
+ if (data.length < 1200) {
143
+ if (DEBUG) console.log('[qserver] unsupported version 0x' +
144
+ firstPkt.version.toString(16) + ' in a short datagram (' +
145
+ data.length + 'B) — dropping without VN');
146
+ return;
147
+ }
148
+
149
+ var vn = build_version_negotiation(firstPkt.dcid, firstPkt.scid);
150
+ if (DEBUG) console.log('[qserver] version 0x' +
151
+ firstPkt.version.toString(16) + ' unsupported — sending Version Negotiation');
152
+ sendUdp(fromIp, fromPort, vn);
153
+ return;
154
+ }
155
+
156
+ var connId = findConnectionId(firstPkt, addressKey);
157
+ if (connId === null) return; // stray/unmatched non-Initial packet — drop
158
+
159
+ if (!(connId in connections)) {
160
+ // Reject new connections when at capacity
161
+ if (Object.keys(connections).length >= maxConnections) return;
162
+ createConnection(connId, fromIp, fromPort, addressKey);
163
+ }
164
+
165
+ connections[connId].quic.feedPackets(fromIp, fromPort, packets);
166
+ }
167
+
168
+
169
+ function findConnectionId(pkt, addressKey) {
170
+ var dcidStr = null;
171
+ if (pkt.dcid && pkt.dcid.byteLength > 0) {
172
+ dcidStr = Array.from(pkt.dcid).join('');
173
+ }
174
+
175
+ if (dcidStr && dcidStr in connections) return dcidStr;
176
+ if (addressKey in addressMap && addressMap[addressKey] in connections) return addressMap[addressKey];
177
+
178
+ // No existing connection matches. Only an Initial packet may *start* a new
179
+ // connection; any other unmatched packet (a stray/late short-header packet,
180
+ // or a Handshake whose Initial was lost, etc.) is dropped rather than
181
+ // spawning a phantom connection keyed by a random id.
182
+ if (pkt.type !== 'initial') return null;
183
+
184
+ var id = dcidStr || String(Math.floor(Math.random() * 9007199254740991));
185
+ addressMap[addressKey] = id;
186
+ return id;
187
+ }
188
+
189
+
190
+ function createConnection(connId, fromIp, fromPort, addressKey) {
191
+ var quic = new QUICConnection({ SNICallback: sniCallback, alpn: alpn });
192
+
193
+ connections[connId] = {
194
+ quic: quic,
195
+ peer: { address: fromIp, port: fromPort }
196
+ };
197
+
198
+ addressMap[addressKey] = connId;
199
+
200
+ // Send packets back via UDP
201
+ quic.on('packet', function (data) {
202
+ sendUdp(fromIp, fromPort, data);
203
+ });
204
+
205
+ // Drop our bookkeeping when the connection closes.
206
+ quic.on('close', function () {
207
+ delete connections[connId];
208
+ if (addressMap[addressKey] === connId) delete addressMap[addressKey];
209
+ });
210
+
211
+ // Hand the raw connection to the application layer (H3, DoQ, ...).
212
+ ev.emit('connection', quic, { address: fromIp, port: fromPort });
213
+ }
214
+
215
+
216
+ // ============================================================
217
+ // UDP send
218
+ // ============================================================
219
+
220
+ function sendUdp(toIp, toPort, data) {
221
+ var isV6 = toIp.indexOf(':') >= 0;
222
+ // Prefer external socket (shared mode) over internally-bound socket
223
+ var socket = isV6
224
+ ? (externalSocket6 || udp6)
225
+ : (externalSocket4 || udp4);
226
+ if (!socket) return;
227
+ socket.send(data, toPort, toIp, function (err) {
228
+ if (err) ev.emit('error', err);
229
+ });
230
+ }
231
+
232
+
233
+ // ============================================================
234
+ // listen / close
235
+ // ============================================================
236
+
237
+ function listen(listenPort, host, callback) {
238
+ if (typeof host === 'function') { callback = host; host = null; }
239
+ port = listenPort || 443;
240
+ host = host || '::';
241
+
242
+ if (isExternal) {
243
+ // External sockets already bound — wire up incoming messages.
244
+ // Starting the sweep timer is safe here (idempotent check below).
245
+ // Externally-owned sockets are already bound, so enlarge them here.
246
+ // The owner may not have done it, and the cost of asking twice is nil.
247
+ if (externalSocket4) {
248
+ externalSocket4.on('message', onUdpMessage);
249
+ try { externalSocket4.setSendBufferSize(2 * 1024 * 1024); } catch (e) {}
250
+ try { externalSocket4.setRecvBufferSize(2 * 1024 * 1024); } catch (e) {}
251
+ }
252
+ if (externalSocket6) {
253
+ externalSocket6.on('message', onUdpMessage);
254
+ try { externalSocket6.setSendBufferSize(2 * 1024 * 1024); } catch (e) {}
255
+ try { externalSocket6.setRecvBufferSize(2 * 1024 * 1024); } catch (e) {}
256
+ }
257
+ startSweepTimer();
258
+ if (typeof callback === 'function') setImmediate(callback);
259
+ return;
260
+ }
261
+
262
+ // Grow the kernel socket buffers once the socket is bound.
263
+ //
264
+ // Node's defaults are small. A full send buffer drops the datagram
265
+ // locally and silently — indistinguishable from network loss, so loss
266
+ // recovery engages and throughput collapses. See LOSS_PTO_PLAN 7.4: this
267
+ // is what defeated the third pacer-calibration attempt.
268
+ //
269
+ // try/catch because some platforms cap the value and throw; not being
270
+ // able to enlarge the buffer must not stop the server from listening.
271
+ function growBuffers(sock) {
272
+ try { sock.setSendBufferSize(2 * 1024 * 1024); } catch (e) {}
273
+ try { sock.setRecvBufferSize(2 * 1024 * 1024); } catch (e) {}
274
+ }
275
+
276
+ udp4 = dgram.createSocket('udp4');
277
+ udp4.on('message', onUdpMessage);
278
+ udp4.on('error', function (err) { ev.emit('error', err); });
279
+ var host4 = host.indexOf('.') !== -1 ? host : '0.0.0.0';
280
+ udp4.bind(port, host4, function () { growBuffers(udp4); });
281
+
282
+ udp6 = dgram.createSocket({ type: 'udp6', ipv6Only: true });
283
+ udp6.on('message', onUdpMessage);
284
+ udp6.on('error', function (err) { ev.emit('error', err); });
285
+ var host6 = host.indexOf(':') !== -1 ? host : '::';
286
+ udp6.bind(port, host6, function () {
287
+ growBuffers(udp6);
288
+ if (typeof callback === 'function') callback();
289
+ });
290
+
291
+ startSweepTimer();
292
+ }
293
+
294
+ // Periodic sweep for dead connections (safety net). Runs independently
295
+ // of socket ownership so external-mode agents get the same cleanup.
296
+ function startSweepTimer() {
297
+ if (sweepTimer) return; // idempotent
298
+ sweepTimer = setInterval(function () {
299
+ for (var id in connections) {
300
+ var st = connections[id].quic.state;
301
+ if (st === 'closed') {
302
+ delete connections[id];
303
+ // Clean address map
304
+ for (var addr in addressMap) {
305
+ if (addressMap[addr] === id) delete addressMap[addr];
306
+ }
307
+ }
308
+ }
309
+ }, 30000);
310
+ if (sweepTimer.unref) sweepTimer.unref();
311
+ }
312
+
313
+ function close(callback) {
314
+ // Stop sweep
315
+ if (sweepTimer) { clearInterval(sweepTimer); sweepTimer = null; }
316
+
317
+ // Close all connections
318
+ for (var id in connections) {
319
+ try { connections[id].quic.close(0, 'server closing'); } catch (e) { }
320
+ }
321
+ connections = {};
322
+ addressMap = {};
323
+
324
+ var closed = 0;
325
+ var total = (udp4 ? 1 : 0) + (udp6 ? 1 : 0);
326
+ if (total === 0) { if (callback) callback(); return; }
327
+
328
+ function onClose() {
329
+ closed++;
330
+ if (closed >= total && typeof callback === 'function') callback();
331
+ }
332
+
333
+ if (udp4) { udp4.close(onClose); udp4 = null; }
334
+ if (udp6) { udp6.close(onClose); udp6 = null; }
335
+ }
336
+
337
+
338
+ // ============================================================
339
+ // External packet API (for shared UDP port scenarios)
340
+ // ============================================================
341
+
342
+ // Query whether the given 5-tuple matches an active QUIC connection.
343
+ // Used by the demuxer to disambiguate byte 0 in range 64-79 between
344
+ // QUIC short header and TURN ChannelData (RFC 9443). Fresh connections
345
+ // must be classified by byte 0 range (80-127 or 192+ = QUIC).
346
+ function hasConnection(rinfo) {
347
+ var key = rinfo.address + ':' + rinfo.port;
348
+ return (key in addressMap) && (addressMap[key] in connections);
349
+ }
350
+
351
+
352
+ // ============================================================
353
+ // Server API
354
+ // ============================================================
355
+
356
+ return {
357
+ listen: listen,
358
+ close: close,
359
+ on: function (name, fn) { ev.on(name, fn); },
360
+
361
+ /** Feed an incoming UDP packet when running in external-socket mode.
362
+ * rinfo must be in Node's dgram format: { address, port, family, size }. */
363
+ handlePacket: onUdpMessage,
364
+
365
+ /** Returns true if the 5-tuple matches an active QUIC connection.
366
+ * Used by demuxers for routing decisions on shared UDP ports. */
367
+ hasConnection: hasConnection
368
+ };
369
+ }
370
+
371
+
372
+ export { createQuicServer };
@@ -0,0 +1,127 @@
1
+ /*
2
+ * quico: HTTP/3 and QUIC implementation for Node.js
3
+ * Copyright 2025 colocohen
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at:
8
+ *
9
+ * https://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * This file is part of the open-source project hosted at:
12
+ * https://github.com/colocohen/quico
13
+ */
14
+
15
+ import dgram from 'node:dgram';
16
+ import { QUICConnection } from './quic_connection.js';
17
+
18
+
19
+ /**
20
+ * Create a UDP socket bound to an ephemeral port and wire it to a client-side
21
+ * QUICConnection. Shared by the HTTP/3 client (h3_client.js) and the
22
+ * WebTransport client (webtransport.js), which had near-identical setup code:
23
+ * pick udp4/udp6, create the socket, forward datagrams into the connection,
24
+ * bind, create the client QUICConnection, forward outgoing packets to the
25
+ * socket, and start the handshake.
26
+ *
27
+ * opts:
28
+ * remoteIp, remotePort — where to send packets
29
+ * hostname — SNI / :authority for the QUIC/TLS handshake
30
+ * alpn — ALPN protocol(s) to offer. String or array.
31
+ * Defaults to ['h3']. Non-HTTP/3 consumers pass
32
+ * their own (e.g. ['hq-interop'] for HTTP/0.9,
33
+ * ['doq'] for DNS-over-QUIC).
34
+ * rejectUnauthorized — verify the server certificate (default true)
35
+ * ca — CA certificate(s) for verification
36
+ * onSocket(quic, socket) — called as soon as the QUICConnection exists,
37
+ * BEFORE the handshake starts. Use this to attach
38
+ * listeners that must observe the handshake, such
39
+ * as 'keylog'. onConnect is too late for those.
40
+ * onConnect(quic, socket) — called once the QUIC handshake completes
41
+ * onClose() — called when the connection closes (the socket is
42
+ * already closed by the time this runs)
43
+ * onError(err) — socket errors and outgoing-packet send errors
44
+ *
45
+ * Returns the UDP socket synchronously. The QUICConnection is created after the
46
+ * socket binds, so it is delivered via onConnect rather than returned here.
47
+ */
48
+ function createQuicClientSocket(opts) {
49
+ var isIPv6 = opts.remoteIp.indexOf(':') >= 0;
50
+ var udpSocket = dgram.createSocket(isIPv6 ? 'udp6' : 'udp4');
51
+ var quic = null;
52
+
53
+ udpSocket.on('message', function (msg, rinfo) {
54
+ if (quic) quic.feedDatagram(rinfo.address, rinfo.port, new Uint8Array(msg));
55
+ });
56
+
57
+ udpSocket.on('error', function (err) {
58
+ if (opts.onError) opts.onError(err);
59
+ });
60
+
61
+ udpSocket.bind(0, function () {
62
+ // Grow the kernel socket buffers before any traffic flows.
63
+ //
64
+ // Node's defaults are small (and notably small on Windows). When the send
65
+ // buffer overflows, the datagram is dropped locally with no error — it
66
+ // looks exactly like network loss, so the loss-recovery machinery kicks in
67
+ // and the connection crawls. This was the failure that killed the third
68
+ // pacer-calibration attempt (see LOSS_PTO_PLAN 7.4): larger bursts spilled
69
+ // over the buffer and turned a lossless link into a slow one.
70
+ //
71
+ // Wrapped in try/catch because the call can throw on platforms that cap
72
+ // the size; failing to enlarge the buffer is not a reason to fail the
73
+ // connection.
74
+ try { udpSocket.setSendBufferSize(2 * 1024 * 1024); } catch (e) {}
75
+ try { udpSocket.setRecvBufferSize(2 * 1024 * 1024); } catch (e) {}
76
+
77
+ quic = new QUICConnection({
78
+ isServer: false,
79
+ hostname: opts.hostname,
80
+ // Without this the client always offered 'h3' regardless of what the
81
+ // caller wanted, because TLSBridge falls back to ['h3'] when alpn is
82
+ // undefined. Normalize a bare string into an array here so callers can
83
+ // pass either form.
84
+ alpn: (function () {
85
+ var a = opts.alpn || ['h3'];
86
+ return Array.isArray(a) ? a : [a];
87
+ })(),
88
+ // Defaults to true. Pass false for self-signed certificates, or supply
89
+ // `ca` for a private CA.
90
+ rejectUnauthorized: opts.rejectUnauthorized !== false,
91
+ ca: opts.ca || null
92
+ });
93
+
94
+ // Hand the connection to the caller BEFORE connect() runs.
95
+ //
96
+ // onConnect fires after the handshake, which is too late for anything that
97
+ // has to observe the handshake itself. Key logging is the concrete case:
98
+ // lemon-tls skips building NSS lines while nothing is listening, and the
99
+ // handshake secrets are emitted during connect() — so a listener attached
100
+ // in onConnect receives nothing at all, and the client half of a capture
101
+ // is undecryptable. onSocket gives callers a hook at the only moment that
102
+ // works.
103
+ if (opts.onSocket) opts.onSocket(quic, udpSocket);
104
+
105
+ quic.on('packet', function (data) {
106
+ udpSocket.send(data, opts.remotePort, opts.remoteIp, function (err) {
107
+ if (err && opts.onError) opts.onError(err);
108
+ });
109
+ });
110
+
111
+ quic.on('connect', function () {
112
+ if (opts.onConnect) opts.onConnect(quic, udpSocket);
113
+ });
114
+
115
+ quic.on('close', function () {
116
+ try { udpSocket.close(); } catch (e) {}
117
+ if (opts.onClose) opts.onClose();
118
+ });
119
+
120
+ quic.connect();
121
+ });
122
+
123
+ return udpSocket;
124
+ }
125
+
126
+
127
+ export { createQuicClientSocket };
package/src/server.js CHANGED
@@ -19,25 +19,9 @@ import nodeHttps from 'node:https';
19
19
  import nodeTls from 'node:tls';
20
20
 
21
21
  import { createServer as createH3Server } from './h3_server.js';
22
- import { createSecureContext as _lemonCreateSecureContext } from './tls_bridge.js';
23
22
  import { IncomingMessage, ServerResponse, _makeSocketStub } from './streams.js';
24
23
 
25
24
 
26
- /**
27
- * Wrapped createSecureContext — stores raw key/cert on the context
28
- * so server.js can reuse them for TCP (node:tls).
29
- */
30
- function createSecureContext(opts) {
31
- var ctx = _lemonCreateSecureContext(opts);
32
- if (opts) {
33
- ctx._rawKey = opts.key || null;
34
- ctx._rawCert = opts.cert || null;
35
- ctx._rawCa = opts.ca || null;
36
- }
37
- return ctx;
38
- }
39
-
40
-
41
25
  // ============================================================
42
26
  // createServer(options, handler)
43
27
  //
@@ -89,9 +73,16 @@ function createServer(options, handler) {
89
73
  cert: tlsCert,
90
74
  ca: tlsCa,
91
75
  SNICallback: options.SNICallback || null,
92
- maxConnections: options.maxConnections || 10000
76
+ maxConnections: options.maxConnections || 10000,
77
+ // External UDP sockets (shared UDP port scenario). When provided,
78
+ // the UDP layer is owned externally — caller feeds packets via
79
+ // server.handlePacket(msg, rinfo). See RFC 9443.
80
+ socket: options.socket || null,
81
+ socket6: options.socket6 || null
93
82
  };
94
83
 
84
+ var isExternalUdp = !!(options.socket || options.socket6);
85
+
95
86
  var h3srv = createH3Server(h3Options, function (plainReq, plainRes) {
96
87
  var socketStub = _makeSocketStub();
97
88
  socketStub.remoteAddress = plainReq.remoteAddress || null;
@@ -111,10 +102,6 @@ function createServer(options, handler) {
111
102
  plainReq.on('data', function (chunk) { req._pushData(chunk); });
112
103
  plainReq.on('end', function () { req._endData(); });
113
104
 
114
- // Store wrapper references for WebTransport event forwarding
115
- plainReq._wrapper = req;
116
- plainRes._wrapper = res;
117
-
118
105
  // ---- Wrap plain res into ServerResponse (Writable) ----
119
106
  // ServerResponse._write → plainRes.write
120
107
  // ServerResponse._flushHeaders → plainRes.writeHead
@@ -125,6 +112,12 @@ function createServer(options, handler) {
125
112
  });
126
113
  res.req = req;
127
114
 
115
+ // Store wrapper references for WebTransport event forwarding.
116
+ // MUST come after `res` is created (was previously set while res was
117
+ // still undefined due to var hoisting).
118
+ plainReq._wrapper = req;
119
+ plainRes._wrapper = res;
120
+
128
121
  // Override internal methods to delegate to the plain H3 res.
129
122
  // Key insight: we buffer one chunk behind so that _final can
130
123
  // send the last data + FIN together via plainRes.end(chunk),
@@ -285,7 +278,9 @@ function createServer(options, handler) {
285
278
  }
286
279
  }
287
280
 
288
- // H3 (UDP) — always
281
+ // H3 — always. In external mode h3srv.listen() wires up the external
282
+ // sockets' message handler and starts the sweep timer, but does not
283
+ // bind anything new.
289
284
  h3srv.listen(port, host, done);
290
285
 
291
286
  // TCP — if needed
@@ -336,6 +331,19 @@ function createServer(options, handler) {
336
331
  tcpServer.setTimeout(msecs, callback);
337
332
  }
338
333
  return server;
334
+ },
335
+
336
+ /** Feed an incoming UDP packet from a shared/external socket.
337
+ * rinfo must be in Node's dgram format: { address, port, family, size }.
338
+ * Only relevant when the server was created with options.socket/socket6. */
339
+ handlePacket: function (msg, rinfo) {
340
+ return h3srv.handlePacket(msg, rinfo);
341
+ },
342
+
343
+ /** Returns true if the 5-tuple matches an active QUIC connection.
344
+ * Used by demuxers for routing decisions on shared UDP ports. */
345
+ hasConnection: function (rinfo) {
346
+ return h3srv.hasConnection(rinfo);
339
347
  }
340
348
  };
341
349
 
package/src/streams.js CHANGED
@@ -291,7 +291,21 @@ class ServerResponse extends Writable {
291
291
  class ClientRequest extends Writable {
292
292
 
293
293
  constructor(options) {
294
- super({ highWaterMark: (options && options.highWaterMark) || 64 * 1024 });
294
+ super({
295
+ highWaterMark: (options && options.highWaterMark) || 64 * 1024,
296
+ // Node's Writable default (autoDestroy: true) marks the stream
297
+ // destroyed the moment the request BODY finishes — i.e. milliseconds
298
+ // after end(), on every GET. But a ClientRequest outlives its body: the
299
+ // transport keeps working on it until the response completes, and
300
+ // node:http's ClientRequest behaves exactly that way. With the default,
301
+ // `destroyed` stops meaning "aborted" and starts meaning "body written",
302
+ // and every liveness guard built on it silently kills healthy requests:
303
+ // the H3→H2/H1 fallback after the 3s timeout hit such a guard and died
304
+ // for every GET, and coalesced requests parked on a shared connection
305
+ // being established were dropped at wake-up the same way. abort() still
306
+ // calls destroy() explicitly, so real cancellations are unaffected.
307
+ autoDestroy: false
308
+ });
295
309
 
296
310
  options = options || {};
297
311