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/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 };