quico 0.4.0 → 0.7.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 +1768 -155
- package/index.js +55 -36
- package/package.json +3 -3
- package/src/agent.js +306 -36
- package/src/client.js +447 -120
- package/src/client_profile.js +266 -0
- package/src/connect_udp.js +578 -0
- package/src/connect_udp_client.js +770 -0
- package/src/crypto.js +240 -140
- package/src/h1.js +953 -0
- package/src/h1_client.js +731 -0
- package/src/h1_server.js +624 -0
- package/src/h2.js +1325 -0
- package/src/h2_client.js +675 -0
- package/src/h2_fingerprint.js +403 -0
- package/src/h2_frames.js +582 -0
- package/src/h2_server.js +862 -0
- package/src/h3.js +1966 -469
- package/src/h3_client.js +467 -48
- package/src/h3_server.js +1349 -94
- package/src/hpack.js +455 -0
- package/src/hpack_primitives.js +520 -0
- package/src/http_common.js +901 -0
- package/src/ja4h.js +256 -0
- package/src/qpack.js +555 -0
- package/src/quic_connection.js +6517 -657
- package/src/quic_server.js +768 -73
- package/src/quic_socket.js +134 -9
- package/src/quic_version.js +143 -0
- package/src/server.js +843 -115
- package/src/server_stats.js +93 -0
- package/src/streams.js +361 -35
- package/src/tcp_listener.js +557 -0
- package/src/tls_bridge.js +474 -73
- package/src/tls_options.js +108 -0
- package/src/transport.js +455 -46
- package/src/utils.js +94 -12
- package/src/webtransport.js +433 -436
- package/src/ws.js +624 -0
- package/src/ws_client.js +641 -0
- package/src/ws_server.js +833 -0
- package/src/wt_stream.js +618 -0
package/src/webtransport.js
CHANGED
|
@@ -12,253 +12,62 @@
|
|
|
12
12
|
* https://github.com/colocohen/quico
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { Duplex, Readable, Writable } from 'node:stream';
|
|
16
15
|
import { EventEmitter } from 'node:events';
|
|
17
16
|
import dns from 'node:dns';
|
|
18
|
-
import { H3Connection } from './h3.js';
|
|
17
|
+
import { H3Connection, wtCloseCapsule, createCapsuleReader } from './h3.js';
|
|
19
18
|
import { createQuicClientSocket } from './quic_socket.js';
|
|
20
|
-
import
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
// stream.on('data', chunk => { ... });
|
|
29
|
-
// stream.pipe(dest);
|
|
30
|
-
//
|
|
31
|
-
// WHATWG style:
|
|
32
|
-
// const writer = stream.writable.getWriter();
|
|
33
|
-
// const reader = stream.readable.getReader();
|
|
34
|
-
// ============================================================
|
|
35
|
-
|
|
36
|
-
class WebTransportBidiStream extends Duplex {
|
|
37
|
-
|
|
38
|
-
constructor(options) {
|
|
39
|
-
super({ highWaterMark: (options && options.highWaterMark) || 64 * 1024 });
|
|
40
|
-
|
|
41
|
-
this._streamId = options.streamId;
|
|
42
|
-
this._quic = options.quic;
|
|
43
|
-
this._sessionId = options.sessionId;
|
|
44
|
-
this._prefixSent = options.prefixSent || false;
|
|
45
|
-
|
|
46
|
-
// Lazy WHATWG wrappers
|
|
47
|
-
this._whatwgReadable = null;
|
|
48
|
-
this._whatwgWritable = null;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// ---- WHATWG getters ----
|
|
52
|
-
|
|
53
|
-
get readable() {
|
|
54
|
-
if (!this._whatwgReadable) {
|
|
55
|
-
var self = this;
|
|
56
|
-
this._whatwgReadable = new ReadableStream({
|
|
57
|
-
start(controller) {
|
|
58
|
-
self.on('data', function (chunk) {
|
|
59
|
-
controller.enqueue(chunk);
|
|
60
|
-
});
|
|
61
|
-
self.on('end', function () {
|
|
62
|
-
controller.close();
|
|
63
|
-
});
|
|
64
|
-
self.on('error', function (err) {
|
|
65
|
-
controller.error(err);
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
return this._whatwgReadable;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
get writable() {
|
|
74
|
-
if (!this._whatwgWritable) {
|
|
75
|
-
var self = this;
|
|
76
|
-
this._whatwgWritable = new WritableStream({
|
|
77
|
-
write(chunk) {
|
|
78
|
-
return new Promise(function (resolve, reject) {
|
|
79
|
-
self.write(chunk, function (err) { err ? reject(err) : resolve(); });
|
|
80
|
-
});
|
|
81
|
-
},
|
|
82
|
-
close() {
|
|
83
|
-
return new Promise(function (resolve) { self.end(resolve); });
|
|
84
|
-
}
|
|
85
|
-
});
|
|
86
|
-
}
|
|
87
|
-
return this._whatwgWritable;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// ---- Node.js Duplex implementation ----
|
|
91
|
-
|
|
92
|
-
_read(_size) { /* data pushed externally via _pushData */ }
|
|
93
|
-
|
|
94
|
-
_write(chunk, encoding, callback) {
|
|
95
|
-
if (typeof chunk === 'string') chunk = Buffer.from(chunk, encoding);
|
|
96
|
-
if (Buffer.isBuffer(chunk)) {
|
|
97
|
-
chunk = new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// First write on this stream: prepend VarInt(0x41) + VarInt(session_id)
|
|
101
|
-
if (!this._prefixSent) {
|
|
102
|
-
this._prefixSent = true;
|
|
103
|
-
this._quic.sendStream(this._streamId, wt_stream_header(true, this._sessionId, chunk), false);
|
|
104
|
-
} else {
|
|
105
|
-
this._quic.sendStream(this._streamId, chunk, false);
|
|
106
|
-
}
|
|
107
|
-
callback();
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
_final(callback) {
|
|
111
|
-
// Send FIN (with type + session_id prefix if nothing was written yet)
|
|
112
|
-
if (!this._prefixSent) {
|
|
113
|
-
this._prefixSent = true;
|
|
114
|
-
this._quic.sendStream(this._streamId, wt_stream_header(true, this._sessionId), true);
|
|
115
|
-
} else {
|
|
116
|
-
this._quic.sendStream(this._streamId, new Uint8Array(0), true);
|
|
117
|
-
}
|
|
118
|
-
callback();
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Called by WebTransport when data arrives for this stream.
|
|
123
|
-
*/
|
|
124
|
-
_pushData(chunk) {
|
|
125
|
-
if (Buffer.isBuffer(chunk)) this.push(chunk);
|
|
126
|
-
else if (chunk instanceof Uint8Array) this.push(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength));
|
|
127
|
-
else this.push(chunk);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
_endData() {
|
|
131
|
-
this.push(null);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
// ============================================================
|
|
137
|
-
// WebTransportUniStream — Writable (outgoing) or Readable (incoming)
|
|
138
|
-
// ============================================================
|
|
139
|
-
|
|
140
|
-
class WebTransportUniWriteStream extends Writable {
|
|
141
|
-
|
|
142
|
-
constructor(options) {
|
|
143
|
-
super({ highWaterMark: (options && options.highWaterMark) || 64 * 1024 });
|
|
144
|
-
this._streamId = options.streamId;
|
|
145
|
-
this._quic = options.quic;
|
|
146
|
-
this._sessionId = options.sessionId;
|
|
147
|
-
this._headerSent = false;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
_write(chunk, encoding, callback) {
|
|
151
|
-
if (typeof chunk === 'string') chunk = Buffer.from(chunk, encoding);
|
|
152
|
-
if (Buffer.isBuffer(chunk)) {
|
|
153
|
-
chunk = new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
if (!this._headerSent) {
|
|
157
|
-
this._headerSent = true;
|
|
158
|
-
// Uni stream: VarInt(0x54) (WEBTRANSPORT_STREAM) + VarInt(session_id) + data
|
|
159
|
-
this._quic.sendStream(this._streamId, wt_stream_header(false, this._sessionId, chunk), false);
|
|
160
|
-
} else {
|
|
161
|
-
this._quic.sendStream(this._streamId, chunk, false);
|
|
162
|
-
}
|
|
163
|
-
callback();
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
_final(callback) {
|
|
167
|
-
if (!this._headerSent) {
|
|
168
|
-
this._headerSent = true;
|
|
169
|
-
this._quic.sendStream(this._streamId, wt_stream_header(false, this._sessionId), true);
|
|
170
|
-
} else {
|
|
171
|
-
this._quic.sendStream(this._streamId, new Uint8Array(0), true);
|
|
172
|
-
}
|
|
173
|
-
callback();
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// WHATWG getter
|
|
177
|
-
get writable() {
|
|
178
|
-
if (!this._whatwgWritable) {
|
|
179
|
-
var self = this;
|
|
180
|
-
this._whatwgWritable = new WritableStream({
|
|
181
|
-
write(chunk) {
|
|
182
|
-
return new Promise(function (resolve, reject) {
|
|
183
|
-
self.write(chunk, function (err) { err ? reject(err) : resolve(); });
|
|
184
|
-
});
|
|
185
|
-
},
|
|
186
|
-
close() {
|
|
187
|
-
return new Promise(function (resolve) { self.end(resolve); });
|
|
188
|
-
}
|
|
189
|
-
});
|
|
190
|
-
}
|
|
191
|
-
return this._whatwgWritable;
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
class WebTransportUniReadStream extends Readable {
|
|
197
|
-
|
|
198
|
-
constructor(options) {
|
|
199
|
-
super({ highWaterMark: (options && options.highWaterMark) || 64 * 1024 });
|
|
200
|
-
this._streamId = options.streamId;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
_read(_size) { /* data pushed externally */ }
|
|
204
|
-
|
|
205
|
-
_pushData(chunk) {
|
|
206
|
-
if (Buffer.isBuffer(chunk)) this.push(chunk);
|
|
207
|
-
else if (chunk instanceof Uint8Array) this.push(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength));
|
|
208
|
-
else this.push(chunk);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
_endData() { this.push(null); }
|
|
212
|
-
|
|
213
|
-
// WHATWG getter
|
|
214
|
-
get readable() {
|
|
215
|
-
if (!this._whatwgReadable) {
|
|
216
|
-
var self = this;
|
|
217
|
-
this._whatwgReadable = new ReadableStream({
|
|
218
|
-
start(controller) {
|
|
219
|
-
self.on('data', function (chunk) { controller.enqueue(chunk); });
|
|
220
|
-
self.on('end', function () { controller.close(); });
|
|
221
|
-
self.on('error', function (err) { controller.error(err); });
|
|
222
|
-
}
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
return this._whatwgReadable;
|
|
226
|
-
}
|
|
227
|
-
}
|
|
19
|
+
import lemonTls from 'lemon-tls';
|
|
20
|
+
import { createHash, X509Certificate } from 'node:crypto';
|
|
21
|
+
import { readVarInt, wt_datagram_payload } from './utils.js';
|
|
22
|
+
import { createWtStream, openWtStream, createSendGroup, wtScheduling, createDatagrams, sessionStats, WebTransportError } from './wt_stream.js';
|
|
23
|
+
import { defaultTransportParams } from './tls_bridge.js';
|
|
24
|
+
import { initialCongestionWindow, checkConnectionOptions } from './quic_connection.js';
|
|
25
|
+
import { sfString, parseSfString } from './http_common.js';
|
|
26
|
+
import { parseVersionList } from './quic_version.js';
|
|
228
27
|
|
|
229
28
|
|
|
230
29
|
// ============================================================
|
|
231
30
|
// WebTransport — Client
|
|
232
31
|
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
// await wt.ready;
|
|
236
|
-
//
|
|
237
|
-
// // Bidi stream (Duplex)
|
|
238
|
-
// var stream = await wt.createBidirectionalStream();
|
|
239
|
-
// stream.write('hello');
|
|
240
|
-
// stream.on('data', chunk => console.log(chunk));
|
|
241
|
-
//
|
|
242
|
-
// // Also works as WHATWG:
|
|
243
|
-
// var writer = stream.writable.getWriter();
|
|
244
|
-
// var reader = stream.readable.getReader();
|
|
245
|
-
//
|
|
246
|
-
// // Uni stream (outgoing only)
|
|
247
|
-
// var uni = await wt.createUnidirectionalStream();
|
|
248
|
-
// uni.write('one-way data');
|
|
249
|
-
// uni.end();
|
|
32
|
+
// The browser's WebTransport API (W3C), complete — code written for the
|
|
33
|
+
// browser runs here unchanged — and a Node-style API on the same object:
|
|
250
34
|
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
// wt.
|
|
35
|
+
// Browser API Node API
|
|
36
|
+
// ----------- --------
|
|
37
|
+
// wt.ready / wt.closed / wt.draining 'ready' / 'close' / 'draining'
|
|
38
|
+
// wt.close({ closeCode, reason })
|
|
39
|
+
// wt.datagrams.readable / .writable / wt.sendDatagram(buf), 'datagram'
|
|
40
|
+
// .createWritable() / .maxDatagramSize wt.maxDatagramSize()
|
|
41
|
+
// wt.incomingBidirectionalStreams 'stream' (a Duplex)
|
|
42
|
+
// wt.incomingUnidirectionalStreams 'unidirectionalStream' (a Readable)
|
|
43
|
+
// await wt.createBidirectionalStream() → a Duplex that also has
|
|
44
|
+
// .readable / .writable (WHATWG)
|
|
45
|
+
// await wt.createUnidirectionalStream() → a WritableStream (WHATWG) that
|
|
46
|
+
// also has write() / end()
|
|
47
|
+
// wt.createSendGroup(), wt.getStats(), wt.reliability, wt.congestionControl,
|
|
48
|
+
// wt.protocol, WebTransportError
|
|
254
49
|
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
50
|
+
// Options: the browser's — serverCertificateHashes, protocols,
|
|
51
|
+
// congestionControl, allowPooling, requireUnreliable, anticipatedConcurrent…
|
|
52
|
+
// — and Node's, as for https.request: rejectUnauthorized (default true),
|
|
53
|
+
// ca, key / cert / passphrase (a client certificate), and session (one
|
|
54
|
+
// saved from the 'session' event, to resume the TLS session). The
|
|
55
|
+
// server's certificate is verified against the platform roots
|
|
56
|
+
// unless `ca` is given, or serverCertificateHashes pins it as a browser
|
|
57
|
+
// does.
|
|
258
58
|
//
|
|
259
|
-
//
|
|
59
|
+
// A server stream goes to a Node listener if there is one, otherwise to the
|
|
60
|
+
// browser API's incoming…Streams once that is read; it waits for either.
|
|
260
61
|
// ============================================================
|
|
261
62
|
|
|
63
|
+
// What the client's unidirectional stream (a WritableStream) carries over
|
|
64
|
+
// from the Node Writable underneath it — see createUnidirectionalStream.
|
|
65
|
+
var UNI_METHODS = ['write', 'end', 'destroy', 'cork', 'uncork', 'setDefaultEncoding', 'resetStream', 'getStats',
|
|
66
|
+
'on', 'once', 'off', 'addListener', 'removeListener', 'removeAllListeners', 'prependListener',
|
|
67
|
+
'prependOnceListener', 'emit', 'listeners', 'listenerCount', 'eventNames'];
|
|
68
|
+
var UNI_STATE = ['_streamId', 'id', 'sessionId', 'sendState', 'pending', 'streamErrorCode', 'destroyed', 'writableLength', 'writableHighWaterMark',
|
|
69
|
+
'writableNeedDrain', 'writableEnded', 'writableFinished', 'writableCorked', 'writableObjectMode', 'closed', 'errored'];
|
|
70
|
+
|
|
262
71
|
class WebTransport extends EventEmitter {
|
|
263
72
|
|
|
264
73
|
constructor(url, options) {
|
|
@@ -270,36 +79,78 @@ class WebTransport extends EventEmitter {
|
|
|
270
79
|
this._port = parseInt(parsed.port) || 443;
|
|
271
80
|
this._path = parsed.pathname + (parsed.search || '');
|
|
272
81
|
this._options = options || {};
|
|
82
|
+
// Checked here, as the browser checks its options: a bad value throws now,
|
|
83
|
+
// not later inside the connection.
|
|
84
|
+
defaultTransportParams(this._options.transportParams);
|
|
85
|
+
if (this._options.ecdhCurve != null) lemonTls.parseEcdhCurve(this._options.ecdhCurve);
|
|
86
|
+
if (this._options.sigalgs != null) lemonTls.parseSigalgs(this._options.sigalgs);
|
|
87
|
+
initialCongestionWindow(this._options.initialCongestionWindow);
|
|
88
|
+
(function (o) { var c = {}; ['startupCap', 'pmtuDiscovery', 'maxUdpPayloadSize', 'initialRtt', 'reorderWindow'].forEach(function (k) { if (o[k] != null) c[k] = o[k]; }); checkConnectionOptions(c, 'WebTransport'); })(this._options);
|
|
89
|
+
parseVersionList(this._options.quicVersions, 'quicVersions');
|
|
273
90
|
|
|
274
91
|
// State
|
|
275
|
-
this._state = 'connecting'; // connecting → connected → closed
|
|
92
|
+
this._state = 'connecting'; // connecting → connected → closed, or connecting → failed
|
|
276
93
|
this._sessionId = null; // stream ID of the CONNECT request
|
|
277
94
|
this._quic = null;
|
|
278
95
|
this._h3 = null;
|
|
279
96
|
this._udpSocket = null;
|
|
280
97
|
|
|
281
|
-
//
|
|
282
|
-
this._streams = {};
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
//
|
|
98
|
+
// This session's streams, ours and the server's: streamId → stream.
|
|
99
|
+
this._streams = {};
|
|
100
|
+
// The server's streams not handed over yet: those that arrived before
|
|
101
|
+
// its 200 (a packet can overtake another), and those nobody listens for
|
|
102
|
+
// yet — the server opens streams with its 200, before `await wt.ready`
|
|
103
|
+
// has even returned to the code that will listen. As the browser's
|
|
104
|
+
// incomingUnidirectionalStreams holds them until read, they wait here for
|
|
105
|
+
// a 'stream' / 'unidirectionalStream' listener. Emitted at once, they
|
|
106
|
+
// were lost.
|
|
107
|
+
this._waiting = []; // [event, stream]
|
|
108
|
+
|
|
109
|
+
// The browser API's promises. A rejection nobody awaits is logged by a
|
|
110
|
+
// browser; in Node it would end the process — so each has a handler of
|
|
111
|
+
// its own, and a caller that awaits it still sees the rejection.
|
|
295
112
|
var self = this;
|
|
296
113
|
this.ready = new Promise(function (resolve, reject) {
|
|
297
114
|
self._readyResolve = resolve;
|
|
298
115
|
self._readyReject = reject;
|
|
299
116
|
});
|
|
300
|
-
|
|
301
|
-
this.closed = new Promise(function (resolve) {
|
|
117
|
+
this.ready.catch(function () {});
|
|
118
|
+
this.closed = new Promise(function (resolve, reject) {
|
|
302
119
|
self._closedResolve = resolve;
|
|
120
|
+
self._closedReject = reject;
|
|
121
|
+
});
|
|
122
|
+
this.closed.catch(function () {});
|
|
123
|
+
this.draining = new Promise(function (resolve) { self._drainingResolve = resolve; });
|
|
124
|
+
|
|
125
|
+
// The browser API's attributes.
|
|
126
|
+
var o = this._options;
|
|
127
|
+
this.reliability = 'pending'; // then 'supports-unreliable' or 'reliable-only'
|
|
128
|
+
this.congestionControl = 'default';
|
|
129
|
+
this.protocol = ''; // the server's choice among options.protocols
|
|
130
|
+
this.anticipatedConcurrentIncomingUnidirectionalStreams = o.anticipatedConcurrentIncomingUnidirectionalStreams != null ? o.anticipatedConcurrentIncomingUnidirectionalStreams : null;
|
|
131
|
+
this.anticipatedConcurrentIncomingBidirectionalStreams = o.anticipatedConcurrentIncomingBidirectionalStreams != null ? o.anticipatedConcurrentIncomingBidirectionalStreams : null;
|
|
132
|
+
this._incoming = { stream: null, unidirectionalStream: null }; // the browser API's queues, once read
|
|
133
|
+
// The session, as wt_stream's createDatagrams / sessionStats take it —
|
|
134
|
+
// the server's res hands in the same.
|
|
135
|
+
var self = this;
|
|
136
|
+
this._dgStats = { droppedIncoming: 0, expiredIncoming: 0 };
|
|
137
|
+
this._dgOwner = { maxAge: null, expired: 0 };
|
|
138
|
+
this._session = {
|
|
139
|
+
get quic() { return self._quic; },
|
|
140
|
+
connected: function () { return self._state === 'connected'; },
|
|
141
|
+
send: function (u8, options) { return self.sendDatagram(u8, options); },
|
|
142
|
+
maxDatagramSize: function () { return self.maxDatagramSize(); },
|
|
143
|
+
owner: this._dgOwner,
|
|
144
|
+
dgStats: this._dgStats
|
|
145
|
+
};
|
|
146
|
+
this.datagrams = createDatagrams(this._session);
|
|
147
|
+
|
|
148
|
+
// A listener for the server's streams: those waiting go to it — once it
|
|
149
|
+
// is in place ('newListener' comes just before).
|
|
150
|
+
this.on('newListener', function (event) {
|
|
151
|
+
if ((event === 'stream' || event === 'unidirectionalStream') && self._waiting.length > 0) {
|
|
152
|
+
queueMicrotask(function () { self._handOver(); });
|
|
153
|
+
}
|
|
303
154
|
});
|
|
304
155
|
|
|
305
156
|
// Start connection
|
|
@@ -309,6 +160,32 @@ class WebTransport extends EventEmitter {
|
|
|
309
160
|
|
|
310
161
|
// ---- Connection setup ----
|
|
311
162
|
|
|
163
|
+
// The session could not be established. `ready` rejects — that is how the
|
|
164
|
+
// browser API reports it, and how a caller written against it (try/await
|
|
165
|
+
// ready) expects to hear. 'error' is emitted as well, but only to listeners:
|
|
166
|
+
// an unheard 'error' would throw from inside a socket callback and take the
|
|
167
|
+
// process down where the browser would have rejected a promise.
|
|
168
|
+
//
|
|
169
|
+
// As in the browser the state becomes 'failed' and the connection is torn
|
|
170
|
+
// down: a session refused by the server (a CONNECT answered 403, say) left
|
|
171
|
+
// its QUIC connection open until the idle timeout.
|
|
172
|
+
_failSetup(err) {
|
|
173
|
+
if (this._state !== 'connecting') return;
|
|
174
|
+
this._state = 'failed';
|
|
175
|
+
// As the browser's: a WebTransportError, source 'session' — keeping the
|
|
176
|
+
// cause's message and code (node:tls's, say) for a Node caller.
|
|
177
|
+
if (!(err instanceof WebTransportError)) {
|
|
178
|
+
var we = new WebTransportError(err.message, { source: 'session' });
|
|
179
|
+
if (err.code) we.code = err.code;
|
|
180
|
+
we.cause = err;
|
|
181
|
+
err = we;
|
|
182
|
+
}
|
|
183
|
+
this._readyReject(err);
|
|
184
|
+
this._closedReject(err);
|
|
185
|
+
if (this.listenerCount('error') > 0) this.emit('error', err);
|
|
186
|
+
if (this._quic) this._quic.close(0x0100, 'WebTransport session not established', true); // H3_NO_ERROR
|
|
187
|
+
}
|
|
188
|
+
|
|
312
189
|
_connect() {
|
|
313
190
|
var host = this._hostname;
|
|
314
191
|
var self = this;
|
|
@@ -318,7 +195,7 @@ class WebTransport extends EventEmitter {
|
|
|
318
195
|
this._startQuic(ip);
|
|
319
196
|
} else {
|
|
320
197
|
dns.lookup(host, function (err, address) {
|
|
321
|
-
if (err) { self.
|
|
198
|
+
if (err) { self._failSetup(err); return; }
|
|
322
199
|
self._startQuic(address);
|
|
323
200
|
});
|
|
324
201
|
}
|
|
@@ -327,29 +204,79 @@ class WebTransport extends EventEmitter {
|
|
|
327
204
|
_startQuic(remoteIp) {
|
|
328
205
|
var self = this;
|
|
329
206
|
var remotePort = this._port;
|
|
207
|
+
// Encrypted Client Hello: options.ech (the server's ECHConfigList), or
|
|
208
|
+
// GREASE ECH — as a browser sends — unless echGrease is false. Rejected
|
|
209
|
+
// with new keys, the session is attempted once more with them; an
|
|
210
|
+
// attempt given up for another is ignored from then on.
|
|
211
|
+
var attempt = this._attempt = (this._attempt || 0) + 1;
|
|
212
|
+
var ech = this._echConfig || this._options.ech || null;
|
|
330
213
|
|
|
331
214
|
this._udpSocket = createQuicClientSocket({
|
|
332
215
|
remoteIp: remoteIp,
|
|
333
216
|
remotePort: remotePort,
|
|
334
217
|
hostname: this._hostname,
|
|
218
|
+
// serverCertificateHashes pins the certificate instead of a CA (checked
|
|
219
|
+
// in onConnect, before anything is sent on the connection).
|
|
220
|
+
rejectUnauthorized: this._options.serverCertificateHashes ? false : this._options.rejectUnauthorized,
|
|
221
|
+
key: this._options.key, cert: this._options.cert, passphrase: this._options.passphrase,
|
|
222
|
+
ca: this._options.ca,
|
|
223
|
+
// A saved session ('session' event) to resume with, as tls.connect
|
|
224
|
+
// takes it. Not with serverCertificateHashes: a resumed handshake has
|
|
225
|
+
// no certificate to pin, and the pin is the only check there is.
|
|
226
|
+
session: this._options.serverCertificateHashes ? null : (this._options.session || null),
|
|
227
|
+
onSocket: function (q) {
|
|
228
|
+
q.on('session', function (buf) { self.emit('session', buf); });
|
|
229
|
+
},
|
|
230
|
+
transportParams: this._options.transportParams,
|
|
231
|
+
quicVersions: this._options.quicVersions,
|
|
232
|
+
initialCongestionWindow: this._options.initialCongestionWindow,
|
|
233
|
+
startupCap: this._options.startupCap,
|
|
234
|
+
pmtuDiscovery: this._options.pmtuDiscovery,
|
|
235
|
+
maxUdpPayloadSize: this._options.maxUdpPayloadSize,
|
|
236
|
+
initialRtt: this._options.initialRtt,
|
|
237
|
+
reorderWindow: this._options.reorderWindow,
|
|
238
|
+
ech: ech,
|
|
239
|
+
echGrease: !ech && this._options.echGrease !== false,
|
|
240
|
+
// The key-exchange groups to offer (tls.connect's ecdhCurve; checked
|
|
241
|
+
// in the constructor).
|
|
242
|
+
ecdhCurve: this._options.ecdhCurve,
|
|
243
|
+
sigalgs: this._options.sigalgs,
|
|
335
244
|
|
|
336
245
|
onError: function (err) {
|
|
337
|
-
if (
|
|
338
|
-
self.
|
|
246
|
+
if (attempt !== self._attempt) return;
|
|
247
|
+
if (self._state === 'connecting' && err && err.code === 'ERR_ECH_REJECTED' && err.retryConfigs && !self._echRetried) {
|
|
248
|
+
self._echRetried = true;
|
|
249
|
+
self._echConfig = err.retryConfigs;
|
|
250
|
+
self._startQuic(remoteIp);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (self._state === 'connecting') { self._failSetup(err); return; }
|
|
254
|
+
if (self.listenerCount('error') > 0) self.emit('error', err);
|
|
339
255
|
},
|
|
340
256
|
|
|
341
257
|
onConnect: function (q, s) {
|
|
258
|
+
if (attempt !== self._attempt) { try { q.close(0x100, 'superseded', true); } catch (e) {} return; }
|
|
342
259
|
self._quic = q;
|
|
343
260
|
self._udpSocket = s;
|
|
261
|
+
var pinErr = self._checkCertificateHashes();
|
|
262
|
+
if (pinErr) { self._failSetup(pinErr); return; }
|
|
344
263
|
self._h3 = new H3Connection({ quicConnection: self._quic, isServer: false, enableWebTransport: true });
|
|
345
264
|
self._setupH3Handlers();
|
|
346
265
|
self._sendConnect();
|
|
347
266
|
},
|
|
348
267
|
|
|
349
268
|
onClose: function () {
|
|
350
|
-
self.
|
|
351
|
-
|
|
352
|
-
|
|
269
|
+
if (attempt !== self._attempt) return;
|
|
270
|
+
// Gone before the CONNECT was answered — after the QUIC handshake, so
|
|
271
|
+
// onError did not see it. `ready` must settle (it rejects in the
|
|
272
|
+
// browser too); left pending, the caller would wait forever.
|
|
273
|
+
if (self._state === 'connecting') {
|
|
274
|
+
var err = new Error('WebTransport connection closed before the session was established');
|
|
275
|
+
err.code = 'ERR_WEBTRANSPORT_SESSION_FAILED';
|
|
276
|
+
self._failSetup(err);
|
|
277
|
+
}
|
|
278
|
+
if (self._state !== 'failed') self._state = 'closed';
|
|
279
|
+
self._sessionClosed();
|
|
353
280
|
// The helper closes the UDP socket.
|
|
354
281
|
}
|
|
355
282
|
});
|
|
@@ -361,31 +288,74 @@ class WebTransport extends EventEmitter {
|
|
|
361
288
|
_setupH3Handlers() {
|
|
362
289
|
var self = this;
|
|
363
290
|
|
|
291
|
+
// The CONNECT stream after the 200: capsules. The server's
|
|
292
|
+
// CLOSE_WEBTRANSPORT_SESSION ends the session, its DRAIN asks us to wind
|
|
293
|
+
// down ('draining').
|
|
294
|
+
var readCapsules = createCapsuleReader(function (info) {
|
|
295
|
+
if (!self.closeInfo) self.closeInfo = info;
|
|
296
|
+
self.close(info);
|
|
297
|
+
}, function () { self._drainingResolve(); self.emit('draining'); });
|
|
298
|
+
this._h3.on('http_body', function (streamId, data) {
|
|
299
|
+
if (streamId === self._sessionId && self._state === 'connected') readCapsules(data);
|
|
300
|
+
});
|
|
301
|
+
|
|
364
302
|
// CONNECT response headers
|
|
365
303
|
this._h3.on('http_headers', function (streamId, headers) {
|
|
366
304
|
if (streamId === self._sessionId) {
|
|
367
305
|
var status = parseInt(headers[':status']) || 0;
|
|
306
|
+
// 1xx (103 Early Hints) is not the answer to the CONNECT: it follows.
|
|
307
|
+
if (status >= 100 && status < 200) return;
|
|
368
308
|
if (status === 200) {
|
|
309
|
+
var reliability = self._quic.maxDatagramSize() > 0 ? 'supports-unreliable' : 'reliable-only';
|
|
310
|
+
if (self._options.requireUnreliable && reliability !== 'supports-unreliable') {
|
|
311
|
+
self._failSetup(new Error('the server does not take datagrams (requireUnreliable)'));
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
369
314
|
self._state = 'connected';
|
|
315
|
+
self.reliability = reliability;
|
|
316
|
+
// The server's choice among options.protocols (WT-Protocol, a
|
|
317
|
+
// structured-field string), if it made one from them.
|
|
318
|
+
var chosen = parseSfString(headers['wt-protocol']);
|
|
319
|
+
if (chosen !== null && (self._options.protocols || []).indexOf(chosen) >= 0) self.protocol = chosen;
|
|
370
320
|
self._readyResolve();
|
|
371
321
|
self.emit('ready');
|
|
322
|
+
self._handOver();
|
|
372
323
|
} else {
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
self.emit('error', err);
|
|
324
|
+
self._failSetup(new Error('WebTransport CONNECT failed: HTTP ' + status));
|
|
325
|
+
return;
|
|
376
326
|
}
|
|
377
327
|
}
|
|
378
328
|
});
|
|
379
329
|
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
//
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
330
|
+
// The server's streams. h3.js recognises them by their prefix — 0x41 or
|
|
331
|
+
// 0x54 and this session's ID — exactly as it does the client's on the
|
|
332
|
+
// server, and hands them over. They were picked out here by stream number
|
|
333
|
+
// (3, 7, 11 taken for HTTP/3's own, 15 and up for WebTransport), true only
|
|
334
|
+
// while the server opened its streams in that order and no others; and
|
|
335
|
+
// their bytes were read past HTTP/3's back, uncredited and unbounded.
|
|
336
|
+
this._h3.on('wt_stream', function (sessionId, streamId, data, fin, isBidi) {
|
|
337
|
+
if (sessionId !== self._sessionId) return;
|
|
338
|
+
var stream = self._newStream(streamId, isBidi ? 'bidi' : 'uni-in');
|
|
339
|
+
if (data.byteLength > 0) stream._pushData(data);
|
|
340
|
+
if (fin) stream._endData();
|
|
341
|
+
self._waiting.push([isBidi ? 'stream' : 'unidirectionalStream', stream]);
|
|
342
|
+
self._handOver();
|
|
343
|
+
});
|
|
344
|
+
this._h3.on('wt_data', function (sessionId, streamId, data, fin) {
|
|
345
|
+
var stream = self._streams[streamId];
|
|
346
|
+
if (!stream) return;
|
|
347
|
+
if (data.byteLength > 0) stream._pushData(data);
|
|
348
|
+
if (fin) stream._endData();
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// The server gave up on a stream: it ends with the server's code.
|
|
352
|
+
this._h3.on('stream_reset', function (streamId, code) { self._peerCancelled(streamId, 'stream_reset', code); });
|
|
353
|
+
this._h3.on('stop_sending', function (streamId, code) { self._peerCancelled(streamId, 'stop_sending', code); });
|
|
387
354
|
|
|
388
|
-
|
|
355
|
+
// The session is over in HTTP/3 (its CONNECT stream ended): its streams,
|
|
356
|
+
// already reset there, end here too.
|
|
357
|
+
this._h3.on('wt_session_closed', function (sessionId) {
|
|
358
|
+
if (sessionId === self._sessionId) self._destroyStreams();
|
|
389
359
|
});
|
|
390
360
|
|
|
391
361
|
// Datagrams — QUIC DATAGRAM payload = VarInt(quarter_stream_id) + app data (RFC 9297)
|
|
@@ -396,125 +366,103 @@ class WebTransport extends EventEmitter {
|
|
|
396
366
|
var result = readVarInt(rawData, 0);
|
|
397
367
|
if (!result) return;
|
|
398
368
|
var qsid = result.value;
|
|
399
|
-
var payload = rawData.slice(result.byteLength);
|
|
400
369
|
|
|
401
370
|
var expectedQsid = Math.floor(self._sessionId / 4);
|
|
402
371
|
if (self._sessionId !== null && qsid === expectedQsid) {
|
|
403
|
-
|
|
372
|
+
// One copy, into a buffer of its own: exact size, byteOffset 0 — not
|
|
373
|
+
// slice() + Buffer.from(), which copied twice and put anything under
|
|
374
|
+
// 4KB into Node's shared 8KB Buffer pool (see h3_server.js).
|
|
375
|
+
var payload = new Uint8Array(rawData.byteLength - result.byteLength);
|
|
376
|
+
payload.set(rawData.subarray(result.byteLength));
|
|
377
|
+
self.datagrams._push(payload);
|
|
378
|
+
self.emit('datagram', Buffer.from(payload.buffer));
|
|
404
379
|
}
|
|
405
380
|
});
|
|
406
381
|
}
|
|
407
382
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
if (type === 0x0) {
|
|
419
|
-
// Client bidi — stream 0 is CONNECT, 4+ are WT
|
|
420
|
-
return streamId >= 4;
|
|
421
|
-
} else if (type === 0x1) {
|
|
422
|
-
// Server bidi — all are potentially WT
|
|
423
|
-
return true;
|
|
424
|
-
} else if (type === 0x2) {
|
|
425
|
-
// Client uni — 2/6/10 are H3, 14+ are WT
|
|
426
|
-
return streamId >= 14;
|
|
427
|
-
} else {
|
|
428
|
-
// Server uni — 3/7/11 are H3, 15+ are potentially WT
|
|
429
|
-
return streamId >= 15;
|
|
430
|
-
}
|
|
383
|
+
// A stream of this session, ours or the server's (wt_stream.js), forgotten
|
|
384
|
+
// when it closes.
|
|
385
|
+
_newStream(streamId, kind, prefixLength) {
|
|
386
|
+
var self = this;
|
|
387
|
+
var stream = createWtStream(this._quic, streamId, kind, function () {
|
|
388
|
+
if (self._streams[streamId] === stream) delete self._streams[streamId];
|
|
389
|
+
if (self._h3) self._h3.releaseWebTransportStream(streamId);
|
|
390
|
+
}, prefixLength, this._sched, this._sessionId);
|
|
391
|
+
this._streams[streamId] = stream;
|
|
392
|
+
return stream;
|
|
431
393
|
}
|
|
432
394
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
return;
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
// New incoming stream — need to parse session_id prefix
|
|
443
|
-
if (!(streamId in this._incomingBuffers)) {
|
|
444
|
-
this._incomingBuffers[streamId] = { chunks: [], totalLen: 0 };
|
|
445
|
-
}
|
|
395
|
+
// The peer gave up on one direction of a stream: that direction ends (the
|
|
396
|
+
// stream is forgotten when it closes — see _newStream).
|
|
397
|
+
_peerCancelled(streamId, how, code) {
|
|
398
|
+
var stream = this._streams[streamId];
|
|
399
|
+
if (stream) stream._peerCancelled(how, code);
|
|
400
|
+
}
|
|
446
401
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
if (!isBidi) {
|
|
463
|
-
if (streamType !== 0x54) {
|
|
464
|
-
// Not a WT uni stream — let H3 handle it
|
|
465
|
-
delete this._incomingBuffers[streamId];
|
|
466
|
-
return;
|
|
467
|
-
}
|
|
468
|
-
} else {
|
|
469
|
-
if (streamType !== 0x41) {
|
|
470
|
-
delete this._incomingBuffers[streamId];
|
|
471
|
-
return;
|
|
472
|
-
}
|
|
402
|
+
// Hand the server's waiting streams to whoever listens for them, in
|
|
403
|
+
// order; the rest keep waiting (see _waiting).
|
|
404
|
+
// A Node listener first; otherwise the browser API's queue, once it is
|
|
405
|
+
// read (incomingBidirectionalStreams yields the Duplex, which has the
|
|
406
|
+
// browser's .readable and .writable; incomingUnidirectionalStreams yields
|
|
407
|
+
// the stream's ReadableStream, as the browser does).
|
|
408
|
+
_handOver() {
|
|
409
|
+
if (this._state !== 'connected') return;
|
|
410
|
+
var keep = [];
|
|
411
|
+
for (var i = 0; i < this._waiting.length; i++) {
|
|
412
|
+
var w = this._waiting[i];
|
|
413
|
+
if (w[1].destroyed) continue;
|
|
414
|
+
if (this.listenerCount(w[0]) > 0) this.emit(w[0], w[1]);
|
|
415
|
+
else if (this._incoming[w[0]]) this._incoming[w[0]].enqueue(w[0] === 'stream' ? w[1] : w[1].readable);
|
|
416
|
+
else keep.push(w);
|
|
473
417
|
}
|
|
418
|
+
this._waiting = keep;
|
|
419
|
+
}
|
|
474
420
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
var
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
421
|
+
_incomingStreams(event) {
|
|
422
|
+
var self = this;
|
|
423
|
+
var rs = new ReadableStream({
|
|
424
|
+
start: function (controller) { self._incoming[event] = controller; }
|
|
425
|
+
});
|
|
426
|
+
queueMicrotask(function () { self._handOver(); });
|
|
427
|
+
return rs;
|
|
428
|
+
}
|
|
482
429
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
430
|
+
// The session is over: each stream still open goes with WT_SESSION_GONE
|
|
431
|
+
// (draft-ietf-webtrans-http3 §4.6), not an application code.
|
|
432
|
+
_destroyStreams() {
|
|
433
|
+
for (var id in this._streams) { this._streams[id]._sessionGone(); this._streams[id].destroy(); }
|
|
434
|
+
for (var i = 0; i < this._waiting.length; i++) { var w = this._waiting[i][1]; if (w._sessionGone) w._sessionGone(); w.destroy(); }
|
|
435
|
+
this._waiting = [];
|
|
436
|
+
for (var ev in this._incoming) {
|
|
437
|
+
if (this._incoming[ev]) { try { this._incoming[ev].close(); } catch (e) {} }
|
|
487
438
|
}
|
|
439
|
+
this.datagrams._close();
|
|
440
|
+
}
|
|
488
441
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
if (
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
this._streams[streamId] = stream;
|
|
514
|
-
if (remaining.byteLength > 0) stream._pushData(remaining);
|
|
515
|
-
if (fin) { stream._endData(); delete this._streams[streamId]; }
|
|
516
|
-
this.emit('unidirectionalStream', stream);
|
|
517
|
-
}
|
|
442
|
+
// serverCertificateHashes (the browser's way to trust a certificate it
|
|
443
|
+
// cannot verify by CA): the leaf's SHA-256 must be one of them, and — as
|
|
444
|
+
// the browser requires of such a certificate — it must be valid now, for
|
|
445
|
+
// no more than 14 days. Checked once the handshake is done and before
|
|
446
|
+
// anything is sent: the handshake has already proved the server holds the
|
|
447
|
+
// certificate's key. null when it passes (or none were given).
|
|
448
|
+
_checkCertificateHashes() {
|
|
449
|
+
var hashes = this._options.serverCertificateHashes;
|
|
450
|
+
if (!hashes || !hashes.length) return null;
|
|
451
|
+
var chain = this._quic.getPeerCertificateChain && this._quic.getPeerCertificateChain();
|
|
452
|
+
if (!chain || !chain.length) return new Error('serverCertificateHashes: the server sent no certificate');
|
|
453
|
+
var der = Buffer.from(chain[0]);
|
|
454
|
+
var digest = createHash('sha256').update(der).digest();
|
|
455
|
+
var match = hashes.some(function (h) {
|
|
456
|
+
if (!h || String(h.algorithm).toLowerCase() !== 'sha-256' || !h.value) return false;
|
|
457
|
+
var v = ArrayBuffer.isView(h.value) ? Buffer.from(h.value.buffer, h.value.byteOffset, h.value.byteLength) : Buffer.from(h.value);
|
|
458
|
+
return v.length === digest.length && v.equals(digest);
|
|
459
|
+
});
|
|
460
|
+
if (!match) return new Error('serverCertificateHashes: the certificate matches none of them');
|
|
461
|
+
var cert = new X509Certificate(der), now = Date.now();
|
|
462
|
+
var from = Date.parse(cert.validFrom), to = Date.parse(cert.validTo);
|
|
463
|
+
if (!(now >= from && now <= to)) return new Error('serverCertificateHashes: the certificate is not valid now');
|
|
464
|
+
if (to - from > 14 * 24 * 3600 * 1000) return new Error('serverCertificateHashes: the certificate is valid for more than 14 days');
|
|
465
|
+
return null;
|
|
518
466
|
}
|
|
519
467
|
|
|
520
468
|
// ---- Send CONNECT ----
|
|
@@ -523,8 +471,11 @@ class WebTransport extends EventEmitter {
|
|
|
523
471
|
// Send H3 control streams (SETTINGS)
|
|
524
472
|
this._h3.sendControlStreams();
|
|
525
473
|
|
|
526
|
-
// CONNECT
|
|
527
|
-
|
|
474
|
+
// The CONNECT goes on the first bidirectional stream; its number is the
|
|
475
|
+
// session's ID, and the server's streams for it carry that ID — so they
|
|
476
|
+
// are recognised from now on, even one that overtakes the 200.
|
|
477
|
+
this._sessionId = this._quic.nextLocalStreamId(true);
|
|
478
|
+
this._sched = wtScheduling(this._quic, this._sessionId);
|
|
528
479
|
|
|
529
480
|
// Send Extended CONNECT — no FIN (session stays open)
|
|
530
481
|
var headers = {
|
|
@@ -534,8 +485,14 @@ class WebTransport extends EventEmitter {
|
|
|
534
485
|
':authority': this._hostname + (this._port !== 443 ? ':' + this._port : ''),
|
|
535
486
|
':path': this._path
|
|
536
487
|
};
|
|
488
|
+
// The browser's `protocols`: the application protocols it offers, as a
|
|
489
|
+
// structured-field list of strings; the server answers with WT-Protocol.
|
|
490
|
+
if (Array.isArray(this._options.protocols) && this._options.protocols.length > 0) {
|
|
491
|
+
headers['wt-available-protocols'] = this._options.protocols.map(sfString).join(', ');
|
|
492
|
+
}
|
|
537
493
|
|
|
538
494
|
this._h3.sendHeaders(this._sessionId, headers, false);
|
|
495
|
+
this._h3.registerWebTransportSession(this._sessionId);
|
|
539
496
|
}
|
|
540
497
|
|
|
541
498
|
|
|
@@ -543,97 +500,137 @@ class WebTransport extends EventEmitter {
|
|
|
543
500
|
// Public API
|
|
544
501
|
// ============================================================
|
|
545
502
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
503
|
+
// The browser's stream options — sendOrder, sendGroup, waitUntilAvailable
|
|
504
|
+
// (wt_stream's openWtStream, as the server's): a stream past the server's
|
|
505
|
+
// limit without waitUntilAvailable is refused, as the browser refuses it.
|
|
506
|
+
// Resolved once the stream is open — at once if the server allows it, or
|
|
507
|
+
// when it does (waitUntilAvailable), as the browser's.
|
|
508
|
+
_openStream(bidi, options) {
|
|
509
|
+
if (this._state !== 'connected') return Promise.reject(new WebTransportError('the session is not connected', { source: 'session' }));
|
|
551
510
|
var self = this;
|
|
552
511
|
return new Promise(function (resolve, reject) {
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
512
|
+
var stream = openWtStream(self._h3, self._quic, self._sessionId, bidi, options,
|
|
513
|
+
function (id, kind, prefix) { return self._newStream(id, kind, prefix); },
|
|
514
|
+
function (err, st) { if (err) reject(new WebTransportError(err.message, { source: 'session' })); else resolve(st); });
|
|
515
|
+
if (!stream) reject(new WebTransportError('the server allows no more streams now', { source: 'session' }));
|
|
516
|
+
});
|
|
517
|
+
}
|
|
556
518
|
|
|
557
|
-
|
|
558
|
-
|
|
519
|
+
/** Whether a stream could be opened now, within the server's MAX_STREAMS — as the server's res.canOpenStream(). */
|
|
520
|
+
canOpenStream(options) {
|
|
521
|
+
return this._state === 'connected' && !!this._quic && this._quic.canOpenStream(!!(options && options.bidirectional));
|
|
522
|
+
}
|
|
559
523
|
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
524
|
+
/**
|
|
525
|
+
* A bidirectional stream: a Duplex (wt_stream.js) — and, as the browser's
|
|
526
|
+
* WebTransportBidirectionalStream, it has .readable and .writable.
|
|
527
|
+
*/
|
|
528
|
+
createBidirectionalStream(options) {
|
|
529
|
+
return this._openStream(true, options);
|
|
530
|
+
}
|
|
563
531
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
532
|
+
/**
|
|
533
|
+
* A unidirectional stream: as the browser's, a WritableStream
|
|
534
|
+
* (getWriter(), pipeTo() into it) — and Node's Writable on the same object,
|
|
535
|
+
* whole: write() / 'drain', end(), resetStream(), its events, the
|
|
536
|
+
* writable* state, and readable.pipe() into it, as the server's stream is.
|
|
537
|
+
* Only write(), end() and a few events were carried over: there was no
|
|
538
|
+
* writableLength, no destroyed, and pipe() failed on the missing emit().
|
|
539
|
+
*/
|
|
540
|
+
createUnidirectionalStream(options) {
|
|
541
|
+
return this._openStream(false, options).then(function (stream) {
|
|
542
|
+
var w = stream.writable;
|
|
543
|
+
UNI_METHODS.forEach(function (m) {
|
|
544
|
+
if (!(m in w) || m === 'getStats') w[m] = stream[m].bind(stream);
|
|
569
545
|
});
|
|
546
|
+
UNI_STATE.forEach(function (k) {
|
|
547
|
+
Object.defineProperty(w, k, { configurable: true, get: function () { return stream[k]; } });
|
|
548
|
+
});
|
|
549
|
+
return w;
|
|
550
|
+
});
|
|
551
|
+
}
|
|
570
552
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
553
|
+
/** The browser's incoming streams, as ReadableStreams of streams. */
|
|
554
|
+
get incomingBidirectionalStreams() {
|
|
555
|
+
return this._incomingBidi || (this._incomingBidi = this._incomingStreams('stream'));
|
|
556
|
+
}
|
|
557
|
+
get incomingUnidirectionalStreams() {
|
|
558
|
+
return this._incomingUni || (this._incomingUni = this._incomingStreams('unidirectionalStream'));
|
|
559
|
+
}
|
|
575
560
|
|
|
576
|
-
|
|
577
|
-
|
|
561
|
+
/** The browser's WebTransportSendGroup. */
|
|
562
|
+
createSendGroup() {
|
|
563
|
+
return createSendGroup();
|
|
578
564
|
}
|
|
579
565
|
|
|
580
566
|
/**
|
|
581
|
-
*
|
|
582
|
-
* Returns a Writable stream (Node.js) with .writable (WHATWG).
|
|
567
|
+
* The browser's getStats(): the connection's figures, as QUIC has them.
|
|
583
568
|
*/
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
return new Promise(function (resolve, reject) {
|
|
587
|
-
if (self._state !== 'connected') {
|
|
588
|
-
return reject(new Error('WebTransport is not connected'));
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
var streamId = self._nextUniStreamId;
|
|
592
|
-
self._nextUniStreamId += 4;
|
|
593
|
-
|
|
594
|
-
// Claim this stream
|
|
595
|
-
self._claimedStreams.add(streamId);
|
|
596
|
-
if (self._h3 && self._h3.claimStream) self._h3.claimStream(streamId);
|
|
597
|
-
|
|
598
|
-
var stream = new WebTransportUniWriteStream({
|
|
599
|
-
streamId: streamId,
|
|
600
|
-
quic: self._quic,
|
|
601
|
-
sessionId: self._sessionId
|
|
602
|
-
});
|
|
603
|
-
|
|
604
|
-
resolve(stream);
|
|
605
|
-
});
|
|
569
|
+
getStats() {
|
|
570
|
+
return sessionStats(this._session); // wt_stream: as the server's res.getStats()
|
|
606
571
|
}
|
|
607
572
|
|
|
608
573
|
/**
|
|
609
574
|
* Send an unreliable datagram.
|
|
610
575
|
*/
|
|
611
|
-
sendDatagram(
|
|
576
|
+
// As the server's res.sendDatagram: true when queued (sent under
|
|
577
|
+
// congestion control, never retransmitted), false when it cannot be sent at
|
|
578
|
+
// all — larger than maxDatagramSize(), or the server takes no datagrams.
|
|
579
|
+
// options { sendOrder, sendGroup }: its place among the session's streams
|
|
580
|
+
// (wt_stream's wtScheduling); without them, ahead of every stream.
|
|
581
|
+
sendDatagram(data, options) {
|
|
612
582
|
if (this._state !== 'connected') throw new Error('WebTransport is not connected');
|
|
613
583
|
if (typeof data === 'string') data = Buffer.from(data);
|
|
614
584
|
if (Buffer.isBuffer(data)) data = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
585
|
+
// VarInt(quarter stream ID) + payload (RFC 9297)
|
|
586
|
+
return this._quic.sendDatagram(wt_datagram_payload(this._sessionId, data), this._sched.forDatagram(options), this._dgOwner);
|
|
587
|
+
}
|
|
615
588
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
if (this._quic && this._quic.sendDatagram) {
|
|
621
|
-
this._quic.sendDatagram(frame);
|
|
622
|
-
}
|
|
589
|
+
/** The largest datagram this session can send, in bytes of payload. */
|
|
590
|
+
maxDatagramSize() {
|
|
591
|
+
if (this._state !== 'connected') return 0;
|
|
592
|
+
return Math.max(0, this._quic.maxDatagramSize() - wt_datagram_payload(this._sessionId, new Uint8Array(0)).byteLength);
|
|
623
593
|
}
|
|
624
594
|
|
|
625
595
|
/**
|
|
626
596
|
* Close the WebTransport session.
|
|
627
597
|
*/
|
|
598
|
+
// The session is over: `closed` resolves and 'close' is emitted, once. As
|
|
599
|
+
// in the browser, closed resolves with the code and reason the session was
|
|
600
|
+
// closed with — the server's CLOSE_WEBTRANSPORT_SESSION, or ours — and
|
|
601
|
+
// { closeCode: 0, reason: '' } when there was none. At the session's close,
|
|
602
|
+
// not the QUIC connection's: that one lingers in its closing period
|
|
603
|
+
// (RFC 9000 §10.2) for three PTOs after, and `closed` waited it out.
|
|
604
|
+
_sessionClosed() {
|
|
605
|
+
if (this._closedDone) return;
|
|
606
|
+
this._closedDone = true;
|
|
607
|
+
this._destroyStreams();
|
|
608
|
+
this._closedResolve(this.closeInfo || { closeCode: 0, reason: '' });
|
|
609
|
+
this.emit('close');
|
|
610
|
+
}
|
|
611
|
+
|
|
628
612
|
close(info) {
|
|
629
|
-
if (this._state === 'closed') return;
|
|
613
|
+
if (this._state === 'closed' || this._state === 'failed') return;
|
|
630
614
|
this._state = 'closed';
|
|
631
615
|
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
|
|
635
|
-
|
|
616
|
+
// CLOSE_WEBTRANSPORT_SESSION with the code and reason, and the CONNECT
|
|
617
|
+
// stream's end — so the server learns them, as from a browser's
|
|
618
|
+
// wt.close(). Then the QUIC connection, which is this session's alone,
|
|
619
|
+
// once the capsule has left: closed at once, it was dropped unsent.
|
|
620
|
+
var quic = this._quic;
|
|
621
|
+
if (!this.closeInfo) this.closeInfo = { closeCode: (info && info.closeCode) || 0, reason: (info && info.reason) || '' };
|
|
622
|
+
if (quic) {
|
|
623
|
+
var closeQuic = function () { quic.close(0x0100, 'WebTransport closed', true); }; // H3_NO_ERROR
|
|
624
|
+
if (this._h3 && this._sessionId !== null) {
|
|
625
|
+
this._h3.sendBody(this._sessionId, wtCloseCapsule(this.closeInfo.closeCode, this.closeInfo.reason), true);
|
|
626
|
+
quic.whenStreamSent(this._sessionId, 0, closeQuic);
|
|
627
|
+
var t = setTimeout(closeQuic, 1000); // a capsule that cannot leave does not hold the close
|
|
628
|
+
if (t.unref) t.unref();
|
|
629
|
+
} else {
|
|
630
|
+
closeQuic();
|
|
631
|
+
}
|
|
636
632
|
}
|
|
633
|
+
this._sessionClosed();
|
|
637
634
|
}
|
|
638
635
|
|
|
639
636
|
/**
|
|
@@ -652,4 +649,4 @@ class WebTransport extends EventEmitter {
|
|
|
652
649
|
}
|
|
653
650
|
|
|
654
651
|
|
|
655
|
-
export { WebTransport,
|
|
652
|
+
export { WebTransport, WebTransportError };
|