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/README.md +381 -216
- package/index.cjs +45 -0
- package/index.d.ts +158 -0
- package/index.js +49 -23
- package/package.json +83 -60
- package/src/agent.js +283 -0
- package/src/client.js +444 -0
- package/src/crypto.js +534 -0
- package/src/h3.js +920 -0
- package/src/h3_client.js +332 -0
- package/src/h3_server.js +392 -0
- package/src/quic_connection.js +2338 -0
- package/src/quic_server.js +314 -0
- package/src/quic_socket.js +76 -0
- package/src/server.js +354 -0
- package/src/streams.js +471 -0
- package/src/tls_bridge.js +273 -0
- package/src/transport.js +667 -0
- package/src/utils.js +345 -0
- package/src/webtransport.js +655 -0
- package/CHANGELOG.md +0 -31
- package/examples/webtransport_datagram_echo_server/client.html +0 -58
- package/examples/webtransport_datagram_echo_server/server.js +0 -23
- package/h3_server.js +0 -416
- package/h3_socket.js +0 -473
- package/libs/crypto.js +0 -1841
- package/libs/h3.js +0 -1461
- package/libs/utils.js +0 -404
- package/quic_socket.js +0 -1550
- package/quico.svg +0 -1
package/src/h3_server.js
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
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 { Duplex, Readable, Writable } from 'node:stream';
|
|
16
|
+
import { Emitter, readVarInt, wt_datagram_payload } from './utils.js';
|
|
17
|
+
import { createQuicServer } from './quic_server.js';
|
|
18
|
+
import { H3Connection } from './h3.js';
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
// ============================================================
|
|
22
|
+
// createServer(options, handler)
|
|
23
|
+
// ============================================================
|
|
24
|
+
|
|
25
|
+
function createServer(options, handler) {
|
|
26
|
+
options = options || {};
|
|
27
|
+
|
|
28
|
+
var ev = Emitter();
|
|
29
|
+
|
|
30
|
+
// Transport-only QUIC server owns the UDP socket(s), demux, connection
|
|
31
|
+
// map and lifecycle. h3_server layers HTTP/3 on top by listening for its
|
|
32
|
+
// 'connection' event (see below) and wrapping each raw QUICConnection in
|
|
33
|
+
// an H3Connection. ALPN is 'h3'. External-socket (shared-port) mode and
|
|
34
|
+
// the handlePacket/hasConnection demux hooks are provided by the
|
|
35
|
+
// transport server and re-exported on this server's API.
|
|
36
|
+
var qserver = createQuicServer({
|
|
37
|
+
alpn: 'h3',
|
|
38
|
+
SNICallback: options.SNICallback || null,
|
|
39
|
+
key: options.key,
|
|
40
|
+
cert: options.cert,
|
|
41
|
+
ca: options.ca,
|
|
42
|
+
maxConnections: options.maxConnections || 10000,
|
|
43
|
+
socket: options.socket || null,
|
|
44
|
+
socket6: options.socket6 || null
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Surface transport-level (UDP socket / send) errors on this server.
|
|
48
|
+
qserver.on('error', function (err) { ev.emit('error', err); });
|
|
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(conn, streamId, params) {
|
|
109
|
+
if (!conn) return;
|
|
110
|
+
|
|
111
|
+
var is_new = false;
|
|
112
|
+
|
|
113
|
+
// Create req/res on first encounter of this stream
|
|
114
|
+
if (!(streamId in conn.http_streams)) {
|
|
115
|
+
|
|
116
|
+
var req = {
|
|
117
|
+
method: null,
|
|
118
|
+
url: null,
|
|
119
|
+
headers: {},
|
|
120
|
+
httpVersion: '3.0',
|
|
121
|
+
stream_id: Number(streamId),
|
|
122
|
+
_listeners: {},
|
|
123
|
+
_isWebTransport: false,
|
|
124
|
+
|
|
125
|
+
on: function (event, fn) {
|
|
126
|
+
(req._listeners[event] = req._listeners[event] || []).push(fn);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
var res = {
|
|
131
|
+
statusCode: 200,
|
|
132
|
+
headers: {},
|
|
133
|
+
headersSent: false,
|
|
134
|
+
_finished: false,
|
|
135
|
+
|
|
136
|
+
setHeader: function (name, value) {
|
|
137
|
+
res.headers[name.toLowerCase()] = String(value);
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
getHeader: function (name) {
|
|
141
|
+
return res.headers[name.toLowerCase()];
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
writeHead: function (statusCode, headers) {
|
|
145
|
+
if (res.headersSent) return res;
|
|
146
|
+
res.statusCode = statusCode;
|
|
147
|
+
|
|
148
|
+
if (typeof headers === 'object') {
|
|
149
|
+
for (var name in headers) {
|
|
150
|
+
res.headers[name.toLowerCase()] = String(headers[name]);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
var h3Headers = { ':status': String(statusCode) };
|
|
155
|
+
for (var name in res.headers) {
|
|
156
|
+
h3Headers[name] = res.headers[name];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (conn.h3) {
|
|
160
|
+
// Send headers WITHOUT FIN for WT (session stays open)
|
|
161
|
+
var isWt = req.headers[':protocol'] === 'webtransport';
|
|
162
|
+
conn.h3.sendHeaders(streamId, h3Headers, isWt ? false : undefined);
|
|
163
|
+
}
|
|
164
|
+
res.headersSent = true;
|
|
165
|
+
|
|
166
|
+
// If this is a WT CONNECT with 200 → register session
|
|
167
|
+
if (req.headers[':protocol'] === 'webtransport' && statusCode === 200 && conn.h3) {
|
|
168
|
+
req._isWebTransport = true;
|
|
169
|
+
conn.h3.registerWebTransportSession(Number(streamId));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return res;
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
write: function (chunk) {
|
|
176
|
+
if (!res.headersSent) res.writeHead(res.statusCode);
|
|
177
|
+
if (typeof chunk === 'string') chunk = new TextEncoder().encode(chunk);
|
|
178
|
+
if (conn.h3) conn.h3.sendBody(streamId, chunk, false);
|
|
179
|
+
return true;
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
end: function (chunk) {
|
|
183
|
+
if (res._finished) return;
|
|
184
|
+
if (!res.headersSent) res.writeHead(res.statusCode);
|
|
185
|
+
if (conn.h3) {
|
|
186
|
+
if (chunk) {
|
|
187
|
+
if (typeof chunk === 'string') chunk = new TextEncoder().encode(chunk);
|
|
188
|
+
conn.h3.sendBody(streamId, chunk, true);
|
|
189
|
+
} else {
|
|
190
|
+
conn.h3.sendBody(streamId, null, true);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
res._finished = true;
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
// ---- WebTransport methods on res ----
|
|
197
|
+
|
|
198
|
+
sendDatagram: function (data) {
|
|
199
|
+
if (!req._isWebTransport) return;
|
|
200
|
+
if (typeof data === 'string') data = Buffer.from(data);
|
|
201
|
+
if (Buffer.isBuffer(data)) data = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
202
|
+
conn.quic.sendDatagram(wt_datagram_payload(Number(streamId), data));
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
createBidirectionalStream: function () {
|
|
206
|
+
if (!req._isWebTransport || !conn.h3) return null;
|
|
207
|
+
var wtStreamId = conn.h3.createWebTransportStream(Number(streamId), true);
|
|
208
|
+
return _createWtStream(conn, conn.quic, wtStreamId, Number(streamId), true);
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
createUnidirectionalStream: function () {
|
|
212
|
+
if (!req._isWebTransport || !conn.h3) return null;
|
|
213
|
+
var wtStreamId = conn.h3.createWebTransportStream(Number(streamId), false);
|
|
214
|
+
var stream = new Writable({
|
|
215
|
+
write: function (chunk, encoding, cb) {
|
|
216
|
+
if (typeof chunk === 'string') chunk = Buffer.from(chunk, encoding);
|
|
217
|
+
conn.quic.sendStream(wtStreamId, new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength), false);
|
|
218
|
+
cb();
|
|
219
|
+
},
|
|
220
|
+
final: function (cb) {
|
|
221
|
+
conn.quic.sendStream(wtStreamId, new Uint8Array(0), true);
|
|
222
|
+
cb();
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
stream._streamId = wtStreamId;
|
|
226
|
+
return stream;
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
conn.http_streams[streamId] = { req: req, res: res };
|
|
231
|
+
is_new = true;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Apply params
|
|
235
|
+
if (typeof params === 'object') {
|
|
236
|
+
|
|
237
|
+
if ('request_headers' in params) {
|
|
238
|
+
var stream = conn.http_streams[streamId];
|
|
239
|
+
for (var name in params.request_headers) {
|
|
240
|
+
stream.req.headers[name] = params.request_headers[name];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Extract :method and :path
|
|
244
|
+
if (stream.req.method === null && ':method' in stream.req.headers) {
|
|
245
|
+
stream.req.method = stream.req.headers[':method'];
|
|
246
|
+
}
|
|
247
|
+
if (stream.req.url === null && ':path' in stream.req.headers) {
|
|
248
|
+
stream.req.url = stream.req.headers[':path'];
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if ('request_body_chunk' in params) {
|
|
253
|
+
var stream = conn.http_streams[streamId];
|
|
254
|
+
var listeners = stream.req._listeners['data'] || [];
|
|
255
|
+
for (var i = 0; i < listeners.length; i++) {
|
|
256
|
+
try { listeners[i](params.request_body_chunk); } catch (e) { }
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if ('request_end' in params) {
|
|
261
|
+
var stream = conn.http_streams[streamId];
|
|
262
|
+
var listeners = stream.req._listeners['end'] || [];
|
|
263
|
+
for (var i = 0; i < listeners.length; i++) {
|
|
264
|
+
try { listeners[i](); } catch (e) { }
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Fire handler on new stream (after headers are set)
|
|
270
|
+
if (is_new) {
|
|
271
|
+
var stream = conn.http_streams[streamId];
|
|
272
|
+
if (typeof handler === 'function') {
|
|
273
|
+
handler(stream.req, stream.res);
|
|
274
|
+
}
|
|
275
|
+
ev.emit('request', stream.req, stream.res);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
// ============================================================
|
|
281
|
+
// HTTP/3 over each accepted QUIC connection
|
|
282
|
+
// The transport server hands us a raw QUICConnection per peer; we wrap
|
|
283
|
+
// it in an H3Connection once the handshake completes and bridge HTTP/3 +
|
|
284
|
+
// WebTransport events to req/res via set_http_stream. Per-connection
|
|
285
|
+
// state lives in this closure — the transport server owns the UDP
|
|
286
|
+
// socket, demux and connection lifecycle.
|
|
287
|
+
// ============================================================
|
|
288
|
+
|
|
289
|
+
qserver.on('connection', function (quic, peer) {
|
|
290
|
+
var conn = { quic: quic, h3: null, http_streams: {} };
|
|
291
|
+
|
|
292
|
+
// QUIC handshake complete → set up H3
|
|
293
|
+
quic.on('connect', function () {
|
|
294
|
+
var h3 = new H3Connection({ quicConnection: quic, enableWebTransport: true });
|
|
295
|
+
conn.h3 = h3;
|
|
296
|
+
|
|
297
|
+
// HTTP request headers received → set_http_stream (reactive)
|
|
298
|
+
h3.on('http_headers', function (streamId, headers) {
|
|
299
|
+
set_http_stream(conn, streamId, { request_headers: headers });
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// HTTP request body chunk → set_http_stream
|
|
303
|
+
h3.on('http_body', function (streamId, data) {
|
|
304
|
+
set_http_stream(conn, streamId, { request_body_chunk: data });
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// HTTP request complete → fire req.on('end')
|
|
308
|
+
h3.on('http_end', function (streamId) {
|
|
309
|
+
set_http_stream(conn, streamId, { request_end: true });
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// ---- WebTransport stream events ----
|
|
313
|
+
|
|
314
|
+
// New WT stream (bidi or uni from client)
|
|
315
|
+
h3.on('wt_stream', function (sessionId, wtStreamId, data, fin, isBidi) {
|
|
316
|
+
var session = conn.http_streams[sessionId];
|
|
317
|
+
if (!session || !session.req._isWebTransport) return;
|
|
318
|
+
|
|
319
|
+
// Create a Duplex-like stream for the WT stream
|
|
320
|
+
var wtStream = _createWtStream(conn, quic, wtStreamId, sessionId, isBidi);
|
|
321
|
+
|
|
322
|
+
// Push initial data
|
|
323
|
+
if (data.byteLength > 0) _emitOnReq(session.req, 'data:' + wtStreamId, data);
|
|
324
|
+
if (fin) _emitOnReq(session.req, 'end:' + wtStreamId);
|
|
325
|
+
|
|
326
|
+
// Emit on req
|
|
327
|
+
if (isBidi) {
|
|
328
|
+
_emitOnReq(session.req, 'stream', wtStream);
|
|
329
|
+
} else {
|
|
330
|
+
_emitOnReq(session.req, 'unidirectionalStream', wtStream);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// If initial data, push to stream
|
|
334
|
+
if (data.byteLength > 0) wtStream._pushData(data);
|
|
335
|
+
if (fin) wtStream._endData();
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// Subsequent data on WT stream
|
|
339
|
+
h3.on('wt_data', function (sessionId, wtStreamId, data, fin) {
|
|
340
|
+
if (!conn._wtStreamObjects) return;
|
|
341
|
+
var wtStream = conn._wtStreamObjects[wtStreamId];
|
|
342
|
+
if (!wtStream) return;
|
|
343
|
+
if (data.byteLength > 0) wtStream._pushData(data);
|
|
344
|
+
if (fin) { wtStream._endData(); delete conn._wtStreamObjects[wtStreamId]; }
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// Datagrams
|
|
348
|
+
quic.on('datagram', function (rawData) {
|
|
349
|
+
if (!rawData || rawData.byteLength === 0) return;
|
|
350
|
+
var result = readVarInt(rawData, 0);
|
|
351
|
+
if (!result) return;
|
|
352
|
+
var quarterSid = result.value;
|
|
353
|
+
var payload = rawData.slice(result.byteLength);
|
|
354
|
+
|
|
355
|
+
// Find the session: quarter_stream_id = session_stream_id / 4
|
|
356
|
+
for (var sid in conn.http_streams) {
|
|
357
|
+
var session = conn.http_streams[sid];
|
|
358
|
+
if (session.req._isWebTransport && Math.floor(Number(sid) / 4) === quarterSid) {
|
|
359
|
+
_emitOnReq(session.req, 'datagram', Buffer.from(payload));
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
// ============================================================
|
|
369
|
+
// Server API
|
|
370
|
+
// Transport concerns (UDP sockets, demux, connection lifecycle,
|
|
371
|
+
// shared-port handlePacket/hasConnection) are delegated to the QUIC
|
|
372
|
+
// server. This layer adds HTTP/3 request events on top.
|
|
373
|
+
// ============================================================
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
listen: qserver.listen,
|
|
377
|
+
close: qserver.close,
|
|
378
|
+
on: function (name, fn) { ev.on(name, fn); },
|
|
379
|
+
setTimeout: function () { /* TODO */ },
|
|
380
|
+
|
|
381
|
+
/** Feed an incoming UDP packet when running in external-socket mode.
|
|
382
|
+
* rinfo must be in Node's dgram format: { address, port, family, size }. */
|
|
383
|
+
handlePacket: qserver.handlePacket,
|
|
384
|
+
|
|
385
|
+
/** Returns true if the 5-tuple matches an active QUIC connection.
|
|
386
|
+
* Used by demuxers for routing decisions on shared UDP ports. */
|
|
387
|
+
hasConnection: qserver.hasConnection
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
export { createServer };
|