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.
@@ -0,0 +1,332 @@
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 dns from 'node:dns';
16
+ import { DEBUG } from './utils.js';
17
+ import { H3Connection } from './h3.js';
18
+ import { createQuicClientSocket } from './quic_socket.js';
19
+
20
+
21
+ // ============================================================
22
+ // quico.request(options, callback)
23
+ // API mirrors Node.js https.request()
24
+ // ============================================================
25
+
26
+ function request(options, callback) {
27
+ if (typeof options === 'string') {
28
+ var url = new URL(options);
29
+ options = {
30
+ hostname: url.hostname,
31
+ port: parseInt(url.port) || 443,
32
+ path: url.pathname + url.search
33
+ };
34
+ }
35
+
36
+ var hostname = options.hostname || options.host || 'localhost';
37
+ var port = options.port || 443;
38
+ var path = options.path || '/';
39
+ var method = (options.method || 'GET').toUpperCase();
40
+ var headers = options.headers || {};
41
+
42
+ // Connection reuse: pass _connection = { quic, h3, udpSocket, nextStreamId }
43
+ var existingConn = options._connection || null;
44
+ // Auto-close the QUIC connection when this request finishes ONLY when we own
45
+ // it — i.e. a fresh, standalone connection. Reused connections (existingConn)
46
+ // and connections whose lifecycle is managed by an external pool
47
+ // (options._managed, set by the unified client/agent) are left open; closing
48
+ // them here would tear down a connection still pooled for reuse. Managed
49
+ // connections are reaped by the agent (sweep/destroy) and the QUIC idle timer.
50
+ var autoClose = !existingConn && !options._managed;
51
+
52
+ // ---- Client Request object ----
53
+ var reqBody = [];
54
+ var reqFinished = false;
55
+ var reqListeners = {};
56
+ var quic = existingConn ? existingConn.quic : null;
57
+ var h3 = existingConn ? existingConn.h3 : null;
58
+ var udpSocket = existingConn ? existingConn.udpSocket : null;
59
+ var streamId = null; // assigned when connection is ready
60
+ var connected = existingConn ? true : false;
61
+
62
+ var clientReq = {
63
+ on: function (event, fn) {
64
+ (reqListeners[event] = reqListeners[event] || []).push(fn);
65
+ return clientReq;
66
+ },
67
+
68
+ write: function (chunk) {
69
+ if (typeof chunk === 'string') chunk = new TextEncoder().encode(chunk);
70
+ reqBody.push(chunk);
71
+ return true;
72
+ },
73
+
74
+ end: function (chunk) {
75
+ if (chunk) clientReq.write(chunk);
76
+ reqFinished = true;
77
+ if (connected) sendRequest();
78
+ return clientReq;
79
+ },
80
+
81
+ destroy: function () {
82
+ if (quic) quic.close(0, 'client destroy');
83
+ if (udpSocket) { try { udpSocket.close(); } catch (e) {} }
84
+ }
85
+ };
86
+
87
+ // ---- Client Response object ----
88
+ var clientRes = {
89
+ statusCode: 0,
90
+ headers: {},
91
+ _listeners: {},
92
+ _headersReceived: false,
93
+
94
+ on: function (event, fn) {
95
+ (clientRes._listeners[event] = clientRes._listeners[event] || []).push(fn);
96
+ return clientRes;
97
+ }
98
+ };
99
+
100
+ function emit(obj, event, data) {
101
+ var listeners = (obj._listeners || obj)[event] || [];
102
+ // For reqListeners which is a plain object
103
+ if (obj === reqListeners) listeners = reqListeners[event] || [];
104
+ for (var i = 0; i < listeners.length; i++) {
105
+ try { if (data !== undefined) listeners[i](data); else listeners[i](); } catch (e) {}
106
+ }
107
+ }
108
+
109
+
110
+ // ---- Resolve hostname and connect (or reuse existing) ----
111
+ if (existingConn) {
112
+ // Reuse existing connection — allocate stream ID and send when ready.
113
+ // The shared dispatch listeners were installed when the connection was
114
+ // first created; installSharedHandlers() is idempotent.
115
+ streamId = allocateStreamId(existingConn);
116
+ installSharedHandlers(existingConn);
117
+ setupResponseHandlers(existingConn);
118
+ if (reqFinished) sendRequest();
119
+ } else {
120
+ resolveAndConnect(hostname, port);
121
+ }
122
+
123
+
124
+ function resolveAndConnect(host, port) {
125
+ // If already IP, skip DNS
126
+ if (/^[\d.]+$/.test(host) || host.indexOf(':') >= 0 || host === 'localhost') {
127
+ var ip = (host === 'localhost') ? '127.0.0.1' : host;
128
+ startConnection(ip, port);
129
+ return;
130
+ }
131
+
132
+ dns.lookup(host, function (err, address) {
133
+ if (err) {
134
+ emit(reqListeners, 'error', err);
135
+ return;
136
+ }
137
+ startConnection(address, port);
138
+ });
139
+ }
140
+
141
+
142
+ function startConnection(remoteIp, remotePort) {
143
+ udpSocket = createQuicClientSocket({
144
+ remoteIp: remoteIp,
145
+ remotePort: remotePort,
146
+ hostname: hostname,
147
+
148
+ onError: function (err) {
149
+ emit(reqListeners, 'error', err);
150
+ },
151
+
152
+ onConnect: function (q, s) {
153
+ quic = q;
154
+ udpSocket = s;
155
+ if (DEBUG) console.log('[client] QUIC connected');
156
+
157
+ h3 = new H3Connection({ quicConnection: quic, isServer: false });
158
+ connected = true;
159
+
160
+ // Build connection object for reuse
161
+ var conn = { quic: quic, h3: h3, udpSocket: udpSocket, _nextStreamId: 0 };
162
+
163
+ // Install the shared per-connection dispatch listeners ONCE, then
164
+ // register this request in the connection's stream-handler map.
165
+ installSharedHandlers(conn);
166
+ streamId = allocateStreamId(conn);
167
+
168
+ // Store on clientReq so caller (unified client) can reuse
169
+ clientReq._connection = conn;
170
+
171
+ setupResponseHandlers(conn);
172
+
173
+ // Send request if body is ready
174
+ if (reqFinished) sendRequest();
175
+ }
176
+ // onClose: the helper closes the UDP socket; nothing extra needed here.
177
+ });
178
+ }
179
+
180
+
181
+ /**
182
+ * Allocate next client-initiated bidi stream ID.
183
+ * Client bidi stream IDs: 0, 4, 8, 12, ... (id = n * 4)
184
+ */
185
+ function allocateStreamId(conn) {
186
+ var id = conn._nextStreamId;
187
+ conn._nextStreamId += 4;
188
+ return id;
189
+ }
190
+
191
+
192
+ /**
193
+ * Install the three HTTP/3 event listeners ONCE per connection. They dispatch
194
+ * each event to the right request via the connection's stream-handler map
195
+ * (keyed by stream id), so multiplexing many requests over a single
196
+ * connection does NOT pile up listeners on the H3 emitter (previously every
197
+ * request added another set of listeners that were never removed).
198
+ * Idempotent per connection.
199
+ */
200
+ function installSharedHandlers(conn) {
201
+ if (conn._streamHandlers) return; // already installed for this connection
202
+ conn._streamHandlers = {};
203
+ var h3c = conn.h3;
204
+
205
+ h3c.on('http_headers', function (sid, hdrs) {
206
+ var h = conn._streamHandlers[sid];
207
+ if (h) h.onHeaders(hdrs);
208
+ });
209
+ h3c.on('http_body', function (sid, data) {
210
+ var h = conn._streamHandlers[sid];
211
+ if (h) h.onBody(data);
212
+ });
213
+ h3c.on('http_end', function (sid) {
214
+ var h = conn._streamHandlers[sid];
215
+ if (!h) return;
216
+ delete conn._streamHandlers[sid]; // free per-stream state before dispatch
217
+ h.onEnd();
218
+ });
219
+ }
220
+
221
+
222
+ /**
223
+ * Register this request's response handlers in the connection's stream map,
224
+ * keyed by its streamId. Replaces per-request h3.on(...) registration —
225
+ * routing is now by map key, so no per-listener streamId check is needed.
226
+ */
227
+ function setupResponseHandlers(conn) {
228
+ conn._streamHandlers[streamId] = {
229
+ onHeaders: function (hdrs) {
230
+ clientRes.headers = hdrs;
231
+ clientRes.statusCode = parseInt(hdrs[':status']) || 0;
232
+ clientRes._headersReceived = true;
233
+
234
+ // Fire callback with response (once)
235
+ if (typeof callback === 'function') {
236
+ callback(clientRes);
237
+ callback = null;
238
+ }
239
+ emit(reqListeners, 'response', clientRes);
240
+ },
241
+
242
+ onBody: function (data) {
243
+ emit(clientRes, 'data', data);
244
+ },
245
+
246
+ onEnd: function () {
247
+ emit(clientRes, 'end');
248
+
249
+ // Auto-close only if we own the connection (see `autoClose` above).
250
+ if (autoClose) {
251
+ setTimeout(function () {
252
+ if (quic) quic.close(0, 'done');
253
+ if (udpSocket) { try { udpSocket.close(); } catch (e) {} }
254
+ }, 100);
255
+ }
256
+ }
257
+ };
258
+ }
259
+
260
+
261
+ function sendRequest() {
262
+ if (!h3 || !connected) return;
263
+
264
+ if (DEBUG) console.log('[client] sendRequest method=' + method + ' path=' + path + ' stream=' + streamId);
265
+
266
+ // MUST send control streams (SETTINGS) before any request
267
+ h3.sendControlStreams();
268
+
269
+ // Build H3 request headers
270
+ var h3Headers = {
271
+ ':method': method,
272
+ ':scheme': 'https',
273
+ ':authority': hostname + (port !== 443 ? ':' + port : ''),
274
+ ':path': path
275
+ };
276
+
277
+ // Add user headers
278
+ for (var name in headers) {
279
+ h3Headers[name.toLowerCase()] = String(headers[name]);
280
+ }
281
+
282
+ // Default headers
283
+ if (!h3Headers['user-agent']) h3Headers['user-agent'] = 'quico/1.0';
284
+ if (!h3Headers['accept']) h3Headers['accept'] = '*/*';
285
+ if (!h3Headers['accept-encoding']) h3Headers['accept-encoding'] = 'identity';
286
+
287
+ // Send body if any
288
+ if (reqBody.length > 0) {
289
+ // Send headers without FIN, then body chunks
290
+ h3.sendHeaders(streamId, h3Headers, false);
291
+ for (var i = 0; i < reqBody.length; i++) {
292
+ var isLast = (i === reqBody.length - 1);
293
+ h3.sendBody(streamId, reqBody[i], isLast);
294
+ }
295
+ } else {
296
+ // No body — send headers with FIN directly
297
+ h3.sendHeaders(streamId, h3Headers, true);
298
+ }
299
+ }
300
+
301
+
302
+ return clientReq;
303
+ }
304
+
305
+
306
+ // ============================================================
307
+ // quico.get(url, callback)
308
+ // Shortcut — like https.get()
309
+ // ============================================================
310
+
311
+ function get(url, callback) {
312
+ var opts;
313
+ if (typeof url === 'string') {
314
+ var parsed = new URL(url);
315
+ opts = {
316
+ hostname: parsed.hostname,
317
+ port: parseInt(parsed.port) || 443,
318
+ path: parsed.pathname + parsed.search,
319
+ method: 'GET'
320
+ };
321
+ } else {
322
+ opts = url;
323
+ opts.method = 'GET';
324
+ }
325
+
326
+ var req = request(opts, callback);
327
+ req.end();
328
+ return req;
329
+ }
330
+
331
+
332
+ export { request, get };