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,314 @@
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 { DEBUG, Emitter } from './utils.js';
17
+ import { parse_quic_datagram } from './transport.js';
18
+ import { QUICConnection } from './quic_connection.js';
19
+ import { createSecureContext } from './tls_bridge.js';
20
+
21
+
22
+ // ============================================================
23
+ // createQuicServer(options)
24
+ //
25
+ // Transport-only QUIC server. Owns the UDP socket(s), demultiplexes
26
+ // incoming datagrams to per-peer QUICConnection objects, and emits a
27
+ // 'connection' event carrying the raw QUICConnection. It knows nothing
28
+ // about HTTP/3 — application protocols (HTTP/3, DNS-over-QUIC, ...) are
29
+ // layered on top by listening for 'connection' and then using the
30
+ // connection's own stream/datagram API.
31
+ //
32
+ // This is the connection-management harness that used to live inside
33
+ // h3_server.js. h3_server now builds on it (wrapping each connection in
34
+ // an H3Connection); other consumers (e.g. a DoQ server) can use it
35
+ // directly.
36
+ //
37
+ // Events:
38
+ // 'connection' (quic, peer) — new QUICConnection created for a peer.
39
+ // peer = { address, port }. Handlers
40
+ // attach quic.on('connect'|'stream'|
41
+ // 'datagram'|'close') as needed.
42
+ // 'error' (err) — UDP socket / send error.
43
+ //
44
+ // Options:
45
+ // alpn — ALPN protocol(s) to advertise (default 'h3').
46
+ // DoQ servers pass 'doq'.
47
+ // SNICallback — TLS SNI callback returning a SecureContext.
48
+ // key/cert/ca — convenience: build a default SecureContext when no
49
+ // SNICallback is given.
50
+ // maxConnections — capacity cap (default 10000).
51
+ // socket/socket6 — externally-owned, already-bound UDP sockets
52
+ // (shared-port mode, RFC 9443). When provided, the
53
+ // caller feeds datagrams via handlePacket(msg, rinfo).
54
+ // ============================================================
55
+
56
+ function createQuicServer(options) {
57
+ options = options || {};
58
+
59
+ var ev = Emitter();
60
+ var udp4 = null;
61
+ var udp6 = null;
62
+ var port = null;
63
+
64
+ // ALPN to advertise on every accepted connection. Default ['h3'].
65
+ var alpn = options.alpn || ['h3'];
66
+
67
+ // ── External socket mode (shared UDP port) ──
68
+ // When socket/socket6 are provided, the UDP layer is owned externally.
69
+ // The caller demuxes packets and feeds them via handlePacket(msg, rinfo).
70
+ // See RFC 9443 for the shared-port multiplexing scheme.
71
+ var externalSocket4 = options.socket || null;
72
+ var externalSocket6 = options.socket6 || null;
73
+
74
+ if (externalSocket4) {
75
+ try { externalSocket4.address(); }
76
+ catch (e) { throw new Error('options.socket must be bound before createQuicServer()'); }
77
+ }
78
+ if (externalSocket6) {
79
+ try { externalSocket6.address(); }
80
+ catch (e) { throw new Error('options.socket6 must be bound before createQuicServer()'); }
81
+ }
82
+
83
+ var isExternal = !!(externalSocket4 || externalSocket6);
84
+
85
+ // Build SNICallback
86
+ var sniCallback = options.SNICallback || null;
87
+
88
+ if (!sniCallback && options.key && options.cert) {
89
+ var defaultCtx = createSecureContext({ key: options.key, cert: options.cert, ca: options.ca });
90
+ sniCallback = function (servername, cb) { cb(null, defaultCtx); };
91
+ }
92
+
93
+ // ---- Connection tracking ----
94
+ var connections = {}; // connId → { quic, peer }
95
+ var addressMap = {}; // "ip:port" → connId
96
+ var maxConnections = options.maxConnections || 10000;
97
+ var sweepTimer = null;
98
+
99
+
100
+ // ============================================================
101
+ // UDP → connection routing
102
+ // ============================================================
103
+
104
+ function onUdpMessage(msg, rinfo) {
105
+ var data = new Uint8Array(msg);
106
+ var fromIp = rinfo.address;
107
+ var fromPort = rinfo.port;
108
+ if (DEBUG) console.log('[qserver] UDP from ' + fromIp + ':' + fromPort + ' len=' + data.length);
109
+
110
+ var addressKey = fromIp + ':' + fromPort;
111
+ var packets = parse_quic_datagram(data);
112
+ if (packets.length === 0) return;
113
+
114
+ // Route the whole datagram by its first packet. Coalesced packets in one
115
+ // UDP datagram belong to the same connection (RFC 9000 §12.2), so a single
116
+ // routing decision covers them all — and the datagram is parsed only once
117
+ // here, handing the parsed packets straight to the connection (previously
118
+ // each packet's raw bytes were re-parsed inside feedDatagram).
119
+ var connId = findConnectionId(packets[0], addressKey);
120
+ if (connId === null) return; // stray/unmatched non-Initial packet — drop
121
+
122
+ if (!(connId in connections)) {
123
+ // Reject new connections when at capacity
124
+ if (Object.keys(connections).length >= maxConnections) return;
125
+ createConnection(connId, fromIp, fromPort, addressKey);
126
+ }
127
+
128
+ connections[connId].quic.feedPackets(fromIp, fromPort, packets);
129
+ }
130
+
131
+
132
+ function findConnectionId(pkt, addressKey) {
133
+ var dcidStr = null;
134
+ if (pkt.dcid && pkt.dcid.byteLength > 0) {
135
+ dcidStr = Array.from(pkt.dcid).join('');
136
+ }
137
+
138
+ if (dcidStr && dcidStr in connections) return dcidStr;
139
+ if (addressKey in addressMap && addressMap[addressKey] in connections) return addressMap[addressKey];
140
+
141
+ // No existing connection matches. Only an Initial packet may *start* a new
142
+ // connection; any other unmatched packet (a stray/late short-header packet,
143
+ // or a Handshake whose Initial was lost, etc.) is dropped rather than
144
+ // spawning a phantom connection keyed by a random id.
145
+ if (pkt.type !== 'initial') return null;
146
+
147
+ var id = dcidStr || String(Math.floor(Math.random() * 9007199254740991));
148
+ addressMap[addressKey] = id;
149
+ return id;
150
+ }
151
+
152
+
153
+ function createConnection(connId, fromIp, fromPort, addressKey) {
154
+ var quic = new QUICConnection({ SNICallback: sniCallback, alpn: alpn });
155
+
156
+ connections[connId] = {
157
+ quic: quic,
158
+ peer: { address: fromIp, port: fromPort }
159
+ };
160
+
161
+ addressMap[addressKey] = connId;
162
+
163
+ // Send packets back via UDP
164
+ quic.on('packet', function (data) {
165
+ sendUdp(fromIp, fromPort, data);
166
+ });
167
+
168
+ // Drop our bookkeeping when the connection closes.
169
+ quic.on('close', function () {
170
+ delete connections[connId];
171
+ if (addressMap[addressKey] === connId) delete addressMap[addressKey];
172
+ });
173
+
174
+ // Hand the raw connection to the application layer (H3, DoQ, ...).
175
+ ev.emit('connection', quic, { address: fromIp, port: fromPort });
176
+ }
177
+
178
+
179
+ // ============================================================
180
+ // UDP send
181
+ // ============================================================
182
+
183
+ function sendUdp(toIp, toPort, data) {
184
+ var isV6 = toIp.indexOf(':') >= 0;
185
+ // Prefer external socket (shared mode) over internally-bound socket
186
+ var socket = isV6
187
+ ? (externalSocket6 || udp6)
188
+ : (externalSocket4 || udp4);
189
+ if (!socket) return;
190
+ socket.send(data, toPort, toIp, function (err) {
191
+ if (err) ev.emit('error', err);
192
+ });
193
+ }
194
+
195
+
196
+ // ============================================================
197
+ // listen / close
198
+ // ============================================================
199
+
200
+ function listen(listenPort, host, callback) {
201
+ if (typeof host === 'function') { callback = host; host = null; }
202
+ port = listenPort || 443;
203
+ host = host || '::';
204
+
205
+ if (isExternal) {
206
+ // External sockets already bound — wire up incoming messages.
207
+ // Starting the sweep timer is safe here (idempotent check below).
208
+ if (externalSocket4) {
209
+ externalSocket4.on('message', onUdpMessage);
210
+ }
211
+ if (externalSocket6) {
212
+ externalSocket6.on('message', onUdpMessage);
213
+ }
214
+ startSweepTimer();
215
+ if (typeof callback === 'function') setImmediate(callback);
216
+ return;
217
+ }
218
+
219
+ udp4 = dgram.createSocket('udp4');
220
+ udp4.on('message', onUdpMessage);
221
+ udp4.on('error', function (err) { ev.emit('error', err); });
222
+ var host4 = host.indexOf('.') !== -1 ? host : '0.0.0.0';
223
+ udp4.bind(port, host4);
224
+
225
+ udp6 = dgram.createSocket({ type: 'udp6', ipv6Only: true });
226
+ udp6.on('message', onUdpMessage);
227
+ udp6.on('error', function (err) { ev.emit('error', err); });
228
+ var host6 = host.indexOf(':') !== -1 ? host : '::';
229
+ udp6.bind(port, host6, function () {
230
+ if (typeof callback === 'function') callback();
231
+ });
232
+
233
+ startSweepTimer();
234
+ }
235
+
236
+ // Periodic sweep for dead connections (safety net). Runs independently
237
+ // of socket ownership so external-mode agents get the same cleanup.
238
+ function startSweepTimer() {
239
+ if (sweepTimer) return; // idempotent
240
+ sweepTimer = setInterval(function () {
241
+ for (var id in connections) {
242
+ var st = connections[id].quic.state;
243
+ if (st === 'closed') {
244
+ delete connections[id];
245
+ // Clean address map
246
+ for (var addr in addressMap) {
247
+ if (addressMap[addr] === id) delete addressMap[addr];
248
+ }
249
+ }
250
+ }
251
+ }, 30000);
252
+ if (sweepTimer.unref) sweepTimer.unref();
253
+ }
254
+
255
+ function close(callback) {
256
+ // Stop sweep
257
+ if (sweepTimer) { clearInterval(sweepTimer); sweepTimer = null; }
258
+
259
+ // Close all connections
260
+ for (var id in connections) {
261
+ try { connections[id].quic.close(0, 'server closing'); } catch (e) { }
262
+ }
263
+ connections = {};
264
+ addressMap = {};
265
+
266
+ var closed = 0;
267
+ var total = (udp4 ? 1 : 0) + (udp6 ? 1 : 0);
268
+ if (total === 0) { if (callback) callback(); return; }
269
+
270
+ function onClose() {
271
+ closed++;
272
+ if (closed >= total && typeof callback === 'function') callback();
273
+ }
274
+
275
+ if (udp4) { udp4.close(onClose); udp4 = null; }
276
+ if (udp6) { udp6.close(onClose); udp6 = null; }
277
+ }
278
+
279
+
280
+ // ============================================================
281
+ // External packet API (for shared UDP port scenarios)
282
+ // ============================================================
283
+
284
+ // Query whether the given 5-tuple matches an active QUIC connection.
285
+ // Used by the demuxer to disambiguate byte 0 in range 64-79 between
286
+ // QUIC short header and TURN ChannelData (RFC 9443). Fresh connections
287
+ // must be classified by byte 0 range (80-127 or 192+ = QUIC).
288
+ function hasConnection(rinfo) {
289
+ var key = rinfo.address + ':' + rinfo.port;
290
+ return (key in addressMap) && (addressMap[key] in connections);
291
+ }
292
+
293
+
294
+ // ============================================================
295
+ // Server API
296
+ // ============================================================
297
+
298
+ return {
299
+ listen: listen,
300
+ close: close,
301
+ on: function (name, fn) { ev.on(name, fn); },
302
+
303
+ /** Feed an incoming UDP packet when running in external-socket mode.
304
+ * rinfo must be in Node's dgram format: { address, port, family, size }. */
305
+ handlePacket: onUdpMessage,
306
+
307
+ /** Returns true if the 5-tuple matches an active QUIC connection.
308
+ * Used by demuxers for routing decisions on shared UDP ports. */
309
+ hasConnection: hasConnection
310
+ };
311
+ }
312
+
313
+
314
+ export { createQuicServer };
@@ -0,0 +1,76 @@
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 { QUICConnection } from './quic_connection.js';
17
+
18
+
19
+ /**
20
+ * Create a UDP socket bound to an ephemeral port and wire it to a client-side
21
+ * QUICConnection. Shared by the HTTP/3 client (h3_client.js) and the
22
+ * WebTransport client (webtransport.js), which had near-identical setup code:
23
+ * pick udp4/udp6, create the socket, forward datagrams into the connection,
24
+ * bind, create the client QUICConnection, forward outgoing packets to the
25
+ * socket, and start the handshake.
26
+ *
27
+ * opts:
28
+ * remoteIp, remotePort — where to send packets
29
+ * hostname — SNI / :authority for the QUIC/TLS handshake
30
+ * onConnect(quic, socket) — called once the QUIC handshake completes
31
+ * onClose() — called when the connection closes (the socket is
32
+ * already closed by the time this runs)
33
+ * onError(err) — socket errors and outgoing-packet send errors
34
+ *
35
+ * Returns the UDP socket synchronously. The QUICConnection is created after the
36
+ * socket binds, so it is delivered via onConnect rather than returned here.
37
+ */
38
+ function createQuicClientSocket(opts) {
39
+ var isIPv6 = opts.remoteIp.indexOf(':') >= 0;
40
+ var udpSocket = dgram.createSocket(isIPv6 ? 'udp6' : 'udp4');
41
+ var quic = null;
42
+
43
+ udpSocket.on('message', function (msg, rinfo) {
44
+ if (quic) quic.feedDatagram(rinfo.address, rinfo.port, new Uint8Array(msg));
45
+ });
46
+
47
+ udpSocket.on('error', function (err) {
48
+ if (opts.onError) opts.onError(err);
49
+ });
50
+
51
+ udpSocket.bind(0, function () {
52
+ quic = new QUICConnection({ isServer: false, hostname: opts.hostname });
53
+
54
+ quic.on('packet', function (data) {
55
+ udpSocket.send(data, opts.remotePort, opts.remoteIp, function (err) {
56
+ if (err && opts.onError) opts.onError(err);
57
+ });
58
+ });
59
+
60
+ quic.on('connect', function () {
61
+ if (opts.onConnect) opts.onConnect(quic, udpSocket);
62
+ });
63
+
64
+ quic.on('close', function () {
65
+ try { udpSocket.close(); } catch (e) {}
66
+ if (opts.onClose) opts.onClose();
67
+ });
68
+
69
+ quic.connect();
70
+ });
71
+
72
+ return udpSocket;
73
+ }
74
+
75
+
76
+ export { createQuicClientSocket };