quico 0.7.0 → 0.8.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
@@ -28,8 +28,10 @@ import lemonTls from 'lemon-tls';
28
28
 
29
29
  // Encrypted Client Hello keys (RFC 9849) — LemonTLS's; for createServer's `ech`.
30
30
  const generateEchKey = lemonTls.generateEchKey;
31
- import { WebTransport, WebTransportError } from './src/webtransport.js';
31
+ import { WebTransport, WebTransportError, WebTransportBidirectionalStream, WebTransportReceiveStream, WebTransportSendStream,
32
+ WebTransportWriter, WebTransportSendGroup, WebTransportDatagramsWritable, WebTransportDatagramDuplexStream } from './src/webtransport.js';
32
33
  import { WebSocket } from './src/ws_client.js';
34
+ import { WebSocketStream, WebSocketError } from './src/ws_stream.js';
33
35
  import { connectUdp } from './src/connect_udp_client.js';
34
36
  import { WebSocketServer, upgradeWebSocket } from './src/ws_server.js';
35
37
  import { ja4h } from './src/ja4h.js';
@@ -52,7 +54,8 @@ import { request as h3Request } from './src/h3_client.js';
52
54
  const api = {
53
55
  createServer, request, get, Agent, globalAgent,
54
56
  IncomingMessage, ServerResponse, ClientRequest,
55
- connectUdp, createSecureContext, generateEchKey, WebTransport, WebTransportError, WebSocket, WebSocketServer, upgradeWebSocket,
57
+ connectUdp, createSecureContext, generateEchKey, WebTransport, WebTransportError, WebTransportBidirectionalStream, WebTransportReceiveStream, WebTransportSendStream,
58
+ WebTransportWriter, WebTransportSendGroup, WebTransportDatagramsWritable, WebTransportDatagramDuplexStream, WebSocket, WebSocketStream, WebSocketError, WebSocketServer, upgradeWebSocket,
56
59
  ja4h, h2Fingerprint, h3Fingerprint, observeH2, profileRequest,
57
60
  createQuicServer, createQuicClientSocket, createTcpListener,
58
61
  createH1Server, createH2Server, createH3Server,
@@ -62,7 +65,8 @@ const api = {
62
65
  export {
63
66
  createServer, request, get, Agent, globalAgent,
64
67
  IncomingMessage, ServerResponse, ClientRequest,
65
- connectUdp, createSecureContext, generateEchKey, WebTransport, WebTransportError, WebSocket, WebSocketServer, upgradeWebSocket,
68
+ connectUdp, createSecureContext, generateEchKey, WebTransport, WebTransportError, WebTransportBidirectionalStream, WebTransportReceiveStream, WebTransportSendStream,
69
+ WebTransportWriter, WebTransportSendGroup, WebTransportDatagramsWritable, WebTransportDatagramDuplexStream, WebSocket, WebSocketStream, WebSocketError, WebSocketServer, upgradeWebSocket,
66
70
  ja4h, h2Fingerprint, h3Fingerprint, observeH2, profileRequest,
67
71
  createQuicServer, createQuicClientSocket, createTcpListener,
68
72
  createH1Server, createH2Server, createH3Server,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quico",
3
- "version": "0.7.0",
3
+ "version": "0.8.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",
@@ -12,7 +12,7 @@
12
12
  }
13
13
  },
14
14
  "engines": {
15
- "node": ">=18.0.0"
15
+ "node": ">=24.0.0"
16
16
  },
17
17
  "types": "index.d.ts",
18
18
  "files": [
@@ -37,6 +37,9 @@
37
37
  "http3-client",
38
38
  "h3-node",
39
39
  "webtransport",
40
+ "moqt",
41
+ "masque",
42
+ "connect-udp",
40
43
  "websocket",
41
44
  "datagram",
42
45
  "udp",
@@ -65,7 +68,7 @@
65
68
  "email": "support@quicojs.dev",
66
69
  "url": "https://github.com/colocohen/quico/issues"
67
70
  },
68
- "homepage": "https://github.com/colocohen/quico#readme",
71
+ "homepage": "https://quicojs.dev",
69
72
  "funding": [
70
73
  {
71
74
  "type": "github",
@@ -78,6 +81,6 @@
78
81
  ],
79
82
  "dependencies": {
80
83
  "flat-ranges": "^2.2.0",
81
- "lemon-tls": "^0.9.0"
84
+ "lemon-tls": "^0.10.0"
82
85
  }
83
86
  }
@@ -407,6 +407,15 @@ function ProxyH3Connection(key, proxy, o, ech) {
407
407
  // Each ticket the proxy sends: the agent's, and each tunnel's 'session'.
408
408
  onSocket: function (q) {
409
409
  self.quic = q;
410
+ // The connection is over the moment either side's CONNECTION_CLOSE is
411
+ // known ('closing'), not when its draining period (RFC 9000 §10.2,
412
+ // some 3 s) ends and 'close' comes: each tunnel on it is told at
413
+ // once, and the connection leaves the pool at once — a tunnel opened
414
+ // meanwhile joined a connection already closed, and failed. As the
415
+ // WebTransport client hears it (r69). Only once open: a handshake
416
+ // that ends is onError's to report — an ECH rejection is retried
417
+ // there with the server's new keys, not failed.
418
+ q.on('closing', function () { if (self.quic === q && self.state === 'open') self._gone(); });
410
419
  q.on('session', function (s) {
411
420
  self.sessions.keep(s);
412
421
  self.tunnels.forEach(function (t) { if (t.session) t.session(s); });
package/src/h1_server.js CHANGED
@@ -155,6 +155,8 @@ function createServer(options, handler) {
155
155
 
156
156
  socket.on('data', function (chunk) {
157
157
  if (destroyed) return;
158
+ // A request is arriving: not idle (closeIdleConnections, close()).
159
+ socket._quicoBusy = true;
158
160
 
159
161
  try {
160
162
  parser.execute(chunk);
@@ -231,6 +233,7 @@ function createServer(options, handler) {
231
233
  });
232
234
 
233
235
  parser.on('headers', function (info) {
236
+ socket._quicoBusy = true;
234
237
  clearTimer();
235
238
  setTimer(requestTimeout, 'request');
236
239
  served++;
@@ -359,6 +362,7 @@ function createServer(options, handler) {
359
362
  socket: socket
360
363
  });
361
364
  req.rawHeaders = info.rawHeaders;
365
+ req.upgrade = true;
362
366
  req._endData();
363
367
 
364
368
  // Hand the raw socket over, exactly as node:http does. `head` is the
@@ -452,7 +456,7 @@ function createServer(options, handler) {
452
456
  willClose = !keepAlive;
453
457
 
454
458
  if (keepAlive && info.httpVersion === '1.0') headers['keep-alive'] = 'timeout=5';
455
- if (!('date' in headers)) headers['date'] = httpDate();
459
+ if (!('date' in headers) && res.sendDate !== false) headers['date'] = httpDate();
456
460
  // A function when the server was built before its port was known —
457
461
  // the unified server builds its layers at creation and learns the
458
462
  // port from listen(), or from options.port when fed from outside.
@@ -484,6 +488,13 @@ function createServer(options, handler) {
484
488
  if (typeof callback === 'function') process.nextTick(callback);
485
489
  };
486
490
 
491
+ // 102 Processing (node:http's writeProcessing): an interim response,
492
+ // as Early Hints are.
493
+ res.writeProcessing = function () {
494
+ if (res.headersSent || info.httpVersion === '1.0' || destroyed) return;
495
+ try { socket.write(buildResponseHead(102, null, {})); } catch (e) {}
496
+ };
497
+
487
498
  res._write = function (chunk, encoding, callback) {
488
499
  if (!res.headersSent) res._flushHeaders();
489
500
  if (typeof chunk === 'string') chunk = Buffer.from(chunk, encoding);
@@ -526,6 +537,7 @@ function createServer(options, handler) {
526
537
  function onResponseComplete() {
527
538
  clearTimer();
528
539
  current = null;
540
+ socket._quicoBusy = false;
529
541
  // Whatever of the body is still to come is dropped from here on (the
530
542
  // parser reads it, to find the next request): the reader no longer
531
543
  // holds the socket back.
@@ -573,7 +585,7 @@ function createServer(options, handler) {
573
585
  ca: options.ca,
574
586
  SNICallback: options.SNICallback
575
587
  });
576
- tcpServer.register('http/1.1', handleConnection);
588
+ tcpServer.register('http/1.1', handleConnection, { managesClose: true });
577
589
  tcpServer.on('error', function (err) { ev.emit('error', err); });
578
590
  tcpServer.on('clientError', function (err, sock) { ev.emit('clientError', err, sock); });
579
591
  tcpServer.on('tlsClientError', function (err, sock) { ev.emit('tlsClientError', err, sock); });
@@ -584,17 +596,42 @@ function createServer(options, handler) {
584
596
  return server;
585
597
  }
586
598
 
587
- function close(callback) {
599
+ // ---- Closing, as node:http ----
600
+ //
601
+ // Idle: a connection between requests (or before its first) — no request
602
+ // arriving, no response being written. Closing one costs nothing.
603
+ function closeIdleConnections() {
604
+ for (var s of connections) if (!s._quicoBusy) { try { s.destroy(); } catch (e) {} }
605
+ }
606
+ // All, mid-request too. Not the sockets handed to 'upgrade' (a WebSocket):
607
+ // those left this server at the upgrade, as in node:http.
608
+ function closeAllConnections() {
609
+ for (var s of connections) { try { s.destroy(); } catch (e) {} }
610
+ }
611
+ // No new connections, the idle ones closed; each busy one closes as its
612
+ // response completes (onResponseComplete). It used to end every socket at
613
+ // once: a response being written was cut off.
614
+ function beginClose() {
588
615
  closing = true;
589
- if (!tcpServer) {
590
- // Sockets we were handed rather than accepted: still ours to close.
591
- for (var s of connections) { try { s.end(); } catch (e) {} }
592
- if (callback) callback();
593
- return;
594
- }
616
+ closeIdleConnections();
617
+ }
618
+
619
+ function close(callback) {
620
+ beginClose();
621
+ if (!tcpServer) { if (callback) callback(); return; }
595
622
  tcpServer.close(callback);
596
623
  }
597
624
 
625
+ // node:http's timeouts and limits, changed while the server runs: from
626
+ // the next request (or connection) on.
627
+ function setLimits(o) {
628
+ if (o.headersTimeout !== undefined) headersTimeout = o.headersTimeout;
629
+ if (o.keepAliveTimeout !== undefined) keepAliveTimeout = o.keepAliveTimeout;
630
+ if (o.requestTimeout !== undefined) requestTimeout = o.requestTimeout;
631
+ if (o.maxHeaderCount !== undefined) maxHeaderCount = o.maxHeaderCount;
632
+ if (o.maxRequestsPerSocket !== undefined) maxRequests = maxRequestsPerSocket(o.maxRequestsPerSocket);
633
+ }
634
+
598
635
  function address() {
599
636
  return tcpServer && tcpServer.address ? tcpServer.address() : null;
600
637
  }
@@ -610,6 +647,11 @@ function createServer(options, handler) {
610
647
  emit: function () { ev.emit.apply(ev, arguments); return server; },
611
648
  listenerCount: function (n) { return ev.listenerCount(n); },
612
649
  setTimeout: function (ms) { headersTimeout = ms; return server; },
650
+ closeIdleConnections: closeIdleConnections,
651
+ closeAllConnections: closeAllConnections,
652
+ beginClose: beginClose,
653
+ setLimits: setLimits,
654
+ get connectionCount() { return connections.size; },
613
655
 
614
656
  /** Attach an already-connected Duplex, bypassing the listener. Lets the
615
657
  * unified server share one TCP port, and makes the whole stack testable
package/src/h2_server.js CHANGED
@@ -255,6 +255,20 @@ function createServer(options, handler) {
255
255
  });
256
256
 
257
257
  connections.add(socket);
258
+ socket._quicoH2 = {
259
+ idle: function () { return h2.activeStreams === 0; },
260
+ drain: function () {
261
+ if (destroyed || draining) return;
262
+ draining = true;
263
+ try { h2.sendGoaway(F.ERROR.NO_ERROR); } catch (e) {}
264
+ finishIfDone();
265
+ },
266
+ destroy: function () {
267
+ if (destroyed) return;
268
+ try { h2.sendGoaway(F.ERROR.NO_ERROR); } catch (e) {}
269
+ destroy(null, true);
270
+ }
271
+ };
258
272
 
259
273
  // `linger`: end the socket rather than destroy it — what was written last
260
274
  // (the GOAWAY saying why) leaves first — and destroy it if the peer has
@@ -340,8 +354,12 @@ function createServer(options, handler) {
340
354
  // only (HTTP/1.1 ends its socket with the client's, as node:http does).
341
355
  socket.allowHalfOpen = true;
342
356
  var peerEnded = false;
357
+ // Closing (server.close(), closeIdleConnections): GOAWAY sent — no new
358
+ // streams — and the connection ends with its last stream, as when the
359
+ // peer half-closes.
360
+ var draining = false;
343
361
  function finishIfDone() {
344
- if (destroyed || !peerEnded || h2.activeStreams > 0) return;
362
+ if (destroyed || !(peerEnded || draining) || h2.activeStreams > 0) return;
345
363
  try { h2.sendGoaway(F.ERROR.NO_ERROR); } catch (e) {}
346
364
  destroy(null, true);
347
365
  }
@@ -375,7 +393,7 @@ function createServer(options, handler) {
375
393
  // a long-lived connection kept every WebSocket it ever carried).
376
394
  var st = streams[id];
377
395
  if (st && st.duplex) delete streams[id];
378
- if (peerEnded) process.nextTick(finishIfDone);
396
+ if (peerEnded || draining) process.nextTick(finishIfDone);
379
397
  });
380
398
  // A response that waits for the client to open a window, from a client
381
399
  // that has half-closed: it sends nothing more — no WINDOW_UPDATE either —
@@ -435,6 +453,7 @@ function createServer(options, handler) {
435
453
  delete streams[streamId];
436
454
  return;
437
455
  }
456
+ creq.upgrade = true;
438
457
  ev.emit('upgrade', creq, stream, Buffer.alloc(0));
439
458
  return;
440
459
  }
@@ -686,7 +705,7 @@ function createServer(options, handler) {
686
705
 
687
706
  bodyAllowed = !statusHasNoBody(code) && req.method !== 'HEAD';
688
707
 
689
- if (!('date' in headers)) headers['date'] = httpDate();
708
+ if (!('date' in headers) && res.sendDate !== false) headers['date'] = httpDate();
690
709
  // A function when the server was built before its port was known —
691
710
  // the unified server builds its layers at creation and learns the
692
711
  // port from listen(), or from options.port when fed from outside.
@@ -767,6 +786,12 @@ function createServer(options, handler) {
767
786
  if (typeof callback === 'function') process.nextTick(callback);
768
787
  };
769
788
 
789
+ // 102 Processing (node:http's writeProcessing), as Early Hints go.
790
+ res.writeProcessing = function () {
791
+ if (res.headersSent || res._aborted || destroyed) return;
792
+ try { h2.sendHeaders(streamId, buildResponsePseudoHeaders(102, {}), false); } catch (e) {}
793
+ };
794
+
770
795
  /**
771
796
  * The response's priority, as the server decides it: { urgency (0..7,
772
797
  * 0 first), incremental } — RFC 9218, as h3_server's. It replaces what
@@ -821,7 +846,7 @@ function createServer(options, handler) {
821
846
  ca: options.ca,
822
847
  SNICallback: options.SNICallback
823
848
  });
824
- listener.register('h2', handleConnection);
849
+ listener.register('h2', handleConnection, { managesClose: true });
825
850
  listener.on('error', function (err) { ev.emit('error', err); });
826
851
  listener.on('clientError', function (err, sock) { ev.emit('clientError', err, sock); });
827
852
  listener.on('tlsClientError', function (err, sock) { ev.emit('tlsClientError', err, sock); });
@@ -832,16 +857,35 @@ function createServer(options, handler) {
832
857
  return server;
833
858
  }
834
859
 
835
- function close(callback) {
860
+ // ---- Closing, as node:http (see h1_server) ----
861
+ // Idle: no stream open. Closed with GOAWAY first, so the client knows
862
+ // nothing it sent was lost.
863
+ function closeIdleConnections() {
864
+ for (var s of connections) if (s._quicoH2 && s._quicoH2.idle()) s._quicoH2.drain();
865
+ }
866
+ function closeAllConnections() {
867
+ for (var s of connections) if (s._quicoH2) s._quicoH2.destroy();
868
+ }
869
+ // GOAWAY on every connection: those idle end now, the others when their
870
+ // last stream does. It used to end every socket at once — responses in
871
+ // progress were cut off.
872
+ function beginClose() {
836
873
  closing = true;
837
- if (!listener) {
838
- for (var s of connections) { try { s.end(); } catch (e) {} }
839
- if (callback) callback();
840
- return;
841
- }
874
+ for (var s of connections) if (s._quicoH2) s._quicoH2.drain();
875
+ }
876
+
877
+ function close(callback) {
878
+ beginClose();
879
+ if (!listener) { if (callback) callback(); return; }
842
880
  listener.close(callback);
843
881
  }
844
882
 
883
+ function setLimits(o) {
884
+ if (o.headersTimeout !== undefined) headersTimeout = o.headersTimeout;
885
+ if (o.requestTimeout !== undefined) requestTimeout = o.requestTimeout;
886
+ if (o.maxRequestsPerSocket !== undefined) maxRequests = maxRequestsPerSocket(o.maxRequestsPerSocket);
887
+ }
888
+
845
889
 
846
890
  var server = {
847
891
  listen: listen,
@@ -852,7 +896,12 @@ function createServer(options, handler) {
852
896
  once: function (n, f) { ev.once(n, f); return server; },
853
897
  off: function (n, f) { ev.off(n, f); return server; },
854
898
  emit: function () { ev.emit.apply(ev, arguments); return server; },
855
- listenerCount: function (n) { return ev.listenerCount(n); }
899
+ listenerCount: function (n) { return ev.listenerCount(n); },
900
+ closeIdleConnections: closeIdleConnections,
901
+ closeAllConnections: closeAllConnections,
902
+ beginClose: beginClose,
903
+ setLimits: setLimits,
904
+ get connectionCount() { return connections.size; }
856
905
  };
857
906
 
858
907
  return server;
package/src/h3.js CHANGED
@@ -87,8 +87,12 @@ import {
87
87
  // deliberately NOT part of this move.
88
88
  // ============================================================
89
89
 
90
+ // One decoder for the module: decode() holds no state between calls (no
91
+ // `stream` option), and constructing one per name and per value cost more
92
+ // than the decoding itself.
93
+ var _utf8Decoder = new TextDecoder();
90
94
  function _text(bytes) {
91
- return new TextDecoder().decode(bytes);
95
+ return _utf8Decoder.decode(bytes);
92
96
  }
93
97
 
94
98
  // Every decodeStringLiteral() call below passes strict = true, enabling the
@@ -249,7 +253,10 @@ function parseQpackHeaderBlock(buf, dynamicTable, totalInserts, maxEntries, maxS
249
253
 
250
254
 
251
255
  // ============================================================
252
- // H3 Frame build/parse
256
+ // H3 Frame build
257
+ //
258
+ // Frames are read in place, from each stream's own buffer, as the bytes
259
+ // arrive: processBidiStream and processControlStream in H3Connection.
253
260
  // ============================================================
254
261
 
255
262
  function buildH3Frames(frames) {
@@ -262,43 +269,6 @@ function buildH3Frames(frames) {
262
269
  return concatUint8Arrays(parts);
263
270
  }
264
271
 
265
- function extractH3Frames(chunks, fromOffset) {
266
- if (!chunks || chunks.length === 0) return { frames: [], new_from_offset: fromOffset };
267
-
268
- var buffers = [];
269
- var acc = 0;
270
- for (var i = 0; i < chunks.length; i++) {
271
- var nextAcc = acc + chunks[i].length;
272
- if (fromOffset < nextAcc) {
273
- buffers.push(chunks[i].slice(fromOffset - acc));
274
- for (var j = i + 1; j < chunks.length; j++) buffers.push(chunks[j]);
275
- break;
276
- }
277
- acc = nextAcc;
278
- }
279
-
280
- if (buffers.length === 0) return { frames: [], new_from_offset: fromOffset };
281
-
282
- var combined = concatUint8Arrays(buffers);
283
- var offset = 0, frames = [];
284
-
285
- while (offset < combined.length) {
286
- var start = offset;
287
- var ft = readVarInt(combined, offset);
288
- if (!ft) break;
289
- offset += ft.byteLength;
290
- var len = readVarInt(combined, offset);
291
- if (!len) { offset = start; break; }
292
- offset += len.byteLength;
293
- if (offset + len.value > combined.length) { offset = start; break; }
294
- frames.push({ frame_type: ft.value, payload: combined.slice(offset, offset + len.value) });
295
- offset += len.value;
296
- }
297
-
298
- return { frames: frames, new_from_offset: fromOffset + offset };
299
- }
300
-
301
-
302
272
  // ============================================================
303
273
  // H3 Settings
304
274
  // ============================================================
@@ -369,24 +339,14 @@ function parseSettingsPayload(buf) {
369
339
  // QPACK Encoder Instructions (from peer's encoder stream)
370
340
  // ============================================================
371
341
 
372
- function parseQpackEncoderInstructions(chunks, fromOffset, maxLiteral) {
373
- if (!chunks || chunks.length === 0) return { instructions: [], new_from_offset: fromOffset, need: 0 };
342
+ // Parse the encoder-stream instructions in `combined` — the stream's unparsed
343
+ // bytes, from its read position. Returns the complete instructions, how many
344
+ // bytes they took (consumed), and, when the last one is cut short, how many
345
+ // bytes it needs (need). It used to take the stream's chunks and an offset
346
+ // and join them itself; the one caller already hands it a single buffer.
347
+ function parseQpackEncoderInstructions(combined, maxLiteral) {
348
+ if (!combined || combined.length === 0) return { instructions: [], consumed: 0, need: 0 };
374
349
  if (maxLiteral === undefined) maxLiteral = Infinity;
375
-
376
- var buffers = [];
377
- var acc = 0;
378
- for (var i = 0; i < chunks.length; i++) {
379
- var nextAcc = acc + chunks[i].length;
380
- if (fromOffset < nextAcc) {
381
- buffers.push(chunks[i].slice(fromOffset - acc));
382
- for (var j = i + 1; j < chunks.length; j++) buffers.push(chunks[j]);
383
- break;
384
- }
385
- acc = nextAcc;
386
- }
387
- if (buffers.length === 0) return { instructions: [], new_from_offset: fromOffset, need: 0 };
388
-
389
- var combined = buffers.length === 1 ? buffers[0] : concatUint8Arrays(buffers);
390
350
  var pos = 0;
391
351
  var instructions = [];
392
352
  // When the last instruction is cut short: how many bytes, from its start,
@@ -457,7 +417,7 @@ function parseQpackEncoderInstructions(chunks, fromOffset, maxLiteral) {
457
417
  }
458
418
  }
459
419
 
460
- return { instructions: instructions, new_from_offset: fromOffset + pos,
420
+ return { instructions: instructions, consumed: pos,
461
421
  need: needAbs > pos ? needAbs - pos : 0 };
462
422
  }
463
423
 
@@ -1700,7 +1660,7 @@ function H3Connection(options) {
1700
1660
  // Only the unparsed bytes are copied (once per completed instruction,
1701
1661
  // given `need`), and no literal larger than our table can hold is
1702
1662
  // waited for.
1703
- ext = parseQpackEncoderInstructions([copyBytes(s, s.buffered)], 0,
1663
+ ext = parseQpackEncoderInstructions(copyBytes(s, s.buffered),
1704
1664
  context.local_qpack_max_table_capacity);
1705
1665
  } catch (err) {
1706
1666
  // RFC 9204 §4.2: a malformed encoder stream is QPACK_ENCODER_STREAM_ERROR,
@@ -1708,7 +1668,7 @@ function H3Connection(options) {
1708
1668
  err.h3ErrorCode = H3_ERR.QPACK_ENCODER_STREAM_ERROR;
1709
1669
  throw err;
1710
1670
  }
1711
- consumeBytes(s, ext.new_from_offset);
1671
+ consumeBytes(s, ext.consumed);
1712
1672
  s.need = ext.need;
1713
1673
  if (ext.instructions.length === 0) return;
1714
1674
 
@@ -2000,6 +1960,11 @@ function H3Connection(options) {
2000
1960
  if (avail < headLen + len.value) break;
2001
1961
  // Waits for the encoder stream when it must (see sectionBlocked): the
2002
1962
  // frame stays in the buffer, read again once unblocked.
1963
+ // Already waiting, and the inserts it needs are not here yet: nothing
1964
+ // to do. The frame was copied and its prefix decoded again on every
1965
+ // packet that arrived on the stream meanwhile.
1966
+ if (_blocked.has(Number(streamId)) &&
1967
+ _blocked.get(Number(streamId)) > context.remote_qpack_table_base_index) return;
2003
1968
  var whole = copyBytes(s, headLen + len.value);
2004
1969
  var payload = whole.subarray(headLen);
2005
1970
  if (sectionBlocked(Number(streamId), payload)) return;
@@ -2138,7 +2103,11 @@ function H3Connection(options) {
2138
2103
  var list = [];
2139
2104
  for (var hname in headers) {
2140
2105
  if (Object.prototype.hasOwnProperty.call(headers, hname)) {
2141
- list.push([hname, headers[hname]]);
2106
+ // A list: one field line per value (Set-Cookie above all, which may
2107
+ // not be combined), not String(list).
2108
+ var hv = headers[hname];
2109
+ if (Array.isArray(hv)) { for (var hi = 0; hi < hv.length; hi++) list.push([hname, String(hv[hi])]); }
2110
+ else list.push([hname, hv]);
2142
2111
  }
2143
2112
  }
2144
2113
  var enc = qpackEncodeHeaderBlock(list, encoderTable, {
package/src/h3_client.js CHANGED
@@ -476,7 +476,11 @@ function request(options, callback) {
476
476
  return e;
477
477
  });
478
478
  });
479
- conn.quic.on('close', function () {
479
+ // On 'closing' — the moment the connection is over (a CONNECTION_CLOSE
480
+ // either way, the idle timeout) — not only on 'close', which waits out
481
+ // the draining period (RFC 9000 §10.2, ~3 s): a response the server cut
482
+ // off hung that long before it failed.
483
+ function connectionOver() {
480
484
  failAll(0, function (sid) {
481
485
  var e = new Error('HTTP/3 connection closed before the response ended');
482
486
  e.code = 'ERR_HTTP3_CONNECTION_CLOSED';
@@ -484,7 +488,9 @@ function request(options, callback) {
484
488
  e.retryable = sid >= conn._goawayId;
485
489
  return e;
486
490
  });
487
- });
491
+ }
492
+ conn.quic.on('closing', connectionOver);
493
+ conn.quic.on('close', connectionOver);
488
494
  }
489
495
 
490
496
 
@@ -662,17 +668,28 @@ function request(options, callback) {
662
668
  else if (reqBody.length) startStreaming();
663
669
  }
664
670
 
665
- function requestHeaders() {
671
+ // contentLength: the body's size when it is known whole (ended before it
672
+ // went) — sent as h2_client and node:https send it, and as browsers do. A
673
+ // server that decides whether there is a body from the headers (Express's
674
+ // body-parser: content-length or transfer-encoding) saw none over HTTP/3,
675
+ // and req.body came out empty.
676
+ function requestHeaders(contentLength) {
666
677
  var h3Headers = {
667
678
  ':method': method,
668
679
  ':scheme': 'https',
669
680
  ':authority': hostname + (port !== 443 ? ':' + port : ''),
670
681
  ':path': path
671
682
  };
672
- for (var name in headers) h3Headers[name.toLowerCase()] = String(headers[name]);
683
+ // A repeated field (an array) stays one line per value: String(list)
684
+ // made two cookies "a=1,b=2", which the server reads as one cookie.
685
+ for (var name in headers) {
686
+ var v = headers[name];
687
+ h3Headers[name.toLowerCase()] = Array.isArray(v) ? v.map(String) : String(v);
688
+ }
673
689
  if (!h3Headers['user-agent']) h3Headers['user-agent'] = 'quico/1.0';
674
690
  if (!h3Headers['accept']) h3Headers['accept'] = '*/*';
675
691
  if (!h3Headers['accept-encoding']) h3Headers['accept-encoding'] = 'identity';
692
+ if (contentLength != null && h3Headers['content-length'] === undefined) h3Headers['content-length'] = String(contentLength);
676
693
  return h3Headers;
677
694
  }
678
695
 
@@ -687,7 +704,9 @@ function request(options, callback) {
687
704
  // Early: kept, to go again if the server refuses it.
688
705
  if (earlyTried && !handshaken) earlyBody = { body: body.slice(), trailers: reqTrailers };
689
706
  if (body.length === 0 && !reqTrailers) { h3.sendHeaders(streamId, requestHeaders(), true); return; }
690
- h3.sendHeaders(streamId, requestHeaders(), false);
707
+ var bodyLength = 0;
708
+ for (var bi = 0; bi < body.length; bi++) bodyLength += body[bi].byteLength;
709
+ h3.sendHeaders(streamId, requestHeaders(body.length ? bodyLength : null), false);
691
710
  for (var i = 0; i < body.length; i++) h3.sendBody(streamId, body[i], i === body.length - 1 && !reqTrailers);
692
711
  if (reqTrailers) h3.sendHeaders(streamId, reqTrailers, true);
693
712
  }