quico 0.3.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.
@@ -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 { readVarInt, concatUint8Arrays, wt_stream_header, wt_datagram_payload } from './utils.js';
21
-
22
-
23
- // ============================================================
24
- // WebTransportBidiStream — Duplex + WHATWG .readable/.writable
25
- //
26
- // Node.js style:
27
- // stream.write(data);
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
- // Usage:
234
- // var wt = new WebTransport('https://server:4433/path');
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
- // // Datagrams
252
- // wt.sendDatagram(Buffer.from('ping'));
253
- // wt.on('datagram', data => console.log('pong:', data));
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
- // // Incoming streams
256
- // wt.on('stream', stream => { ... }); // bidi from server
257
- // wt.on('unidirectionalStream', stream => { ... }); // uni from server
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
- // wt.close();
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
- // Stream tracking
282
- this._streams = {}; // streamId → WebTransportBidiStream | ReadStream
283
- this._claimedStreams = new Set(); // stream IDs owned by WT (not H3)
284
-
285
- // Client-initiated stream ID counters
286
- // Bidi: 0 used for CONNECT, so WT bidi starts at 4, 8, 12...
287
- // Uni: 2, 6, 10 used for H3 control/qpack, so WT uni starts at 14, 18, 22...
288
- this._nextBidiStreamId = 4;
289
- this._nextUniStreamId = 14;
290
-
291
- // Incoming stream prefix buffers (for parsing session_id)
292
- this._incomingBuffers = {};
293
-
294
- // Promises (browser-compatible API)
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._readyReject(err); self.emit('error', err); return; }
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 (self._state === 'connecting') self._readyReject(err);
338
- self.emit('error', err);
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._state = 'closed';
351
- self._closedResolve();
352
- self.emit('close');
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
- var err = new Error('WebTransport CONNECT failed: HTTP ' + status);
374
- self._readyReject(err);
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
- // Raw QUIC stream data — intercept WT streams before H3 processes them
381
- // We register on QUIC directly. H3 will also receive these events,
382
- // but we call h3.claimStream() for WT streams so H3 skips them.
383
- this._quic.on('stream', function (streamId, data, fin) {
384
- // Skip streams owned by H3 (CONNECT stream, control streams)
385
- if (streamId === self._sessionId) return; // H3 handles CONNECT response
386
- if (!self._isWebTransportStream(streamId)) return;
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
- self._handleStreamData(streamId, data, fin);
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
- self.emit('datagram', Buffer.from(payload));
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
- _isWebTransportStream(streamId) {
409
- // Already claimed
410
- if (this._claimedStreams.has(streamId)) return true;
411
-
412
- // Server-initiated bidi: 1, 5, 9, 13... (could be WT)
413
- // Server-initiated uni: 3, 7, 11, 15... (11+ could be WT, 3/7/11 are H3)
414
- // Client-initiated bidi: 4+ are WT (0 is CONNECT)
415
- // Client-initiated uni: 14+ are WT (2/6/10 are H3)
416
- var type = streamId & 0x3;
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
- _handleStreamData(streamId, data, fin) {
434
- // Already have a stream object?
435
- if (streamId in this._streams) {
436
- var stream = this._streams[streamId];
437
- if (data.byteLength > 0) stream._pushData(data);
438
- if (fin) { stream._endData(); delete this._streams[streamId]; }
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
- var buf = this._incomingBuffers[streamId];
448
- buf.chunks.push(data);
449
- buf.totalLen += data.byteLength;
450
-
451
- // Try to parse prefix
452
- var combined = buf.totalLen === data.byteLength ? data : concatUint8Arrays(buf.chunks);
453
- var isBidi = (streamId & 0x2) === 0;
454
- var offset = 0;
455
-
456
- // Parse stream type as VarInt (0x41=bidi WT, 0x54=uni WT)
457
- var typeResult = readVarInt(combined, 0);
458
- if (!typeResult) { if (fin) delete this._incomingBuffers[streamId]; return; }
459
- var streamType = typeResult.value;
460
- offset = typeResult.byteLength;
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
- // Parse VarInt session_id
476
- if (offset >= combined.byteLength) { if (fin) delete this._incomingBuffers[streamId]; return; }
477
- var result = readVarInt(combined, offset);
478
- if (!result) { if (fin) delete this._incomingBuffers[streamId]; return; }
479
-
480
- var sessionId = result.value;
481
- var afterPrefix = offset + result.byteLength;
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
- // Verify it matches our session
484
- if (sessionId !== this._sessionId) {
485
- delete this._incomingBuffers[streamId];
486
- return;
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
- // Claim stream — tell H3 to skip it
490
- this._claimedStreams.add(streamId);
491
- if (this._h3 && this._h3.claimStream) this._h3.claimStream(streamId);
492
- delete this._incomingBuffers[streamId];
493
-
494
- // Create stream object
495
- var remaining = combined.byteLength > afterPrefix
496
- ? combined.slice(afterPrefix)
497
- : new Uint8Array(0);
498
-
499
- if (isBidi) {
500
- var stream = new WebTransportBidiStream({
501
- streamId: streamId,
502
- quic: this._quic,
503
- sessionId: this._sessionId,
504
- prefixSent: false // server-initiated, prefix not needed for writes back
505
- });
506
- this._streams[streamId] = stream;
507
- if (remaining.byteLength > 0) stream._pushData(remaining);
508
- if (fin) { stream._endData(); delete this._streams[streamId]; }
509
- this.emit('stream', stream);
510
-
511
- } else {
512
- var stream = new WebTransportUniReadStream({ streamId: streamId });
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 uses stream 0 (first client bidi)
527
- this._sessionId = 0;
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
- * Create a bidirectional stream.
548
- * Returns a Duplex stream (Node.js) with .readable/.writable (WHATWG).
549
- */
550
- createBidirectionalStream() {
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
- if (self._state !== 'connected') {
554
- return reject(new Error('WebTransport is not connected'));
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
- var streamId = self._nextBidiStreamId;
558
- self._nextBidiStreamId += 4;
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
- // Claim this stream so H3 doesn't process it
561
- self._claimedStreams.add(streamId);
562
- if (self._h3 && self._h3.claimStream) self._h3.claimStream(streamId);
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
- var stream = new WebTransportBidiStream({
565
- streamId: streamId,
566
- quic: self._quic,
567
- sessionId: self._sessionId,
568
- prefixSent: false
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
- self._streams[streamId] = stream;
572
-
573
- // Listen for incoming data on this bidi stream
574
- // (already handled by _handleStreamData via QUIC stream event)
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
- resolve(stream);
577
- });
561
+ /** The browser's WebTransportSendGroup. */
562
+ createSendGroup() {
563
+ return createSendGroup();
578
564
  }
579
565
 
580
566
  /**
581
- * Create a unidirectional stream (outgoing only).
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
- createUnidirectionalStream() {
585
- var self = this;
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(data) {
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
- // Build datagram: VarInt(quarter_stream_id) + payload (RFC 9297)
617
- var frame = wt_datagram_payload(this._sessionId, data);
618
-
619
- // Send via QUIC DATAGRAM frame
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
- // Send CLOSE_WEBTRANSPORT_SESSION on CONNECT stream (optional)
633
- // Then close QUIC connection
634
- if (this._quic) {
635
- this._quic.close(0, (info && info.reason) || 'WebTransport closed');
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, WebTransportBidiStream, WebTransportUniWriteStream, WebTransportUniReadStream };
652
+ export { WebTransport, WebTransportError };