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/h2_client.js
ADDED
|
@@ -0,0 +1,675 @@
|
|
|
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 { H2Connection, SOCKET_HIGH_WATER } from './h2.js';
|
|
19
|
+
import * as F from './h2_frames.js';
|
|
20
|
+
import { buildRequestPseudoHeaders, corkForTick, trailerFields, formatPriority } from './http_common.js';
|
|
21
|
+
import { IncomingMessage } from './streams.js';
|
|
22
|
+
import { tlsIdentity } from './agent.js';
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
// ============================================================
|
|
26
|
+
// HTTP/2 client.
|
|
27
|
+
//
|
|
28
|
+
// The pool here is the OPPOSITE shape to h1_client's, and the difference is
|
|
29
|
+
// the whole point of the protocol. HTTP/1.1 carries one request per socket,
|
|
30
|
+
// so its pool maps an origin to a LIST of idle sockets and concurrency means
|
|
31
|
+
// more sockets. HTTP/2 multiplexes, so this pool maps an origin to ONE
|
|
32
|
+
// session and concurrency means more streams on it.
|
|
33
|
+
//
|
|
34
|
+
// agent.js already keeps _h1Pool, _h2Pool and _h3Pool as separate maps for
|
|
35
|
+
// exactly this reason. Forcing the two shapes into one abstraction would
|
|
36
|
+
// distort both — and the H3 pool, which is the same shape as this one, is
|
|
37
|
+
// where the coalescing logic worth copying already lives.
|
|
38
|
+
// ============================================================
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
function H2Pool(options) {
|
|
42
|
+
options = options || {};
|
|
43
|
+
var sessions = {}; // "host:port" -> session
|
|
44
|
+
var pending = {}; // "host:port" -> [callbacks] while one is being established
|
|
45
|
+
|
|
46
|
+
function key(host, port) { return host + ':' + port; }
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
get: function (host, port) {
|
|
50
|
+
var s = sessions[key(host, port)];
|
|
51
|
+
if (!s) return null;
|
|
52
|
+
if (s.destroyed || s.goingAway) { delete sessions[key(host, port)]; return null; }
|
|
53
|
+
return s;
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
set: function (host, port, session) {
|
|
57
|
+
var k = key(host, port);
|
|
58
|
+
sessions[k] = session;
|
|
59
|
+
session.on('close', function () { if (sessions[k] === session) delete sessions[k]; });
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Coalesce concurrent connection attempts to one origin.
|
|
64
|
+
*
|
|
65
|
+
* Without this, N requests issued in the same tick each miss the pool and
|
|
66
|
+
* open N sessions to the same host — N handshakes, N HPACK table pairs,
|
|
67
|
+
* zero multiplexing. It is the exact failure the H3 agent already guards
|
|
68
|
+
* against, and it is invisible in any test that issues requests one at a
|
|
69
|
+
* time.
|
|
70
|
+
*
|
|
71
|
+
* Returns false to the first caller, which becomes the lead and MUST call
|
|
72
|
+
* settle(); true to every later one, whose callback fires when the lead
|
|
73
|
+
* finishes and which then simply retries its pool lookup.
|
|
74
|
+
*/
|
|
75
|
+
join: function (host, port, cb) {
|
|
76
|
+
var k = key(host, port);
|
|
77
|
+
if (pending[k]) { pending[k].push(cb); return true; }
|
|
78
|
+
pending[k] = [];
|
|
79
|
+
return false;
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
settle: function (host, port, session) {
|
|
83
|
+
var k = key(host, port);
|
|
84
|
+
var waiters = pending[k] || [];
|
|
85
|
+
delete pending[k];
|
|
86
|
+
if (session) this.set(host, port, session);
|
|
87
|
+
for (var i = 0; i < waiters.length; i++) {
|
|
88
|
+
try { waiters[i](); } catch (e) { /* a waiter's failure is its own */ }
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
destroy: function () {
|
|
93
|
+
for (var k in sessions) { try { sessions[k].destroy(); } catch (e) {} }
|
|
94
|
+
sessions = {};
|
|
95
|
+
pending = {};
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
var globalPool = new H2Pool();
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
// ============================================================
|
|
104
|
+
// Session — one connection, many streams
|
|
105
|
+
// ============================================================
|
|
106
|
+
|
|
107
|
+
function H2Session(socket, options) {
|
|
108
|
+
var ev = new EventEmitter();
|
|
109
|
+
var h2 = new H2Connection({
|
|
110
|
+
isServer: false,
|
|
111
|
+
// The stream window a response may fill before we return credit: 6 MB,
|
|
112
|
+
// Chrome's. HTTP/2's own default, 64 KB, holds one response to 64 KB a
|
|
113
|
+
// round trip — 5 Mbps over 100 ms — and brings a WINDOW_UPDATE every
|
|
114
|
+
// 32 KB. Credit still goes back only as the application reads
|
|
115
|
+
// (autoWindowUpdate false), so a response nobody reads stops at 6 MB.
|
|
116
|
+
// The server's side keeps HTTP/2's default (h2_server): what it
|
|
117
|
+
// advertises bounds every client's uploads.
|
|
118
|
+
initialWindowSize: options.initialWindowSize != null ? options.initialWindowSize : 6 * 1024 * 1024,
|
|
119
|
+
maxHeaderListSize: options.maxHeaderListSize,
|
|
120
|
+
autoWindowUpdate: false,
|
|
121
|
+
// DATA is held while the socket's unsent bytes are over this mark, as on
|
|
122
|
+
// the server (h2_server): the flow-control windows bound what the SERVER
|
|
123
|
+
// may receive, this what we queue for a socket that is not draining.
|
|
124
|
+
// Without it every request body went straight into the socket, whatever
|
|
125
|
+
// the network took — an upload to a slow server sat in memory whole.
|
|
126
|
+
isWriteBlocked: function () { return socket.writableLength >= SOCKET_HIGH_WATER; }
|
|
127
|
+
});
|
|
128
|
+
// node:http2's maxSessionMemory (MB, default 10): what may be queued on the
|
|
129
|
+
// socket. DATA waits (above); what can pile up is everything else — the
|
|
130
|
+
// replies a server provokes (PING and SETTINGS acknowledgements) while it
|
|
131
|
+
// does not read. Past the bound the session goes.
|
|
132
|
+
var maxSessionMemory = (options.maxSessionMemory > 0 ? options.maxSessionMemory : 10) * 1024 * 1024;
|
|
133
|
+
|
|
134
|
+
var streams = {};
|
|
135
|
+
var destroyed = false;
|
|
136
|
+
var goingAway = false;
|
|
137
|
+
|
|
138
|
+
h2.on('send', function (data) {
|
|
139
|
+
if (destroyed || !socket.writable) return;
|
|
140
|
+
corkForTick(socket);
|
|
141
|
+
try { socket.write(data); } catch (e) { fail(e); return; }
|
|
142
|
+
if (socket.writableLength > maxSessionMemory) {
|
|
143
|
+
var e = new Error('server is not reading: ' + socket.writableLength + ' bytes queued');
|
|
144
|
+
e.code = 'ERR_HTTP2_SESSION_MEMORY';
|
|
145
|
+
fail(e);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
// DATA was waiting for the socket; it has room again.
|
|
149
|
+
socket.on('drain', function () { if (!destroyed) h2.flushAll(); });
|
|
150
|
+
|
|
151
|
+
h2.on('error', function (err) { fail(err); });
|
|
152
|
+
|
|
153
|
+
h2.on('goaway', function (lastStreamId, code) {
|
|
154
|
+
goingAway = true;
|
|
155
|
+
session.goingAway = true;
|
|
156
|
+
// Streams above lastStreamId were never processed, so they are safe to
|
|
157
|
+
// retry on a fresh connection — that is what the id is FOR.
|
|
158
|
+
for (var id in streams) {
|
|
159
|
+
if (Number(id) > lastStreamId) {
|
|
160
|
+
var e = new Error('stream not processed before GOAWAY');
|
|
161
|
+
e.code = 'ERR_HTTP2_GOAWAY_UNPROCESSED';
|
|
162
|
+
e.retryable = true;
|
|
163
|
+
rejectStream(Number(id), e);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
ev.emit('goaway', lastStreamId, code);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
h2.on('headers', function (streamId, headers) {
|
|
170
|
+
var st = streams[streamId];
|
|
171
|
+
if (!st) return;
|
|
172
|
+
|
|
173
|
+
var status = parseInt(headers[':status'], 10);
|
|
174
|
+
|
|
175
|
+
// 1xx other than 101 is informational: the real response follows on the
|
|
176
|
+
// same stream, so it must not be delivered as THE response.
|
|
177
|
+
if (status >= 100 && status < 200) {
|
|
178
|
+
ev.emit('information', streamId, headers);
|
|
179
|
+
// And on the request, as node:http's 'information' (103 Early Hints).
|
|
180
|
+
if (st.onInformation) {
|
|
181
|
+
var ih = {}, raw = [];
|
|
182
|
+
for (var hn in headers) { if (hn[0] !== ':') { ih[hn] = headers[hn]; raw.push(hn, String(headers[hn])); } }
|
|
183
|
+
st.onInformation({ statusCode: status, statusMessage: '', httpVersion: '2.0', httpVersionMajor: 2, httpVersionMinor: 0, headers: ih, rawHeaders: raw });
|
|
184
|
+
}
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
var flat = {};
|
|
189
|
+
for (var name in headers) { if (name[0] !== ':') flat[name] = headers[name]; }
|
|
190
|
+
|
|
191
|
+
var res = new IncomingMessage({
|
|
192
|
+
httpVersion: '2.0',
|
|
193
|
+
statusCode: status,
|
|
194
|
+
statusMessage: '', // HTTP/2 has no reason phrase
|
|
195
|
+
headers: flat,
|
|
196
|
+
streamId: streamId,
|
|
197
|
+
socket: socket
|
|
198
|
+
});
|
|
199
|
+
res.on('data', function (chunk) { h2.consume(streamId, chunk.length); });
|
|
200
|
+
|
|
201
|
+
st.res = res;
|
|
202
|
+
st.onResponse(res);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
h2.on('data', function (streamId, chunk) {
|
|
206
|
+
var st = streams[streamId];
|
|
207
|
+
if (st && st.res) st.res._pushData(chunk);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
h2.on('trailers', function (streamId, trailers) {
|
|
211
|
+
var st = streams[streamId];
|
|
212
|
+
if (st && st.res) st.res.trailers = trailers;
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
h2.on('end', function (streamId) {
|
|
216
|
+
var st = streams[streamId];
|
|
217
|
+
if (!st) return;
|
|
218
|
+
if (st.res) st.res._endData();
|
|
219
|
+
delete streams[streamId];
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
h2.on('rst', function (streamId, code) {
|
|
223
|
+
var e = new Error('stream reset: ' + (F.ERROR_NAME[code] || code));
|
|
224
|
+
e.code = 'ERR_HTTP2_STREAM_ERROR';
|
|
225
|
+
// REFUSED_STREAM specifically means the server never processed it.
|
|
226
|
+
e.retryable = (code === F.ERROR.REFUSED_STREAM);
|
|
227
|
+
// Any other code: the server had the request and ended it — it is the
|
|
228
|
+
// answer, not a failure to reach the server. Not sent again over HTTP/1.1
|
|
229
|
+
// (a POST would have run twice) under "all protocols failed".
|
|
230
|
+
if (!e.retryable) e.requestError = true;
|
|
231
|
+
rejectStream(streamId, e);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
socket.on('data', function (chunk) {
|
|
235
|
+
if (destroyed) return;
|
|
236
|
+
try { h2.execute(chunk); } catch (err) { fail(err); }
|
|
237
|
+
});
|
|
238
|
+
socket.on('error', fail);
|
|
239
|
+
socket.on('close', function () { fail(new Error('socket closed')); });
|
|
240
|
+
|
|
241
|
+
// The connection lost under a request already sent, before its response:
|
|
242
|
+
// the server may have acted on it. An idempotent one may go again over
|
|
243
|
+
// another protocol (RFC 9110 §9.2.2); any other is not sent twice — the
|
|
244
|
+
// error is the request's (requestError: the unified client tries no other
|
|
245
|
+
// protocol). A POST lost so was sent again over HTTP/1.1.
|
|
246
|
+
var IDEMPOTENT = { GET: 1, HEAD: 1, OPTIONS: 1, TRACE: 1, PUT: 1, DELETE: 1 };
|
|
247
|
+
function sentAndLost(st, err) {
|
|
248
|
+
if (!st.sent || (err && (err.retryable || err.requestError)) || IDEMPOTENT[st.method]) return err;
|
|
249
|
+
var e = new Error('the connection was lost after the request was sent: ' + (err && err.message), { cause: err });
|
|
250
|
+
e.code = (err && err.code) || 'ECONNRESET';
|
|
251
|
+
e.requestError = true;
|
|
252
|
+
return e;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function rejectStream(streamId, err) {
|
|
256
|
+
var st = streams[streamId];
|
|
257
|
+
if (!st) return;
|
|
258
|
+
delete streams[streamId];
|
|
259
|
+
if (st.res) st.res._abort(err);
|
|
260
|
+
else st.onError(err);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function fail(err) {
|
|
264
|
+
if (destroyed) return;
|
|
265
|
+
destroyed = true;
|
|
266
|
+
session.destroyed = true;
|
|
267
|
+
for (var id in streams) {
|
|
268
|
+
var st = streams[id];
|
|
269
|
+
if (st.res) st.res._abort(err);
|
|
270
|
+
else st.onError(sentAndLost(st, err));
|
|
271
|
+
}
|
|
272
|
+
streams = {};
|
|
273
|
+
try { socket.destroy(); } catch (e) {}
|
|
274
|
+
ev.emit('close');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
h2.sendPreface();
|
|
278
|
+
|
|
279
|
+
var session = {
|
|
280
|
+
destroyed: false,
|
|
281
|
+
goingAway: false,
|
|
282
|
+
on: function (n, f) { ev.on(n, f); return session; },
|
|
283
|
+
|
|
284
|
+
request: function (headerList, callbacks) {
|
|
285
|
+
if (destroyed) { callbacks.onError(new Error('session destroyed')); return null; }
|
|
286
|
+
if (goingAway) {
|
|
287
|
+
var e = new Error('session is going away');
|
|
288
|
+
e.retryable = true;
|
|
289
|
+
callbacks.onError(e);
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
var streamId = h2.nextStreamId();
|
|
293
|
+
streams[streamId] = { onResponse: callbacks.onResponse, onError: callbacks.onError, onInformation: callbacks.onInformation, res: null, method: callbacks.method || null, sent: false };
|
|
294
|
+
return streamId;
|
|
295
|
+
},
|
|
296
|
+
|
|
297
|
+
sendHeaders: function (streamId, list, endStream) { if (streams[streamId]) streams[streamId].sent = true; h2.sendHeaders(streamId, list, endStream); },
|
|
298
|
+
sendData: function (streamId, data, endStream) { return h2.sendData(streamId, data, endStream); },
|
|
299
|
+
// A request's trailers (node:http's addTrailers): HEADERS with END_STREAM.
|
|
300
|
+
sendTrailers: function (streamId, fields) { h2.sendTrailers(streamId, fields); },
|
|
301
|
+
// A request's new priority, after it went (RFC 9218 §7.1).
|
|
302
|
+
sendPriorityUpdate: function (streamId, p) { h2.sendPriorityUpdate(streamId, p); },
|
|
303
|
+
// Only a stream still open: RST_STREAM on one already closed is not ours
|
|
304
|
+
// to send. Forgotten before the RST goes: h2 reports a reset it sends as
|
|
305
|
+
// it does one it receives ('rst'), and a cancellation of our own read as
|
|
306
|
+
// the server's reset — the response destroyed with an error nobody was
|
|
307
|
+
// listening for.
|
|
308
|
+
cancel: function (streamId) {
|
|
309
|
+
if (!(streamId in streams)) return;
|
|
310
|
+
delete streams[streamId];
|
|
311
|
+
h2.sendRstStream(streamId, F.ERROR.CANCEL);
|
|
312
|
+
},
|
|
313
|
+
onDrain: function (fn) { h2.on('drain', fn); },
|
|
314
|
+
offDrain: function (fn) { h2.off('drain', fn); },
|
|
315
|
+
ping: function (payload) { h2.sendPing(payload); },
|
|
316
|
+
destroy: function () { fail(new Error('destroyed')); },
|
|
317
|
+
|
|
318
|
+
get activeStreams() { return Object.keys(streams).length; }
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
return session;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
// ============================================================
|
|
326
|
+
// request()
|
|
327
|
+
// ============================================================
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* request(options, callback) -> an EventEmitter that emits 'response' with an
|
|
331
|
+
* IncomingMessage. The same shape as h1_client's, so the unified client can
|
|
332
|
+
* treat the two interchangeably.
|
|
333
|
+
*/
|
|
334
|
+
function request(options, callback) {
|
|
335
|
+
options = options || {};
|
|
336
|
+
|
|
337
|
+
var hostname = options.hostname || options.host || 'localhost';
|
|
338
|
+
var port = parseInt(options.port, 10) || (options.tls ? 443 : 80);
|
|
339
|
+
var method = String(options.method || 'GET').toUpperCase();
|
|
340
|
+
var pool = options.pool || globalPool;
|
|
341
|
+
// The pool's name for this origin: the host, and over TLS what decides the
|
|
342
|
+
// connection's identity (agent.js tlsIdentity) — one opened without
|
|
343
|
+
// verification, or with another client certificate, carries no other
|
|
344
|
+
// request. The connection itself goes to `hostname`.
|
|
345
|
+
var poolHost = options.tls ? hostname + '#' + tlsIdentity(options) : hostname;
|
|
346
|
+
|
|
347
|
+
var ev = new EventEmitter();
|
|
348
|
+
// The body. Written before the stream exists (the connection is still
|
|
349
|
+
// being made), it waits here; once the stream exists, each write goes out
|
|
350
|
+
// as it comes (streaming), and write() answers as a stream's does — false
|
|
351
|
+
// while the send window or the socket is full, then 'drain'. It used to
|
|
352
|
+
// keep the whole body until end() and answer true to every write: an
|
|
353
|
+
// upload was held in memory entire, with nothing telling the writer to
|
|
354
|
+
// slow down.
|
|
355
|
+
var bodyChunks = [];
|
|
356
|
+
var bodyBuffered = 0;
|
|
357
|
+
var needDrain = false;
|
|
358
|
+
var streaming = false; // HEADERS sent without END_STREAM; DATA follows as written
|
|
359
|
+
var drainHooked = false;
|
|
360
|
+
var BODY_HIGH_WATER = 64 * 1024; // what write() takes before the stream exists, before saying wait
|
|
361
|
+
var ended = false;
|
|
362
|
+
var settled = false;
|
|
363
|
+
var session = null;
|
|
364
|
+
var streamId = null;
|
|
365
|
+
var sent = false;
|
|
366
|
+
// 0-RTT (TLS early data, RFC 8446 §4.2.10): with earlyData and a session
|
|
367
|
+
// whose ticket allows it, a GET, HEAD or OPTIONS goes in the first flight
|
|
368
|
+
// — the preface, SETTINGS and HEADERS with the ClientHello. Not taken, the
|
|
369
|
+
// TLS layer sends them again after the handshake; answered 425 (RFC 8470),
|
|
370
|
+
// the request fails retryable, and the unified client sends it again on
|
|
371
|
+
// the connection, now up. this.earlyData: 'accepted', 'rejected' or null.
|
|
372
|
+
// Decided when a connection is made (connect): the session is read there,
|
|
373
|
+
// so a request on a pooled session uses none up (the unified client's is
|
|
374
|
+
// taken from its agent on that read).
|
|
375
|
+
var wantEarly = false;
|
|
376
|
+
var sentEarly = false;
|
|
377
|
+
ev.earlyData = null;
|
|
378
|
+
var reqTrailers = null;
|
|
379
|
+
|
|
380
|
+
function fail(err) {
|
|
381
|
+
if (settled) return;
|
|
382
|
+
settled = true;
|
|
383
|
+
ev.emit('error', err);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function connect() {
|
|
387
|
+
var existing = pool.get(poolHost, port);
|
|
388
|
+
if (existing) { start(existing); return; }
|
|
389
|
+
|
|
390
|
+
if (pool.join(poolHost, port, function () {
|
|
391
|
+
// The lead settled; retry from the top. On success the pool lookup now
|
|
392
|
+
// hits and we multiplex on its session; on failure we become the next
|
|
393
|
+
// lead — a serial retry, never a thundering herd.
|
|
394
|
+
if (!settled) connect();
|
|
395
|
+
})) return;
|
|
396
|
+
|
|
397
|
+
var socket, earlySession = null;
|
|
398
|
+
var sessionNow = options.session;
|
|
399
|
+
wantEarly = options.earlyData === true && !!sessionNow && !!options.tls && (method === 'GET' || method === 'HEAD' || method === 'OPTIONS');
|
|
400
|
+
var onReady = function () {
|
|
401
|
+
var s = new H2Session(socket, options);
|
|
402
|
+
pool.settle(poolHost, port, s);
|
|
403
|
+
start(s);
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
try {
|
|
407
|
+
if (options.tls) {
|
|
408
|
+
socket = options.tls.connect({
|
|
409
|
+
host: hostname,
|
|
410
|
+
port: port,
|
|
411
|
+
servername: options.servername || hostname,
|
|
412
|
+
// ALPN is not optional for h2 over TLS (RFC 9113 §3.2): a server
|
|
413
|
+
// that does not select 'h2' is not speaking it, and sending the
|
|
414
|
+
// preface anyway would be talking past it.
|
|
415
|
+
ALPNProtocols: ['h2'],
|
|
416
|
+
rejectUnauthorized: options.rejectUnauthorized !== false,
|
|
417
|
+
ca: options.ca,
|
|
418
|
+
// A client certificate, for a server that asks (tls.connect's names).
|
|
419
|
+
key: options.key, cert: options.cert, passphrase: options.passphrase,
|
|
420
|
+
// A saved session to resume with (tls.connect's `session`).
|
|
421
|
+
session: sessionNow,
|
|
422
|
+
// The server's certificate chain may come compressed (RFC 8879):
|
|
423
|
+
// brotli, zstd, zlib — as the HTTP/3 client and the browsers offer.
|
|
424
|
+
certificateCompression: true,
|
|
425
|
+
// The server's stapled OCSP response ('OCSPResponse').
|
|
426
|
+
requestOCSP: options.requestOCSP === true,
|
|
427
|
+
// Encrypted Client Hello: the server's ECHConfigList, or GREASE ECH.
|
|
428
|
+
ech: options.ech || undefined,
|
|
429
|
+
echGrease: options.echGrease === true,
|
|
430
|
+
// The key-exchange groups to offer (tls.connect's ecdhCurve).
|
|
431
|
+
ecdhCurve: options.ecdhCurve,
|
|
432
|
+
sigalgs: options.sigalgs,
|
|
433
|
+
earlyData: wantEarly,
|
|
434
|
+
// TCP_NODELAY, as node:http's client sets it (its noDelay, default true).
|
|
435
|
+
// Without it Nagle held a small write — a request's HEADERS behind the
|
|
436
|
+
// preface, a WINDOW_UPDATE, a body after its head — until the last one
|
|
437
|
+
// was acknowledged, and the server's delayed ACK made that up to 40 ms:
|
|
438
|
+
// 3000 small HTTP/2 requests took 2.8 s instead of 0.5.
|
|
439
|
+
noDelay: options.noDelay !== false
|
|
440
|
+
}, function () {
|
|
441
|
+
if (socket.alpnProtocol !== 'h2') {
|
|
442
|
+
pool.settle(poolHost, port, null);
|
|
443
|
+
fail(new Error('server did not negotiate h2 (got ' +
|
|
444
|
+
JSON.stringify(socket.alpnProtocol) + ')'));
|
|
445
|
+
try { socket.destroy(); } catch (e) {}
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
// Started early: the session is the pool's now that it is up —
|
|
449
|
+
// not before, or a request that is not safe to replay would ride
|
|
450
|
+
// in the early data.
|
|
451
|
+
if (earlySession) { pool.settle(poolHost, port, earlySession); return; }
|
|
452
|
+
onReady();
|
|
453
|
+
});
|
|
454
|
+
// The ClientHello is built a turn after connect(): by then the socket
|
|
455
|
+
// knows whether it offered early data (the ticket allows it).
|
|
456
|
+
if (wantEarly) setTimeout(function () {
|
|
457
|
+
if (settled || earlySession || !socket || socket.destroyed || socket.isEarlyData !== true) return;
|
|
458
|
+
sentEarly = true;
|
|
459
|
+
earlySession = new H2Session(socket, options);
|
|
460
|
+
start(earlySession);
|
|
461
|
+
}, 0);
|
|
462
|
+
} else {
|
|
463
|
+
// h2c with prior knowledge (§3.3): no ALPN, the preface speaks for us.
|
|
464
|
+
socket = net.connect({ host: hostname, port: port, noDelay: options.noDelay !== false }, onReady);
|
|
465
|
+
}
|
|
466
|
+
// The request that opened the connection has the TLS socket's
|
|
467
|
+
// 'session' events — each a session to resume with, as Node's socket
|
|
468
|
+
// gives them. A request on a pooled session sees no handshake.
|
|
469
|
+
if (options.tls) socket.on('session', function (s) { ev.emit('session', s); });
|
|
470
|
+
if (options.tls) socket.on('OCSPResponse', function (r) { ev.emit('OCSPResponse', r); });
|
|
471
|
+
} catch (err) {
|
|
472
|
+
pool.settle(poolHost, port, null);
|
|
473
|
+
fail(err);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
socket.on('error', function (err) {
|
|
478
|
+
pool.settle(poolHost, port, null);
|
|
479
|
+
fail(err);
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function start(s) {
|
|
484
|
+
if (settled) return;
|
|
485
|
+
// options.onReady (the unified client) may turn the request down here —
|
|
486
|
+
// another protocol was ready first and carries it: no stream is opened,
|
|
487
|
+
// and 'superseded' says so; the session stays in the pool.
|
|
488
|
+
if (typeof options.onReady === 'function' && !options.onReady()) {
|
|
489
|
+
settled = true;
|
|
490
|
+
process.nextTick(function () { ev.emit('superseded'); });
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
session = s;
|
|
494
|
+
streamId = session.request(null, {
|
|
495
|
+
method: method,
|
|
496
|
+
onInformation: function (info) { ev.emit('information', info); },
|
|
497
|
+
onResponse: function (res) {
|
|
498
|
+
if (settled) return;
|
|
499
|
+
if (sentEarly) {
|
|
500
|
+
var st = res.socket && res.socket.earlyDataStatus ? res.socket.earlyDataStatus : (session && session.socket ? session.socket.earlyDataStatus : null);
|
|
501
|
+
ev.earlyData = st === 'accepted' ? 'accepted' : 'rejected';
|
|
502
|
+
if (res.statusCode === 425) {
|
|
503
|
+
var tooEarly = new Error('425 Too Early: the request is sent again after the handshake');
|
|
504
|
+
tooEarly.code = 'ERR_HTTP_TOO_EARLY';
|
|
505
|
+
tooEarly.retryable = true;
|
|
506
|
+
try { res.resume(); } catch (e) {}
|
|
507
|
+
fail(tooEarly);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
settled = true;
|
|
512
|
+
ev.emit('response', res);
|
|
513
|
+
if (typeof callback === 'function') { callback(res); callback = null; }
|
|
514
|
+
},
|
|
515
|
+
onError: fail
|
|
516
|
+
});
|
|
517
|
+
if (streamId === null) return;
|
|
518
|
+
if (ended) { sendRequest(); return; }
|
|
519
|
+
// Written while connecting: out now, as a stream.
|
|
520
|
+
if (bodyChunks.length) {
|
|
521
|
+
var held = bodyChunks; bodyChunks = []; bodyBuffered = 0;
|
|
522
|
+
var more = true;
|
|
523
|
+
for (var i = 0; i < held.length; i++) more = streamChunk(held[i]);
|
|
524
|
+
if (more && needDrain) { needDrain = false; ev.emit('drain'); }
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function requestHeaderList(contentLength) {
|
|
529
|
+
var headers = Object.assign({}, options.headers);
|
|
530
|
+
if (contentLength !== null && headers['content-length'] === undefined &&
|
|
531
|
+
headers['Content-Length'] === undefined) {
|
|
532
|
+
headers['content-length'] = String(contentLength);
|
|
533
|
+
}
|
|
534
|
+
return buildRequestPseudoHeaders({
|
|
535
|
+
method: method,
|
|
536
|
+
scheme: options.tls ? 'https' : 'http',
|
|
537
|
+
hostname: hostname,
|
|
538
|
+
port: port,
|
|
539
|
+
path: options.path || '/',
|
|
540
|
+
headers: headers
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// One chunk of a streamed body: HEADERS first if not yet (without a
|
|
545
|
+
// content-length — HTTP/2 frames the body itself, RFC 9113 §8.1.1), then
|
|
546
|
+
// DATA. Returns whether more may be written now.
|
|
547
|
+
function streamChunk(chunk) {
|
|
548
|
+
try {
|
|
549
|
+
if (!streaming) { streaming = true; sent = true; session.sendHeaders(streamId, requestHeaderList(null), false); }
|
|
550
|
+
var more = session.sendData(streamId, chunk, false);
|
|
551
|
+
if (!more) waitDrain();
|
|
552
|
+
return more;
|
|
553
|
+
} catch (err) { fail(err); return false; }
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// The stream's send window (or the socket) has room again: 'drain'.
|
|
557
|
+
function waitDrain() {
|
|
558
|
+
needDrain = true;
|
|
559
|
+
if (drainHooked) return;
|
|
560
|
+
drainHooked = true;
|
|
561
|
+
var onDrain = function (id) {
|
|
562
|
+
if (id !== streamId) return;
|
|
563
|
+
session.offDrain(onDrain);
|
|
564
|
+
drainHooked = false;
|
|
565
|
+
if (needDrain) { needDrain = false; ev.emit('drain'); }
|
|
566
|
+
};
|
|
567
|
+
session.onDrain(onDrain);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// The whole request at once — ended before the stream existed, or with
|
|
571
|
+
// nothing streamed: a content-length, as before, and the body (if any).
|
|
572
|
+
function sendRequest() {
|
|
573
|
+
if (sent || streamId === null) return;
|
|
574
|
+
sent = true;
|
|
575
|
+
var body = bodyChunks.length ? Buffer.concat(bodyChunks) : null;
|
|
576
|
+
bodyChunks = []; bodyBuffered = 0;
|
|
577
|
+
try {
|
|
578
|
+
session.sendHeaders(streamId, requestHeaderList(body ? body.length : null), !body && !reqTrailers);
|
|
579
|
+
if (body) session.sendData(streamId, body, !reqTrailers);
|
|
580
|
+
if (reqTrailers) session.sendTrailers(streamId, reqTrailers);
|
|
581
|
+
} catch (err) {
|
|
582
|
+
fail(err);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
var clientReq = {
|
|
587
|
+
on: function (n, f) { ev.on(n, f); return clientReq; },
|
|
588
|
+
once: function (n, f) { ev.once(n, f); return clientReq; },
|
|
589
|
+
emit: function () { ev.emit.apply(ev, arguments); return clientReq; },
|
|
590
|
+
|
|
591
|
+
write: function (chunk, encoding) {
|
|
592
|
+
if (ended || settled) return false;
|
|
593
|
+
if (typeof chunk === 'string') chunk = Buffer.from(chunk, encoding || 'utf8');
|
|
594
|
+
if (!chunk || chunk.length === 0) return !needDrain;
|
|
595
|
+
if (streamId === null) {
|
|
596
|
+
bodyChunks.push(chunk);
|
|
597
|
+
bodyBuffered += chunk.length;
|
|
598
|
+
if (bodyBuffered >= BODY_HIGH_WATER) { needDrain = true; return false; }
|
|
599
|
+
return true;
|
|
600
|
+
}
|
|
601
|
+
return streamChunk(chunk);
|
|
602
|
+
},
|
|
603
|
+
|
|
604
|
+
// RFC 9218: the `priority` header while HEADERS have not gone, a
|
|
605
|
+
// PRIORITY_UPDATE after.
|
|
606
|
+
setPriority: function (p) {
|
|
607
|
+
if (sent || streaming) { if (session && streamId !== null && !settled) session.sendPriorityUpdate(streamId, p); return; }
|
|
608
|
+
var h = options.headers || (options.headers = {});
|
|
609
|
+
for (var k in h) if (k.toLowerCase() === 'priority') delete h[k];
|
|
610
|
+
var v = formatPriority(p);
|
|
611
|
+
if (v) h.priority = v;
|
|
612
|
+
},
|
|
613
|
+
|
|
614
|
+
// `trailers` (node:http's addTrailers, through the unified client): a
|
|
615
|
+
// HEADERS frame after the body, with END_STREAM (RFC 9113 §8.1).
|
|
616
|
+
end: function (chunk, encoding, trailers) {
|
|
617
|
+
if (ended) return clientReq;
|
|
618
|
+
if (encoding && typeof encoding === 'object') { trailers = encoding; encoding = undefined; }
|
|
619
|
+
if (typeof chunk === 'string') chunk = Buffer.from(chunk, encoding || 'utf8');
|
|
620
|
+
reqTrailers = trailerFields(trailers);
|
|
621
|
+
if (streaming) {
|
|
622
|
+
// The body went as it was written: its last piece, and END_STREAM —
|
|
623
|
+
// on the trailers when there are some.
|
|
624
|
+
ended = true;
|
|
625
|
+
try {
|
|
626
|
+
if (reqTrailers) {
|
|
627
|
+
if (chunk && chunk.length) session.sendData(streamId, chunk, false);
|
|
628
|
+
session.sendTrailers(streamId, reqTrailers);
|
|
629
|
+
} else session.sendData(streamId, chunk && chunk.length ? chunk : null, true);
|
|
630
|
+
} catch (err) { fail(err); }
|
|
631
|
+
return clientReq;
|
|
632
|
+
}
|
|
633
|
+
if (chunk && chunk.length) { bodyChunks.push(chunk); bodyBuffered += chunk.length; }
|
|
634
|
+
ended = true;
|
|
635
|
+
if (streamId !== null) sendRequest();
|
|
636
|
+
return clientReq;
|
|
637
|
+
},
|
|
638
|
+
|
|
639
|
+
abort: function () {
|
|
640
|
+
if (session && streamId !== null) session.cancel(streamId);
|
|
641
|
+
fail(new Error('Request aborted'));
|
|
642
|
+
},
|
|
643
|
+
destroy: function (err) {
|
|
644
|
+
if (session && streamId !== null) session.cancel(streamId);
|
|
645
|
+
fail(err || new Error('Request destroyed'));
|
|
646
|
+
},
|
|
647
|
+
|
|
648
|
+
setTimeout: function (ms, cb) {
|
|
649
|
+
if (cb) ev.once('timeout', cb);
|
|
650
|
+
if (ms > 0) {
|
|
651
|
+
var t = setTimeout(function () { ev.emit('timeout'); }, ms);
|
|
652
|
+
if (t.unref) t.unref();
|
|
653
|
+
}
|
|
654
|
+
return clientReq;
|
|
655
|
+
},
|
|
656
|
+
|
|
657
|
+
get session() { return session; },
|
|
658
|
+
// 0-RTT: 'accepted', 'rejected', or null (not sent early).
|
|
659
|
+
get earlyData() { return ev.earlyData; }
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
process.nextTick(function () { if (!settled) connect(); });
|
|
663
|
+
|
|
664
|
+
return clientReq;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
function get(options, callback) {
|
|
669
|
+
var req = request(Object.assign({}, options, { method: 'GET' }), callback);
|
|
670
|
+
req.end();
|
|
671
|
+
return req;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
export { request, get, H2Pool, H2Session, globalPool };
|