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
|
@@ -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 (
|
|
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({
|
|
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
|
|