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/src/h3_client.js CHANGED
@@ -12,10 +12,10 @@
12
12
  * https://github.com/colocohen/quico
13
13
  */
14
14
 
15
- import dgram from 'node:dgram';
16
15
  import dns from 'node:dns';
17
- import { QUICConnection } from './quic_connection.js';
16
+ import { DEBUG } from './utils.js';
18
17
  import { H3Connection } from './h3.js';
18
+ import { createQuicClientSocket } from './quic_socket.js';
19
19
 
20
20
 
21
21
  // ============================================================
@@ -34,15 +34,27 @@ function request(options, callback) {
34
34
  }
35
35
 
36
36
  var hostname = options.hostname || options.host || 'localhost';
37
+ // Certificate verification options, forwarded to the QUIC/TLS layer.
38
+ // Defaults to verifying (node:tls semantics); without this passthrough the
39
+ // option dies here and H3 connections can never opt out — which makes the
40
+ // one transport this library exists for the only one that can't reach a
41
+ // self-signed or private-CA server (H2/H1.1 fallbacks honour the option).
42
+ var rejectUnauthorized = options.rejectUnauthorized !== false;
43
+ var ca = options.ca || null;
37
44
  var port = options.port || 443;
38
45
  var path = options.path || '/';
39
46
  var method = (options.method || 'GET').toUpperCase();
40
47
  var headers = options.headers || {};
41
- var rejectUnauthorized = options.rejectUnauthorized !== false;
42
48
 
43
49
  // Connection reuse: pass _connection = { quic, h3, udpSocket, nextStreamId }
44
50
  var existingConn = options._connection || null;
45
- var autoClose = existingConn ? false : true;
51
+ // Auto-close the QUIC connection when this request finishes ONLY when we own
52
+ // it — i.e. a fresh, standalone connection. Reused connections (existingConn)
53
+ // and connections whose lifecycle is managed by an external pool
54
+ // (options._managed, set by the unified client/agent) are left open; closing
55
+ // them here would tear down a connection still pooled for reuse. Managed
56
+ // connections are reaped by the agent (sweep/destroy) and the QUIC idle timer.
57
+ var autoClose = !existingConn && !options._managed;
46
58
 
47
59
  // ---- Client Request object ----
48
60
  var reqBody = [];
