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.
- package/README.md +381 -216
- package/index.cjs +45 -0
- package/index.d.ts +158 -0
- package/index.js +46 -23
- package/package.json +83 -60
- package/src/agent.js +275 -0
- package/src/client.js +439 -0
- package/src/crypto.js +519 -0
- package/src/h3.js +911 -0
- package/src/h3_client.js +317 -0
- package/src/h3_server.js +523 -0
- package/src/quic_connection.js +1238 -0
- package/src/server.js +346 -0
- package/src/streams.js +471 -0
- package/src/tls_bridge.js +211 -0
- package/src/transport.js +651 -0
- package/src/utils.js +290 -0
- package/src/webtransport.js +708 -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
|
@@ -0,0 +1,708 @@
|
|
|
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 { EventEmitter } from 'node:events';
|
|
17
|
+
import dgram from 'node:dgram';
|
|
18
|
+
import dns from 'node:dns';
|
|
19
|
+
import { QUICConnection } from './quic_connection.js';
|
|
20
|
+
import { H3Connection } from './h3.js';
|
|
21
|
+
import { writeVarInt, readVarInt } from './utils.js';
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
// ============================================================
|
|
25
|
+
// WebTransportBidiStream — Duplex + WHATWG .readable/.writable
|
|
26
|
+
//
|
|
27
|
+
// Node.js style:
|
|
28
|
+
// stream.write(data);
|
|
29
|
+
// stream.on('data', chunk => { ... });
|
|
30
|
+
// stream.pipe(dest);
|
|
31
|
+
//
|
|
32
|
+
// WHATWG style:
|
|
33
|
+
// const writer = stream.writable.getWriter();
|
|
34
|
+
// const reader = stream.readable.getReader();
|
|
35
|
+
// ============================================================
|
|
36
|
+
|
|
37
|
+
class WebTransportBidiStream extends Duplex {
|
|
38
|
+
|
|
39
|
+
constructor(options) {
|
|
40
|
+
super({ highWaterMark: (options && options.highWaterMark) || 64 * 1024 });
|
|
41
|
+
|
|
42
|
+
this._streamId = options.streamId;
|
|
43
|
+
this._quic = options.quic;
|
|
44
|
+
this._sessionId = options.sessionId;
|
|
45
|
+
this._prefixSent = options.prefixSent || false;
|
|
46
|
+
|
|
47
|
+
// Lazy WHATWG wrappers
|
|
48
|
+
this._whatwgReadable = null;
|
|
49
|
+
this._whatwgWritable = null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---- WHATWG getters ----
|
|
53
|
+
|
|
54
|
+
get readable() {
|
|
55
|
+
if (!this._whatwgReadable) {
|
|
56
|
+
var self = this;
|
|
57
|
+
this._whatwgReadable = new ReadableStream({
|
|
58
|
+
start(controller) {
|
|
59
|
+
self.on('data', function (chunk) {
|
|
60
|
+
controller.enqueue(chunk);
|
|
61
|
+
});
|
|
62
|
+
self.on('end', function () {
|
|
63
|
+
controller.close();
|
|
64
|
+
});
|
|
65
|
+
self.on('error', function (err) {
|
|
66
|
+
controller.error(err);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return this._whatwgReadable;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
get writable() {
|
|
75
|
+
if (!this._whatwgWritable) {
|
|
76
|
+
var self = this;
|
|
77
|
+
this._whatwgWritable = new WritableStream({
|
|
78
|
+
write(chunk) {
|
|
79
|
+
return new Promise(function (resolve, reject) {
|
|
80
|
+
self.write(chunk, function (err) { err ? reject(err) : resolve(); });
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
close() {
|
|
84
|
+
return new Promise(function (resolve) { self.end(resolve); });
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return this._whatwgWritable;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---- Node.js Duplex implementation ----
|
|
92
|
+
|
|
93
|
+
_read(_size) { /* data pushed externally via _pushData */ }
|
|
94
|
+
|
|
95
|
+
_write(chunk, encoding, callback) {
|
|
96
|
+
if (typeof chunk === 'string') chunk = Buffer.from(chunk, encoding);
|
|
97
|
+
if (Buffer.isBuffer(chunk)) {
|
|
98
|
+
chunk = new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// First write on this stream: prepend VarInt(0x41) + VarInt(session_id)
|
|
102
|
+
if (!this._prefixSent) {
|
|
103
|
+
this._prefixSent = true;
|
|
104
|
+
var typePrefix = writeVarInt(0x41); // WEBTRANSPORT_STREAM bidi type
|
|
105
|
+
var sessionPrefix = writeVarInt(this._sessionId);
|
|
106
|
+
var combined = new Uint8Array(typePrefix.length + sessionPrefix.length + chunk.byteLength);
|
|
107
|
+
combined.set(typePrefix, 0);
|
|
108
|
+
combined.set(sessionPrefix, typePrefix.length);
|
|
109
|
+
combined.set(chunk, typePrefix.length + sessionPrefix.length);
|
|
110
|
+
this._quic.sendStream(this._streamId, combined, false);
|
|
111
|
+
} else {
|
|
112
|
+
this._quic.sendStream(this._streamId, chunk, false);
|
|
113
|
+
}
|
|
114
|
+
callback();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
_final(callback) {
|
|
118
|
+
// Send FIN (with type + session_id prefix if nothing was written yet)
|
|
119
|
+
if (!this._prefixSent) {
|
|
120
|
+
this._prefixSent = true;
|
|
121
|
+
var typePrefix = writeVarInt(0x41);
|
|
122
|
+
var sessionPrefix = writeVarInt(this._sessionId);
|
|
123
|
+
var header = new Uint8Array(typePrefix.length + sessionPrefix.length);
|
|
124
|
+
header.set(typePrefix, 0);
|
|
125
|
+
header.set(sessionPrefix, typePrefix.length);
|
|
126
|
+
this._quic.sendStream(this._streamId, header, true);
|
|
127
|
+
} else {
|
|
128
|
+
this._quic.sendStream(this._streamId, new Uint8Array(0), true);
|
|
129
|
+
}
|
|
130
|
+
callback();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Called by WebTransport when data arrives for this stream.
|
|
135
|
+
*/
|
|
136
|
+
_pushData(chunk) {
|
|
137
|
+
if (Buffer.isBuffer(chunk)) this.push(chunk);
|
|
138
|
+
else if (chunk instanceof Uint8Array) this.push(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength));
|
|
139
|
+
else this.push(chunk);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
_endData() {
|
|
143
|
+
this.push(null);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
// ============================================================
|
|
149
|
+
// WebTransportUniStream — Writable (outgoing) or Readable (incoming)
|
|
150
|
+
// ============================================================
|
|
151
|
+
|
|
152
|
+
class WebTransportUniWriteStream extends Writable {
|
|
153
|
+
|
|
154
|
+
constructor(options) {
|
|
155
|
+
super({ highWaterMark: (options && options.highWaterMark) || 64 * 1024 });
|
|
156
|
+
this._streamId = options.streamId;
|
|
157
|
+
this._quic = options.quic;
|
|
158
|
+
this._sessionId = options.sessionId;
|
|
159
|
+
this._headerSent = false;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
_write(chunk, encoding, callback) {
|
|
163
|
+
if (typeof chunk === 'string') chunk = Buffer.from(chunk, encoding);
|
|
164
|
+
if (Buffer.isBuffer(chunk)) {
|
|
165
|
+
chunk = new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!this._headerSent) {
|
|
169
|
+
this._headerSent = true;
|
|
170
|
+
// Uni stream: VarInt(0x54) (WEBTRANSPORT_STREAM) + VarInt(session_id) + data
|
|
171
|
+
var typePrefix = writeVarInt(0x54);
|
|
172
|
+
var sessionPrefix = writeVarInt(this._sessionId);
|
|
173
|
+
var header = new Uint8Array(typePrefix.length + sessionPrefix.length + chunk.byteLength);
|
|
174
|
+
header.set(typePrefix, 0);
|
|
175
|
+
header.set(sessionPrefix, typePrefix.length);
|
|
176
|
+
header.set(chunk, typePrefix.length + sessionPrefix.length);
|
|
177
|
+
this._quic.sendStream(this._streamId, header, false);
|
|
178
|
+
} else {
|
|
179
|
+
this._quic.sendStream(this._streamId, chunk, false);
|
|
180
|
+
}
|
|
181
|
+
callback();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
_final(callback) {
|
|
185
|
+
if (!this._headerSent) {
|
|
186
|
+
this._headerSent = true;
|
|
187
|
+
var typePrefix = writeVarInt(0x54);
|
|
188
|
+
var sessionPrefix = writeVarInt(this._sessionId);
|
|
189
|
+
var header = new Uint8Array(typePrefix.length + sessionPrefix.length);
|
|
190
|
+
header.set(typePrefix, 0);
|
|
191
|
+
header.set(sessionPrefix, typePrefix.length);
|
|
192
|
+
this._quic.sendStream(this._streamId, header, true);
|
|
193
|
+
} else {
|
|
194
|
+
this._quic.sendStream(this._streamId, new Uint8Array(0), true);
|
|
195
|
+
}
|
|
196
|
+
callback();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// WHATWG getter
|
|
200
|
+
get writable() {
|
|
201
|
+
if (!this._whatwgWritable) {
|
|
202
|
+
var self = this;
|
|
203
|
+
this._whatwgWritable = new WritableStream({
|
|
204
|
+
write(chunk) {
|
|
205
|
+
return new Promise(function (resolve, reject) {
|
|
206
|
+
self.write(chunk, function (err) { err ? reject(err) : resolve(); });
|
|
207
|
+
});
|
|
208
|
+
},
|
|
209
|
+
close() {
|
|
210
|
+
return new Promise(function (resolve) { self.end(resolve); });
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
return this._whatwgWritable;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class WebTransportUniReadStream extends Readable {
|
|
220
|
+
|
|
221
|
+
constructor(options) {
|
|
222
|
+
super({ highWaterMark: (options && options.highWaterMark) || 64 * 1024 });
|
|
223
|
+
this._streamId = options.streamId;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
_read(_size) { /* data pushed externally */ }
|
|
227
|
+
|
|
228
|
+
_pushData(chunk) {
|
|
229
|
+
if (Buffer.isBuffer(chunk)) this.push(chunk);
|
|
230
|
+
else if (chunk instanceof Uint8Array) this.push(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength));
|
|
231
|
+
else this.push(chunk);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
_endData() { this.push(null); }
|
|
235
|
+
|
|
236
|
+
// WHATWG getter
|
|
237
|
+
get readable() {
|
|
238
|
+
if (!this._whatwgReadable) {
|
|
239
|
+
var self = this;
|
|
240
|
+
this._whatwgReadable = new ReadableStream({
|
|
241
|
+
start(controller) {
|
|
242
|
+
self.on('data', function (chunk) { controller.enqueue(chunk); });
|
|
243
|
+
self.on('end', function () { controller.close(); });
|
|
244
|
+
self.on('error', function (err) { controller.error(err); });
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
return this._whatwgReadable;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
// ============================================================
|
|
254
|
+
// WebTransport — Client
|
|
255
|
+
//
|
|
256
|
+
// Usage:
|
|
257
|
+
// var wt = new WebTransport('https://server:4433/path');
|
|
258
|
+
// await wt.ready;
|
|
259
|
+
//
|
|
260
|
+
// // Bidi stream (Duplex)
|
|
261
|
+
// var stream = await wt.createBidirectionalStream();
|
|
262
|
+
// stream.write('hello');
|
|
263
|
+
// stream.on('data', chunk => console.log(chunk));
|
|
264
|
+
//
|
|
265
|
+
// // Also works as WHATWG:
|
|
266
|
+
// var writer = stream.writable.getWriter();
|
|
267
|
+
// var reader = stream.readable.getReader();
|
|
268
|
+
//
|
|
269
|
+
// // Uni stream (outgoing only)
|
|
270
|
+
// var uni = await wt.createUnidirectionalStream();
|
|
271
|
+
// uni.write('one-way data');
|
|
272
|
+
// uni.end();
|
|
273
|
+
//
|
|
274
|
+
// // Datagrams
|
|
275
|
+
// wt.sendDatagram(Buffer.from('ping'));
|
|
276
|
+
// wt.on('datagram', data => console.log('pong:', data));
|
|
277
|
+
//
|
|
278
|
+
// // Incoming streams
|
|
279
|
+
// wt.on('stream', stream => { ... }); // bidi from server
|
|
280
|
+
// wt.on('unidirectionalStream', stream => { ... }); // uni from server
|
|
281
|
+
//
|
|
282
|
+
// wt.close();
|
|
283
|
+
// ============================================================
|
|
284
|
+
|
|
285
|
+
class WebTransport extends EventEmitter {
|
|
286
|
+
|
|
287
|
+
constructor(url, options) {
|
|
288
|
+
super();
|
|
289
|
+
|
|
290
|
+
// Parse URL
|
|
291
|
+
var parsed = new URL(url);
|
|
292
|
+
this._hostname = parsed.hostname;
|
|
293
|
+
this._port = parseInt(parsed.port) || 443;
|
|
294
|
+
this._path = parsed.pathname + (parsed.search || '');
|
|
295
|
+
this._options = options || {};
|
|
296
|
+
|
|
297
|
+
// State
|
|
298
|
+
this._state = 'connecting'; // connecting → connected → closed
|
|
299
|
+
this._sessionId = null; // stream ID of the CONNECT request
|
|
300
|
+
this._quic = null;
|
|
301
|
+
this._h3 = null;
|
|
302
|
+
this._udpSocket = null;
|
|
303
|
+
|
|
304
|
+
// Stream tracking
|
|
305
|
+
this._streams = {}; // streamId → WebTransportBidiStream | ReadStream
|
|
306
|
+
this._claimedStreams = new Set(); // stream IDs owned by WT (not H3)
|
|
307
|
+
|
|
308
|
+
// Client-initiated stream ID counters
|
|
309
|
+
// Bidi: 0 used for CONNECT, so WT bidi starts at 4, 8, 12...
|
|
310
|
+
// Uni: 2, 6, 10 used for H3 control/qpack, so WT uni starts at 14, 18, 22...
|
|
311
|
+
this._nextBidiStreamId = 4;
|
|
312
|
+
this._nextUniStreamId = 14;
|
|
313
|
+
|
|
314
|
+
// Incoming stream prefix buffers (for parsing session_id)
|
|
315
|
+
this._incomingBuffers = {};
|
|
316
|
+
|
|
317
|
+
// Promises (browser-compatible API)
|
|
318
|
+
var self = this;
|
|
319
|
+
this.ready = new Promise(function (resolve, reject) {
|
|
320
|
+
self._readyResolve = resolve;
|
|
321
|
+
self._readyReject = reject;
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
this.closed = new Promise(function (resolve) {
|
|
325
|
+
self._closedResolve = resolve;
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// Start connection
|
|
329
|
+
this._connect();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
// ---- Connection setup ----
|
|
334
|
+
|
|
335
|
+
_connect() {
|
|
336
|
+
var host = this._hostname;
|
|
337
|
+
var self = this;
|
|
338
|
+
|
|
339
|
+
if (/^[\d.]+$/.test(host) || host.indexOf(':') >= 0 || host === 'localhost') {
|
|
340
|
+
var ip = (host === 'localhost') ? '127.0.0.1' : host;
|
|
341
|
+
this._startQuic(ip);
|
|
342
|
+
} else {
|
|
343
|
+
dns.lookup(host, function (err, address) {
|
|
344
|
+
if (err) { self._readyReject(err); self.emit('error', err); return; }
|
|
345
|
+
self._startQuic(address);
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
_startQuic(remoteIp) {
|
|
351
|
+
var self = this;
|
|
352
|
+
var remotePort = this._port;
|
|
353
|
+
var isIPv6 = remoteIp.indexOf(':') >= 0;
|
|
354
|
+
|
|
355
|
+
this._udpSocket = dgram.createSocket(isIPv6 ? 'udp6' : 'udp4');
|
|
356
|
+
|
|
357
|
+
this._udpSocket.on('message', function (msg, rinfo) {
|
|
358
|
+
if (self._quic) self._quic.feedDatagram(rinfo.address, rinfo.port, new Uint8Array(msg));
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
this._udpSocket.on('error', function (err) {
|
|
362
|
+
if (self._state === 'connecting') self._readyReject(err);
|
|
363
|
+
self.emit('error', err);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
this._udpSocket.bind(0, function () {
|
|
367
|
+
self._quic = new QUICConnection({
|
|
368
|
+
isServer: false,
|
|
369
|
+
hostname: self._hostname
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
self._quic.on('packet', function (data) {
|
|
373
|
+
self._udpSocket.send(data, remotePort, remoteIp);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
self._quic.on('connect', function () {
|
|
377
|
+
self._h3 = new H3Connection({ quicConnection: self._quic, isServer: false, enableWebTransport: true });
|
|
378
|
+
self._setupH3Handlers();
|
|
379
|
+
self._sendConnect();
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
self._quic.on('close', function () {
|
|
383
|
+
self._state = 'closed';
|
|
384
|
+
self._closedResolve();
|
|
385
|
+
self.emit('close');
|
|
386
|
+
if (self._udpSocket) { try { self._udpSocket.close(); } catch (e) {} }
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
self._quic.connect();
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
// ---- H3 + QUIC event handlers ----
|
|
395
|
+
|
|
396
|
+
_setupH3Handlers() {
|
|
397
|
+
var self = this;
|
|
398
|
+
|
|
399
|
+
// CONNECT response headers
|
|
400
|
+
this._h3.on('http_headers', function (streamId, headers) {
|
|
401
|
+
if (streamId === self._sessionId) {
|
|
402
|
+
var status = parseInt(headers[':status']) || 0;
|
|
403
|
+
if (status === 200) {
|
|
404
|
+
self._state = 'connected';
|
|
405
|
+
self._readyResolve();
|
|
406
|
+
self.emit('ready');
|
|
407
|
+
} else {
|
|
408
|
+
var err = new Error('WebTransport CONNECT failed: HTTP ' + status);
|
|
409
|
+
self._readyReject(err);
|
|
410
|
+
self.emit('error', err);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// Raw QUIC stream data — intercept WT streams before H3 processes them
|
|
416
|
+
// We register on QUIC directly. H3 will also receive these events,
|
|
417
|
+
// but we call h3.claimStream() for WT streams so H3 skips them.
|
|
418
|
+
this._quic.on('stream', function (streamId, data, fin) {
|
|
419
|
+
// Skip streams owned by H3 (CONNECT stream, control streams)
|
|
420
|
+
if (streamId === self._sessionId) return; // H3 handles CONNECT response
|
|
421
|
+
if (!self._isWebTransportStream(streamId)) return;
|
|
422
|
+
|
|
423
|
+
self._handleStreamData(streamId, data, fin);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// Datagrams — QUIC DATAGRAM payload = VarInt(quarter_stream_id) + app data (RFC 9297)
|
|
427
|
+
this._quic.on('datagram', function (_contextId, rawData) {
|
|
428
|
+
if (!rawData || rawData.byteLength === 0) return;
|
|
429
|
+
|
|
430
|
+
// Always parse VarInt quarter_stream_id from payload
|
|
431
|
+
var result = readVarInt(rawData, 0);
|
|
432
|
+
if (!result) return;
|
|
433
|
+
var qsid = result.value;
|
|
434
|
+
var payload = rawData.slice(result.byteLength);
|
|
435
|
+
|
|
436
|
+
var expectedQsid = Math.floor(self._sessionId / 4);
|
|
437
|
+
if (self._sessionId !== null && qsid === expectedQsid) {
|
|
438
|
+
self.emit('datagram', Buffer.from(payload));
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
_isWebTransportStream(streamId) {
|
|
444
|
+
// Already claimed
|
|
445
|
+
if (this._claimedStreams.has(streamId)) return true;
|
|
446
|
+
|
|
447
|
+
// Server-initiated bidi: 1, 5, 9, 13... (could be WT)
|
|
448
|
+
// Server-initiated uni: 3, 7, 11, 15... (11+ could be WT, 3/7/11 are H3)
|
|
449
|
+
// Client-initiated bidi: 4+ are WT (0 is CONNECT)
|
|
450
|
+
// Client-initiated uni: 14+ are WT (2/6/10 are H3)
|
|
451
|
+
var type = streamId & 0x3;
|
|
452
|
+
|
|
453
|
+
if (type === 0x0) {
|
|
454
|
+
// Client bidi — stream 0 is CONNECT, 4+ are WT
|
|
455
|
+
return streamId >= 4;
|
|
456
|
+
} else if (type === 0x1) {
|
|
457
|
+
// Server bidi — all are potentially WT
|
|
458
|
+
return true;
|
|
459
|
+
} else if (type === 0x2) {
|
|
460
|
+
// Client uni — 2/6/10 are H3, 14+ are WT
|
|
461
|
+
return streamId >= 14;
|
|
462
|
+
} else {
|
|
463
|
+
// Server uni — 3/7/11 are H3, 15+ are potentially WT
|
|
464
|
+
return streamId >= 15;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
_handleStreamData(streamId, data, fin) {
|
|
469
|
+
// Already have a stream object?
|
|
470
|
+
if (streamId in this._streams) {
|
|
471
|
+
var stream = this._streams[streamId];
|
|
472
|
+
if (data.byteLength > 0) stream._pushData(data);
|
|
473
|
+
if (fin) { stream._endData(); delete this._streams[streamId]; }
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// New incoming stream — need to parse session_id prefix
|
|
478
|
+
if (!(streamId in this._incomingBuffers)) {
|
|
479
|
+
this._incomingBuffers[streamId] = { chunks: [], totalLen: 0 };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
var buf = this._incomingBuffers[streamId];
|
|
483
|
+
buf.chunks.push(data);
|
|
484
|
+
buf.totalLen += data.byteLength;
|
|
485
|
+
|
|
486
|
+
// Try to parse prefix
|
|
487
|
+
var combined = buf.totalLen === data.byteLength ? data : this._combineChunks(buf.chunks, buf.totalLen);
|
|
488
|
+
var isBidi = (streamId & 0x2) === 0;
|
|
489
|
+
var offset = 0;
|
|
490
|
+
|
|
491
|
+
// Parse stream type as VarInt (0x41=bidi WT, 0x54=uni WT)
|
|
492
|
+
var typeResult = readVarInt(combined, 0);
|
|
493
|
+
if (!typeResult) { if (fin) delete this._incomingBuffers[streamId]; return; }
|
|
494
|
+
var streamType = typeResult.value;
|
|
495
|
+
offset = typeResult.byteLength;
|
|
496
|
+
|
|
497
|
+
if (!isBidi) {
|
|
498
|
+
if (streamType !== 0x54) {
|
|
499
|
+
// Not a WT uni stream — let H3 handle it
|
|
500
|
+
delete this._incomingBuffers[streamId];
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
} else {
|
|
504
|
+
if (streamType !== 0x41) {
|
|
505
|
+
delete this._incomingBuffers[streamId];
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Parse VarInt session_id
|
|
511
|
+
if (offset >= combined.byteLength) { if (fin) delete this._incomingBuffers[streamId]; return; }
|
|
512
|
+
var result = readVarInt(combined, offset);
|
|
513
|
+
if (!result) { if (fin) delete this._incomingBuffers[streamId]; return; }
|
|
514
|
+
|
|
515
|
+
var sessionId = result.value;
|
|
516
|
+
var afterPrefix = offset + result.byteLength;
|
|
517
|
+
|
|
518
|
+
// Verify it matches our session
|
|
519
|
+
if (sessionId !== this._sessionId) {
|
|
520
|
+
delete this._incomingBuffers[streamId];
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Claim stream — tell H3 to skip it
|
|
525
|
+
this._claimedStreams.add(streamId);
|
|
526
|
+
if (this._h3 && this._h3.claimStream) this._h3.claimStream(streamId);
|
|
527
|
+
delete this._incomingBuffers[streamId];
|
|
528
|
+
|
|
529
|
+
// Create stream object
|
|
530
|
+
var remaining = combined.byteLength > afterPrefix
|
|
531
|
+
? combined.slice(afterPrefix)
|
|
532
|
+
: new Uint8Array(0);
|
|
533
|
+
|
|
534
|
+
if (isBidi) {
|
|
535
|
+
var stream = new WebTransportBidiStream({
|
|
536
|
+
streamId: streamId,
|
|
537
|
+
quic: this._quic,
|
|
538
|
+
sessionId: this._sessionId,
|
|
539
|
+
prefixSent: false // server-initiated, prefix not needed for writes back
|
|
540
|
+
});
|
|
541
|
+
this._streams[streamId] = stream;
|
|
542
|
+
if (remaining.byteLength > 0) stream._pushData(remaining);
|
|
543
|
+
if (fin) { stream._endData(); delete this._streams[streamId]; }
|
|
544
|
+
this.emit('stream', stream);
|
|
545
|
+
|
|
546
|
+
} else {
|
|
547
|
+
var stream = new WebTransportUniReadStream({ streamId: streamId });
|
|
548
|
+
this._streams[streamId] = stream;
|
|
549
|
+
if (remaining.byteLength > 0) stream._pushData(remaining);
|
|
550
|
+
if (fin) { stream._endData(); delete this._streams[streamId]; }
|
|
551
|
+
this.emit('unidirectionalStream', stream);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
_combineChunks(chunks, totalLen) {
|
|
556
|
+
if (chunks.length === 1) return chunks[0];
|
|
557
|
+
var out = new Uint8Array(totalLen);
|
|
558
|
+
var off = 0;
|
|
559
|
+
for (var i = 0; i < chunks.length; i++) {
|
|
560
|
+
out.set(chunks[i], off);
|
|
561
|
+
off += chunks[i].byteLength;
|
|
562
|
+
}
|
|
563
|
+
return out;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
// ---- Send CONNECT ----
|
|
568
|
+
|
|
569
|
+
_sendConnect() {
|
|
570
|
+
// Send H3 control streams (SETTINGS)
|
|
571
|
+
this._h3.sendControlStreams();
|
|
572
|
+
|
|
573
|
+
// CONNECT uses stream 0 (first client bidi)
|
|
574
|
+
this._sessionId = 0;
|
|
575
|
+
|
|
576
|
+
// Send Extended CONNECT — no FIN (session stays open)
|
|
577
|
+
var headers = {
|
|
578
|
+
':method': 'CONNECT',
|
|
579
|
+
':protocol': 'webtransport',
|
|
580
|
+
':scheme': 'https',
|
|
581
|
+
':authority': this._hostname + (this._port !== 443 ? ':' + this._port : ''),
|
|
582
|
+
':path': this._path
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
this._h3.sendHeaders(this._sessionId, headers, false);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
// ============================================================
|
|
590
|
+
// Public API
|
|
591
|
+
// ============================================================
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Create a bidirectional stream.
|
|
595
|
+
* Returns a Duplex stream (Node.js) with .readable/.writable (WHATWG).
|
|
596
|
+
*/
|
|
597
|
+
createBidirectionalStream() {
|
|
598
|
+
var self = this;
|
|
599
|
+
return new Promise(function (resolve, reject) {
|
|
600
|
+
if (self._state !== 'connected') {
|
|
601
|
+
return reject(new Error('WebTransport is not connected'));
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
var streamId = self._nextBidiStreamId;
|
|
605
|
+
self._nextBidiStreamId += 4;
|
|
606
|
+
|
|
607
|
+
// Claim this stream so H3 doesn't process it
|
|
608
|
+
self._claimedStreams.add(streamId);
|
|
609
|
+
if (self._h3 && self._h3.claimStream) self._h3.claimStream(streamId);
|
|
610
|
+
|
|
611
|
+
var stream = new WebTransportBidiStream({
|
|
612
|
+
streamId: streamId,
|
|
613
|
+
quic: self._quic,
|
|
614
|
+
sessionId: self._sessionId,
|
|
615
|
+
prefixSent: false
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
self._streams[streamId] = stream;
|
|
619
|
+
|
|
620
|
+
// Listen for incoming data on this bidi stream
|
|
621
|
+
// (already handled by _handleStreamData via QUIC stream event)
|
|
622
|
+
|
|
623
|
+
resolve(stream);
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Create a unidirectional stream (outgoing only).
|
|
629
|
+
* Returns a Writable stream (Node.js) with .writable (WHATWG).
|
|
630
|
+
*/
|
|
631
|
+
createUnidirectionalStream() {
|
|
632
|
+
var self = this;
|
|
633
|
+
return new Promise(function (resolve, reject) {
|
|
634
|
+
if (self._state !== 'connected') {
|
|
635
|
+
return reject(new Error('WebTransport is not connected'));
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
var streamId = self._nextUniStreamId;
|
|
639
|
+
self._nextUniStreamId += 4;
|
|
640
|
+
|
|
641
|
+
// Claim this stream
|
|
642
|
+
self._claimedStreams.add(streamId);
|
|
643
|
+
if (self._h3 && self._h3.claimStream) self._h3.claimStream(streamId);
|
|
644
|
+
|
|
645
|
+
var stream = new WebTransportUniWriteStream({
|
|
646
|
+
streamId: streamId,
|
|
647
|
+
quic: self._quic,
|
|
648
|
+
sessionId: self._sessionId
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
resolve(stream);
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Send an unreliable datagram.
|
|
657
|
+
*/
|
|
658
|
+
sendDatagram(data) {
|
|
659
|
+
if (this._state !== 'connected') throw new Error('WebTransport is not connected');
|
|
660
|
+
if (typeof data === 'string') data = Buffer.from(data);
|
|
661
|
+
if (Buffer.isBuffer(data)) data = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
662
|
+
|
|
663
|
+
// Quarter stream ID as context ID (RFC 9297)
|
|
664
|
+
var contextId = Math.floor(this._sessionId / 4);
|
|
665
|
+
|
|
666
|
+
// Build datagram: VarInt(contextId) + payload
|
|
667
|
+
var contextPrefix = writeVarInt(contextId);
|
|
668
|
+
var frame = new Uint8Array(contextPrefix.length + data.byteLength);
|
|
669
|
+
frame.set(contextPrefix, 0);
|
|
670
|
+
frame.set(data, contextPrefix.length);
|
|
671
|
+
|
|
672
|
+
// Send via QUIC DATAGRAM frame
|
|
673
|
+
if (this._quic && this._quic.sendDatagram) {
|
|
674
|
+
this._quic.sendDatagram(frame);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Close the WebTransport session.
|
|
680
|
+
*/
|
|
681
|
+
close(info) {
|
|
682
|
+
if (this._state === 'closed') return;
|
|
683
|
+
this._state = 'closed';
|
|
684
|
+
|
|
685
|
+
// Send CLOSE_WEBTRANSPORT_SESSION on CONNECT stream (optional)
|
|
686
|
+
// Then close QUIC connection
|
|
687
|
+
if (this._quic) {
|
|
688
|
+
this._quic.close(0, (info && info.reason) || 'WebTransport closed');
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Current state.
|
|
694
|
+
*/
|
|
695
|
+
get state() {
|
|
696
|
+
return this._state;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* URL this session connected to.
|
|
701
|
+
*/
|
|
702
|
+
get url() {
|
|
703
|
+
return 'https://' + this._hostname + ':' + this._port + this._path;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
export { WebTransport, WebTransportBidiStream, WebTransportUniWriteStream, WebTransportUniReadStream };
|