quico 0.2.0 → 0.3.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
@@ -18,6 +18,7 @@ import { Agent, globalAgent } from './src/agent.js';
18
18
  import { IncomingMessage, ServerResponse, ClientRequest } from './src/streams.js';
19
19
  import { createSecureContext } from './src/tls_bridge.js';
20
20
  import { WebTransport } from './src/webtransport.js';
21
+ import { createQuicServer } from './src/quic_server.js';
21
22
 
22
23
  export {
23
24
  createServer,
@@ -29,7 +30,8 @@ export {
29
30
  ServerResponse,
30
31
  ClientRequest,
31
32
  createSecureContext,
32
- WebTransport
33
+ WebTransport,
34
+ createQuicServer
33
35
  };
34
36
 
35
37
  export default {
@@ -42,5 +44,6 @@ export default {
42
44
  ServerResponse,
43
45
  ClientRequest,
44
46
  createSecureContext,
45
- WebTransport
47
+ WebTransport,
48
+ createQuicServer
46
49
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quico",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",
@@ -78,6 +78,6 @@
78
78
  ],
79
79
  "dependencies": {
80
80
  "flat-ranges": "^2.0.0",
81
- "lemon-tls": "^0.2.0"
81
+ "lemon-tls": "^0.4.1"
82
82
  }
83
83
  }
package/src/agent.js CHANGED
@@ -158,7 +158,7 @@ class Agent extends EventEmitter {
158
158
  var conn = this._h3Pool[key];
159
159
  if (!conn) return null;
160
160
  var state = conn.quic ? conn.quic.state : 'closed';
161
- if (state === 'closed' || state === 'draining') {
161
+ if (state === 'closed' || state === 'draining' || state === 'closing') {
162
162
  delete this._h3Pool[key];
163
163
  return null;
164
164
  }
@@ -173,6 +173,14 @@ class Agent extends EventEmitter {
173
173
  if (this._h3Pool[key] === conn) delete this._h3Pool[key];
174
174
  });
175
175
  }
176
+ // RFC 9114 §5.2: once the peer sends GOAWAY it won't accept new requests, so
177
+ // stop reusing this connection for new ones. In-flight requests keep running
178
+ // on it; they just won't be joined by new requests (which open a fresh conn).
179
+ if (conn.h3 && typeof conn.h3.on === 'function') {
180
+ conn.h3.on('goaway', () => {
181
+ if (this._h3Pool[key] === conn) delete this._h3Pool[key];
182
+ });
183
+ }
176
184
  }
177
185
 
178
186
 
package/src/client.js CHANGED
@@ -209,8 +209,13 @@ function request(url, options, callback) {
209
209
  headers: Object.assign({}, headers)
210
210
  };
211
211
 
212
- // Check Agent for existing QUIC connection (multiplexing)
212
+ // Check Agent for existing QUIC connection (multiplexing).
213
+ // With an agent, the connection's lifecycle is owned by the pool
214
+ // (sweep / idle timer / agent.destroy), so signal h3_client not to
215
+ // auto-close it when this request finishes — otherwise the first
216
+ // request would tear down a connection still pooled for reuse.
213
217
  if (agent) {
218
+ h3Opts._managed = true;
214
219
  var existing = agent.getH3Connection(hostname, port);
215
220
  if (existing) h3Opts._connection = existing;
216
221
  }
