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/README.md +1768 -155
- package/index.js +55 -36
- package/package.json +3 -3
- package/src/agent.js +306 -36
- package/src/client.js +447 -120
- package/src/client_profile.js +266 -0
- package/src/connect_udp.js +578 -0
- package/src/connect_udp_client.js +770 -0
- package/src/crypto.js +240 -140
- package/src/h1.js +953 -0
- package/src/h1_client.js +731 -0
- package/src/h1_server.js +624 -0
- package/src/h2.js +1325 -0
- package/src/h2_client.js +675 -0
- package/src/h2_fingerprint.js +403 -0
- package/src/h2_frames.js +582 -0
- package/src/h2_server.js +862 -0
- package/src/h3.js +1966 -469
- package/src/h3_client.js +467 -48
- package/src/h3_server.js +1349 -94
- package/src/hpack.js +455 -0
- package/src/hpack_primitives.js +520 -0
- package/src/http_common.js +901 -0
- package/src/ja4h.js +256 -0
- package/src/qpack.js +555 -0
- package/src/quic_connection.js +6517 -657
- package/src/quic_server.js +768 -73
- package/src/quic_socket.js +134 -9
- package/src/quic_version.js +143 -0
- package/src/server.js +843 -115
- package/src/server_stats.js +93 -0
- package/src/streams.js +361 -35
- package/src/tcp_listener.js +557 -0
- package/src/tls_bridge.js +474 -73
- package/src/tls_options.js +108 -0
- package/src/transport.js +455 -46
- package/src/utils.js +94 -12
- package/src/webtransport.js +433 -436
- package/src/ws.js +624 -0
- package/src/ws_client.js +641 -0
- package/src/ws_server.js +833 -0
- package/src/wt_stream.js +618 -0
package/src/h1_client.js
ADDED
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* quico: HTTP/3 and QUIC implementation for Node.js
|
|
3
|
+
* Copyright 2025 colocohen
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at:
|
|
8
|
+
*
|
|
9
|
+
* https://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* This file is part of the open-source project hosted at:
|
|
12
|
+
* https://github.com/colocohen/quico
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import net from 'node:net';
|
|
16
|
+
import { EventEmitter } from 'node:events';
|
|
17
|
+
|
|
18
|
+
import { H1Parser, buildRequestHead, encodeChunk, encodeLastChunk } from './h1.js';
|
|
19
|
+
import { IncomingMessage } from './streams.js';
|
|
20
|
+
import { tlsIdentity } from './agent.js';
|
|
21
|
+
import { validateHeaderName, validateHeaderValue, validateRequestTarget, trailerFields, formatPriority } from './http_common.js';
|
|
22
|
+
|
|
23
|
+
// What the body takes before write() says wait, while the socket is not yet
|
|
24
|
+
// the request's.
|
|
25
|
+
var BODY_HIGH_WATER = 64 * 1024;
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
// ============================================================
|
|
29
|
+
// HTTP/1.1 client.
|
|
30
|
+
//
|
|
31
|
+
// request(options, callback) -> an EventEmitter that emits 'response' with an
|
|
32
|
+
// IncomingMessage, mirroring node:http.request. The unified client in
|
|
33
|
+
// client.js calls this where it currently calls nodeHttps.request.
|
|
34
|
+
//
|
|
35
|
+
// The connection pool here is deliberately NOT the H2/H3 pool. Those
|
|
36
|
+
// multiplex: one connection serves every concurrent request, so the pool maps
|
|
37
|
+
// origin -> connection. HTTP/1.1 does not. A socket carries one request at a
|
|
38
|
+
// time, so the pool maps origin -> a LIST of idle sockets, and concurrency
|
|
39
|
+
// means more sockets. Forcing both shapes into one abstraction would distort
|
|
40
|
+
// each; agent.js keeps them as separate maps for the same reason.
|
|
41
|
+
// ============================================================
|
|
42
|
+
|
|
43
|
+
// The module's own pool (requests made with no agent): unlimited, as it has
|
|
44
|
+
// always behaved — a limit nobody chose would queue requests without warning.
|
|
45
|
+
// An agent passes its own maxSockets.
|
|
46
|
+
var DEFAULT_MAX_SOCKETS = Infinity;
|
|
47
|
+
var DEFAULT_IDLE_TIMEOUT = 5000; // node:https.globalAgent's free-socket timeout
|
|
48
|
+
var DEFAULT_MAX_FREE_SOCKETS = 256; // node:http.Agent's default
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
// The pool follows node:http.Agent's rules, option for option:
|
|
52
|
+
//
|
|
53
|
+
// maxSockets sockets in use per origin at once. A request beyond it
|
|
54
|
+
// waits for one to be released — it is not refused, and not
|
|
55
|
+
// sent on a socket of its own. (This was not enforced at
|
|
56
|
+
// all: canOpen() existed, and nothing called it.)
|
|
57
|
+
// keepAlive keep a socket for the next request once its response is
|
|
58
|
+
// done. false: close it — unless a request is already
|
|
59
|
+
// waiting for this origin, which takes it, as Node does.
|
|
60
|
+
// maxFreeSockets idle sockets kept per origin; beyond, closed.
|
|
61
|
+
// idleTimeout how long an idle socket is kept (Node: the agent's
|
|
62
|
+
// `timeout`, which it applies to free sockets).
|
|
63
|
+
// keepAliveMsecs TCP keep-alive on kept sockets: the initial delay before
|
|
64
|
+
// the first probe (Node's meaning; it was used here as the
|
|
65
|
+
// idle timeout).
|
|
66
|
+
function H1Pool(options) {
|
|
67
|
+
options = options || {};
|
|
68
|
+
var idle = {}; // "host:port" -> [ { socket, parser, timer } ]
|
|
69
|
+
var active = {}; // "host:port" -> count
|
|
70
|
+
var waiting = {}; // "host:port" -> [ fn ] — requests waiting for a socket
|
|
71
|
+
var maxSockets = options.maxSockets || DEFAULT_MAX_SOCKETS;
|
|
72
|
+
var idleTimeout = options.idleTimeout != null ? options.idleTimeout : DEFAULT_IDLE_TIMEOUT;
|
|
73
|
+
var keepAlive = options.keepAlive !== false;
|
|
74
|
+
var maxFreeSockets = options.maxFreeSockets != null ? options.maxFreeSockets : DEFAULT_MAX_FREE_SOCKETS;
|
|
75
|
+
var keepAliveMsecs = options.keepAliveMsecs != null ? options.keepAliveMsecs : 1000;
|
|
76
|
+
|
|
77
|
+
function key(host, port) { return host + ':' + port; }
|
|
78
|
+
|
|
79
|
+
// A slot was freed (or a socket parked): the next waiting request runs. A
|
|
80
|
+
// waiter answers whether it took the slot — one cancelled while it waited
|
|
81
|
+
// does not, and the next one is tried.
|
|
82
|
+
function wake(k) {
|
|
83
|
+
var list = waiting[k];
|
|
84
|
+
while (list && list.length && (active[k] || 0) < maxSockets) {
|
|
85
|
+
var fn = list.shift();
|
|
86
|
+
if (fn() !== false) break;
|
|
87
|
+
}
|
|
88
|
+
if (list && !list.length) delete waiting[k];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
/** An idle socket for this origin, or null. */
|
|
93
|
+
take: function (host, port) {
|
|
94
|
+
var k = key(host, port);
|
|
95
|
+
var list = idle[k];
|
|
96
|
+
while (list && list.length) {
|
|
97
|
+
var entry = list.pop();
|
|
98
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
99
|
+
// Between being parked and being taken the peer may have closed. A
|
|
100
|
+
// socket that looks writable but is half-closed produces the classic
|
|
101
|
+
// intermittent ECONNRESET on the first request after an idle period.
|
|
102
|
+
if (!entry.socket.destroyed && entry.socket.writable) {
|
|
103
|
+
entry.socket.removeAllListeners('data');
|
|
104
|
+
entry.socket.removeAllListeners('error');
|
|
105
|
+
entry.socket.removeAllListeners('close');
|
|
106
|
+
entry.socket.removeAllListeners('end');
|
|
107
|
+
return entry.socket;
|
|
108
|
+
}
|
|
109
|
+
try { entry.socket.destroy(); } catch (e) {}
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
/** Park a reusable socket — or close it, when the pool keeps none. */
|
|
115
|
+
put: function (host, port, socket) {
|
|
116
|
+
if (socket.destroyed || !socket.writable) return;
|
|
117
|
+
var k = key(host, port);
|
|
118
|
+
var someoneWaiting = !!(waiting[k] && waiting[k].length);
|
|
119
|
+
if ((!keepAlive && !someoneWaiting) ||
|
|
120
|
+
(idle[k] && idle[k].length >= maxFreeSockets)) {
|
|
121
|
+
try { socket.destroy(); } catch (e) {}
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (!idle[k]) idle[k] = [];
|
|
125
|
+
if (keepAlive && typeof socket.setKeepAlive === 'function') {
|
|
126
|
+
try { socket.setKeepAlive(true, keepAliveMsecs); } catch (e) {}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
var entry = { socket: socket, timer: null };
|
|
130
|
+
// An idle socket must be watched: if the peer closes it while parked and
|
|
131
|
+
// nobody is listening, the 'error' surfaces as an uncaught exception.
|
|
132
|
+
socket.removeAllListeners('data');
|
|
133
|
+
socket.on('error', function () { drop(k, entry); });
|
|
134
|
+
socket.on('close', function () { drop(k, entry); });
|
|
135
|
+
socket.on('end', function () { drop(k, entry); });
|
|
136
|
+
|
|
137
|
+
if (idleTimeout > 0) {
|
|
138
|
+
entry.timer = setTimeout(function () { drop(k, entry); }, idleTimeout);
|
|
139
|
+
if (entry.timer.unref) entry.timer.unref();
|
|
140
|
+
}
|
|
141
|
+
idle[k].push(entry);
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
canOpen: function (host, port) {
|
|
145
|
+
return (active[key(host, port)] || 0) < maxSockets;
|
|
146
|
+
},
|
|
147
|
+
/** Wait for a slot: `fn` runs once one is free, and returns false if it
|
|
148
|
+
* no longer wants it. */
|
|
149
|
+
wait: function (host, port, fn) {
|
|
150
|
+
var k = key(host, port);
|
|
151
|
+
(waiting[k] = waiting[k] || []).push(fn);
|
|
152
|
+
},
|
|
153
|
+
acquire: function (host, port) {
|
|
154
|
+
var k = key(host, port);
|
|
155
|
+
active[k] = (active[k] || 0) + 1;
|
|
156
|
+
},
|
|
157
|
+
release: function (host, port) {
|
|
158
|
+
var k = key(host, port);
|
|
159
|
+
if (active[k]) { active[k]--; if (active[k] <= 0) delete active[k]; }
|
|
160
|
+
// On the next tick: the releasing request parks its socket right after
|
|
161
|
+
// releasing, and the waiter should find it there.
|
|
162
|
+
if (waiting[k]) process.nextTick(function () { wake(k); });
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
destroy: function () {
|
|
166
|
+
for (var k in idle) {
|
|
167
|
+
for (var i = 0; i < idle[k].length; i++) {
|
|
168
|
+
if (idle[k][i].timer) clearTimeout(idle[k][i].timer);
|
|
169
|
+
try { idle[k][i].socket.destroy(); } catch (e) {}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
idle = {}; active = {}; waiting = {};
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
function drop(k, entry) {
|
|
177
|
+
if (entry.timer) { clearTimeout(entry.timer); entry.timer = null; }
|
|
178
|
+
var list = idle[k];
|
|
179
|
+
if (list) {
|
|
180
|
+
var i = list.indexOf(entry);
|
|
181
|
+
if (i !== -1) list.splice(i, 1);
|
|
182
|
+
}
|
|
183
|
+
try { entry.socket.destroy(); } catch (e) {}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
var globalPool = new H1Pool();
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* request(options, callback)
|
|
193
|
+
*
|
|
194
|
+
* options: hostname/host, port, method, path, headers, body-writing via the
|
|
195
|
+
* returned object's write()/end(), tls (lemon-tls module), pool,
|
|
196
|
+
* timeout, socket (use this Duplex instead of connecting).
|
|
197
|
+
*/
|
|
198
|
+
function request(options, callback) {
|
|
199
|
+
options = options || {};
|
|
200
|
+
|
|
201
|
+
var hostname = options.hostname || options.host || 'localhost';
|
|
202
|
+
var port = parseInt(options.port, 10) || (options.tls ? 443 : 80);
|
|
203
|
+
// As node:http, thrown from request() itself (see validateRequestTarget).
|
|
204
|
+
validateRequestTarget(options.path, options.method);
|
|
205
|
+
var method = String(options.method || 'GET').toUpperCase();
|
|
206
|
+
var path = options.path || '/';
|
|
207
|
+
var pool = options.pool || globalPool;
|
|
208
|
+
// The pool's name for this origin: the host, and over TLS what decides the
|
|
209
|
+
// connection's identity (agent.js tlsIdentity) — one opened without
|
|
210
|
+
// verification, or with another client certificate, carries no other
|
|
211
|
+
// request. The connection itself goes to `hostname`.
|
|
212
|
+
var poolHost = options.tls ? hostname + '#' + tlsIdentity(options) : hostname;
|
|
213
|
+
|
|
214
|
+
var ev = new EventEmitter();
|
|
215
|
+
var bodyChunks = [];
|
|
216
|
+
var bodyBuffered = 0;
|
|
217
|
+
var ended = false;
|
|
218
|
+
var sent = false;
|
|
219
|
+
var readyDone = false; // the socket is this request's to send on (onSocketReady)
|
|
220
|
+
var streaming = false; // the head went out before the body ended: the body follows as written
|
|
221
|
+
var framing = null; // streaming: 'chunked', or 'length' (the caller's Content-Length)
|
|
222
|
+
var declaredLength = 0, sentLength = 0;
|
|
223
|
+
var needDrain = false;
|
|
224
|
+
var reqTrailers = null;
|
|
225
|
+
var socket = null;
|
|
226
|
+
var parser = null;
|
|
227
|
+
var reused = false;
|
|
228
|
+
var settled = false;
|
|
229
|
+
var sentEarly = false; // the request went in TLS early data (see connect)
|
|
230
|
+
ev.earlyData = null; // 'accepted', 'rejected' or null (not sent early)
|
|
231
|
+
var timeoutTimer = null;
|
|
232
|
+
|
|
233
|
+
// Headers are collected case-insensitively but sent with the caller's
|
|
234
|
+
// casing: some servers (and more middleboxes) are sensitive to it, and there
|
|
235
|
+
// is no reason to normalise what the caller chose.
|
|
236
|
+
var headers = {};
|
|
237
|
+
var headerNames = {};
|
|
238
|
+
function setHeader(name, value) {
|
|
239
|
+
validateHeaderName(name);
|
|
240
|
+
validateHeaderValue(name, value);
|
|
241
|
+
var lower = String(name).toLowerCase();
|
|
242
|
+
headers[lower] = value;
|
|
243
|
+
headerNames[lower] = name;
|
|
244
|
+
}
|
|
245
|
+
if (options.headers) {
|
|
246
|
+
for (var n in options.headers) setHeader(n, options.headers[n]);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
var headSent = false;
|
|
250
|
+
var IDEMPOTENT = { GET: 1, HEAD: 1, OPTIONS: 1, TRACE: 1, PUT: 1, DELETE: 1 };
|
|
251
|
+
|
|
252
|
+
function fail(err) {
|
|
253
|
+
if (settled) return;
|
|
254
|
+
settled = true;
|
|
255
|
+
cleanupTimer();
|
|
256
|
+
releaseSocket(true);
|
|
257
|
+
ev.emit('error', err);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function cleanupTimer() {
|
|
261
|
+
if (timeoutTimer) { clearTimeout(timeoutTimer); timeoutTimer = null; }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function releaseSocket(destroy) {
|
|
265
|
+
if (!socket) return;
|
|
266
|
+
var s = socket;
|
|
267
|
+
socket = null;
|
|
268
|
+
pool.release(poolHost, port);
|
|
269
|
+
if (destroy) { try { s.destroy(); } catch (e) {} }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
// ---- Connect ----
|
|
274
|
+
|
|
275
|
+
// Returns false when the request no longer wants a socket (it was
|
|
276
|
+
// cancelled while waiting for one) — the pool then offers the slot on.
|
|
277
|
+
function connect() {
|
|
278
|
+
if (settled) return false;
|
|
279
|
+
if (options.socket) { // caller supplied a Duplex
|
|
280
|
+
socket = options.socket;
|
|
281
|
+
pool.acquire(poolHost, port);
|
|
282
|
+
onSocketReady();
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// maxSockets: every socket for this origin is busy — wait for one.
|
|
287
|
+
if (pool.canOpen && !pool.canOpen(poolHost, port)) {
|
|
288
|
+
pool.wait(poolHost, port, connect);
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
var pooled = pool.take(poolHost, port);
|
|
293
|
+
if (pooled) {
|
|
294
|
+
socket = pooled;
|
|
295
|
+
reused = true;
|
|
296
|
+
pool.acquire(poolHost, port);
|
|
297
|
+
onSocketReady();
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
pool.acquire(poolHost, port);
|
|
302
|
+
|
|
303
|
+
if (options.tls) {
|
|
304
|
+
// lemon-tls. ALPN offers http/1.1 only — an h2 offer here would be a
|
|
305
|
+
// promise this client cannot keep.
|
|
306
|
+
var tlsOpts = {
|
|
307
|
+
host: hostname,
|
|
308
|
+
port: port,
|
|
309
|
+
servername: options.servername || hostname,
|
|
310
|
+
ALPNProtocols: ['http/1.1'],
|
|
311
|
+
rejectUnauthorized: options.rejectUnauthorized !== false,
|
|
312
|
+
// The server's certificate chain may come compressed (RFC 8879):
|
|
313
|
+
// brotli, zstd, zlib — as the HTTP/3 client and the browsers offer.
|
|
314
|
+
certificateCompression: true,
|
|
315
|
+
// The server's stapled OCSP response ('OCSPResponse').
|
|
316
|
+
requestOCSP: options.requestOCSP === true,
|
|
317
|
+
// Encrypted Client Hello: the server's ECHConfigList, or GREASE ECH.
|
|
318
|
+
echGrease: options.echGrease === true,
|
|
319
|
+
// TCP_NODELAY, as node:http's client sets it (its noDelay, default true).
|
|
320
|
+
// Without it Nagle held a small write — a request's HEADERS behind the
|
|
321
|
+
// preface, a WINDOW_UPDATE, a body after its head — until the last one
|
|
322
|
+
// was acknowledged, and the server's delayed ACK made that up to 40 ms:
|
|
323
|
+
// 3000 small HTTP/2 requests took 2.8 s instead of 0.5.
|
|
324
|
+
noDelay: options.noDelay !== false
|
|
325
|
+
};
|
|
326
|
+
if (options.ech) tlsOpts.ech = options.ech;
|
|
327
|
+
// The key-exchange groups to offer (tls.connect's ecdhCurve).
|
|
328
|
+
if (options.ecdhCurve != null) tlsOpts.ecdhCurve = options.ecdhCurve;
|
|
329
|
+
if (options.sigalgs != null) tlsOpts.sigalgs = options.sigalgs;
|
|
330
|
+
if (options.ca) tlsOpts.ca = options.ca;
|
|
331
|
+
// A client certificate, for a server that asks (tls.connect's names).
|
|
332
|
+
if (options.key && options.cert) { tlsOpts.key = options.key; tlsOpts.cert = options.cert; if (options.passphrase != null) tlsOpts.passphrase = options.passphrase; }
|
|
333
|
+
// A saved session to resume with (tls.connect's `session`); the
|
|
334
|
+
// socket's 'session' events, each one to resume with next time, on the
|
|
335
|
+
// request that opened it.
|
|
336
|
+
if (options.session) tlsOpts.session = options.session;
|
|
337
|
+
// 0-RTT (TLS early data): a GET, HEAD or OPTIONS with a session whose
|
|
338
|
+
// ticket allows it goes in the first flight — see h2_client.
|
|
339
|
+
var wantEarly = options.earlyData === true && !!options.session && (method === 'GET' || method === 'HEAD' || method === 'OPTIONS');
|
|
340
|
+
if (wantEarly) tlsOpts.earlyData = true;
|
|
341
|
+
socket = options.tls.connect(tlsOpts, function () { if (!sentEarly) onSocketReady(); });
|
|
342
|
+
if (wantEarly) {
|
|
343
|
+
var early = socket;
|
|
344
|
+
setTimeout(function () {
|
|
345
|
+
if (settled || socket !== early || early.destroyed || early.isEarlyData !== true) return;
|
|
346
|
+
sentEarly = true;
|
|
347
|
+
onSocketReady();
|
|
348
|
+
}, 0);
|
|
349
|
+
}
|
|
350
|
+
socket.on('session', function (s) { ev.emit('session', s); });
|
|
351
|
+
socket.on('OCSPResponse', function (r) { ev.emit('OCSPResponse', r); });
|
|
352
|
+
socket.on('error', fail);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
socket = net.connect({ host: hostname, port: port, noDelay: options.noDelay !== false }, onSocketReady);
|
|
357
|
+
socket.on('error', fail);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
function onSocketReady() {
|
|
362
|
+
if (settled) { releaseSocket(true); return; }
|
|
363
|
+
|
|
364
|
+
socket.removeAllListeners('data');
|
|
365
|
+
socket.on('error', fail);
|
|
366
|
+
|
|
367
|
+
parser = new H1Parser({ isServer: false });
|
|
368
|
+
// Response framing depends on the request method: a HEAD reply carries the
|
|
369
|
+
// Content-Length of a body it does not send, and a 2xx to CONNECT is a
|
|
370
|
+
// tunnel. Without this the parser would read the next response's head as
|
|
371
|
+
// this one's body.
|
|
372
|
+
parser.pushRequestMethod(method);
|
|
373
|
+
|
|
374
|
+
var res = null;
|
|
375
|
+
|
|
376
|
+
parser.on('headers', function (info) {
|
|
377
|
+
if (sentEarly && info.statusCode >= 200) {
|
|
378
|
+
ev.earlyData = socket && socket.earlyDataStatus === 'accepted' ? 'accepted' : 'rejected';
|
|
379
|
+
if (info.statusCode === 425) {
|
|
380
|
+
// RFC 8470: sent again after the handshake (the unified client,
|
|
381
|
+
// on this failure being retryable); this socket goes.
|
|
382
|
+
var tooEarly = new Error('425 Too Early: the request is sent again after the handshake');
|
|
383
|
+
tooEarly.code = 'ERR_HTTP_TOO_EARLY';
|
|
384
|
+
tooEarly.retryable = true;
|
|
385
|
+
fail(tooEarly); // settles first, then lets the socket go (its close is no news)
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
res = new IncomingMessage({
|
|
390
|
+
httpVersion: info.httpVersion,
|
|
391
|
+
statusCode: info.statusCode,
|
|
392
|
+
statusMessage: info.statusMessage,
|
|
393
|
+
headers: info.headers,
|
|
394
|
+
socket: socket
|
|
395
|
+
});
|
|
396
|
+
res.rawHeaders = info.rawHeaders;
|
|
397
|
+
res._keepAlive = info.keepAlive;
|
|
398
|
+
|
|
399
|
+
// 1xx other than 101 is informational: the real response follows on the
|
|
400
|
+
// same connection, so it must not be delivered as THE response.
|
|
401
|
+
if (info.statusCode >= 100 && info.statusCode < 200 && info.statusCode !== 101) {
|
|
402
|
+
if (info.statusCode === 100) ev.emit('continue');
|
|
403
|
+
else ev.emit('information', info);
|
|
404
|
+
res = null;
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
settled = true;
|
|
409
|
+
cleanupTimer();
|
|
410
|
+
ev.emit('response', res);
|
|
411
|
+
if (typeof callback === 'function') { callback(res); callback = null; }
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
parser.on('body', function (chunk) { if (res) res._pushData(chunk); });
|
|
415
|
+
parser.on('trailers', function (t) { if (res) res.trailers = t; });
|
|
416
|
+
|
|
417
|
+
parser.on('end', function () {
|
|
418
|
+
if (!res) return;
|
|
419
|
+
var keepAlive = res._keepAlive;
|
|
420
|
+
res._endData();
|
|
421
|
+
res = null;
|
|
422
|
+
|
|
423
|
+
if (keepAlive && socket && !socket.destroyed) {
|
|
424
|
+
var s = socket;
|
|
425
|
+
releaseSocket(false);
|
|
426
|
+
pool.put(poolHost, port, s);
|
|
427
|
+
} else {
|
|
428
|
+
releaseSocket(true);
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
parser.on('upgrade', function (info, head) {
|
|
433
|
+
settled = true;
|
|
434
|
+
cleanupTimer();
|
|
435
|
+
var s = socket;
|
|
436
|
+
releaseSocket(false);
|
|
437
|
+
if (s) { s.removeAllListeners('data'); s.removeAllListeners('error'); }
|
|
438
|
+
ev.emit('upgrade', info, s, head);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
// Once the response has begun, a failure is the response's: fail()
|
|
442
|
+
// reports only on the request, and a body cut short — the socket closed
|
|
443
|
+
// mid-body, or bytes the parser refused — neither ended nor failed: it hung.
|
|
444
|
+
function abortResponse(err) {
|
|
445
|
+
if (!res) return;
|
|
446
|
+
var r = res;
|
|
447
|
+
res = null;
|
|
448
|
+
r._abort(err);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
parser.on('error', function (err) { abortResponse(err); fail(err); });
|
|
452
|
+
|
|
453
|
+
socket.on('data', function (chunk) {
|
|
454
|
+
try { parser.execute(chunk); } catch (err) { fail(err); }
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
socket.on('end', function () {
|
|
458
|
+
try { parser.eof(); } catch (e) {}
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
socket.on('close', function () {
|
|
462
|
+
if (!settled) {
|
|
463
|
+
var lost = new Error('socket closed before response');
|
|
464
|
+
lost.code = 'ECONNRESET';
|
|
465
|
+
// Sent and not idempotent: the server may have acted on it, so it is
|
|
466
|
+
// not sent again over another protocol (RFC 9110 §9.2.2) — the
|
|
467
|
+
// unified client tried HTTP/2 and HTTP/3 with the same POST.
|
|
468
|
+
if (headSent && !IDEMPOTENT[method]) lost.requestError = true;
|
|
469
|
+
fail(lost);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
var err = new Error('socket closed before the response ended');
|
|
473
|
+
err.code = 'ECONNRESET';
|
|
474
|
+
abortResponse(err);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// options.onReady (the unified client) may turn the request down here —
|
|
478
|
+
// another protocol was ready first and carries it: the socket goes (it
|
|
479
|
+
// is not known idle to anyone else), and 'superseded' says so.
|
|
480
|
+
if (typeof options.onReady === 'function' && !options.onReady()) {
|
|
481
|
+
settled = true;
|
|
482
|
+
releaseSocket(true);
|
|
483
|
+
process.nextTick(function () { ev.emit('superseded'); });
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
readyDone = true;
|
|
487
|
+
if (ended) sendRequest();
|
|
488
|
+
else if (bodyChunks.length) startStreaming();
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
// ---- Sending ----
|
|
493
|
+
|
|
494
|
+
// The defaults every request head gets, unless the caller set them.
|
|
495
|
+
function defaultHeaders() {
|
|
496
|
+
if (!('host' in headers)) {
|
|
497
|
+
// RFC 9112 §3.2. The default port for the scheme is omitted, as every
|
|
498
|
+
// client does — including it changes the origin as far as many servers
|
|
499
|
+
// and virtual hosts are concerned.
|
|
500
|
+
var defaultPort = options.tls ? 443 : 80;
|
|
501
|
+
setHeader('Host', port === defaultPort ? hostname : hostname + ':' + port);
|
|
502
|
+
}
|
|
503
|
+
if (!('user-agent' in headers)) setHeader('User-Agent', 'quico/1.0');
|
|
504
|
+
if (!('accept' in headers)) setHeader('Accept', '*/*');
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function headOut() {
|
|
508
|
+
var out = {};
|
|
509
|
+
for (var lower in headers) out[headerNames[lower] || lower] = headers[lower];
|
|
510
|
+
return out;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function lengthMismatch(actual, declared) {
|
|
514
|
+
var mismatch = new Error('Response body\'s content-length of ' + actual + ' byte(s) does not match the content-length of ' + declared + ' byte(s) in header');
|
|
515
|
+
mismatch.code = 'ERR_HTTP_CONTENT_LENGTH_MISMATCH';
|
|
516
|
+
mismatch.requestError = true; // not the protocol's fault: no other is tried (client.js)
|
|
517
|
+
return mismatch;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// The request whole: ended before the socket was its own.
|
|
521
|
+
function sendRequest() {
|
|
522
|
+
if (sent || !socket) return;
|
|
523
|
+
sent = true;
|
|
524
|
+
|
|
525
|
+
var body = bodyChunks.length ? Buffer.concat(bodyChunks) : null;
|
|
526
|
+
bodyChunks = []; bodyBuffered = 0;
|
|
527
|
+
defaultHeaders();
|
|
528
|
+
|
|
529
|
+
// Trailers go after the last chunk: chunked, unless the caller declared
|
|
530
|
+
// a Content-Length (then they are dropped, as node:http drops them).
|
|
531
|
+
if (reqTrailers && !('content-length' in headers) && !('transfer-encoding' in headers)) {
|
|
532
|
+
setHeader('Transfer-Encoding', 'chunked');
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Framing. If the caller declared neither and there is a body, state its
|
|
536
|
+
// length: chunked would work too, but a plain Content-Length is understood
|
|
537
|
+
// by every server including HTTP/1.0 ones.
|
|
538
|
+
if (!('content-length' in headers) && !('transfer-encoding' in headers)) {
|
|
539
|
+
if (body) setHeader('Content-Length', String(body.length));
|
|
540
|
+
else if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
|
541
|
+
setHeader('Content-Length', '0');
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
var out = headOut();
|
|
546
|
+
|
|
547
|
+
var chunked = String(headers['transfer-encoding'] || '').toLowerCase().indexOf('chunked') !== -1;
|
|
548
|
+
|
|
549
|
+
// A Content-Length the caller set must be the body's. The body is whole
|
|
550
|
+
// here, so it can be checked, and it has to be: bytes past the length are
|
|
551
|
+
// read by the server as the start of the NEXT request on this connection
|
|
552
|
+
// — which the agent reuses — and too few leave the server waiting for
|
|
553
|
+
// bytes that become the next request's start. Either way a request is
|
|
554
|
+
// injected into the connection (nginx's CVE-2026-42926 is this, in a
|
|
555
|
+
// proxy). node:http checks it with strictContentLength; a library that
|
|
556
|
+
// pools connections cannot afford not to.
|
|
557
|
+
if (!chunked && 'content-length' in headers) {
|
|
558
|
+
var declared = String(headers['content-length']).trim();
|
|
559
|
+
var actual = body ? body.length : 0;
|
|
560
|
+
if (!/^\d+$/.test(declared) || Number(declared) !== actual) {
|
|
561
|
+
fail(lengthMismatch(actual, declared));
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
try {
|
|
567
|
+
socket.write(buildRequestHead(method, path, out));
|
|
568
|
+
if (body) socket.write(chunked ? Buffer.concat(encodeChunk(body).concat([encodeLastChunk(reqTrailers)])) : body);
|
|
569
|
+
else if (chunked) socket.write(encodeLastChunk(reqTrailers));
|
|
570
|
+
} catch (err) {
|
|
571
|
+
fail(err);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
// The body streamed: the head now — chunked, or the caller's
|
|
575
|
+
// Content-Length, which the bytes must then add up to (checked as they go
|
|
576
|
+
// and at the end: too many or too few, and the next request on this
|
|
577
|
+
// connection would be read wrong) — then what was held.
|
|
578
|
+
function startStreaming() {
|
|
579
|
+
if (sent || !socket) return;
|
|
580
|
+
sent = true;
|
|
581
|
+
streaming = true;
|
|
582
|
+
defaultHeaders();
|
|
583
|
+
if ('content-length' in headers && String(headers['transfer-encoding'] || '').toLowerCase().indexOf('chunked') === -1) {
|
|
584
|
+
var declared = String(headers['content-length']).trim();
|
|
585
|
+
if (!/^\d+$/.test(declared)) { fail(lengthMismatch(bodyBuffered, declared)); releaseSocket(true); return; }
|
|
586
|
+
framing = 'length';
|
|
587
|
+
declaredLength = Number(declared);
|
|
588
|
+
} else {
|
|
589
|
+
framing = 'chunked';
|
|
590
|
+
if (!('transfer-encoding' in headers)) setHeader('Transfer-Encoding', 'chunked');
|
|
591
|
+
}
|
|
592
|
+
try { socket.write(buildRequestHead(method, path, headOut())); } catch (err) { fail(err); return; }
|
|
593
|
+
headSent = true;
|
|
594
|
+
var held = bodyChunks; bodyChunks = []; bodyBuffered = 0;
|
|
595
|
+
var more = true;
|
|
596
|
+
for (var i = 0; i < held.length; i++) more = writeOut(held[i]);
|
|
597
|
+
if (more && needDrain) { needDrain = false; ev.emit('drain'); }
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function writeOut(chunk) {
|
|
601
|
+
if (!socket) return false;
|
|
602
|
+
if (framing === 'length') {
|
|
603
|
+
sentLength += chunk.length;
|
|
604
|
+
if (sentLength > declaredLength) { fail(lengthMismatch(sentLength, declaredLength)); releaseSocket(true); return false; }
|
|
605
|
+
return socketWrite(chunk);
|
|
606
|
+
}
|
|
607
|
+
return socketWrite(Buffer.concat(encodeChunk(chunk)));
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function socketWrite(buf) {
|
|
611
|
+
var ok;
|
|
612
|
+
try { ok = socket.write(buf); } catch (err) { fail(err); return false; }
|
|
613
|
+
if (!ok && !needDrain) {
|
|
614
|
+
needDrain = true;
|
|
615
|
+
socket.once('drain', function () { if (needDrain) { needDrain = false; ev.emit('drain'); } });
|
|
616
|
+
}
|
|
617
|
+
return ok;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function finishStream() {
|
|
621
|
+
if (!socket) return;
|
|
622
|
+
if (framing === 'chunked') { socketWrite(encodeLastChunk(reqTrailers)); return; }
|
|
623
|
+
if (sentLength !== declaredLength) { fail(lengthMismatch(sentLength, declaredLength)); releaseSocket(true); }
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
// ---- Public surface (node:http.ClientRequest shape) ----
|
|
629
|
+
|
|
630
|
+
var clientReq = {
|
|
631
|
+
on: function (name, fn) { ev.on(name, fn); return clientReq; },
|
|
632
|
+
once: function (name, fn) { ev.once(name, fn); return clientReq; },
|
|
633
|
+
off: function (name, fn) { ev.off(name, fn); return clientReq; },
|
|
634
|
+
emit: function () { ev.emit.apply(ev, arguments); return clientReq; },
|
|
635
|
+
|
|
636
|
+
setHeader: setHeader,
|
|
637
|
+
getHeader: function (name) { return headers[String(name).toLowerCase()]; },
|
|
638
|
+
removeHeader: function (name) {
|
|
639
|
+
var l = String(name).toLowerCase();
|
|
640
|
+
delete headers[l]; delete headerNames[l];
|
|
641
|
+
},
|
|
642
|
+
|
|
643
|
+
// The body, as node:http's: held while connecting (write() false past
|
|
644
|
+
// BODY_HIGH_WATER, then 'drain'), then streamed — the head on the first
|
|
645
|
+
// write once the socket is the request's, chunked unless the caller set
|
|
646
|
+
// a Content-Length — with the socket's backpressure.
|
|
647
|
+
write: function (chunk, encoding) {
|
|
648
|
+
if (ended || settled) return false;
|
|
649
|
+
if (typeof chunk === 'string') chunk = Buffer.from(chunk, encoding || 'utf8');
|
|
650
|
+
if (!chunk || chunk.length === 0) return !needDrain;
|
|
651
|
+
if (!streaming && readyDone && !sent) startStreaming();
|
|
652
|
+
if (streaming) return writeOut(chunk);
|
|
653
|
+
bodyChunks.push(chunk);
|
|
654
|
+
bodyBuffered += chunk.length;
|
|
655
|
+
if (bodyBuffered >= BODY_HIGH_WATER) { needDrain = true; return false; }
|
|
656
|
+
return true;
|
|
657
|
+
},
|
|
658
|
+
|
|
659
|
+
// RFC 9218 §5: the `priority` header, while the head has not gone;
|
|
660
|
+
// HTTP/1.1 has no frame to change it after.
|
|
661
|
+
setPriority: function (p) {
|
|
662
|
+
if (sent) return;
|
|
663
|
+
var v = formatPriority(p);
|
|
664
|
+
if (v) setHeader('Priority', v);
|
|
665
|
+
else { delete headers.priority; delete headerNames.priority; }
|
|
666
|
+
},
|
|
667
|
+
|
|
668
|
+
// `trailers` (node:http's addTrailers, through the unified client): after
|
|
669
|
+
// the last chunk — so only with chunked encoding, as in node:http.
|
|
670
|
+
end: function (chunk, encoding, trailers) {
|
|
671
|
+
if (ended) return clientReq;
|
|
672
|
+
if (encoding && typeof encoding === 'object') { trailers = encoding; encoding = undefined; }
|
|
673
|
+
if (typeof chunk === 'string') chunk = Buffer.from(chunk, encoding || 'utf8');
|
|
674
|
+
reqTrailers = trailerFields(trailers);
|
|
675
|
+
if (streaming) {
|
|
676
|
+
if (chunk && chunk.length) writeOut(chunk);
|
|
677
|
+
ended = true;
|
|
678
|
+
finishStream();
|
|
679
|
+
return clientReq;
|
|
680
|
+
}
|
|
681
|
+
if (chunk && chunk.length) { bodyChunks.push(chunk); bodyBuffered += chunk.length; }
|
|
682
|
+
ended = true;
|
|
683
|
+
if (readyDone) sendRequest();
|
|
684
|
+
return clientReq;
|
|
685
|
+
},
|
|
686
|
+
|
|
687
|
+
abort: function () { fail(new Error('Request aborted')); },
|
|
688
|
+
destroy: function (err) {
|
|
689
|
+
if (!settled) { fail(err || new Error('Request destroyed')); return; }
|
|
690
|
+
// The response has begun (settled): the rest of it is abandoned, and
|
|
691
|
+
// the socket with it — mid-body it cannot go back to the pool. fail()
|
|
692
|
+
// returned early here, so the body went on downloading and the socket
|
|
693
|
+
// was pooled when it ended.
|
|
694
|
+
cleanupTimer();
|
|
695
|
+
releaseSocket(true);
|
|
696
|
+
},
|
|
697
|
+
|
|
698
|
+
setTimeout: function (ms, cb) {
|
|
699
|
+
if (cb) ev.once('timeout', cb);
|
|
700
|
+
cleanupTimer();
|
|
701
|
+
if (ms > 0) {
|
|
702
|
+
timeoutTimer = setTimeout(function () { ev.emit('timeout'); }, ms);
|
|
703
|
+
if (timeoutTimer.unref) timeoutTimer.unref();
|
|
704
|
+
}
|
|
705
|
+
return clientReq;
|
|
706
|
+
},
|
|
707
|
+
|
|
708
|
+
get reusedSocket() { return reused; },
|
|
709
|
+
get socket() { return socket; },
|
|
710
|
+
// 0-RTT: 'accepted', 'rejected', or null (not sent early).
|
|
711
|
+
get earlyData() { return ev.earlyData; }
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
// Connect on the next tick so the caller can attach listeners and write a
|
|
715
|
+
// body first — the same ordering guarantee node:http gives.
|
|
716
|
+
process.nextTick(function () {
|
|
717
|
+
if (!settled) connect();
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
return clientReq;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
function get(options, callback) {
|
|
725
|
+
var req = request(Object.assign({}, options, { method: 'GET' }), callback);
|
|
726
|
+
req.end();
|
|
727
|
+
return req;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
export { request, get, H1Pool, globalPool };
|