quico 0.1.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +381 -216
- package/index.cjs +45 -0
- package/index.d.ts +158 -0
- package/index.js +49 -23
- package/package.json +83 -60
- package/src/agent.js +283 -0
- package/src/client.js +444 -0
- package/src/crypto.js +534 -0
- package/src/h3.js +920 -0
- package/src/h3_client.js +332 -0
- package/src/h3_server.js +392 -0
- package/src/quic_connection.js +2338 -0
- package/src/quic_server.js +314 -0
- package/src/quic_socket.js +76 -0
- package/src/server.js +354 -0
- package/src/streams.js +471 -0
- package/src/tls_bridge.js +273 -0
- package/src/transport.js +667 -0
- package/src/utils.js +345 -0
- package/src/webtransport.js +655 -0
- package/CHANGELOG.md +0 -31
- package/examples/webtransport_datagram_echo_server/client.html +0 -58
- package/examples/webtransport_datagram_echo_server/server.js +0 -23
- package/h3_server.js +0 -416
- package/h3_socket.js +0 -473
- package/libs/crypto.js +0 -1841
- package/libs/h3.js +0 -1461
- package/libs/utils.js +0 -404
- package/quic_socket.js +0 -1550
- package/quico.svg +0 -1
package/src/client.js
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
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 nodeHttps from 'node:https';
|
|
16
|
+
import nodeHttp2 from 'node:http2';
|
|
17
|
+
import { URL } from 'node:url';
|
|
18
|
+
|
|
19
|
+
import { request as h3Request } from './h3_client.js';
|
|
20
|
+
import { ClientRequest, IncomingMessage } from './streams.js';
|
|
21
|
+
import { globalAgent } from './agent.js';
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
// ============================================================
|
|
25
|
+
// Unified request(url, options, callback)
|
|
26
|
+
//
|
|
27
|
+
// Mirrors node:https.request() signature.
|
|
28
|
+
// Returns a ClientRequest (Writable stream).
|
|
29
|
+
//
|
|
30
|
+
// HTTP/3 is always tried first. Fallback chain depends on flags:
|
|
31
|
+
// options.http2 (default: agent.http2 or true)
|
|
32
|
+
// options.http1 (default: agent.http1 or true)
|
|
33
|
+
// ============================================================
|
|
34
|
+
|
|
35
|
+
function request(url, options, callback) {
|
|
36
|
+
|
|
37
|
+
// ---- Normalize arguments (same overloads as node:https) ----
|
|
38
|
+
if (typeof url === 'string') {
|
|
39
|
+
var parsed = new URL(url);
|
|
40
|
+
var fromUrl = {
|
|
41
|
+
protocol: parsed.protocol,
|
|
42
|
+
hostname: parsed.hostname,
|
|
43
|
+
port: parsed.port ? parseInt(parsed.port) : 443,
|
|
44
|
+
path: parsed.pathname + parsed.search
|
|
45
|
+
};
|
|
46
|
+
if (typeof options === 'function') {
|
|
47
|
+
callback = options;
|
|
48
|
+
options = fromUrl;
|
|
49
|
+
} else {
|
|
50
|
+
options = Object.assign(fromUrl, options || {});
|
|
51
|
+
}
|
|
52
|
+
} else if (typeof url === 'object') {
|
|
53
|
+
if (typeof options === 'function') { callback = options; }
|
|
54
|
+
options = url;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
options = options || {};
|
|
58
|
+
|
|
59
|
+
var hostname = options.hostname || options.host || 'localhost';
|
|
60
|
+
// Strip port suffix from host (e.g. "example.com:443")
|
|
61
|
+
var colonIdx = hostname.lastIndexOf(':');
|
|
62
|
+
if (colonIdx > 0) {
|
|
63
|
+
var afterColon = hostname.substring(colonIdx + 1);
|
|
64
|
+
if (/^\d+$/.test(afterColon)) {
|
|
65
|
+
if (!options.port) options.port = parseInt(afterColon);
|
|
66
|
+
hostname = hostname.substring(0, colonIdx);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
var port = parseInt(options.port) || 443;
|
|
71
|
+
var method = (options.method || 'GET').toUpperCase();
|
|
72
|
+
var path = options.path || '/';
|
|
73
|
+
var headers = options.headers || {};
|
|
74
|
+
var agent = options.agent !== undefined ? options.agent : globalAgent;
|
|
75
|
+
|
|
76
|
+
// ---- Resolve protocol flags: options → agent → true ----
|
|
77
|
+
var enableH2 = options.http2 !== undefined ? options.http2
|
|
78
|
+
: (agent && agent.http2 !== undefined) ? agent.http2
|
|
79
|
+
: true;
|
|
80
|
+
var enableH1 = options.http1 !== undefined ? options.http1
|
|
81
|
+
: (agent && agent.http1 !== undefined) ? agent.http1
|
|
82
|
+
: true;
|
|
83
|
+
|
|
84
|
+
var h3Timeout = options.h3Timeout
|
|
85
|
+
|| (agent && agent.h3Timeout)
|
|
86
|
+
|| 3000;
|
|
87
|
+
|
|
88
|
+
// ---- Create ClientRequest (returned immediately) ----
|
|
89
|
+
var clientReq = new ClientRequest({
|
|
90
|
+
method: method,
|
|
91
|
+
path: path,
|
|
92
|
+
hostname: hostname,
|
|
93
|
+
port: port,
|
|
94
|
+
headers: headers,
|
|
95
|
+
_responseCallback: callback
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Track if response was already delivered (prevent double-delivery)
|
|
99
|
+
var responded = false;
|
|
100
|
+
var _fallbackTimer = null;
|
|
101
|
+
|
|
102
|
+
// ---- Wait for body if needed, then connect ----
|
|
103
|
+
var bodyReady = (method === 'GET' || method === 'HEAD');
|
|
104
|
+
|
|
105
|
+
if (!bodyReady) {
|
|
106
|
+
clientReq.once('_bodyReady', function () {
|
|
107
|
+
bodyReady = true;
|
|
108
|
+
maybeConnect();
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
process.nextTick(function () { maybeConnect(); });
|
|
113
|
+
|
|
114
|
+
function maybeConnect() {
|
|
115
|
+
if (method !== 'GET' && method !== 'HEAD' && !bodyReady) return;
|
|
116
|
+
doProtocolSelection();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
// ============================================================
|
|
121
|
+
// Build fallback chain based on flags + cache
|
|
122
|
+
// ============================================================
|
|
123
|
+
|
|
124
|
+
function doProtocolSelection() {
|
|
125
|
+
var cached = agent ? agent.getProtocol(hostname, port) : null;
|
|
126
|
+
|
|
127
|
+
// Build ordered fallback chain
|
|
128
|
+
// H3 is always first (that's the point of quico)
|
|
129
|
+
var chain = [tryH3];
|
|
130
|
+
if (enableH2) chain.push(tryH2);
|
|
131
|
+
if (enableH1) chain.push(tryHttps);
|
|
132
|
+
|
|
133
|
+
// If we have a cached protocol, try it first (skip discovery)
|
|
134
|
+
if (cached === 'h3') {
|
|
135
|
+
runChain(chain, 0);
|
|
136
|
+
} else if (cached === 'h2' && enableH2) {
|
|
137
|
+
runChain([tryH2, tryH3, enableH1 ? tryHttps : null].filter(Boolean), 0);
|
|
138
|
+
} else if (cached === 'https' && enableH1) {
|
|
139
|
+
runChain([tryHttps, tryH3, enableH2 ? tryH2 : null].filter(Boolean), 0);
|
|
140
|
+
} else {
|
|
141
|
+
// No cache — H3 first with timeout, then fallbacks
|
|
142
|
+
runChainWithH3Timeout(chain, h3Timeout);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Run through the fallback chain. If step N fails, try step N+1.
|
|
148
|
+
*/
|
|
149
|
+
function runChain(chain, index) {
|
|
150
|
+
if (responded || clientReq.destroyed || clientReq.aborted) return;
|
|
151
|
+
if (index >= chain.length) {
|
|
152
|
+
// All protocols failed
|
|
153
|
+
clientReq.emit('error', new Error('All protocols failed for ' + hostname + ':' + port));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
chain[index](function onFail(err) {
|
|
158
|
+
if (responded) return;
|
|
159
|
+
runChain(chain, index + 1);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Special first-run: try H3 with a timeout.
|
|
165
|
+
* If H3 doesn't connect in time, start fallbacks in parallel.
|
|
166
|
+
*/
|
|
167
|
+
function runChainWithH3Timeout(chain, timeout) {
|
|
168
|
+
if (chain.length <= 1) {
|
|
169
|
+
// H3 only, no fallback — just run it
|
|
170
|
+
runChain(chain, 0);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
var h3Done = false;
|
|
175
|
+
var timer = setTimeout(function () {
|
|
176
|
+
if (!h3Done && !responded) {
|
|
177
|
+
h3Done = true;
|
|
178
|
+
// H3 timed out — try rest of chain
|
|
179
|
+
runChain(chain.slice(1), 0);
|
|
180
|
+
}
|
|
181
|
+
}, timeout);
|
|
182
|
+
_fallbackTimer = timer;
|
|
183
|
+
if (timer.unref) timer.unref(); // Don't prevent process exit
|
|
184
|
+
|
|
185
|
+
// Try H3
|
|
186
|
+
tryH3(function onFail(err) {
|
|
187
|
+
clearTimeout(timer);
|
|
188
|
+
if (!h3Done && !responded) {
|
|
189
|
+
h3Done = true;
|
|
190
|
+
runChain(chain.slice(1), 0);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
// ============================================================
|
|
197
|
+
// H3 transport (wraps existing h3_client.js)
|
|
198
|
+
// ============================================================
|
|
199
|
+
|
|
200
|
+
function tryH3(onFail) {
|
|
201
|
+
if (responded || clientReq.destroyed || clientReq.aborted) return;
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
var h3Opts = {
|
|
205
|
+
hostname: hostname,
|
|
206
|
+
port: port,
|
|
207
|
+
path: path,
|
|
208
|
+
method: method,
|
|
209
|
+
headers: Object.assign({}, headers)
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// Check Agent for existing QUIC connection (multiplexing).
|
|
213
|
+
// With an agent, the connection's lifecycle is owned by the pool
|
|
214
|
+
// (sweep / idle timer / agent.destroy), so signal h3_client not to
|
|
215
|
+
// auto-close it when this request finishes — otherwise the first
|
|
216
|
+
// request would tear down a connection still pooled for reuse.
|
|
217
|
+
if (agent) {
|
|
218
|
+
h3Opts._managed = true;
|
|
219
|
+
var existing = agent.getH3Connection(hostname, port);
|
|
220
|
+
if (existing) h3Opts._connection = existing;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
var h3Req = h3Request(h3Opts, function (h3Res) {
|
|
224
|
+
if (responded) return;
|
|
225
|
+
|
|
226
|
+
// Store connection for future reuse
|
|
227
|
+
if (agent && h3Req._connection && !h3Opts._connection) {
|
|
228
|
+
agent.setH3Connection(hostname, port, h3Req._connection);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
var res = new IncomingMessage({
|
|
232
|
+
httpVersion: '3.0',
|
|
233
|
+
statusCode: h3Res.statusCode,
|
|
234
|
+
statusMessage: '',
|
|
235
|
+
headers: h3Res.headers || {}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
clientReq.protocol = 'h3';
|
|
239
|
+
if (agent) agent.setProtocol(hostname, port, 'h3');
|
|
240
|
+
|
|
241
|
+
h3Res.on('data', function (chunk) { res._pushData(chunk); });
|
|
242
|
+
h3Res.on('end', function () { res._endData(); });
|
|
243
|
+
h3Res.on('error', function (err) { res._abort(err); });
|
|
244
|
+
|
|
245
|
+
deliverResponse(res);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
h3Req.on('error', function (err) {
|
|
249
|
+
onFail(err);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
var body = clientReq._getBody();
|
|
253
|
+
if (body) h3Req.write(body);
|
|
254
|
+
h3Req.end();
|
|
255
|
+
|
|
256
|
+
} catch (err) {
|
|
257
|
+
onFail(err);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
// ============================================================
|
|
263
|
+
// H2 transport (node:http2)
|
|
264
|
+
// ============================================================
|
|
265
|
+
|
|
266
|
+
function tryH2(onFail) {
|
|
267
|
+
if (responded || clientReq.destroyed || clientReq.aborted) return;
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
var session = agent ? agent.getH2Session(hostname, port) : null;
|
|
271
|
+
|
|
272
|
+
if (!session) {
|
|
273
|
+
var authority = 'https://' + hostname + ':' + port;
|
|
274
|
+
session = nodeHttp2.connect(authority, {
|
|
275
|
+
rejectUnauthorized: options.rejectUnauthorized !== false
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
session.on('error', function (err) {
|
|
279
|
+
onFail(err);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
if (agent) agent.setH2Session(hostname, port, session);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
var h2Headers = {
|
|
286
|
+
':method': method,
|
|
287
|
+
':path': path,
|
|
288
|
+
':authority': hostname,
|
|
289
|
+
':scheme': 'https'
|
|
290
|
+
};
|
|
291
|
+
for (var name in headers) {
|
|
292
|
+
if (name[0] !== ':') h2Headers[name.toLowerCase()] = headers[name];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
var h2Req = session.request(h2Headers);
|
|
296
|
+
|
|
297
|
+
h2Req.on('response', function (responseHeaders) {
|
|
298
|
+
if (responded) return;
|
|
299
|
+
|
|
300
|
+
var statusCode = responseHeaders[':status'] || 200;
|
|
301
|
+
var flatHeaders = {};
|
|
302
|
+
for (var k in responseHeaders) {
|
|
303
|
+
if (k[0] !== ':') flatHeaders[k] = responseHeaders[k];
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Alt-Svc discovery
|
|
307
|
+
if (agent && flatHeaders['alt-svc']) {
|
|
308
|
+
agent.parseAltSvc(hostname, port, flatHeaders['alt-svc']);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
var res = new IncomingMessage({
|
|
312
|
+
httpVersion: '2.0',
|
|
313
|
+
statusCode: statusCode,
|
|
314
|
+
headers: flatHeaders
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
clientReq.protocol = 'h2';
|
|
318
|
+
if (agent) agent.setProtocol(hostname, port, 'h2');
|
|
319
|
+
|
|
320
|
+
h2Req.on('data', function (chunk) { res._pushData(chunk); });
|
|
321
|
+
h2Req.on('end', function () { res._endData(); });
|
|
322
|
+
h2Req.on('error', function (err) { res._abort(err); });
|
|
323
|
+
|
|
324
|
+
deliverResponse(res);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
h2Req.on('error', function (err) {
|
|
328
|
+
onFail(err);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
var body = clientReq._getBody();
|
|
332
|
+
if (body) h2Req.write(Buffer.from(body));
|
|
333
|
+
h2Req.end();
|
|
334
|
+
|
|
335
|
+
} catch (err) {
|
|
336
|
+
onFail(err);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
// ============================================================
|
|
342
|
+
// HTTPS transport (node:https — HTTP/1.1)
|
|
343
|
+
// ============================================================
|
|
344
|
+
|
|
345
|
+
function tryHttps(onFail) {
|
|
346
|
+
if (responded || clientReq.destroyed || clientReq.aborted) return;
|
|
347
|
+
|
|
348
|
+
try {
|
|
349
|
+
var httpsOpts = {
|
|
350
|
+
hostname: hostname,
|
|
351
|
+
port: port,
|
|
352
|
+
path: path,
|
|
353
|
+
method: method,
|
|
354
|
+
headers: Object.assign({}, headers),
|
|
355
|
+
rejectUnauthorized: options.rejectUnauthorized !== false
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
var httpsReq = nodeHttps.request(httpsOpts, function (httpsRes) {
|
|
359
|
+
if (responded) return;
|
|
360
|
+
|
|
361
|
+
if (agent && httpsRes.headers['alt-svc']) {
|
|
362
|
+
agent.parseAltSvc(hostname, port, httpsRes.headers['alt-svc']);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
var res = new IncomingMessage({
|
|
366
|
+
httpVersion: httpsRes.httpVersion || '1.1',
|
|
367
|
+
statusCode: httpsRes.statusCode,
|
|
368
|
+
statusMessage: httpsRes.statusMessage || '',
|
|
369
|
+
headers: httpsRes.headers || {}
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
clientReq.protocol = 'https';
|
|
373
|
+
if (agent) agent.setProtocol(hostname, port, 'https');
|
|
374
|
+
|
|
375
|
+
httpsRes.on('data', function (chunk) { res._pushData(chunk); });
|
|
376
|
+
httpsRes.on('end', function () { res._endData(); });
|
|
377
|
+
httpsRes.on('error', function (err) { res._abort(err); });
|
|
378
|
+
|
|
379
|
+
deliverResponse(res);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
httpsReq.on('error', function (err) {
|
|
383
|
+
onFail(err);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
var body = clientReq._getBody();
|
|
387
|
+
if (body) httpsReq.write(Buffer.from(body));
|
|
388
|
+
httpsReq.end();
|
|
389
|
+
|
|
390
|
+
} catch (err) {
|
|
391
|
+
onFail(err);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
// ============================================================
|
|
397
|
+
// Deliver response
|
|
398
|
+
// ============================================================
|
|
399
|
+
|
|
400
|
+
function deliverResponse(res) {
|
|
401
|
+
if (responded) return;
|
|
402
|
+
responded = true;
|
|
403
|
+
|
|
404
|
+
if (clientReq._timeoutTimer) {
|
|
405
|
+
clearTimeout(clientReq._timeoutTimer);
|
|
406
|
+
clientReq._timeoutTimer = null;
|
|
407
|
+
}
|
|
408
|
+
if (_fallbackTimer) {
|
|
409
|
+
clearTimeout(_fallbackTimer);
|
|
410
|
+
_fallbackTimer = null;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
clientReq.emit('response', res);
|
|
414
|
+
if (clientReq._responseCallback) clientReq._responseCallback(res);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
// ---- Auto-end GET/HEAD on next tick ----
|
|
419
|
+
if (method === 'GET' || method === 'HEAD') {
|
|
420
|
+
process.nextTick(function () {
|
|
421
|
+
if (!clientReq.finished && !clientReq.writableEnded) {
|
|
422
|
+
clientReq.end();
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return clientReq;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
// ============================================================
|
|
432
|
+
// get() — convenience for GET requests
|
|
433
|
+
// ============================================================
|
|
434
|
+
|
|
435
|
+
function get(url, options, callback) {
|
|
436
|
+
if (typeof options === 'function') { callback = options; options = {}; }
|
|
437
|
+
if (typeof url === 'object' && !options) { options = url; url = undefined; }
|
|
438
|
+
var opts = url ? { method: 'GET' } : Object.assign({}, options, { method: 'GET' });
|
|
439
|
+
var req = url ? request(url, opts, callback) : request(opts, callback);
|
|
440
|
+
return req;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
export { request, get };
|