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,523 @@
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 { Duplex, Readable, Writable } from 'node:stream';
17
+ import { Emitter, readVarInt, writeVarInt } from './utils.js';
18
+ import { parse_quic_datagram } from './transport.js';
19
+ import { QUICConnection } from './quic_connection.js';
20
+ import { H3Connection } from './h3.js';
21
+ import { createSecureContext } from './tls_bridge.js';
22
+
23
+
24
+ // ============================================================
25
+ // createServer(options, handler)
26
+ // ============================================================
27
+
28
+ function createServer(options, handler) {
29
+ options = options || {};
30
+
31
+ var ev = Emitter();
32
+ var udp4 = null;
33
+ var udp6 = null;
34
+ var port = null;
35
+
36
+ // Build SNICallback
37
+ var sniCallback = options.SNICallback || null;
38
+
39
+ if (!sniCallback && options.key && options.cert) {
40
+ var defaultCtx = createSecureContext({ key: options.key, cert: options.cert, ca: options.ca });
41
+ sniCallback = function (servername, cb) { cb(null, defaultCtx); };
42
+ }
43
+
44
+ // ---- Connection tracking ----
45
+ var connections = {}; // connId → { quic, h3, http_streams }
46
+ var addressMap = {}; // "ip:port" → connId
47
+ var maxConnections = options.maxConnections || 10000;
48
+ var sweepTimer = null;
49
+
50
+
51
+ // ============================================================
52
+ // WebTransport helpers
53
+ // ============================================================
54
+
55
+ function _emitOnReq(req, event, data) {
56
+ // If unified server wrapped the req, emit on the wrapper (IncomingMessage/EventEmitter)
57
+ if (req._wrapper && typeof req._wrapper.emit === 'function') {
58
+ req._wrapper.emit(event, data);
59
+ return;
60
+ }
61
+ // Fallback: emit on raw req's _listeners
62
+ var listeners = req._listeners[event] || [];
63
+ for (var i = 0; i < listeners.length; i++) {
64
+ try { if (data !== undefined) listeners[i](data); else listeners[i](); } catch (e) {}
65
+ }
66
+ }
67
+
68
+ function _createWtStream(conn, quic, wtStreamId, sessionId, isBidi) {
69
+ if (!conn._wtStreamObjects) conn._wtStreamObjects = {};
70
+
71
+ var stream;
72
+ if (isBidi) {
73
+ stream = new Duplex({
74
+ read: function () {},
75
+ write: function (chunk, encoding, cb) {
76
+ if (typeof chunk === 'string') chunk = Buffer.from(chunk, encoding);
77
+ quic.sendStream(wtStreamId, new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength), false);
78
+ cb();
79
+ },
80
+ final: function (cb) {
81
+ quic.sendStream(wtStreamId, new Uint8Array(0), true);
82
+ cb();
83
+ }
84
+ });
85
+ } else {
86
+ stream = new Readable({ read: function () {} });
87
+ }
88
+
89
+ stream._streamId = wtStreamId;
90
+ stream._sessionId = sessionId;
91
+ stream._pushData = function (data) {
92
+ if (data instanceof Uint8Array) data = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
93
+ stream.push(data);
94
+ };
95
+ stream._endData = function () { stream.push(null); };
96
+
97
+ conn._wtStreamObjects[wtStreamId] = stream;
98
+ return stream;
99
+ }
100
+
101
+
102
+ // ============================================================
103
+ // set_http_stream — reactive HTTP stream builder
104
+ // Creates req/res on first call, fires handler when ready.
105
+ // Same pattern as old quico h3_server.js
106
+ // ============================================================
107
+
108
+ function set_http_stream(connId, streamId, params) {
109
+ var conn = connections[connId];
110
+ if (!conn) return;
111
+
112
+ var is_new = false;
113
+
114
+ // Create req/res on first encounter of this stream
115
+ if (!(streamId in conn.http_streams)) {
116
+
117
+ var req = {
118
+ method: null,
119
+ url: null,
120
+ headers: {},
121
+ httpVersion: '3.0',
122
+ stream_id: Number(streamId),
123
+ _listeners: {},
124
+ _isWebTransport: false,
125
+
126
+ on: function (event, fn) {
127
+ (req._listeners[event] = req._listeners[event] || []).push(fn);
128
+ }
129
+ };
130
+
131
+ var res = {
132
+ statusCode: 200,
133
+ headers: {},
134
+ headersSent: false,
135
+ _finished: false,
136
+
137
+ setHeader: function (name, value) {
138
+ res.headers[name.toLowerCase()] = String(value);
139
+ },
140
+
141
+ getHeader: function (name) {
142
+ return res.headers[name.toLowerCase()];
143
+ },
144
+
145
+ writeHead: function (statusCode, headers) {
146
+ if (res.headersSent) return res;
147
+ res.statusCode = statusCode;
148
+
149
+ if (typeof headers === 'object') {
150
+ for (var name in headers) {
151
+ res.headers[name.toLowerCase()] = String(headers[name]);
152
+ }
153
+ }
154
+
155
+ var h3Headers = { ':status': String(statusCode) };
156
+ for (var name in res.headers) {
157
+ h3Headers[name] = res.headers[name];
158
+ }
159
+
160
+ if (conn.h3) {
161
+ // Send headers WITHOUT FIN for WT (session stays open)
162
+ var isWt = req.headers[':protocol'] === 'webtransport';
163
+ conn.h3.sendHeaders(streamId, h3Headers, isWt ? false : undefined);
164
+ }
165
+ res.headersSent = true;
166
+
167
+ // If this is a WT CONNECT with 200 → register session
168
+ if (req.headers[':protocol'] === 'webtransport' && statusCode === 200 && conn.h3) {
169
+ req._isWebTransport = true;
170
+ conn.h3.registerWebTransportSession(Number(streamId));
171
+ }
172
+
173
+ return res;
174
+ },
175
+
176
+ write: function (chunk) {
177
+ if (!res.headersSent) res.writeHead(res.statusCode);
178
+ if (typeof chunk === 'string') chunk = new TextEncoder().encode(chunk);
179
+ if (conn.h3) conn.h3.sendBody(streamId, chunk, false);
180
+ return true;
181
+ },
182
+
183
+ end: function (chunk) {
184
+ if (res._finished) return;
185
+ if (!res.headersSent) res.writeHead(res.statusCode);
186
+ if (conn.h3) {
187
+ if (chunk) {
188
+ if (typeof chunk === 'string') chunk = new TextEncoder().encode(chunk);
189
+ conn.h3.sendBody(streamId, chunk, true);
190
+ } else {
191
+ conn.h3.sendBody(streamId, null, true);
192
+ }
193
+ }
194
+ res._finished = true;
195
+ },
196
+
197
+ // ---- WebTransport methods on res ----
198
+
199
+ sendDatagram: function (data) {
200
+ if (!req._isWebTransport) return;
201
+ if (typeof data === 'string') data = Buffer.from(data);
202
+ if (Buffer.isBuffer(data)) data = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
203
+ var quarterSid = Math.floor(Number(streamId) / 4);
204
+ var prefix = writeVarInt(quarterSid);
205
+ var frame = new Uint8Array(prefix.length + data.byteLength);
206
+ frame.set(prefix, 0);
207
+ frame.set(data, prefix.length);
208
+ conn.quic.sendDatagram(frame);
209
+ },
210
+
211
+ createBidirectionalStream: function () {
212
+ if (!req._isWebTransport || !conn.h3) return null;
213
+ var wtStreamId = conn.h3.createWebTransportStream(Number(streamId), true);
214
+ return _createWtStream(conn, conn.quic, wtStreamId, Number(streamId), true);
215
+ },
216
+
217
+ createUnidirectionalStream: function () {
218
+ if (!req._isWebTransport || !conn.h3) return null;
219
+ var wtStreamId = conn.h3.createWebTransportStream(Number(streamId), false);
220
+ var stream = new Writable({
221
+ write: function (chunk, encoding, cb) {
222
+ if (typeof chunk === 'string') chunk = Buffer.from(chunk, encoding);
223
+ conn.quic.sendStream(wtStreamId, new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength), false);
224
+ cb();
225
+ },
226
+ final: function (cb) {
227
+ conn.quic.sendStream(wtStreamId, new Uint8Array(0), true);
228
+ cb();
229
+ }
230
+ });
231
+ stream._streamId = wtStreamId;
232
+ return stream;
233
+ }
234
+ };
235
+
236
+ conn.http_streams[streamId] = { req: req, res: res };
237
+ is_new = true;
238
+ }
239
+
240
+ // Apply params
241
+ if (typeof params === 'object') {
242
+
243
+ if ('request_headers' in params) {
244
+ var stream = conn.http_streams[streamId];
245
+ for (var name in params.request_headers) {
246
+ stream.req.headers[name] = params.request_headers[name];
247
+ }
248
+
249
+ // Extract :method and :path
250
+ if (stream.req.method === null && ':method' in stream.req.headers) {
251
+ stream.req.method = stream.req.headers[':method'];
252
+ }
253
+ if (stream.req.url === null && ':path' in stream.req.headers) {
254
+ stream.req.url = stream.req.headers[':path'];
255
+ }
256
+ }
257
+
258
+ if ('request_body_chunk' in params) {
259
+ var stream = conn.http_streams[streamId];
260
+ var listeners = stream.req._listeners['data'] || [];
261
+ for (var i = 0; i < listeners.length; i++) {
262
+ try { listeners[i](params.request_body_chunk); } catch (e) { }
263
+ }
264
+ }
265
+
266
+ if ('request_end' in params) {
267
+ var stream = conn.http_streams[streamId];
268
+ var listeners = stream.req._listeners['end'] || [];
269
+ for (var i = 0; i < listeners.length; i++) {
270
+ try { listeners[i](); } catch (e) { }
271
+ }
272
+ }
273
+ }
274
+
275
+ // Fire handler on new stream (after headers are set)
276
+ if (is_new) {
277
+ var stream = conn.http_streams[streamId];
278
+ if (typeof handler === 'function') {
279
+ handler(stream.req, stream.res);
280
+ }
281
+ ev.emit('request', stream.req, stream.res);
282
+ }
283
+ }
284
+
285
+
286
+ // ============================================================
287
+ // UDP → connection routing
288
+ // ============================================================
289
+
290
+ function onUdpMessage(msg, rinfo) {
291
+ var data = new Uint8Array(msg);
292
+ var fromIp = rinfo.address;
293
+ var fromPort = rinfo.port;
294
+ console.log('[server] UDP from ' + fromIp + ':' + fromPort + ' len=' + data.length);
295
+
296
+ var addressKey = fromIp + ':' + fromPort;
297
+ var packets = parse_quic_datagram(data);
298
+ if (packets.length === 0) return;
299
+
300
+ for (var i = 0; i < packets.length; i++) {
301
+ var pkt = packets[i];
302
+ if (!pkt) continue;
303
+
304
+ var connId = findConnectionId(pkt, addressKey);
305
+
306
+ if (!(connId in connections)) {
307
+ // Reject new connections when at capacity
308
+ if (Object.keys(connections).length >= maxConnections) return;
309
+ createConnection(connId, fromIp, fromPort, addressKey);
310
+ }
311
+
312
+ connections[connId].quic.feedDatagram(fromIp, fromPort, pkt.raw);
313
+ }
314
+ }
315
+
316
+
317
+ function findConnectionId(pkt, addressKey) {
318
+ var dcidStr = null;
319
+ if (pkt.dcid && pkt.dcid.byteLength > 0) {
320
+ dcidStr = Array.from(pkt.dcid).join('');
321
+ }
322
+
323
+ if (dcidStr && dcidStr in connections) return dcidStr;
324
+ if (addressKey in addressMap && addressMap[addressKey] in connections) return addressMap[addressKey];
325
+
326
+ var id = dcidStr || String(Math.floor(Math.random() * 9007199254740991));
327
+ addressMap[addressKey] = id;
328
+ return id;
329
+ }
330
+
331
+
332
+ function createConnection(connId, fromIp, fromPort, addressKey) {
333
+ var quic = new QUICConnection({ SNICallback: sniCallback });
334
+
335
+ connections[connId] = {
336
+ quic: quic,
337
+ h3: null,
338
+ http_streams: {}
339
+ };
340
+
341
+ addressMap[addressKey] = connId;
342
+
343
+ // Send packets back via UDP
344
+ quic.on('packet', function (data) {
345
+ sendUdp(fromIp, fromPort, data);
346
+ });
347
+
348
+ // QUIC handshake complete → set up H3
349
+ quic.on('connect', function () {
350
+ var h3 = new H3Connection({ quicConnection: quic, enableWebTransport: true });
351
+ connections[connId].h3 = h3;
352
+
353
+ // HTTP request headers received → set_http_stream (reactive)
354
+ h3.on('http_headers', function (streamId, headers) {
355
+ set_http_stream(connId, streamId, { request_headers: headers });
356
+ });
357
+
358
+ // HTTP request body chunk → set_http_stream
359
+ h3.on('http_body', function (streamId, data) {
360
+ set_http_stream(connId, streamId, { request_body_chunk: data });
361
+ });
362
+
363
+ // HTTP request complete → fire req.on('end')
364
+ h3.on('http_end', function (streamId) {
365
+ set_http_stream(connId, streamId, { request_end: true });
366
+ });
367
+
368
+ // ---- WebTransport stream events ----
369
+
370
+ // New WT stream (bidi or uni from client)
371
+ h3.on('wt_stream', function (sessionId, wtStreamId, data, fin, isBidi) {
372
+ var conn = connections[connId];
373
+ if (!conn) return;
374
+ var session = conn.http_streams[sessionId];
375
+ if (!session || !session.req._isWebTransport) return;
376
+
377
+ // Create a Duplex-like stream for the WT stream
378
+ var wtStream = _createWtStream(conn, quic, wtStreamId, sessionId, isBidi);
379
+
380
+ // Push initial data
381
+ if (data.byteLength > 0) _emitOnReq(session.req, 'data:' + wtStreamId, data);
382
+ if (fin) _emitOnReq(session.req, 'end:' + wtStreamId);
383
+
384
+ // Emit on req
385
+ if (isBidi) {
386
+ _emitOnReq(session.req, 'stream', wtStream);
387
+ } else {
388
+ _emitOnReq(session.req, 'unidirectionalStream', wtStream);
389
+ }
390
+
391
+ // If initial data, push to stream
392
+ if (data.byteLength > 0) wtStream._pushData(data);
393
+ if (fin) wtStream._endData();
394
+ });
395
+
396
+ // Subsequent data on WT stream
397
+ h3.on('wt_data', function (sessionId, wtStreamId, data, fin) {
398
+ var conn = connections[connId];
399
+ if (!conn || !conn._wtStreamObjects) return;
400
+ var wtStream = conn._wtStreamObjects[wtStreamId];
401
+ if (!wtStream) return;
402
+ if (data.byteLength > 0) wtStream._pushData(data);
403
+ if (fin) { wtStream._endData(); delete conn._wtStreamObjects[wtStreamId]; }
404
+ });
405
+
406
+ // Datagrams
407
+ quic.on('datagram', function (_contextId, rawData) {
408
+ if (!rawData || rawData.byteLength === 0) return;
409
+ var result = readVarInt(rawData, 0);
410
+ if (!result) return;
411
+ var quarterSid = result.value;
412
+ var payload = rawData.slice(result.byteLength);
413
+
414
+ // Find the session: quarter_stream_id = session_stream_id / 4
415
+ var conn = connections[connId];
416
+ if (!conn) return;
417
+ for (var sid in conn.http_streams) {
418
+ var session = conn.http_streams[sid];
419
+ if (session.req._isWebTransport && Math.floor(Number(sid) / 4) === quarterSid) {
420
+ _emitOnReq(session.req, 'datagram', Buffer.from(payload));
421
+ break;
422
+ }
423
+ }
424
+ });
425
+ });
426
+
427
+ quic.on('close', function () {
428
+ delete connections[connId];
429
+ if (addressMap[addressKey] === connId) delete addressMap[addressKey];
430
+ });
431
+ }
432
+
433
+
434
+ // ============================================================
435
+ // UDP send
436
+ // ============================================================
437
+
438
+ function sendUdp(toIp, toPort, data) {
439
+ var socket = toIp.indexOf(':') >= 0 ? udp6 : udp4;
440
+ if (!socket) return;
441
+ socket.send(data, toPort, toIp, function (err) {
442
+ if (err) ev.emit('error', err);
443
+ });
444
+ }
445
+
446
+
447
+ // ============================================================
448
+ // listen / close
449
+ // ============================================================
450
+
451
+ function listen(listenPort, host, callback) {
452
+ if (typeof host === 'function') { callback = host; host = null; }
453
+ port = listenPort || 443;
454
+ host = host || '::';
455
+
456
+ udp4 = dgram.createSocket('udp4');
457
+ udp4.on('message', onUdpMessage);
458
+ udp4.on('error', function (err) { ev.emit('error', err); });
459
+ var host4 = host.indexOf('.') !== -1 ? host : '0.0.0.0';
460
+ udp4.bind(port, host4);
461
+
462
+ udp6 = dgram.createSocket({ type: 'udp6', ipv6Only: true });
463
+ udp6.on('message', onUdpMessage);
464
+ udp6.on('error', function (err) { ev.emit('error', err); });
465
+ var host6 = host.indexOf(':') !== -1 ? host : '::';
466
+ udp6.bind(port, host6, function () {
467
+ if (typeof callback === 'function') callback();
468
+ });
469
+
470
+ // Periodic sweep for dead connections (safety net)
471
+ sweepTimer = setInterval(function () {
472
+ for (var id in connections) {
473
+ var st = connections[id].quic.state;
474
+ if (st === 'closed') {
475
+ delete connections[id];
476
+ // Clean address map
477
+ for (var addr in addressMap) {
478
+ if (addressMap[addr] === id) delete addressMap[addr];
479
+ }
480
+ }
481
+ }
482
+ }, 30000);
483
+ }
484
+
485
+ function close(callback) {
486
+ // Stop sweep
487
+ if (sweepTimer) { clearInterval(sweepTimer); sweepTimer = null; }
488
+
489
+ // Close all connections
490
+ for (var id in connections) {
491
+ try { connections[id].quic.close(0, 'server closing'); } catch (e) { }
492
+ }
493
+ connections = {};
494
+ addressMap = {};
495
+
496
+ var closed = 0;
497
+ var total = (udp4 ? 1 : 0) + (udp6 ? 1 : 0);
498
+ if (total === 0) { if (callback) callback(); return; }
499
+
500
+ function onClose() {
501
+ closed++;
502
+ if (closed >= total && typeof callback === 'function') callback();
503
+ }
504
+
505
+ if (udp4) { udp4.close(onClose); udp4 = null; }
506
+ if (udp6) { udp6.close(onClose); udp6 = null; }
507
+ }
508
+
509
+
510
+ // ============================================================
511
+ // Server API
512
+ // ============================================================
513
+
514
+ return {
515
+ listen: listen,
516
+ close: close,
517
+ on: function (name, fn) { ev.on(name, fn); },
518
+ setTimeout: function () { /* TODO */ }
519
+ };
520
+ }
521
+
522
+
523
+ export { createServer };