quico 0.1.3 → 0.2.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,317 @@
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 dgram from 'node:dgram';
16
+ import dns from 'node:dns';
17
+ import { QUICConnection } from './quic_connection.js';
18
+ import { H3Connection } from './h3.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
+ var rejectUnauthorized = options.rejectUnauthorized !== false;
42
+
43
+ // Connection reuse: pass _connection = { quic, h3, udpSocket, nextStreamId }
44
+ var existingConn = options._connection || null;
45
+ var autoClose = existingConn ? false : true;
46
+
47
+ // ---- Client Request object ----
48
+ var reqBody = [];
49
+ var reqFinished = false;
50
+ var reqListeners = {};
51
+ var quic = existingConn ? existingConn.quic : null;
52
+ var h3 = existingConn ? existingConn.h3 : null;
53
+ var udpSocket = existingConn ? existingConn.udpSocket : null;
54
+ var streamId = null; // assigned when connection is ready
55
+ var connected = existingConn ? true : false;
56
+
57
+ var clientReq = {
58
+ on: function (event, fn) {
59
+ (reqListeners[event] = reqListeners[event] || []).push(fn);
60
+ return clientReq;
61
+ },
62
+
63
+ write: function (chunk) {
64
+ if (typeof chunk === 'string') chunk = new TextEncoder().encode(chunk);
65
+ reqBody.push(chunk);
66
+ return true;
67
+ },
68
+
69
+ end: function (chunk) {
70
+ if (chunk) clientReq.write(chunk);
71
+ reqFinished = true;
72
+ if (connected) sendRequest();
73
+ return clientReq;
74
+ },
75
+
76
+ destroy: function () {
77
+ if (quic) quic.close(0, 'client destroy');
78
+ if (udpSocket) { try { udpSocket.close(); } catch (e) {} }
79
+ }
80
+ };
81
+
82
+ // ---- Client Response object ----
83
+ var clientRes = {
84
+ statusCode: 0,
85
+ headers: {},
86
+ _listeners: {},
87
+ _headersReceived: false,
88
+
89
+ on: function (event, fn) {
90
+ (clientRes._listeners[event] = clientRes._listeners[event] || []).push(fn);
91
+ return clientRes;
92
+ }
93
+ };
94
+
95
+ function emit(obj, event, data) {
96
+ var listeners = (obj._listeners || obj)[event] || [];
97
+ // For reqListeners which is a plain object
98
+ if (obj === reqListeners) listeners = reqListeners[event] || [];
99
+ for (var i = 0; i < listeners.length; i++) {
100
+ try { if (data !== undefined) listeners[i](data); else listeners[i](); } catch (e) {}
101
+ }
102
+ }
103
+
104
+
105
+ // ---- Resolve hostname and connect (or reuse existing) ----
106
+ if (existingConn) {
107
+ // Reuse existing connection — allocate stream ID and send when ready
108
+ streamId = allocateStreamId(existingConn);
109
+ setupResponseHandlers();
110
+ if (reqFinished) sendRequest();
111
+ } else {
112
+ resolveAndConnect(hostname, port);
113
+ }
114
+
115
+
116
+ function resolveAndConnect(host, port) {
117
+ // If already IP, skip DNS
118
+ if (/^[\d.]+$/.test(host) || host.indexOf(':') >= 0 || host === 'localhost') {
119
+ var ip = (host === 'localhost') ? '127.0.0.1' : host;
120
+ startConnection(ip, port);
121
+ return;
122
+ }
123
+
124
+ dns.lookup(host, function (err, address) {
125
+ if (err) {
126
+ emit(reqListeners, 'error', err);
127
+ return;
128
+ }
129
+ startConnection(address, port);
130
+ });
131
+ }
132
+
133
+
134
+ function startConnection(remoteIp, remotePort) {
135
+ // Create UDP socket
136
+ var isIPv6 = remoteIp.indexOf(':') >= 0;
137
+ udpSocket = dgram.createSocket(isIPv6 ? 'udp6' : 'udp4');
138
+
139
+ udpSocket.on('message', function (msg, rinfo) {
140
+ if (quic) quic.feedDatagram(rinfo.address, rinfo.port, new Uint8Array(msg));
141
+ });
142
+
143
+ udpSocket.on('error', function (err) {
144
+ emit(reqListeners, 'error', err);
145
+ });
146
+
147
+ // Bind to random port
148
+ udpSocket.bind(0, function () {
149
+ // Create QUIC connection (client mode)
150
+ quic = new QUICConnection({
151
+ isServer: false,
152
+ SNICallback: null,
153
+ hostname: hostname
154
+ });
155
+
156
+ // Send packets via UDP
157
+ quic.on('packet', function (data) {
158
+ udpSocket.send(data, remotePort, remoteIp, function (err) {
159
+ if (err) emit(reqListeners, 'error', err);
160
+ });
161
+ });
162
+
163
+ // QUIC connected → set up H3
164
+ quic.on('connect', function () {
165
+ console.log('[client] QUIC connected');
166
+
167
+ h3 = new H3Connection({ quicConnection: quic, isServer: false });
168
+ connected = true;
169
+
170
+ // Build connection object for reuse
171
+ var conn = { quic: quic, h3: h3, udpSocket: udpSocket, _nextStreamId: 0 };
172
+ streamId = allocateStreamId(conn);
173
+
174
+ // Store on clientReq so caller (unified client) can reuse
175
+ clientReq._connection = conn;
176
+
177
+ setupResponseHandlers();
178
+
179
+ // Send request if body is ready
180
+ if (reqFinished) sendRequest();
181
+ });
182
+
183
+ quic.on('close', function () {
184
+ if (udpSocket) { try { udpSocket.close(); } catch (e) {} }
185
+ });
186
+
187
+ // Start QUIC handshake
188
+ quic.connect();
189
+ });
190
+ }
191
+
192
+
193
+ /**
194
+ * Allocate next client-initiated bidi stream ID.
195
+ * Client bidi stream IDs: 0, 4, 8, 12, ... (id = n * 4)
196
+ */
197
+ function allocateStreamId(conn) {
198
+ var id = conn._nextStreamId;
199
+ conn._nextStreamId += 4;
200
+ return id;
201
+ }
202
+
203
+
204
+ /**
205
+ * Set up HTTP/3 response handlers for this request's streamId.
206
+ * Works for both new and reused connections.
207
+ */
208
+ function setupResponseHandlers() {
209
+ // HTTP response headers
210
+ h3.on('http_headers', function (sid, hdrs) {
211
+ if (sid !== streamId) return;
212
+ clientRes.headers = hdrs;
213
+ clientRes.statusCode = parseInt(hdrs[':status']) || 0;
214
+ clientRes._headersReceived = true;
215
+
216
+ // Fire callback with response
217
+ if (typeof callback === 'function') {
218
+ callback(clientRes);
219
+ callback = null; // only once
220
+ }
221
+ emit(reqListeners, 'response', clientRes);
222
+ });
223
+
224
+ // HTTP response body
225
+ h3.on('http_body', function (sid, data) {
226
+ if (sid !== streamId) return;
227
+ emit(clientRes, 'data', data);
228
+ });
229
+
230
+ // HTTP response end
231
+ h3.on('http_end', function (sid) {
232
+ if (sid !== streamId) return;
233
+ emit(clientRes, 'end');
234
+
235
+ // Auto-close only if we own the connection (not reusing)
236
+ if (autoClose) {
237
+ setTimeout(function () {
238
+ if (quic) quic.close(0, 'done');
239
+ if (udpSocket) { try { udpSocket.close(); } catch (e) {} }
240
+ }, 100);
241
+ }
242
+ });
243
+ }
244
+
245
+
246
+ function sendRequest() {
247
+ if (!h3 || !connected) return;
248
+
249
+ console.log('[client] sendRequest method=' + method + ' path=' + path + ' stream=' + streamId);
250
+
251
+ // MUST send control streams (SETTINGS) before any request
252
+ h3.sendControlStreams();
253
+
254
+ // Build H3 request headers
255
+ var h3Headers = {
256
+ ':method': method,
257
+ ':scheme': 'https',
258
+ ':authority': hostname + (port !== 443 ? ':' + port : ''),
259
+ ':path': path
260
+ };
261
+
262
+ // Add user headers
263
+ for (var name in headers) {
264
+ h3Headers[name.toLowerCase()] = String(headers[name]);
265
+ }
266
+
267
+ // Default headers
268
+ if (!h3Headers['user-agent']) h3Headers['user-agent'] = 'quico/1.0';
269
+ if (!h3Headers['accept']) h3Headers['accept'] = '*/*';
270
+ if (!h3Headers['accept-encoding']) h3Headers['accept-encoding'] = 'identity';
271
+
272
+ // Send body if any
273
+ if (reqBody.length > 0) {
274
+ // Send headers without FIN, then body chunks
275
+ h3.sendHeaders(streamId, h3Headers, false);
276
+ for (var i = 0; i < reqBody.length; i++) {
277
+ var isLast = (i === reqBody.length - 1);
278
+ h3.sendBody(streamId, reqBody[i], isLast);
279
+ }
280
+ } else {
281
+ // No body — send headers with FIN directly
282
+ h3.sendHeaders(streamId, h3Headers, true);
283
+ }
284
+ }
285
+
286
+
287
+ return clientReq;
288
+ }
289
+
290
+
291
+ // ============================================================
292
+ // quico.get(url, callback)
293
+ // Shortcut — like https.get()
294
+ // ============================================================
295
+
296
+ function get(url, callback) {
297
+ var opts;
298
+ if (typeof url === 'string') {
299
+ var parsed = new URL(url);
300
+ opts = {
301
+ hostname: parsed.hostname,
302
+ port: parseInt(parsed.port) || 443,
303
+ path: parsed.pathname + parsed.search,
304
+ method: 'GET'
305
+ };
306
+ } else {
307
+ opts = url;
308
+ opts.method = 'GET';
309
+ }
310
+
311
+ var req = request(opts, callback);
312
+ req.end();
313
+ return req;
314
+ }
315
+
316
+
317
+ export { request, get };