quico 0.4.0 → 0.7.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
@@ -14,8 +14,15 @@
14
14
 
15
15
  import dns from 'node:dns';
16
16
  import { DEBUG } from './utils.js';
17
- import { H3Connection } from './h3.js';
17
+ import { H3Connection, H3_ERR_NAME } from './h3.js';
18
18
  import { createQuicClientSocket } from './quic_socket.js';
19
+ import { parseVersionList } from './quic_version.js';
20
+ import { checkConnectionOptions, CONNECTION_OPTIONS } from './quic_connection.js';
21
+ import { trailerFields, formatPriority } from './http_common.js';
22
+
23
+ // What the body takes before write() says wait — written while connecting,
24
+ // or not yet sent on the stream.
25
+ var BODY_HIGH_WATER = 64 * 1024;
19
26
 
20
27
 
21
28
  // ============================================================
@@ -40,6 +47,11 @@ function request(options, callback) {
40
47
  // one transport this library exists for the only one that can't reach a
41
48
  // self-signed or private-CA server (H2/H1.1 fallbacks honour the option).
42
49
  var rejectUnauthorized = options.rejectUnauthorized !== false;
50
+ // Checked now, as https.request checks its options: thrown here, not from
51
+ // inside the socket's callback later.
52
+ parseVersionList(options.quicVersions, 'quicVersions');
53
+ // The connection options it sets, checked now (thrown here, as above).
54
+ (function () { var o = {}; for (var k in CONNECTION_OPTIONS) if (k !== 'carefulResume' && options[k] != null) o[k] = options[k]; checkConnectionOptions(o, 'request'); })();
43
55
  var ca = options.ca || null;
44
56
  var port = options.port || 443;
45
57
  var path = options.path || '/';
@@ -65,6 +77,28 @@ function request(options, callback) {
65
77
  var udpSocket = existingConn ? existingConn.udpSocket : null;
66
78
  var streamId = null; // assigned when connection is ready
67
79
  var connected = existingConn ? true : false;
80
+ var requestSent = false; // HEADERS went out on streamId
81
+ var readyDone = false; // the stream exists and the request is this one's to send (ready)
82
+ // 0-RTT (RFC 9001 §4.6, RFC 8470): with earlyData and a saved session, a
83
+ // request that may be replayed safely — GET, HEAD, OPTIONS — goes before
84
+ // the handshake ends. res.earlyData: 'accepted' (it went early and was
85
+ // taken), 'rejected' (refused — by the server's TLS, or by a 425 — and
86
+ // sent again after the handshake), null (not tried).
87
+ // Decided when a connection is made (startConnection): the session is
88
+ // read there — the unified client's is taken from its agent on that read,
89
+ // and a request on a connection already open must not use one up.
90
+ var earlyWanted = false;
91
+ var earlyTried = false; // set up on the early key (onEarly)
92
+ var earlyResult = null;
93
+ var earlyBody = null; // what went early, to send again if refused
94
+ var handshaken = false; // onConnect has come
95
+ var retry425 = false; // the server answered the early request 425: again, after the handshake
96
+ var after425 = null; // to run at onConnect, when the 425 came first
97
+ var streaming = false; // HEADERS sent before the body ended: DATA follows as written
98
+ var reqBodyBytes = 0, needDrain = false, drainWaiting = false;
99
+ var reqTrailers = null;
100
+ var finished = false; // the response ended or failed
101
+ var cancelled = false; // destroy() was called
68
102
 
69
103
  var clientReq = {
70
104
  on: function (event, fn) {
@@ -72,22 +106,70 @@ function request(options, callback) {
72
106
  return clientReq;
73
107
  },
74
108
 
109
+ // The body, as node:http's: held while connecting (write() false past
110
+ // BODY_HIGH_WATER, then 'drain'), then streamed — HEADERS on the first
111
+ // write once the stream exists, DATA as written, with the stream's
112
+ // backpressure.
75
113
  write: function (chunk) {
114
+ if (finished || cancelled || reqFinished) return false;
76
115
  if (typeof chunk === 'string') chunk = new TextEncoder().encode(chunk);
116
+ if (!chunk || chunk.byteLength === 0) return !needDrain;
117
+ if (!streaming && readyDone && !requestSent) startStreaming();
118
+ if (streaming) return streamOut(chunk);
77
119
  reqBody.push(chunk);
120
+ reqBodyBytes += chunk.byteLength;
121
+ if (reqBodyBytes >= BODY_HIGH_WATER) { needDrain = true; return false; }
78
122
  return true;
79
123
  },
80
124
 
81
- end: function (chunk) {
82
- if (chunk) clientReq.write(chunk);
125
+ // RFC 9218: the `priority` header while the request has not gone, a
126
+ // PRIORITY_UPDATE on the control stream after.
127
+ setPriority: function (p) {
128
+ if (requestSent) { if (h3 && !finished && !cancelled) h3.sendPriorityUpdate(streamId, p); return; }
129
+ for (var k in headers) if (k.toLowerCase() === 'priority') delete headers[k];
130
+ var v = formatPriority(p);
131
+ if (v) headers.priority = v;
132
+ },
133
+
134
+ // `trailers` (node:http's addTrailers, through the unified client): a
135
+ // HEADERS frame after the body, ending the stream (RFC 9114 §4.1).
136
+ end: function (chunk, trailers) {
137
+ if (reqFinished) return clientReq;
138
+ if (typeof chunk === 'string') chunk = new TextEncoder().encode(chunk);
139
+ reqTrailers = trailerFields(trailers);
140
+ if (streaming) {
141
+ reqFinished = true;
142
+ if (chunk && chunk.byteLength) h3.sendBody(streamId, chunk, !reqTrailers);
143
+ else if (!reqTrailers) h3.sendBody(streamId, null, true);
144
+ if (reqTrailers) h3.sendHeaders(streamId, reqTrailers, true);
145
+ return clientReq;
146
+ }
147
+ if (chunk && chunk.byteLength) { reqBody.push(chunk); reqBodyBytes += chunk.byteLength; }
83
148
  reqFinished = true;
84
- if (connected) sendRequest();
149
+ if (readyDone) sendRequest();
85
150
  return clientReq;
86
151
  },
87
152
 
153
+ // Cancel this request. Its stream is what ends (RFC 9114 §4.1.1:
154
+ // STOP_SENDING and RESET_STREAM with H3_REQUEST_CANCELLED) — not the
155
+ // connection, which may carry other requests (a pooled one does). This
156
+ // closed the whole connection, every other request on it included.
157
+ // A connection that is this request's alone is closed with it.
88
158
  destroy: function () {
89
- if (quic) quic.close(0, 'client destroy');
90
- if (udpSocket) { try { udpSocket.close(); } catch (e) {} }
159
+ if (cancelled || finished) return;
160
+ cancelled = true;
161
+ var conn = clientReq._connection || existingConn;
162
+ if (conn && conn._streamHandlers && streamId !== null) delete conn._streamHandlers[streamId];
163
+ // Still connecting: closed in onConnect (or ends by itself if the
164
+ // handshake fails) — closing the socket under a handshake in progress
165
+ // would leave its timers sending into a closed socket.
166
+ if (autoClose) { if (quic) closeConnection(); return; }
167
+ // Sent or not: its stream ID is taken, and a later request's higher
168
+ // one opens it at the server (RFC 9000 §3.2) — where nothing would
169
+ // ever end it, a stream of the connection's limit lost for good. So it
170
+ // is cancelled either way; never written, its RESET_STREAM carries a
171
+ // final size of 0 (sendRequest() will not run).
172
+ if (h3 && streamId !== null) h3.abortStream(streamId, 0x10c); // H3_REQUEST_CANCELLED
91
173
  }
92
174
  };
93
175
 
@@ -95,14 +177,26 @@ function request(options, callback) {
95
177
  var clientRes = {
96
178
  statusCode: 0,
97
179
  headers: {},
180
+ trailers: {},
181
+ rawTrailers: [],
98
182
  _listeners: {},
99
183
  _headersReceived: false,
100
184
 
101
185
  on: function (event, fn) {
102
186
  (clientRes._listeners[event] = clientRes._listeners[event] || []).push(fn);
103
187
  return clientRes;
104
- }
188
+ },
189
+
190
+ // The body's flow-control credit, returned as the layer above reads it
191
+ // rather than as it arrives (h3's holdBody) — as the server does with a
192
+ // request body. Credited on delivery, a response nobody read yet was
193
+ // taken in as fast as the server could send it: a slow consumer of a
194
+ // large download held all of it in memory, and the server never slowed.
195
+ _holdBody: function () { if (respConn && respConn.h3) respConn.h3.holdBody(streamId); },
196
+ _creditBody: function (n) { if (respConn && respConn.h3) respConn.h3.creditBody(streamId, n); },
197
+ _releaseBody: function () { if (respConn && respConn.h3) respConn.h3.releaseBody(streamId); }
105
198
  };
199
+ var respConn = null; // the connection the response comes on (setupResponseHandlers)
106
200
 
107
201
  function emit(obj, event, data) {
108
202
  var listeners = (obj._listeners || obj)[event] || [];
@@ -122,7 +216,7 @@ function request(options, callback) {
122
216
  streamId = allocateStreamId(existingConn);
123
217
  installSharedHandlers(existingConn);
124
218
  setupResponseHandlers(existingConn);
125
- if (reqFinished) sendRequest();
219
+ ready(existingConn);
126
220
  } else {
127
221
  resolveAndConnect(hostname, port);
128
222
  }
@@ -147,20 +241,122 @@ function request(options, callback) {
147
241
 
148
242
 
149
243
  function startConnection(remoteIp, remotePort) {
244
+ if (cancelled) return;
245
+ var sessionNow = options.session || null;
246
+ earlyWanted = options.earlyData === true && !!sessionNow &&
247
+ (method === 'GET' || method === 'HEAD' || method === 'OPTIONS');
150
248
  udpSocket = createQuicClientSocket({
151
249
  remoteIp: remoteIp,
152
250
  remotePort: remotePort,
153
- hostname: hostname,
251
+ // SNI and the name the certificate is checked for: tls.connect's
252
+ // servername where given.
253
+ hostname: options.servername || hostname,
154
254
  rejectUnauthorized: rejectUnauthorized,
155
255
  ca: ca,
256
+ // A client certificate, for a server that asks (https.request's names).
257
+ key: options.key || null, cert: options.cert || null, passphrase: options.passphrase,
258
+ // A saved session to resume with, and the request's 'session' event for
259
+ // each the server sends — as https.request does (its socket's
260
+ // 'session'). Only the request that opened the connection has one: a
261
+ // request on a connection already open sees no handshake.
262
+ session: sessionNow,
263
+ // The server's stapled OCSP response, as the request's 'OCSPResponse'
264
+ // (tls.connect's requestOCSP) — on the request that opened the connection.
265
+ requestOCSP: options.requestOCSP === true,
266
+ // Encrypted Client Hello: the server's ECHConfigList, or GREASE ECH.
267
+ ech: options.ech || null,
268
+ echGrease: options.echGrease === true,
269
+ // The key-exchange groups to offer (tls.connect's ecdhCurve).
270
+ ecdhCurve: options.ecdhCurve,
271
+ // The signature schemes to offer (tls.connect's sigalgs).
272
+ sigalgs: options.sigalgs,
273
+ quicVersions: options.quicVersions,
274
+ // This client's connection options (quic_connection's CONNECTION_OPTIONS):
275
+ // Startup's cap on what it sends (an upload's), path MTU discovery, the
276
+ // largest UDP payload, the RTT assumed before a sample, the reordering
277
+ // a packet is allowed, the initial window.
278
+ startupCap: options.startupCap,
279
+ pmtuDiscovery: options.pmtuDiscovery,
280
+ maxUdpPayloadSize: options.maxUdpPayloadSize,
281
+ initialRtt: options.initialRtt,
282
+ reorderWindow: options.reorderWindow,
283
+ initialCongestionWindow: options.initialCongestionWindow,
284
+ // A NEW_TOKEN token for the Initial (the agent keeps them), and the
285
+ // request's 'newToken' event for the one the server gives for next time.
286
+ token: options.token || null,
287
+ earlyData: earlyWanted,
288
+ onEarly: function (q, s) {
289
+ if (cancelled) return;
290
+ earlyTried = true;
291
+ earlyResult = 'accepted'; // until the server says otherwise
292
+ setUp(q, s);
293
+ },
294
+ onSocket: function (q) {
295
+ q.on('session', function (buf) { emit(reqListeners, 'session', buf); });
296
+ q.on('new_token', function (buf) { emit(reqListeners, 'newToken', buf); });
297
+ q.on('OCSPResponse', function (buf) { emit(reqListeners, 'OCSPResponse', buf); });
298
+ },
156
299
 
157
300
  onError: function (err) {
301
+ if (cancelled) return;
158
302
  emit(reqListeners, 'error', err);
159
303
  },
160
304
 
161
305
  onConnect: function (q, s) {
306
+ // Sent early: taken, nothing more to do — or refused, and the
307
+ // connection has forgotten it: HTTP/3 starts over on it, and the
308
+ // request goes again, in 1-RTT.
309
+ if (earlyTried) {
310
+ handshaken = true;
311
+ if (cancelled) { closeConnection(); return; }
312
+ if (after425) { var a425 = after425; after425 = null; a425(); return; }
313
+ if (q.earlyDataStatus !== 'rejected') return;
314
+ earlyResult = 'rejected';
315
+ redoAfterEarly();
316
+ return;
317
+ }
318
+ setUp(q, s);
319
+ }
320
+ // onClose: the helper closes the UDP socket; nothing extra needed here.
321
+ });
322
+ }
323
+
324
+ // What was sent early, refused: this request's stream and the HTTP/3
325
+ // layer it went through are dropped, and both made again.
326
+ function redoAfterEarly() {
327
+ var old = respConn;
328
+ if (old && old._streamHandlers) delete old._streamHandlers[streamId];
329
+ if (h3 && h3.detach) h3.detach();
330
+ requestSent = false; readyDone = false; streaming = false;
331
+ if (earlyBody) { reqBody = earlyBody.body; reqTrailers = earlyBody.trailers; reqBodyBytes = 0; }
332
+ setUp(quic, udpSocket);
333
+ }
334
+
335
+ // The early request answered 425: on a new stream of the same connection,
336
+ // once the handshake is done (now, or at onConnect).
337
+ function againAfter425() {
338
+ function go() {
339
+ if (cancelled || finished) return;
340
+ var conn = respConn;
341
+ requestSent = false; readyDone = false; streaming = false;
342
+ if (earlyBody) { reqBody = earlyBody.body; reqTrailers = earlyBody.trailers; }
343
+ earlyBody = null;
344
+ retry425 = false;
345
+ streamId = allocateStreamId(conn);
346
+ setupResponseHandlers(conn);
347
+ ready(conn);
348
+ }
349
+ if (handshaken) go(); else after425 = go;
350
+ }
351
+
352
+ function setUp(q, s) {
353
+ (function () {
162
354
  quic = q;
163
355
  udpSocket = s;
356
+ // Cancelled while connecting: the connection was for this request
357
+ // (an owned one, or a pool lead whose followers have been released),
358
+ // and nobody else holds it.
359
+ if (cancelled) { closeConnection(); return; }
164
360
  if (DEBUG) console.log('[client] QUIC connected');
165
361
 
166
362
  h3 = new H3Connection({ quicConnection: quic, isServer: false });
@@ -178,12 +374,8 @@ function request(options, callback) {
178
374
  clientReq._connection = conn;
179
375
 
180
376
  setupResponseHandlers(conn);
181
-
182
- // Send request if body is ready
183
- if (reqFinished) sendRequest();
184
- }
185
- // onClose: the helper closes the UDP socket; nothing extra needed here.
186
- });
377
+ ready(conn);
378
+ })();
187
379
  }
188
380
 
189
381
 
@@ -219,12 +411,80 @@ function request(options, callback) {
219
411
  var h = conn._streamHandlers[sid];
220
412
  if (h) h.onBody(data);
221
413
  });
414
+ h3c.on('http_trailers', function (sid, trailers) {
415
+ var h = conn._streamHandlers[sid];
416
+ if (h && h.onTrailers) h.onTrailers(trailers);
417
+ });
222
418
  h3c.on('http_end', function (sid) {
223
419
  var h = conn._streamHandlers[sid];
224
420
  if (!h) return;
225
421
  delete conn._streamHandlers[sid]; // free per-stream state before dispatch
226
422
  h.onEnd();
227
423
  });
424
+ // The response stream was ended over what the server sent on it (an
425
+ // oversized HEADERS, a stream with no response): the request fails,
426
+ // rather than waiting for an end that will not come.
427
+ h3c.on('stream_error', function (sid, code, reason) {
428
+ var h = conn._streamHandlers[sid];
429
+ if (!h) return;
430
+ delete conn._streamHandlers[sid];
431
+ if (h.onError) h.onError(code, reason);
432
+ });
433
+ // The server reset the request stream (RESET_STREAM): the response, or
434
+ // what is left of it, will not come. Nothing listened, and a response
435
+ // cut off this way never ended nor failed — it hung.
436
+ h3c.on('stream_reset', function (sid, code) {
437
+ var h = conn._streamHandlers[sid];
438
+ if (!h) return;
439
+ delete conn._streamHandlers[sid];
440
+ if (h.onReset) h.onReset(code);
441
+ });
442
+ // The server will not read the rest of the request (STOP_SENDING; QUIC
443
+ // has reset our sending part in answer). Not heard before: a request
444
+ // the server refused this way went on "writing" into nothing, and was
445
+ // never told.
446
+ h3c.on('stop_sending', function (sid, code) {
447
+ var h = conn._streamHandlers[sid];
448
+ if (h && h.onStopSending) h.onStopSending(code);
449
+ });
450
+
451
+ // GOAWAY (RFC 9114 §5.2): requests on streams from its id on were not,
452
+ // and will not be, processed — retryable, as HTTP/2's (h2_client). And
453
+ // the connection's end ends every request still on it. Neither was
454
+ // heard: a request sent as the server's GOAWAY was on its way — the
455
+ // server rejected it and closed, its RESET_STREAM behind the close —
456
+ // waited for a response forever.
457
+ function failAll(fromId, make) {
458
+ for (var sid in conn._streamHandlers) {
459
+ if (Number(sid) < fromId) continue;
460
+ var h = conn._streamHandlers[sid];
461
+ delete conn._streamHandlers[sid];
462
+ if (h.onGone) h.onGone(make(Number(sid)));
463
+ }
464
+ }
465
+ conn._goawayId = Infinity;
466
+ h3c.on('goaway', function (id) {
467
+ var goawayId = conn._goawayId = Math.min(conn._goawayId, Number(id));
468
+ // For a pool: no new requests here (as an HTTP/2 session's goingAway).
469
+ // The GOAWAY can come before the response that puts the connection in
470
+ // the pool, so the pool asks rather than waits for the event.
471
+ conn.goingAway = true;
472
+ failAll(goawayId, function () {
473
+ var e = new Error('HTTP/3 request not processed before GOAWAY');
474
+ e.code = 'ERR_HTTP3_GOAWAY_UNPROCESSED';
475
+ e.retryable = true;
476
+ return e;
477
+ });
478
+ });
479
+ conn.quic.on('close', function () {
480
+ failAll(0, function (sid) {
481
+ var e = new Error('HTTP/3 connection closed before the response ended');
482
+ e.code = 'ERR_HTTP3_CONNECTION_CLOSED';
483
+ // Past a GOAWAY's id the server said it did not process it.
484
+ e.retryable = sid >= conn._goawayId;
485
+ return e;
486
+ });
487
+ });
228
488
  }
229
489
 
230
490
 
@@ -234,11 +494,37 @@ function request(options, callback) {
234
494
  * routing is now by map key, so no per-listener streamId check is needed.
235
495
  */
236
496
  function setupResponseHandlers(conn) {
497
+ respConn = conn;
498
+ // A connection the server is done with — GOAWAY'd below this stream, or
499
+ // gone — takes no request: it fails now, as it would have on the wire.
500
+ // On the next tick: the caller has not attached its listeners yet.
501
+ var state = conn.quic ? conn.quic.state : 'closed';
502
+ if (streamId >= conn._goawayId || state === 'closed' || state === 'closing' || state === 'draining') {
503
+ var gone = new Error(streamId >= conn._goawayId ? 'HTTP/3 request not processed before GOAWAY' : 'HTTP/3 connection closed before the response ended');
504
+ gone.code = streamId >= conn._goawayId ? 'ERR_HTTP3_GOAWAY_UNPROCESSED' : 'ERR_HTTP3_CONNECTION_CLOSED';
505
+ gone.retryable = true;
506
+ process.nextTick(function () { fail(gone); });
507
+ return;
508
+ }
237
509
  conn._streamHandlers[streamId] = {
238
510
  onHeaders: function (hdrs) {
511
+ // 1xx (103 Early Hints): informational — the response follows on the
512
+ // same stream. node:http's 'information', not the response: taken as
513
+ // it, the request got status 103 and the real head was lost.
514
+ var st1 = parseInt(hdrs[':status'], 10);
515
+ if (st1 >= 100 && st1 < 200) {
516
+ var ih = {}, raw = [];
517
+ for (var hn in hdrs) { if (hn[0] !== ':') { ih[hn] = hdrs[hn]; raw.push(hn, String(hdrs[hn])); } }
518
+ emit(reqListeners, 'information', { statusCode: st1, statusMessage: '', httpVersion: '3.0', httpVersionMajor: 3, httpVersionMinor: 0, headers: ih, rawHeaders: raw });
519
+ return;
520
+ }
521
+ // 425 Too Early to what went in 0-RTT (RFC 8470 §5.2): not the
522
+ // response — the request goes again once the handshake is done.
523
+ if (st1 === 425 && earlyBody && !retry425) { retry425 = true; earlyResult = 'rejected'; return; }
239
524
  clientRes.headers = hdrs;
240
525
  clientRes.statusCode = parseInt(hdrs[':status']) || 0;
241
526
  clientRes._headersReceived = true;
527
+ clientRes.earlyData = earlyTried ? earlyResult : null;
242
528
 
243
529
  // Fire callback with response (once)
244
530
  if (typeof callback === 'function') {
@@ -249,62 +535,195 @@ function request(options, callback) {
249
535
  },
250
536
 
251
537
  onBody: function (data) {
538
+ if (retry425) return;
252
539
  emit(clientRes, 'data', data);
253
540
  },
254
541
 
542
+ // The response's trailers: res.trailers / res.rawTrailers, as node:http
543
+ // has them by 'end' (gRPC's grpc-status).
544
+ onTrailers: function (trailers) {
545
+ clientRes.trailers = trailers;
546
+ clientRes.rawTrailers = [];
547
+ for (var tn in trailers) clientRes.rawTrailers.push(tn, String(trailers[tn]));
548
+ },
549
+
550
+ onError: function (code, reason) {
551
+ var err = new Error('HTTP/3 stream error: ' + reason);
552
+ err.code = 'ERR_HTTP3_STREAM_ERROR';
553
+ err.errorCode = code;
554
+ fail(err);
555
+ },
556
+
557
+ onReset: function (code) {
558
+ var name = H3_ERR_NAME[code] || ('0x' + Number(code).toString(16));
559
+ var err = new Error('HTTP/3 stream reset by the server: ' + name);
560
+ err.code = 'ERR_HTTP3_STREAM_RESET';
561
+ err.errorCode = code;
562
+ // H3_REQUEST_REJECTED: the server did not process it (RFC 9114 §4.1.1)
563
+ // — safe to retry, as HTTP/2's REFUSED_STREAM. Any other code is the
564
+ // server's answer, not a failure to reach it: not sent again over TCP.
565
+ err.retryable = (code === 0x10b);
566
+ if (!err.retryable) err.requestError = true;
567
+ fail(err);
568
+ },
569
+
570
+ // H3_NO_ERROR: the server answers without the rest of the body (RFC
571
+ // 9114 §4.1.2) — the response comes, and is not to be discarded. Any
572
+ // other code before a response: the request is refused or cancelled,
573
+ // as by a reset (H3_REQUEST_REJECTED retryable).
574
+ onStopSending: function (code) {
575
+ if (code === 0x100 || clientRes._headersReceived) return;
576
+ var h = conn._streamHandlers[streamId];
577
+ if (h) delete conn._streamHandlers[streamId];
578
+ var name = H3_ERR_NAME[code] || ('0x' + Number(code).toString(16));
579
+ var err = new Error('HTTP/3 request stopped by the server: ' + name);
580
+ err.code = 'ERR_HTTP3_STREAM_RESET';
581
+ err.errorCode = code;
582
+ err.retryable = (code === 0x10b);
583
+ if (!err.retryable) err.requestError = true;
584
+ fail(err);
585
+ },
586
+
587
+ // The connection gone (or past GOAWAY) under it. Sent, not processed
588
+ // per GOAWAY, and not idempotent: the server may have acted on it — not
589
+ // sent again over TCP (RFC 9110 §9.2.2).
590
+ onGone: function (err) {
591
+ if (!err.retryable && requestSent && !({ GET: 1, HEAD: 1, OPTIONS: 1, TRACE: 1, PUT: 1, DELETE: 1 })[method]) err.requestError = true;
592
+ fail(err);
593
+ },
594
+
255
595
  onEnd: function () {
596
+ if (retry425 && !clientRes._headersReceived) { againAfter425(); return; }
597
+ finished = true;
598
+ clientRes._releaseBody(); // all of it has arrived: nothing more to wait for
256
599
  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
- }
600
+ closeIfOwned();
265
601
  }
266
602
  };
267
603
  }
268
604
 
605
+ // The request failed. Before the response began, the request reports it
606
+ // ('error' — a fallback may still be tried); after, the response does —
607
+ // the request's error was reported to a caller that had moved on to the
608
+ // response, and the response hung.
609
+ function fail(err) {
610
+ if (finished) return;
611
+ finished = true;
612
+ clientRes._releaseBody();
613
+ if (clientRes._headersReceived) emit(clientRes, 'error', err);
614
+ else emit(reqListeners, 'error', err);
615
+ closeIfOwned();
616
+ }
269
617
 
270
- function sendRequest() {
271
- if (!h3 || !connected) return;
618
+ // Auto-close only if we own the connection (see `autoClose` above) — after
619
+ // the response ends, or fails.
620
+ // An answer to early data can come before the handshake is confirmed,
621
+ // and with it the server's session ticket: the close waits for that, or
622
+ // the next visit would have no ticket to resume (and send early) with.
623
+ function closeIfOwned() {
624
+ if (!autoClose) return;
625
+ if (earlyTried && quic && quic.whenConfirmed) quic.whenConfirmed(function () { setTimeout(closeConnection, 100); });
626
+ else setTimeout(closeConnection, 100);
627
+ }
272
628
 
273
- if (DEBUG) console.log('[client] sendRequest method=' + method + ' path=' + path + ' stream=' + streamId);
629
+ // RFC 9114 §5.2: closed with an HTTP/3 code in the application space.
630
+ // H3_NO_ERROR. The UDP socket closes when the connection does — its
631
+ // closing period over (quic_socket, on 'close') — not here: closed at once,
632
+ // the CONNECTION_CLOSE just queued on it was cancelled with it, and the
633
+ // server never learned the connection was over. A request with agent:
634
+ // false left the server's connection (and a response it was still
635
+ // writing) up until its idle timeout.
636
+ function closeConnection() {
637
+ if (quic) quic.close(0x0100, 'done', true);
638
+ else if (udpSocket) { try { udpSocket.close(); } catch (e) {} }
639
+ }
274
640
 
275
- // MUST send control streams (SETTINGS) before any request
276
- h3.sendControlStreams();
277
641
 
278
- // Build H3 request headers
642
+ // The stream exists. options.onReady (the unified client) may still turn
643
+ // the request down — another protocol was ready first and carries it:
644
+ // nothing goes out, and 'superseded' says so (the connection stays, for
645
+ // the pool). Otherwise: sent whole if the body is complete, streamed if
646
+ // some of it was written, or on the first write or end().
647
+ function ready(conn) {
648
+ if (typeof options.onReady === 'function' && !options.onReady()) {
649
+ finished = true;
650
+ if (conn && conn._streamHandlers && streamId !== null) delete conn._streamHandlers[streamId];
651
+ // Its stream ID, taken and never used: cancelled, as by destroy()
652
+ // (an unused ID below a later request's is opened by that request).
653
+ if (!autoClose && conn && conn.h3 && streamId !== null) { try { conn.h3.abortStream(streamId, 0x10c); } catch (e) {} }
654
+ // Next tick: on a pooled connection this runs inside request(), before
655
+ // the caller has its listeners on.
656
+ process.nextTick(function () { emit(reqListeners, 'superseded'); });
657
+ if (autoClose && quic) closeConnection();
658
+ return;
659
+ }
660
+ readyDone = true;
661
+ if (reqFinished) sendRequest();
662
+ else if (reqBody.length) startStreaming();
663
+ }
664
+
665
+ function requestHeaders() {
279
666
  var h3Headers = {
280
667
  ':method': method,
281
668
  ':scheme': 'https',
282
669
  ':authority': hostname + (port !== 443 ? ':' + port : ''),
283
670
  ':path': path
284
671
  };
285
-
286
- // Add user headers
287
- for (var name in headers) {
288
- h3Headers[name.toLowerCase()] = String(headers[name]);
289
- }
290
-
291
- // Default headers
672
+ for (var name in headers) h3Headers[name.toLowerCase()] = String(headers[name]);
292
673
  if (!h3Headers['user-agent']) h3Headers['user-agent'] = 'quico/1.0';
293
674
  if (!h3Headers['accept']) h3Headers['accept'] = '*/*';
294
675
  if (!h3Headers['accept-encoding']) h3Headers['accept-encoding'] = 'identity';
676
+ return h3Headers;
677
+ }
295
678
 
296
- // Send body if any
297
- if (reqBody.length > 0) {
298
- // Send headers without FIN, then body chunks
299
- h3.sendHeaders(streamId, h3Headers, false);
300
- for (var i = 0; i < reqBody.length; i++) {
301
- var isLast = (i === reqBody.length - 1);
302
- h3.sendBody(streamId, reqBody[i], isLast);
303
- }
304
- } else {
305
- // No body — send headers with FIN directly
306
- h3.sendHeaders(streamId, h3Headers, true);
307
- }
679
+ // The request whole: ended before it could go — headers, the body, the
680
+ // trailers; FIN on the last of them.
681
+ function sendRequest() {
682
+ if (!h3 || !connected || cancelled || finished || requestSent) return;
683
+ requestSent = true;
684
+ if (DEBUG) console.log('[client] sendRequest method=' + method + ' path=' + path + ' stream=' + streamId);
685
+ h3.sendControlStreams(); // SETTINGS before any request
686
+ var body = reqBody; reqBody = []; reqBodyBytes = 0;
687
+ // Early: kept, to go again if the server refuses it.
688
+ if (earlyTried && !handshaken) earlyBody = { body: body.slice(), trailers: reqTrailers };
689
+ if (body.length === 0 && !reqTrailers) { h3.sendHeaders(streamId, requestHeaders(), true); return; }
690
+ h3.sendHeaders(streamId, requestHeaders(), false);
691
+ for (var i = 0; i < body.length; i++) h3.sendBody(streamId, body[i], i === body.length - 1 && !reqTrailers);
692
+ if (reqTrailers) h3.sendHeaders(streamId, reqTrailers, true);
693
+ }
694
+
695
+ // The body streamed: HEADERS now, without FIN, then what was held.
696
+ function startStreaming() {
697
+ if (!h3 || cancelled || finished || requestSent) return;
698
+ requestSent = true;
699
+ streaming = true;
700
+ if (DEBUG) console.log('[client] streaming request method=' + method + ' path=' + path + ' stream=' + streamId);
701
+ h3.sendControlStreams();
702
+ h3.sendHeaders(streamId, requestHeaders(), false);
703
+ var held = reqBody; reqBody = []; reqBodyBytes = 0;
704
+ var more = true;
705
+ for (var i = 0; i < held.length; i++) more = streamOut(held[i]);
706
+ if (more && needDrain) { needDrain = false; emit(reqListeners, 'drain'); }
707
+ }
708
+
709
+ // One piece of a streamed body. Returns whether more may be written now:
710
+ // false while more than BODY_HIGH_WATER of the stream is not yet sent,
711
+ // and 'drain' when it is (the stream's send side, whenStreamSent).
712
+ function streamOut(chunk) {
713
+ h3.sendBody(streamId, chunk, false);
714
+ if (drainWaiting) { needDrain = true; return false; }
715
+ var later = false, over = true;
716
+ quic.whenStreamSent(streamId, BODY_HIGH_WATER, function () {
717
+ over = false;
718
+ if (!later) return;
719
+ drainWaiting = false;
720
+ if (needDrain) { needDrain = false; emit(reqListeners, 'drain'); }
721
+ });
722
+ later = true;
723
+ if (!over) return true;
724
+ drainWaiting = true;
725
+ needDrain = true;
726
+ return false;
308
727
  }
309
728
 
310
729