package/src/crypto.js CHANGED
@@ -60,8 +60,16 @@ var INITIAL_SALTS = {
60
60
  // ============================================================
61
61
 
62
62
  /**
63
- * Derive Initial encryption keys from the client DCID.
64
- * direction: 'read' = client keys, 'write' = server keys
63
+ * Derive an Initial-packet secret + keys from the client's original DCID.
64
+ *
65
+ * `direction` selects WHICH endpoint's Initial secret to derive — it is NOT
66
+ * "the keys I read/write with" in the obvious sense:
67
+ * 'read' → the client's secret (TLS label "client in")
68
+ * 'write' → the server's secret (TLS label "server in")
69
+ * Each role then maps these to its own read/write keys: a client WRITES with
70
+ * the "client in" secret and READS with "server in"; a server is the mirror.
71
+ * So callers pass 'read' to get the client-side keys and 'write' for the
72
+ * server-side keys, and assign them to initial_read / initial_write per role.
65
73
  */
66
74
  function quic_derive_init_secrets(client_dcid, version, direction) {
67
75
  var hashName = 'sha256';
@@ -237,8 +245,10 @@ function expandPacketNumber(truncated, pnLen, largestReceived) {
237
245
 
238
246
  function decode_packet_number(array, offset, pnLength) {
239
247
  var value = 0;
248
+ // Use *256 (not <<8): a 4-byte packet number whose leading byte has the high
249
+ // bit set would overflow JS's 32-bit signed bitwise math and go negative.
240
250
  for (var i = 0; i < pnLength; i++) {
241
- value = (value << 8) | array[offset + i];
251
+ value = value * 256 + array[offset + i];
242
252
  }
243
253
  return value;
244
254
  }
@@ -354,8 +364,13 @@ function encrypt_quic_packet(packetType, encodedFrames, writeKey, writeIv, write
354
364
 
355
365
  /**
356
366
  * Decrypt a QUIC packet (raw bytes → plaintext frames).
367
+ *
368
+ * `ownCid` is the connection ID by which the receiver is addressed. It is used
369
+ * ONLY for short (1-RTT) headers, which carry no DCID-length field — the
370
+ * receiver must already know its own CID length to locate the packet number.
371
+ * Long headers encode the DCID length on the wire, so `ownCid` is ignored there.
357
372
  */
358
- function decrypt_quic_packet(array, readKey, readIv, readHp, dcid, largestPn) {
373
+ function decrypt_quic_packet(array, readKey, readIv, readHp, ownCid, largestPn) {
359
374
  if (!(array instanceof Uint8Array)) throw new Error('Invalid input');
360
375
 
361
376
  var firstByte = array[0];
@@ -405,7 +420,7 @@ function decrypt_quic_packet(array, readKey, readIv, readHp, dcid, largestPn) {
405
420
 
406
421
  } else {
407
422
  // Short Header
408
- var dcidLen = dcid.length;
423
+ var dcidLen = ownCid.length;
409
424
  pnOffset = 1 + dcidLen;
410
425
 
411
426
  pnLength = remove_header_protection(array, pnOffset, readHp, true);
package/src/h3.js CHANGED
@@ -13,10 +13,12 @@
13
13
  */
14
14
 
15
15
  import {
16
+ DEBUG,
16
17
  Emitter,
17
18
  concatUint8Arrays,
18
19
  writeVarInt,
19
- readVarInt
20
+ readVarInt,
21
+ wt_stream_header
20
22
  } from './utils.js';
21
23
 
22
24
 
@@ -156,26 +158,30 @@ function huffDecode(buf) {
156
158
  // QPACK parse/build helpers
157
159
  // ============================================================
158
160
 
159
- function decodeQpackVarInt(buf, prefixBits, pos) {
161
+ // Read a QPACK/HPACK prefixed integer at buf[pos]. Returns { value, next } or
162
+ // null if the buffer ends mid-integer (truncated). Used by both the header
163
+ // block parser and the encoder-instruction parser.
164
+ function readPrefixedInt(buf, prefixBits, pos) {
165
+ if (pos >= buf.length) return null;
160
166
  var max = (1 << prefixBits) - 1;
161
167
  var value = buf[pos] & max;
162
168
  pos++;
163
169
  if (value < max) return { value: value, next: pos };
164
170
  var m = 0;
165
- while (true) {
171
+ while (pos < buf.length) {
166
172
  var byte = buf[pos++];
167
173
  value += (byte & 0x7f) << m;
168
- if ((byte & 0x80) === 0) break;
174
+ if ((byte & 0x80) === 0) return { value: value, next: pos };
169
175
  m += 7;
170
176
  }
171
- return { value: value, next: pos };
177
+ return null; // truncated mid-integer
172
178
  }
173
179
 
174
180
  function parseQpackHeaderBlock(buf, dynamicTable, baseIndex) {
175
181
  var pos = 0;
176
- var ric = decodeQpackVarInt(buf, 8, pos); pos = ric.next;
182
+ var ric = readPrefixedInt(buf, 8, pos); if (!ric) return {}; pos = ric.next;
177
183
  var postBase = (buf[pos] & 0x80) !== 0;
178
- var db = decodeQpackVarInt(buf, 7, pos); pos = db.next;
184
+ var db = readPrefixedInt(buf, 7, pos); if (!db) return {}; pos = db.next;
179
185
  var bi = postBase ? ric.value + db.value : ric.value - db.value;
180
186
 
181
187
  var headers = {};
@@ -185,7 +191,7 @@ function parseQpackHeaderBlock(buf, dynamicTable, baseIndex) {
185
191
  if ((byte & 0x80) === 0x80) {
186
192
  // Indexed
187
193
  var fromStatic = (byte & 0x40) !== 0;
188
- var idx = decodeQpackVarInt(buf, 6, pos); pos = idx.next;
194
+ var idx = readPrefixedInt(buf, 6, pos); if (!idx) break; pos = idx.next;
189
195
  if (fromStatic && idx.value < QPACK_STATIC.length) {
190
196
  headers[QPACK_STATIC[idx.value][0]] = QPACK_STATIC[idx.value][1];
191
197
  } else if (!fromStatic && dynamicTable) {
@@ -198,9 +204,9 @@ function parseQpackHeaderBlock(buf, dynamicTable, baseIndex) {
198
204
  } else if ((byte & 0xc0) === 0x40) {
199
205
  // Literal with name ref
200
206
  var fromStatic = (byte & 0x10) !== 0;
201
- var nameIdx = decodeQpackVarInt(buf, 4, pos); pos = nameIdx.next;
207
+ var nameIdx = readPrefixedInt(buf, 4, pos); if (!nameIdx) break; pos = nameIdx.next;
202
208
  var valH = (buf[pos] & 0x80) !== 0;
203
- var valLen = decodeQpackVarInt(buf, 7, pos); pos = valLen.next;
209
+ var valLen = readPrefixedInt(buf, 7, pos); if (!valLen) break; pos = valLen.next;
204
210
  var valBytes = buf.slice(pos, pos + valLen.value); pos += valLen.value;
205
211
  var value = valH ? huffDecode(valBytes) : new TextDecoder().decode(valBytes);
206
212
 
@@ -216,11 +222,11 @@ function parseQpackHeaderBlock(buf, dynamicTable, baseIndex) {
216
222
  } else if ((byte & 0xe0) === 0x20) {
217
223
  // Literal with literal name
218
224
  var nameH = (byte & 0x08) !== 0;
219
- var nameLen = decodeQpackVarInt(buf, 3, pos); pos = nameLen.next;
225
+ var nameLen = readPrefixedInt(buf, 3, pos); if (!nameLen) break; pos = nameLen.next;
220
226
  var nameBytes = buf.slice(pos, pos + nameLen.value); pos += nameLen.value;
221
227
  var name = nameH ? huffDecode(nameBytes) : new TextDecoder().decode(nameBytes);
222
228
  var valH = (buf[pos] & 0x80) !== 0;
223
- var valLen = decodeQpackVarInt(buf, 7, pos); pos = valLen.next;
229
+ var valLen = readPrefixedInt(buf, 7, pos); if (!valLen) break; pos = valLen.next;
224
230
  var valBytes = buf.slice(pos, pos + valLen.value); pos += valLen.value;
225
231
  var value = valH ? huffDecode(valBytes) : new TextDecoder().decode(valBytes);
226
232
  headers[name] = value;
@@ -421,22 +427,13 @@ function parseQpackEncoderInstructions(chunks, fromOffset) {
421
427
  var pos = 0;
422
428
  var instructions = [];
423
429
 
430
+ // Thin wrapper over the shared readPrefixedInt that advances the local `pos`
431
+ // and returns the value (or null on truncation), preserving call-site usage.
424
432
  function safeVarInt(prefixBits) {
425
- if (pos >= combined.length) return null;
426
- var max = (1 << prefixBits) - 1;
427
- var value = combined[pos] & max;
428
- var startPos = pos;
429
- pos++;
430
- if (value < max) return value;
431
- var m = 0;
432
- while (pos < combined.length) {
433
- var b = combined[pos++];
434
- value += (b & 0x7f) << m;
435
- if ((b & 0x80) === 0) return value;
436
- m += 7;
437
- }
438
- pos = startPos;
439
- return null;
433
+ var r = readPrefixedInt(combined, prefixBits, pos);
434
+ if (!r) return null;
435
+ pos = r.next;
436
+ return r.value;
440
437
  }
441
438
 
442
439
  while (pos < combined.length) {
@@ -624,8 +621,8 @@ function H3Connection(options) {
624
621
  var encId = context.isServer ? 7 : 6;
625
622
  var decId = context.isServer ? 11 : 10;
626
623
 
627
- console.log('[h3] sendControlStreams ctrl=' + ctrlId + ' enc=' + encId + ' dec=' + decId);
628
- console.log('[h3] SETTINGS hex: ' + Array.from(controlFrame).map(function(b){ return b.toString(16).padStart(2,'0'); }).join(' '));
624
+ if (DEBUG) console.log('[h3] sendControlStreams ctrl=' + ctrlId + ' enc=' + encId + ' dec=' + decId);
625
+ if (DEBUG) console.log('[h3] SETTINGS hex: ' + Array.from(controlFrame).map(function(b){ return b.toString(16).padStart(2,'0'); }).join(' '));
629
626
 
630
627
  // Send type byte + SETTINGS together as one chunk (some implementations need this)
631
628
  var ctrlData = new Uint8Array(1 + controlFrame.byteLength);
@@ -637,6 +634,17 @@ function H3Connection(options) {
637
634
  }
638
635
 
639
636
 
637
+ // ---- Graceful shutdown: GOAWAY (RFC 9114 §5.2) ----
638
+
639
+ function sendGoaway(id) {
640
+ sendControlStreams(); // idempotent — ensures the control stream + its type byte exist
641
+ var ctrlId = context.isServer ? 3 : 2;
642
+ var frame = buildH3Frames([{ frame_type: 0x07, payload: writeVarInt(id || 0) }]);
643
+ if (DEBUG) console.log('[h3] → GOAWAY id=' + (id || 0));
644
+ quic.sendStream(ctrlId, frame, false);
645
+ }
646
+
647
+
640
648
  // ---- Incoming stream data ----
641
649
 
642
650
  var _claimedStreams = new Set(); // streams owned by WebTransport (skip H3 processing)
@@ -655,7 +663,7 @@ function H3Connection(options) {
655
663
  }
656
664
 
657
665
  if (_claimedStreams.has(sid)) return; // owned by WebTransport client
658
- console.log('[h3] stream ' + streamId + ' data=' + data.byteLength + ' fin=' + fin);
666
+ if (DEBUG) console.log('[h3] stream ' + streamId + ' data=' + data.byteLength + ' fin=' + fin);
659
667
  if (!(streamId in context.streams)) {
660
668
  context.streams[streamId] = { chunks: [], from_offset: 0, type: null, fin_received: false };
661
669
  }
@@ -671,7 +679,7 @@ function H3Connection(options) {
671
679
  // ---- WT stream detection ----
672
680
  if (context.local_webtransport && _wtSessions.size > 0) {
673
681
  var allData = concatUint8Arrays(s.chunks);
674
- console.log('[h3] WT check: stream=' + sid + ' firstByte=0x' + (allData[0] || 0).toString(16) + ' len=' + allData.byteLength + ' sessions=' + Array.from(_wtSessions));
682
+ if (DEBUG) console.log('[h3] WT check: stream=' + sid + ' firstByte=0x' + (allData[0] || 0).toString(16) + ' len=' + allData.byteLength + ' sessions=' + Array.from(_wtSessions));
675
683
  if (allData.byteLength >= 2 && allData[0] === 0x40) {
676
684
  var typeResult = readVarInt(allData, 0);
677
685
  if (typeResult) {
@@ -689,7 +697,7 @@ function H3Connection(options) {
689
697
  _wtStreams[sid] = { sessionId: sessionId, isBidi: !isUni };
690
698
  delete context.streams[streamId];
691
699
 
692
- console.log('[h3] WT stream detected: stream=' + sid + ' session=' + sessionId + ' bidi=' + !isUni + ' dataLen=' + remaining.byteLength);
700
+ if (DEBUG) console.log('[h3] WT stream detected: stream=' + sid + ' session=' + sessionId + ' bidi=' + !isUni + ' dataLen=' + remaining.byteLength);
693
701
  ev.emit('wt_stream', sessionId, sid, remaining, fin, !isUni);
694
702
  if (fin) delete _wtStreams[sid];
695
703
  return;
@@ -739,7 +747,7 @@ function H3Connection(options) {
739
747
 
740
748
  function processControlStream(s) {
741
749
  var ext = extractH3Frames(s.chunks, s.from_offset);
742
- console.log('[h3] control: extracted ' + ext.frames.length + ' H3 frames');
750
+ if (DEBUG) console.log('[h3] control: extracted ' + ext.frames.length + ' H3 frames');
743
751
  if (ext.frames.length === 0) return;
744
752
  s.from_offset = ext.new_from_offset;
745
753
 
@@ -761,7 +769,12 @@ function H3Connection(options) {
761
769
  set_context(updates);
762
770
 
763
771
  } else if (ext.frames[i].frame_type === 0x07) {
764
- // GOAWAY — TODO
772
+ // GOAWAY (RFC 9114 §5.2): the peer will not process requests/pushes
773
+ // with an identifier >= this value. Surface it so consumers (e.g. the
774
+ // agent) can stop sending new requests on this connection.
775
+ var gp = readVarInt(ext.frames[i].payload, 0);
776
+ if (DEBUG) console.log('[h3] ← GOAWAY id=' + (gp ? gp.value : '?'));
777
+ ev.emit('goaway', gp ? gp.value : 0);
765
778
  }
766
779
  }
767
780
  }
@@ -801,7 +814,7 @@ function H3Connection(options) {
801
814
 
802
815
  function processBidiStream(streamId, s) {
803
816
  var ext = extractH3Frames(s.chunks, s.from_offset);
804
- console.log('[h3] bidi stream ' + streamId + ': extracted ' + ext.frames.length + ' H3 frames, types=' + ext.frames.map(function(f){ return '0x' + f.frame_type.toString(16); }).join(','));
817
+ if (DEBUG) console.log('[h3] bidi stream ' + streamId + ': extracted ' + ext.frames.length + ' H3 frames, types=' + ext.frames.map(function(f){ return '0x' + f.frame_type.toString(16); }).join(','));
805
818
 
806
819
  if (ext.frames.length > 0) {
807
820
  s.from_offset = ext.new_from_offset;
@@ -836,12 +849,12 @@ function H3Connection(options) {
836
849
  function sendHeaders(streamId, headers, fin) {
837
850
  var payload = buildQpackHeaderBlock(headers);
838
851
  var frame = buildH3Frames([{ frame_type: 1, payload: payload }]);
839
- console.log('[h3] sendHeaders stream=' + streamId + ' len=' + frame.byteLength + ' fin=' + !!fin);
852
+ if (DEBUG) console.log('[h3] sendHeaders stream=' + streamId + ' len=' + frame.byteLength + ' fin=' + !!fin);
840
853
  quic.sendStream(streamId, frame, !!fin);
841
854
  }
842
855
 
843
856
  function sendBody(streamId, data, fin) {
844
- console.log('[h3] sendBody stream=' + streamId + ' data=' + (data ? data.byteLength : 'null') + ' fin=' + fin);
857
+ if (DEBUG) console.log('[h3] sendBody stream=' + streamId + ' data=' + (data ? data.byteLength : 'null') + ' fin=' + fin);
845
858
  if (data === null || data === undefined) {
846
859
  // Just set FIN on the stream (no additional H3 frame needed for empty body)
847
860
  quic.sendStream(streamId, new Uint8Array(0), true);
@@ -864,6 +877,7 @@ function H3Connection(options) {
864
877
  sendHeaders: sendHeaders,
865
878
  sendBody: sendBody,
866
879
  sendControlStreams: sendControlStreams,
880
+ sendGoaway: sendGoaway,
867
881
  claimStream: function (streamId) { _claimedStreams.add(Number(streamId)); },
868
882
 
869
883
  // WebTransport server-side API
@@ -887,12 +901,7 @@ function H3Connection(options) {
887
901
  }
888
902
 
889
903
  // Send WT prefix: VarInt(type) + VarInt(session_id)
890
- var typePrefix = writeVarInt(isBidi ? 0x41 : 0x54);
891
- var sessionPrefix = writeVarInt(sessionId);
892
- var header = new Uint8Array(typePrefix.length + sessionPrefix.length);
893
- header.set(typePrefix, 0);
894
- header.set(sessionPrefix, typePrefix.length);
895
- quic.sendStream(streamId, header, false);
904
+ quic.sendStream(streamId, wt_stream_header(isBidi, sessionId), false);
896
905
 
897
906
  _claimedStreams.add(streamId);
898
907
  _wtStreams[streamId] = { sessionId: sessionId, isBidi: isBidi };
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
  // ============================================================
@@ -38,11 +38,16 @@ function request(options, callback) {
38
38
  var path = options.path || '/';
39
39
  var method = (options.method || 'GET').toUpperCase();
40
40
  var headers = options.headers || {};
41
- var rejectUnauthorized = options.rejectUnauthorized !== false;
42
41
 
43
42
  // Connection reuse: pass _connection = { quic, h3, udpSocket, nextStreamId }
44
43
  var existingConn = options._connection || null;
45
- var autoClose = existingConn ? false : true;
44
+ // Auto-close the QUIC connection when this request finishes ONLY when we own
45
+ // it — i.e. a fresh, standalone connection. Reused connections (existingConn)
46
+ // and connections whose lifecycle is managed by an external pool
47
+ // (options._managed, set by the unified client/agent) are left open; closing
48
+ // them here would tear down a connection still pooled for reuse. Managed
49
+ // connections are reaped by the agent (sweep/destroy) and the QUIC idle timer.
50
+ var autoClose = !existingConn && !options._managed;
46
51
 
47
52
  // ---- Client Request object ----
48
53
  var reqBody = [];
@@ -104,9 +109,12 @@ function request(options, callback) {
104
109
 
105
110
  // ---- Resolve hostname and connect (or reuse existing) ----
106
111
  if (existingConn) {
107
- // Reuse existing connection — allocate stream ID and send when ready
112
+ // Reuse existing connection — allocate stream ID and send when ready.
113
+ // The shared dispatch listeners were installed when the connection was
114
+ // first created; installSharedHandlers() is idempotent.
108
115
  streamId = allocateStreamId(existingConn);
109
- setupResponseHandlers();
116
+ installSharedHandlers(existingConn);
117
+ setupResponseHandlers(existingConn);
110
118
  if (reqFinished) sendRequest();
111
119
  } else {
112
120
  resolveAndConnect(hostname, port);
@@ -132,60 +140,40 @@ function request(options, callback) {
132
140
 
133
141
 
134
142
  function startConnection(remoteIp, remotePort) {
135
- // Create UDP socket
136
- var isIPv6 = remoteIp.indexOf(':') >= 0;
137
- udpSocket = dgram.createSocket(isIPv6 ? 'udp6' : 'udp4');
143
+ udpSocket = createQuicClientSocket({
144
+ remoteIp: remoteIp,
145
+ remotePort: remotePort,
146
+ hostname: hostname,
138
147
 
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
- });
148
+ onError: function (err) {
149
+ emit(reqListeners, 'error', err);
150
+ },
146
151
 
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');
152
+ onConnect: function (q, s) {
153
+ quic = q;
154
+ udpSocket = s;
155
+ if (DEBUG) console.log('[client] QUIC connected');
166
156
 
167
157
  h3 = new H3Connection({ quicConnection: quic, isServer: false });
168
158
  connected = true;
169
159
 
170
160
  // Build connection object for reuse
171
161
  var conn = { quic: quic, h3: h3, udpSocket: udpSocket, _nextStreamId: 0 };
162
+
163
+ // Install the shared per-connection dispatch listeners ONCE, then
164
+ // register this request in the connection's stream-handler map.
165
+ installSharedHandlers(conn);
172
166
  streamId = allocateStreamId(conn);
173
167
 
174
168
  // Store on clientReq so caller (unified client) can reuse
175
169
  clientReq._connection = conn;
176
170
 
177
- setupResponseHandlers();
171
+ setupResponseHandlers(conn);
178
172
 
179
173
  // Send request if body is ready
180
174
  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();
175
+ }
176
+ // onClose: the helper closes the UDP socket; nothing extra needed here.
189
177
  });
190
178
  }
191
179
 
@@ -202,51 +190,78 @@ function request(options, callback) {
202
190
 
203
191
 
204
192
  /**
205
- * Set up HTTP/3 response handlers for this request's streamId.
206
- * Works for both new and reused connections.
193
+ * Install the three HTTP/3 event listeners ONCE per connection. They dispatch
194
+ * each event to the right request via the connection's stream-handler map
195
+ * (keyed by stream id), so multiplexing many requests over a single
196
+ * connection does NOT pile up listeners on the H3 emitter (previously every
197
+ * request added another set of listeners that were never removed).
198
+ * Idempotent per connection.
207
199
  */
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);
200
+ function installSharedHandlers(conn) {
201
+ if (conn._streamHandlers) return; // already installed for this connection
202
+ conn._streamHandlers = {};
203
+ var h3c = conn.h3;
204
+
205
+ h3c.on('http_headers', function (sid, hdrs) {
206
+ var h = conn._streamHandlers[sid];
207
+ if (h) h.onHeaders(hdrs);
222
208
  });
223
-
224
- // HTTP response body
225
- h3.on('http_body', function (sid, data) {
226
- if (sid !== streamId) return;
227
- emit(clientRes, 'data', data);
209
+ h3c.on('http_body', function (sid, data) {
210
+ var h = conn._streamHandlers[sid];
211
+ if (h) h.onBody(data);
212
+ });
213
+ h3c.on('http_end', function (sid) {
214
+ var h = conn._streamHandlers[sid];
215
+ if (!h) return;
216
+ delete conn._streamHandlers[sid]; // free per-stream state before dispatch
217
+ h.onEnd();
228
218
  });
219
+ }
220
+
229
221
 
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);
222
+ /**
223
+ * Register this request's response handlers in the connection's stream map,
224
+ * keyed by its streamId. Replaces per-request h3.on(...) registration —
225
+ * routing is now by map key, so no per-listener streamId check is needed.
226
+ */
227
+ function setupResponseHandlers(conn) {
228
+ conn._streamHandlers[streamId] = {
229
+ onHeaders: function (hdrs) {
230
+ clientRes.headers = hdrs;
231
+ clientRes.statusCode = parseInt(hdrs[':status']) || 0;
232
+ clientRes._headersReceived = true;
233
+
234
+ // Fire callback with response (once)
235
+ if (typeof callback === 'function') {
236
+ callback(clientRes);
237
+ callback = null;
238
+ }
239
+ emit(reqListeners, 'response', clientRes);
240
+ },
241
+
242
+ onBody: function (data) {
243
+ emit(clientRes, 'data', data);
244
+ },
245
+
246
+ onEnd: function () {
247
+ emit(clientRes, 'end');
248
+
249
+ // Auto-close only if we own the connection (see `autoClose` above).
250
+ if (autoClose) {
251
+ setTimeout(function () {
252
+ if (quic) quic.close(0, 'done');
253
+ if (udpSocket) { try { udpSocket.close(); } catch (e) {} }
254
+ }, 100);
255
+ }
241
256
  }
242
- });
257
+ };
243
258
  }
244
259
 
245
260
 
246
261
  function sendRequest() {
247
262
  if (!h3 || !connected) return;
248
263
 
249
- console.log('[client] sendRequest method=' + method + ' path=' + path + ' stream=' + streamId);
264
+ if (DEBUG) console.log('[client] sendRequest method=' + method + ' path=' + path + ' stream=' + streamId);
250
265
 
251
266
  // MUST send control streams (SETTINGS) before any request
252
267
  h3.sendControlStreams();