@@ -104,9 +116,12 @@ function request(options, callback) {
104
116
 
105
117
  // ---- Resolve hostname and connect (or reuse existing) ----
106
118
  if (existingConn) {
107
- // Reuse existing connection — allocate stream ID and send when ready
119
+ // Reuse existing connection — allocate stream ID and send when ready.
120
+ // The shared dispatch listeners were installed when the connection was
121
+ // first created; installSharedHandlers() is idempotent.
108
122
  streamId = allocateStreamId(existingConn);
109
- setupResponseHandlers();
123
+ installSharedHandlers(existingConn);
124
+ setupResponseHandlers(existingConn);
110
125
  if (reqFinished) sendRequest();
111
126
  } else {
112
127
  resolveAndConnect(hostname, port);
@@ -132,60 +147,42 @@ function request(options, callback) {
132
147
 
133
148
 
134
149
  function startConnection(remoteIp, remotePort) {
135
- // Create UDP socket
136
- var isIPv6 = remoteIp.indexOf(':') >= 0;
137
- udpSocket = dgram.createSocket(isIPv6 ? 'udp6' : 'udp4');
138
-
139
- udpSocket.on('message', function (msg, rinfo) {
140
- if (quic) quic.feedDatagram(rinfo.address, rinfo.port, new Uint8Array(msg));
141
- });
142
-
143
- udpSocket.on('error', function (err) {
144
- emit(reqListeners, 'error', err);
145
- });
150
+ udpSocket = createQuicClientSocket({
151
+ remoteIp: remoteIp,
152
+ remotePort: remotePort,
153
+ hostname: hostname,
154
+ rejectUnauthorized: rejectUnauthorized,
155
+ ca: ca,
156
+
157
+ onError: function (err) {
158
+ emit(reqListeners, 'error', err);
159
+ },
146
160
 
147
- // Bind to random port
148
- udpSocket.bind(0, function () {
149
- // Create QUIC connection (client mode)
150
- quic = new QUICConnection({
151
- isServer: false,
152
- SNICallback: null,
153
- hostname: hostname
154
- });
155
-
156
- // Send packets via UDP
157
- quic.on('packet', function (data) {
158
- udpSocket.send(data, remotePort, remoteIp, function (err) {
159
- if (err) emit(reqListeners, 'error', err);
160
- });
161
- });
162
-
163
- // QUIC connected → set up H3
164
- quic.on('connect', function () {
165
- console.log('[client] QUIC connected');
161
+ onConnect: function (q, s) {
162
+ quic = q;
163
+ udpSocket = s;
164
+ if (DEBUG) console.log('[client] QUIC connected');
166
165
 
167
166
  h3 = new H3Connection({ quicConnection: quic, isServer: false });
168
167
  connected = true;
169
168
 
170
169
  // Build connection object for reuse
171
170
  var conn = { quic: quic, h3: h3, udpSocket: udpSocket, _nextStreamId: 0 };
171
+
172
+ // Install the shared per-connection dispatch listeners ONCE, then
173
+ // register this request in the connection's stream-handler map.
174
+ installSharedHandlers(conn);
172
175
  streamId = allocateStreamId(conn);
173
176
 
174
177
  // Store on clientReq so caller (unified client) can reuse
175
178
  clientReq._connection = conn;
176
179
 
177
- setupResponseHandlers();
180
+ setupResponseHandlers(conn);
178
181
 
179
182
  // Send request if body is ready
180
183
  if (reqFinished) sendRequest();
181
- });
182
-
183
- quic.on('close', function () {
184
- if (udpSocket) { try { udpSocket.close(); } catch (e) {} }
185
- });
186
-
187
- // Start QUIC handshake
188
- quic.connect();
184
+ }
185
+ // onClose: the helper closes the UDP socket; nothing extra needed here.
189
186
  });
190
187
  }
191
188
 
@@ -202,51 +199,78 @@ function request(options, callback) {
202
199
 
203
200
 
204
201
  /**
205
- * Set up HTTP/3 response handlers for this request's streamId.
206
- * Works for both new and reused connections.
202
+ * Install the three HTTP/3 event listeners ONCE per connection. They dispatch
203
+ * each event to the right request via the connection's stream-handler map
204
+ * (keyed by stream id), so multiplexing many requests over a single
205
+ * connection does NOT pile up listeners on the H3 emitter (previously every
206
+ * request added another set of listeners that were never removed).
207
+ * Idempotent per connection.
207
208
  */
208
- function setupResponseHandlers() {
209
- // HTTP response headers
210
- h3.on('http_headers', function (sid, hdrs) {
211
- if (sid !== streamId) return;
212
- clientRes.headers = hdrs;
213
- clientRes.statusCode = parseInt(hdrs[':status']) || 0;
214
- clientRes._headersReceived = true;
215
-
216
- // Fire callback with response
217
- if (typeof callback === 'function') {
218
- callback(clientRes);
219
- callback = null; // only once
220
- }
221
- emit(reqListeners, 'response', clientRes);
209
+ function installSharedHandlers(conn) {
210
+ if (conn._streamHandlers) return; // already installed for this connection
211
+ conn._streamHandlers = {};
212
+ var h3c = conn.h3;
213
+
214
+ h3c.on('http_headers', function (sid, hdrs) {
215
+ var h = conn._streamHandlers[sid];
216
+ if (h) h.onHeaders(hdrs);
222
217
  });
223
-
224
- // HTTP response body
225
- h3.on('http_body', function (sid, data) {
226
- if (sid !== streamId) return;
227
- emit(clientRes, 'data', data);
218
+ h3c.on('http_body', function (sid, data) {
219
+ var h = conn._streamHandlers[sid];
220
+ if (h) h.onBody(data);
228
221
  });
222
+ h3c.on('http_end', function (sid) {
223
+ var h = conn._streamHandlers[sid];
224
+ if (!h) return;
225
+ delete conn._streamHandlers[sid]; // free per-stream state before dispatch
226
+ h.onEnd();
227
+ });
228
+ }
229
229
 
230
- // HTTP response end
231
- h3.on('http_end', function (sid) {
232
- if (sid !== streamId) return;
233
- emit(clientRes, 'end');
234
-
235
- // Auto-close only if we own the connection (not reusing)
236
- if (autoClose) {
237
- setTimeout(function () {
238
- if (quic) quic.close(0, 'done');
239
- if (udpSocket) { try { udpSocket.close(); } catch (e) {} }
240
- }, 100);
230
+
231
+ /**
232
+ * Register this request's response handlers in the connection's stream map,
233
+ * keyed by its streamId. Replaces per-request h3.on(...) registration —
234
+ * routing is now by map key, so no per-listener streamId check is needed.
235
+ */
236
+ function setupResponseHandlers(conn) {
237
+ conn._streamHandlers[streamId] = {
238
+ onHeaders: function (hdrs) {
239
+ clientRes.headers = hdrs;
240
+ clientRes.statusCode = parseInt(hdrs[':status']) || 0;
241
+ clientRes._headersReceived = true;
242
+
243
+ // Fire callback with response (once)
244
+ if (typeof callback === 'function') {
245
+ callback(clientRes);
246
+ callback = null;
247
+ }
248
+ emit(reqListeners, 'response', clientRes);
249
+ },
250
+
251
+ onBody: function (data) {
252
+ emit(clientRes, 'data', data);
253
+ },
254
+
255
+ onEnd: function () {
256
+ emit(clientRes, 'end');
257
+
258
+ // Auto-close only if we own the connection (see `autoClose` above).
259
+ if (autoClose) {
260
+ setTimeout(function () {
261
+ if (quic) quic.close(0, 'done');
262
+ if (udpSocket) { try { udpSocket.close(); } catch (e) {} }
263
+ }, 100);
264
+ }
241
265
  }
242
- });
266
+ };
243
267
  }
244
268
 
245
269
 
246
270
  function sendRequest() {
247
271
  if (!h3 || !connected) return;
248
272
 
249
- console.log('[client] sendRequest method=' + method + ' path=' + path + ' stream=' + streamId);
273
+ if (DEBUG) console.log('[client] sendRequest method=' + method + ' path=' + path + ' stream=' + streamId);
250
274
 
251
275
  // MUST send control streams (SETTINGS) before any request
252
276
  h3.sendControlStreams();
package/src/h3_server.js CHANGED
@@ -12,13 +12,10 @@
12
12
  * https://github.com/colocohen/quico
13
13
  */
14
14
 
15
- import dgram from 'node:dgram';
16
15
  import { Duplex, Readable, Writable } from 'node:stream';
17
- import { Emitter, readVarInt, writeVarInt } from './utils.js';
18
- import { parse_quic_datagram } from './transport.js';
19
- import { QUICConnection } from './quic_connection.js';
16
+ import { Emitter, readVarInt, wt_datagram_payload } from './utils.js';
17
+ import { createQuicServer } from './quic_server.js';
20
18
  import { H3Connection } from './h3.js';
21
- import { createSecureContext } from './tls_bridge.js';
22
19
 
23
20
 
24
21
  // ============================================================
@@ -29,23 +26,26 @@ function createServer(options, handler) {
29
26
  options = options || {};
30
27
 
31
28
  var ev = Emitter();
32
- var udp4 = null;
33
- var udp6 = null;
34
- var port = null;
35
29
 
36
- // Build SNICallback
37
- var sniCallback = options.SNICallback || null;
38
-
39
- if (!sniCallback && options.key && options.cert) {
40
- var defaultCtx = createSecureContext({ key: options.key, cert: options.cert, ca: options.ca });
41
- sniCallback = function (servername, cb) { cb(null, defaultCtx); };
42
- }
43
-
44
- // ---- Connection tracking ----
45
- var connections = {}; // connId → { quic, h3, http_streams }
46
- var addressMap = {}; // "ip:port" → connId
47
- var maxConnections = options.maxConnections || 10000;
48
- var sweepTimer = null;
30
+ // Transport-only QUIC server owns the UDP socket(s), demux, connection
31
+ // map and lifecycle. h3_server layers HTTP/3 on top by listening for its
32
+ // 'connection' event (see below) and wrapping each raw QUICConnection in
33
+ // an H3Connection. ALPN is 'h3'. External-socket (shared-port) mode and
34
+ // the handlePacket/hasConnection demux hooks are provided by the
35
+ // transport server and re-exported on this server's API.
36
+ var qserver = createQuicServer({
37
+ alpn: 'h3',
38
+ SNICallback: options.SNICallback || null,
39
+ key: options.key,
40
+ cert: options.cert,
41
+ ca: options.ca,
42
+ maxConnections: options.maxConnections || 10000,
43
+ socket: options.socket || null,
44
+ socket6: options.socket6 || null
45
+ });
46
+
47
+ // Surface transport-level (UDP socket / send) errors on this server.
48
+ qserver.on('error', function (err) { ev.emit('error', err); });
49
49
 
50
50
 
51
51
  // ============================================================
@@ -105,8 +105,7 @@ function createServer(options, handler) {
105
105
  // Same pattern as old quico h3_server.js
106
106
  // ============================================================
107
107
 
108
- function set_http_stream(connId, streamId, params) {
109
- var conn = connections[connId];
108
+ function set_http_stream(conn, streamId, params) {
110
109
  if (!conn) return;
111
110
 
112
111
  var is_new = false;
@@ -200,12 +199,7 @@ function createServer(options, handler) {
200
199
  if (!req._isWebTransport) return;
201
200
  if (typeof data === 'string') data = Buffer.from(data);
202
201
  if (Buffer.isBuffer(data)) data = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
203
- var quarterSid = Math.floor(Number(streamId) / 4);
204
- var prefix = writeVarInt(quarterSid);
205
- var frame = new Uint8Array(prefix.length + data.byteLength);
206
- frame.set(prefix, 0);
207
- frame.set(data, prefix.length);
208
- conn.quic.sendDatagram(frame);
202
+ conn.quic.sendDatagram(wt_datagram_payload(Number(streamId), data));
209
203
  },
210
204
 
211
205
  createBidirectionalStream: function () {
@@ -284,93 +278,41 @@ function createServer(options, handler) {
284
278
 
285
279
 
286
280
  // ============================================================
287
- // UDP → connection routing
281
+ // HTTP/3 over each accepted QUIC connection
282
+ // The transport server hands us a raw QUICConnection per peer; we wrap
283
+ // it in an H3Connection once the handshake completes and bridge HTTP/3 +
284
+ // WebTransport events to req/res via set_http_stream. Per-connection
285
+ // state lives in this closure — the transport server owns the UDP
286
+ // socket, demux and connection lifecycle.
288
287
  // ============================================================
289
288
 
290
- function onUdpMessage(msg, rinfo) {
291
- var data = new Uint8Array(msg);
292
- var fromIp = rinfo.address;
293
- var fromPort = rinfo.port;
294
- console.log('[server] UDP from ' + fromIp + ':' + fromPort + ' len=' + data.length);
295
-
296
- var addressKey = fromIp + ':' + fromPort;
297
- var packets = parse_quic_datagram(data);
298
- if (packets.length === 0) return;
299
-
300
- for (var i = 0; i < packets.length; i++) {
301
- var pkt = packets[i];
302
- if (!pkt) continue;
303
-
304
- var connId = findConnectionId(pkt, addressKey);
305
-
306
- if (!(connId in connections)) {
307
- // Reject new connections when at capacity
308
- if (Object.keys(connections).length >= maxConnections) return;
309
- createConnection(connId, fromIp, fromPort, addressKey);
310
- }
311
-
312
- connections[connId].quic.feedDatagram(fromIp, fromPort, pkt.raw);
313
- }
314
- }
315
-
316
-
317
- function findConnectionId(pkt, addressKey) {
318
- var dcidStr = null;
319
- if (pkt.dcid && pkt.dcid.byteLength > 0) {
320
- dcidStr = Array.from(pkt.dcid).join('');
321
- }
322
-
323
- if (dcidStr && dcidStr in connections) return dcidStr;
324
- if (addressKey in addressMap && addressMap[addressKey] in connections) return addressMap[addressKey];
325
-
326
- var id = dcidStr || String(Math.floor(Math.random() * 9007199254740991));
327
- addressMap[addressKey] = id;
328
- return id;
329
- }
330
-
331
-
332
- function createConnection(connId, fromIp, fromPort, addressKey) {
333
- var quic = new QUICConnection({ SNICallback: sniCallback });
334
-
335
- connections[connId] = {
336
- quic: quic,
337
- h3: null,
338
- http_streams: {}
339
- };
340
-
341
- addressMap[addressKey] = connId;
342
-
343
- // Send packets back via UDP
344
- quic.on('packet', function (data) {
345
- sendUdp(fromIp, fromPort, data);
346
- });
289
+ qserver.on('connection', function (quic, peer) {
290
+ var conn = { quic: quic, h3: null, http_streams: {} };
347
291
 
348
292
  // QUIC handshake complete → set up H3
349
293
  quic.on('connect', function () {
350
294
  var h3 = new H3Connection({ quicConnection: quic, enableWebTransport: true });
351
- connections[connId].h3 = h3;
295
+ conn.h3 = h3;
352
296
 
353
297
  // HTTP request headers received → set_http_stream (reactive)
354
298
  h3.on('http_headers', function (streamId, headers) {
355
- set_http_stream(connId, streamId, { request_headers: headers });
299
+ set_http_stream(conn, streamId, { request_headers: headers });
356
300
  });
357
301
 
358
302
  // HTTP request body chunk → set_http_stream
359
303
  h3.on('http_body', function (streamId, data) {
360
- set_http_stream(connId, streamId, { request_body_chunk: data });
304
+ set_http_stream(conn, streamId, { request_body_chunk: data });
361
305
  });
362
306
 
363
307
  // HTTP request complete → fire req.on('end')
364
308
  h3.on('http_end', function (streamId) {
365
- set_http_stream(connId, streamId, { request_end: true });
309
+ set_http_stream(conn, streamId, { request_end: true });
366
310
  });
367
311
 
368
312
  // ---- WebTransport stream events ----
369
313
 
370
314
  // New WT stream (bidi or uni from client)
371
315
  h3.on('wt_stream', function (sessionId, wtStreamId, data, fin, isBidi) {
372
- var conn = connections[connId];
373
- if (!conn) return;
374
316
  var session = conn.http_streams[sessionId];
375
317
  if (!session || !session.req._isWebTransport) return;
376
318
 
@@ -395,8 +337,7 @@ function createServer(options, handler) {
395
337
 
396
338
  // Subsequent data on WT stream
397
339
  h3.on('wt_data', function (sessionId, wtStreamId, data, fin) {
398
- var conn = connections[connId];
399
- if (!conn || !conn._wtStreamObjects) return;
340
+ if (!conn._wtStreamObjects) return;
400
341
  var wtStream = conn._wtStreamObjects[wtStreamId];
401
342
  if (!wtStream) return;
402
343
  if (data.byteLength > 0) wtStream._pushData(data);
@@ -404,7 +345,7 @@ function createServer(options, handler) {
404
345
  });
405
346
 
406
347
  // Datagrams
407
- quic.on('datagram', function (_contextId, rawData) {
348
+ quic.on('datagram', function (rawData) {
408
349
  if (!rawData || rawData.byteLength === 0) return;
409
350
  var result = readVarInt(rawData, 0);
410
351
  if (!result) return;
@@ -412,8 +353,6 @@ function createServer(options, handler) {
412
353
  var payload = rawData.slice(result.byteLength);
413
354
 
414
355
  // Find the session: quarter_stream_id = session_stream_id / 4
415
- var conn = connections[connId];
416
- if (!conn) return;
417
356
  for (var sid in conn.http_streams) {
418
357
  var session = conn.http_streams[sid];
419
358
  if (session.req._isWebTransport && Math.floor(Number(sid) / 4) === quarterSid) {
@@ -423,99 +362,29 @@ function createServer(options, handler) {
423
362
  }
424
363
  });
425
364
  });
426
-
427
- quic.on('close', function () {
428
- delete connections[connId];
429
- if (addressMap[addressKey] === connId) delete addressMap[addressKey];
430
- });
431
- }
432
-
433
-
434
- // ============================================================
435
- // UDP send
436
- // ============================================================
437
-
438
- function sendUdp(toIp, toPort, data) {
439
- var socket = toIp.indexOf(':') >= 0 ? udp6 : udp4;
440
- if (!socket) return;
441
- socket.send(data, toPort, toIp, function (err) {
442
- if (err) ev.emit('error', err);
443
- });
444
- }
445
-
446
-
447
- // ============================================================
448
- // listen / close
449
- // ============================================================
450
-
451
- function listen(listenPort, host, callback) {
452
- if (typeof host === 'function') { callback = host; host = null; }
453
- port = listenPort || 443;
454
- host = host || '::';
455
-
456
- udp4 = dgram.createSocket('udp4');
457
- udp4.on('message', onUdpMessage);
458
- udp4.on('error', function (err) { ev.emit('error', err); });
459
- var host4 = host.indexOf('.') !== -1 ? host : '0.0.0.0';
460
- udp4.bind(port, host4);
461
-
462
- udp6 = dgram.createSocket({ type: 'udp6', ipv6Only: true });
463
- udp6.on('message', onUdpMessage);
464
- udp6.on('error', function (err) { ev.emit('error', err); });
465
- var host6 = host.indexOf(':') !== -1 ? host : '::';
466
- udp6.bind(port, host6, function () {
467
- if (typeof callback === 'function') callback();
468
- });
469
-
470
- // Periodic sweep for dead connections (safety net)
471
- sweepTimer = setInterval(function () {
472
- for (var id in connections) {
473
- var st = connections[id].quic.state;
474
- if (st === 'closed') {
475
- delete connections[id];
476
- // Clean address map
477
- for (var addr in addressMap) {
478
- if (addressMap[addr] === id) delete addressMap[addr];
479
- }
480
- }
481
- }
482
- }, 30000);
483
- }
484
-
485
- function close(callback) {
486
- // Stop sweep
487
- if (sweepTimer) { clearInterval(sweepTimer); sweepTimer = null; }
488
-
489
- // Close all connections
490
- for (var id in connections) {
491
- try { connections[id].quic.close(0, 'server closing'); } catch (e) { }
492
- }
493
- connections = {};
494
- addressMap = {};
495
-
496
- var closed = 0;
497
- var total = (udp4 ? 1 : 0) + (udp6 ? 1 : 0);
498
- if (total === 0) { if (callback) callback(); return; }
499
-
500
- function onClose() {
501
- closed++;
502
- if (closed >= total && typeof callback === 'function') callback();
503
- }
504
-
505
- if (udp4) { udp4.close(onClose); udp4 = null; }
506
- if (udp6) { udp6.close(onClose); udp6 = null; }
507
- }
365
+ });
508
366
 
509
367
 
510
368
  // ============================================================
511
369
  // Server API
370
+ // Transport concerns (UDP sockets, demux, connection lifecycle,
371
+ // shared-port handlePacket/hasConnection) are delegated to the QUIC
372
+ // server. This layer adds HTTP/3 request events on top.
512
373
  // ============================================================
513
374
 
514
375
  return {
515
- listen: listen,
516
- close: close,
376
+ listen: qserver.listen,
377
+ close: qserver.close,
517
378
  on: function (name, fn) { ev.on(name, fn); },
518
- setTimeout: function () { /* TODO */ }
379
+ setTimeout: function () { /* TODO */ },
380
+
381
+ /** Feed an incoming UDP packet when running in external-socket mode.
382
+ * rinfo must be in Node's dgram format: { address, port, family, size }. */
383
+ handlePacket: qserver.handlePacket,
384
+
385
+ /** Returns true if the 5-tuple matches an active QUIC connection.
386
+ * Used by demuxers for routing decisions on shared UDP ports. */
387
+ hasConnection: qserver.hasConnection
519
388
  };
520
389
  }
521
390