quico 0.7.0 → 0.8.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.
@@ -19,10 +19,13 @@ import { createQuicClientSocket } from './quic_socket.js';
19
19
  import lemonTls from 'lemon-tls';
20
20
  import { createHash, X509Certificate } from 'node:crypto';
21
21
  import { readVarInt, wt_datagram_payload } from './utils.js';
22
- import { createWtStream, openWtStream, createSendGroup, wtScheduling, createDatagrams, sessionStats, WebTransportError } from './wt_stream.js';
22
+ import { createWtStream, openWtStream, createSendGroup, wtScheduling, createDatagrams, sessionStats, setErrorCode,
23
+ exporterArgs, exportSessionKeyingMaterial,
24
+ WebTransportError, WebTransportBidirectionalStream, WebTransportReceiveStream, WebTransportSendStream,
25
+ WebTransportWriter, WebTransportSendGroup, WebTransportDatagramsWritable, WebTransportDatagramDuplexStream } from './wt_stream.js';
23
26
  import { defaultTransportParams } from './tls_bridge.js';
24
27
  import { initialCongestionWindow, checkConnectionOptions } from './quic_connection.js';
25
- import { sfString, parseSfString } from './http_common.js';
28
+ import { sfString, parseSfString, validateHeaderName, validateHeaderValue, isConnectionSpecific } from './http_common.js';
26
29
  import { parseVersionList } from './quic_version.js';
27
30
 
28
31
 
@@ -50,6 +53,8 @@ import { parseVersionList } from './quic_version.js';
50
53
  // Options: the browser's — serverCertificateHashes, protocols,
51
54
  // congestionControl, allowPooling, requireUnreliable, anticipatedConcurrent…
52
55
  // — and Node's, as for https.request: rejectUnauthorized (default true),
56
+ // origin and headers (fields for the CONNECT request, as the WebSocket
57
+ // client takes them: a browser sends its page's Origin, Node has none),
53
58
  // ca, key / cert / passphrase (a client certificate), and session (one
54
59
  // saved from the 'session' event, to resume the TLS session). The
55
60
  // server's certificate is verified against the platform roots
@@ -68,25 +73,213 @@ var UNI_METHODS = ['write', 'end', 'destroy', 'cork', 'uncork', 'setDefaultEncod
68
73
  var UNI_STATE = ['_streamId', 'id', 'sessionId', 'sendState', 'pending', 'streamErrorCode', 'destroyed', 'writableLength', 'writableHighWaterMark',
69
74
  'writableNeedDrain', 'writableEnded', 'writableFinished', 'writableCorked', 'writableObjectMode', 'closed', 'errored'];
70
75
 
76
+ // The CONNECT request's own fields, besides those the session sets:
77
+ // options.headers — the browser's HeadersInit: a Headers, a sequence of
78
+ // [name, value] pairs, or an object of them — and Origin (options.origin):
79
+ // a browser always sends its page's, and servers check it (WPT's
80
+ // WebTransport server refuses a CONNECT without one); Node has no page.
81
+ //
82
+ // As the browser's Headers takes them: names lower-cased, values with their
83
+ // leading and trailing whitespace removed (a field value may not begin or end
84
+ // with it — RFC 9110 §5.5 — and a server refuses the request), a name given
85
+ // twice sent once with its values joined by ", ". An invalid name or value,
86
+ // or a field the session sets itself, throws a TypeError now, not later on
87
+ // the wire:
88
+ // a pseudo-header (not a token: TypeError from validateHeaderName);
89
+ // wt-available-protocols (that is options.protocols).
90
+ // Fields that cannot go on an HTTP/3 request at all are dropped, as the
91
+ // browser drops them: connection-specific ones (RFC 9114 §4.2), host (the
92
+ // authority is :authority, §4.3.1), te, and content-length and trailer,
93
+ // which do not apply to a CONNECT whose stream carries capsules. The fields
94
+ // a browser keeps from a page for its own safety — cookie, referer, sec-* —
95
+ // are sent: Node is the user agent itself here, as with Node's fetch.
96
+ var CONNECT_DROPPED = { 'host': 1, 'te': 1, 'content-length': 1, 'trailer': 1 };
97
+ var HTTP_WHITESPACE = /^[\t\n\r ]+|[\t\n\r ]+$/g;
98
+ function headersInitEntries(h) {
99
+ if (h == null) return [];
100
+ if (typeof h !== 'object' && typeof h !== 'function') {
101
+ throw new TypeError('options.headers must be a Headers, a sequence of [name, value] pairs, or an object');
102
+ }
103
+ var out = [];
104
+ if (typeof Headers !== 'undefined' && h instanceof Headers) {
105
+ h.forEach(function (value, name) { out.push([name, value]); });
106
+ return out;
107
+ }
108
+ if (typeof h[Symbol.iterator] === 'function') {
109
+ for (var pair of h) {
110
+ if (pair == null || typeof pair !== 'object' || typeof pair[Symbol.iterator] !== 'function') {
111
+ throw new TypeError('options.headers: each item must be a [name, value] pair');
112
+ }
113
+ var p = Array.from(pair);
114
+ if (p.length !== 2) throw new TypeError('options.headers: each pair must have exactly two items, a name and a value; got ' + p.length);
115
+ out.push([p[0], p[1]]);
116
+ }
117
+ return out;
118
+ }
119
+ for (var name in h) {
120
+ if (!Object.prototype.hasOwnProperty.call(h, name)) continue;
121
+ var v = h[name];
122
+ if (v === undefined || v === null) continue;
123
+ // A list, as Node's http takes it: each value a field of its own.
124
+ if (Array.isArray(v)) { for (var i = 0; i < v.length; i++) out.push([name, v[i]]); }
125
+ else out.push([name, v]);
126
+ }
127
+ return out;
128
+ }
129
+ function connectRequestFields(options) {
130
+ var out = {};
131
+ var entries = headersInitEntries(options.headers);
132
+ for (var i = 0; i < entries.length; i++) {
133
+ var name = String(entries[i][0]);
134
+ validateHeaderName(name);
135
+ var lower = name.toLowerCase();
136
+ if (lower === 'wt-available-protocols') {
137
+ throw new TypeError('options.headers: wt-available-protocols cannot be set; it is options.protocols');
138
+ }
139
+ var value = String(entries[i][1]).replace(HTTP_WHITESPACE, '');
140
+ validateHeaderValue(lower, value);
141
+ if (isConnectionSpecific(lower) || CONNECT_DROPPED[lower]) continue;
142
+ out[lower] = Object.prototype.hasOwnProperty.call(out, lower) ? out[lower] + ', ' + value : value;
143
+ }
144
+ if (options.origin != null) {
145
+ var origin = String(options.origin).replace(HTTP_WHITESPACE, '');
146
+ validateHeaderValue('origin', origin);
147
+ out['origin'] = origin;
148
+ }
149
+ return out;
150
+ }
151
+
152
+ // The URL, as the browser's constructor checks it: one that does not parse,
153
+ // whose scheme is not https, or that has a fragment, is a SyntaxError
154
+ // (DOMException) — as Node's own WebSocket throws for its URL. An http: URL
155
+ // and a fragment were taken, and a port of 0 became 443.
156
+ function parseWebTransportUrl(url) {
157
+ var u;
158
+ try { u = new URL(String(url)); }
159
+ catch (e) { throw new DOMException('Invalid URL: ' + url, 'SyntaxError'); }
160
+ if (u.protocol !== 'https:') throw new DOMException('A WebTransport URL\'s scheme must be https:, not ' + u.protocol, 'SyntaxError');
161
+ if (u.href.indexOf('#') >= 0) throw new DOMException('A WebTransport URL must not have a fragment', 'SyntaxError');
162
+ return u;
163
+ }
164
+
165
+ // options.protocols, as the browser checks them: a list of strings, each
166
+ // one a valid structured-field string (printable ASCII), 1 to 512 long, none
167
+ // twice — otherwise a SyntaxError. They were sent as given: a CR or LF in
168
+ // one went into the field.
169
+ function checkProtocols(list) {
170
+ if (list == null) return [];
171
+ if (typeof list !== 'object' || typeof list[Symbol.iterator] !== 'function') {
172
+ throw new TypeError('options.protocols must be a sequence of strings');
173
+ }
174
+ var out = [];
175
+ for (var p of list) {
176
+ p = String(p);
177
+ if (p.length === 0 || p.length > 512 || !/^[\x20-\x7e]*$/.test(p) || out.indexOf(p) >= 0) {
178
+ throw new DOMException('Invalid protocol in options.protocols: ' + JSON.stringify(p) +
179
+ ' (each must be printable ASCII, 1 to 512 characters, and given once)', 'SyntaxError');
180
+ }
181
+ out.push(p);
182
+ }
183
+ return out;
184
+ }
185
+
186
+ // options.serverCertificateHashes, as the browser's WebIDL takes it: each an
187
+ // object with an algorithm and a value — a BufferSource, not shared memory.
188
+ // A malformed one was skipped, and the connection then failed for a reason
189
+ // that did not say why.
190
+ function checkCertificateHashes(list) {
191
+ if (list == null) return;
192
+ if (typeof list !== 'object' || typeof list[Symbol.iterator] !== 'function') {
193
+ throw new TypeError('options.serverCertificateHashes must be a sequence of { algorithm, value }');
194
+ }
195
+ for (var h of list) {
196
+ if (h == null || typeof h !== 'object') throw new TypeError('options.serverCertificateHashes: each must be an object { algorithm, value }');
197
+ if (h.algorithm === undefined) throw new TypeError('options.serverCertificateHashes: algorithm is required');
198
+ var v = h.value;
199
+ if (v === undefined) throw new TypeError('options.serverCertificateHashes: value is required');
200
+ var shared = typeof SharedArrayBuffer !== 'undefined' &&
201
+ (v instanceof SharedArrayBuffer || (ArrayBuffer.isView(v) && v.buffer instanceof SharedArrayBuffer));
202
+ if (shared || !(v instanceof ArrayBuffer || ArrayBuffer.isView(v))) {
203
+ throw new TypeError('options.serverCertificateHashes: value must be an ArrayBuffer or an ArrayBufferView, not shared memory');
204
+ }
205
+ }
206
+ }
207
+
208
+ // The anticipatedConcurrentIncoming… hints, as WebIDL's [EnforceRange]
209
+ // unsigned short? takes them: null, or an integer 0 to 65535 (a fraction
210
+ // dropped); anything else a TypeError.
211
+ function streamHint(v, name) {
212
+ if (v === null || v === undefined) return null;
213
+ var n = Number(v);
214
+ if (!isFinite(n)) throw new TypeError(name + ' must be null or an integer from 0 to 65535, got ' + v);
215
+ n = Math.trunc(n);
216
+ if (n < 0 || n > 65535) throw new TypeError(name + ' must be null or an integer from 0 to 65535, got ' + v);
217
+ return n;
218
+ }
219
+
220
+ var CONGESTION_CONTROL = ['default', 'throughput', 'low-latency'];
221
+
222
+ // The browser's responseHeaders: the server's answer to the CONNECT, as a
223
+ // Headers that cannot be changed — without the pseudo-headers, and without
224
+ // wt-protocol, which is negotiation (its outcome is wt.protocol). A browser
225
+ // also keeps Set-Cookie from its page; here it is there, as with Node's
226
+ // fetch: the program is the user agent (getSetCookie() reads it).
227
+ class ImmutableHeaders extends Headers {
228
+ set() { throw new TypeError('responseHeaders cannot be changed'); }
229
+ append() { throw new TypeError('responseHeaders cannot be changed'); }
230
+ delete() { throw new TypeError('responseHeaders cannot be changed'); }
231
+ }
232
+ function responseHeadersOf(headers) {
233
+ var list = headers && headers._list ? headers._list
234
+ : Object.keys(headers || {}).map(function (k) { return [k, headers[k]]; });
235
+ var pairs = [];
236
+ for (var i = 0; i < list.length; i++) {
237
+ var name = String(list[i][0]).toLowerCase(), value = list[i][1];
238
+ if (name.charAt(0) === ':' || name === 'wt-protocol') continue;
239
+ var values = Array.isArray(value) ? value : [value];
240
+ for (var j = 0; j < values.length; j++) {
241
+ var pair = [name, String(values[j])];
242
+ try { new Headers([pair]); pairs.push(pair); }
243
+ catch (e) {} // a field Headers cannot hold is left out, not the whole answer
244
+ }
245
+ }
246
+ return new ImmutableHeaders(pairs);
247
+ }
248
+
249
+ function invalidState(message) { return new DOMException(message, 'InvalidStateError'); }
250
+
71
251
  class WebTransport extends EventEmitter {
72
252
 
73
253
  constructor(url, options) {
74
254
  super();
75
255
 
76
- // Parse URL
77
- var parsed = new URL(url);
78
- this._hostname = parsed.hostname;
79
- this._port = parseInt(parsed.port) || 443;
256
+ // The URL and the browser's options, checked as the browser checks them.
257
+ var parsed = parseWebTransportUrl(url);
258
+ this._hostname = parsed.hostname.replace(/^\[(.*)\]$/, '$1'); // an IPv6 literal, without its brackets
259
+ this._authority = parsed.host; // with them, and the port unless 443
260
+ this._port = parsed.port === '' ? 443 : Number(parsed.port);
80
261
  this._path = parsed.pathname + (parsed.search || '');
81
262
  this._options = options || {};
263
+ this._protocols = checkProtocols(this._options.protocols);
264
+ checkCertificateHashes(this._options.serverCertificateHashes);
265
+ if (this._options.datagramsReadableType != null && String(this._options.datagramsReadableType) !== 'bytes') {
266
+ throw new TypeError("options.datagramsReadableType must be 'bytes'");
267
+ }
268
+ if (this._options.congestionControl != null && CONGESTION_CONTROL.indexOf(String(this._options.congestionControl)) < 0) {
269
+ throw new TypeError('options.congestionControl must be one of ' + CONGESTION_CONTROL.join(', '));
270
+ }
271
+ this._hintUni = streamHint(this._options.anticipatedConcurrentIncomingUnidirectionalStreams, 'anticipatedConcurrentIncomingUnidirectionalStreams');
272
+ this._hintBidi = streamHint(this._options.anticipatedConcurrentIncomingBidirectionalStreams, 'anticipatedConcurrentIncomingBidirectionalStreams');
82
273
  // Checked here, as the browser checks its options: a bad value throws now,
83
274
  // not later inside the connection.
84
275
  defaultTransportParams(this._options.transportParams);
276
+ this._transportParams = this._hintedTransportParams();
85
277
  if (this._options.ecdhCurve != null) lemonTls.parseEcdhCurve(this._options.ecdhCurve);
86
278
  if (this._options.sigalgs != null) lemonTls.parseSigalgs(this._options.sigalgs);
87
279
  initialCongestionWindow(this._options.initialCongestionWindow);
88
280
  (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
281
  parseVersionList(this._options.quicVersions, 'quicVersions');
282
+ this._connectFields = connectRequestFields(this._options);
90
283
 
91
284
  // State
92
285
  this._state = 'connecting'; // connecting → connected → closed, or connecting → failed
@@ -127,8 +320,7 @@ class WebTransport extends EventEmitter {
127
320
  this.reliability = 'pending'; // then 'supports-unreliable' or 'reliable-only'
128
321
  this.congestionControl = 'default';
129
322
  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;
323
+ this._responseHeaders = null; // responseHeaders: the server's, once the session is up
132
324
  this._incoming = { stream: null, unidirectionalStream: null }; // the browser API's queues, once read
133
325
  // The session, as wt_stream's createDatagrams / sessionStats take it —
134
326
  // the server's res hands in the same.
@@ -138,6 +330,9 @@ class WebTransport extends EventEmitter {
138
330
  this._session = {
139
331
  get quic() { return self._quic; },
140
332
  connected: function () { return self._state === 'connected'; },
333
+ connecting: function () { return self._state === 'connecting'; },
334
+ readableType: this._options.datagramsReadableType != null ? 'bytes' : undefined,
335
+ get transport() { return self; },
141
336
  send: function (u8, options) { return self.sendDatagram(u8, options); },
142
337
  maxDatagramSize: function () { return self.maxDatagramSize(); },
143
338
  owner: this._dgOwner,
@@ -172,29 +367,45 @@ class WebTransport extends EventEmitter {
172
367
  _failSetup(err) {
173
368
  if (this._state !== 'connecting') return;
174
369
  this._state = 'failed';
370
+ this._attempt = (this._attempt || 0) + 1; // whatever is still on its way is ignored from now
175
371
  // As the browser's: a WebTransportError, source 'session' — keeping the
176
372
  // cause's message and code (node:tls's, say) for a Node caller.
177
373
  if (!(err instanceof WebTransportError)) {
178
374
  var we = new WebTransportError(err.message, { source: 'session' });
179
- if (err.code) we.code = err.code;
375
+ if (err.code) setErrorCode(we, err.code);
180
376
  we.cause = err;
181
377
  err = we;
182
378
  }
183
379
  this._readyReject(err);
184
380
  this._closedReject(err);
381
+ this._destroyStreams(err, false);
185
382
  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
383
+ // Over: 'close' now — the connection's own end is ignored from here.
384
+ if (!this._closedDone) { this._closedDone = true; this.emit('close'); }
385
+ // The connection goes: the one the CONNECT was sent on, or the one still
386
+ // in its handshake (close() before `ready`: it went on, and `ready`
387
+ // resolved once the server answered).
388
+ var q = this._quic || this._handshaking;
389
+ if (q) { try { q.close(0x0100, 'WebTransport session not established', true); } catch (e) {} } // H3_NO_ERROR
187
390
  }
188
391
 
189
392
  _connect() {
190
393
  var host = this._hostname;
191
394
  var self = this;
192
395
 
396
+ // Port 0 names no server (RFC 6335 §6): nothing can answer, and the
397
+ // attempt waited for a handshake timeout — the browser fails it.
398
+ if (this._port === 0) {
399
+ process.nextTick(function () { self._failSetup(new WebTransportError('port 0 cannot be connected to', { source: 'session' })); });
400
+ return;
401
+ }
402
+
193
403
  if (/^[\d.]+$/.test(host) || host.indexOf(':') >= 0 || host === 'localhost') {
194
404
  var ip = (host === 'localhost') ? '127.0.0.1' : host;
195
405
  this._startQuic(ip);
196
406
  } else {
197
407
  dns.lookup(host, function (err, address) {
408
+ if (self._state !== 'connecting') return; // closed meanwhile
198
409
  if (err) { self._failSetup(err); return; }
199
410
  self._startQuic(address);
200
411
  });
@@ -225,9 +436,10 @@ class WebTransport extends EventEmitter {
225
436
  // no certificate to pin, and the pin is the only check there is.
226
437
  session: this._options.serverCertificateHashes ? null : (this._options.session || null),
227
438
  onSocket: function (q) {
439
+ if (attempt === self._attempt) self._handshaking = q;
228
440
  q.on('session', function (buf) { self.emit('session', buf); });
229
441
  },
230
- transportParams: this._options.transportParams,
442
+ transportParams: this._transportParams,
231
443
  quicVersions: this._options.quicVersions,
232
444
  initialCongestionWindow: this._options.initialCongestionWindow,
233
445
  startupCap: this._options.startupCap,
@@ -257,7 +469,9 @@ class WebTransport extends EventEmitter {
257
469
  onConnect: function (q, s) {
258
470
  if (attempt !== self._attempt) { try { q.close(0x100, 'superseded', true); } catch (e) {} return; }
259
471
  self._quic = q;
472
+ self._handshaking = null;
260
473
  self._udpSocket = s;
474
+ q.on('closing', function () { if (attempt === self._attempt) self._connectionEnded(); });
261
475
  var pinErr = self._checkCertificateHashes();
262
476
  if (pinErr) { self._failSetup(pinErr); return; }
263
477
  self._h3 = new H3Connection({ quicConnection: self._quic, isServer: false, enableWebTransport: true });
@@ -267,16 +481,7 @@ class WebTransport extends EventEmitter {
267
481
 
268
482
  onClose: function () {
269
483
  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();
484
+ self._connectionEnded();
280
485
  // The helper closes the UDP socket.
281
486
  }
282
487
  });
@@ -293,7 +498,7 @@ class WebTransport extends EventEmitter {
293
498
  // down ('draining').
294
499
  var readCapsules = createCapsuleReader(function (info) {
295
500
  if (!self.closeInfo) self.closeInfo = info;
296
- self.close(info);
501
+ self._close(info);
297
502
  }, function () { self._drainingResolve(); self.emit('draining'); });
298
503
  this._h3.on('http_body', function (streamId, data) {
299
504
  if (streamId === self._sessionId && self._state === 'connected') readCapsules(data);
@@ -305,7 +510,9 @@ class WebTransport extends EventEmitter {
305
510
  var status = parseInt(headers[':status']) || 0;
306
511
  // 1xx (103 Early Hints) is not the answer to the CONNECT: it follows.
307
512
  if (status >= 100 && status < 200) return;
308
- if (status === 200) {
513
+ // Any 2xx establishes the session (draft-ietf-webtrans-http3 §3.3,
514
+ // as in the browser): a 204 failed here as "CONNECT failed".
515
+ if (status >= 200 && status < 300) {
309
516
  var reliability = self._quic.maxDatagramSize() > 0 ? 'supports-unreliable' : 'reliable-only';
310
517
  if (self._options.requireUnreliable && reliability !== 'supports-unreliable') {
311
518
  self._failSetup(new Error('the server does not take datagrams (requireUnreliable)'));
@@ -316,10 +523,12 @@ class WebTransport extends EventEmitter {
316
523
  // The server's choice among options.protocols (WT-Protocol, a
317
524
  // structured-field string), if it made one from them.
318
525
  var chosen = parseSfString(headers['wt-protocol']);
319
- if (chosen !== null && (self._options.protocols || []).indexOf(chosen) >= 0) self.protocol = chosen;
526
+ if (chosen !== null && self._protocols.indexOf(chosen) >= 0) self.protocol = chosen;
527
+ self._responseHeaders = responseHeadersOf(headers);
320
528
  self._readyResolve();
321
529
  self.emit('ready');
322
530
  self._handOver();
531
+ self.datagrams._flush(); // what was written before `ready`
323
532
  } else {
324
533
  self._failSetup(new Error('WebTransport CONNECT failed: HTTP ' + status));
325
534
  return;
@@ -352,10 +561,24 @@ class WebTransport extends EventEmitter {
352
561
  this._h3.on('stream_reset', function (streamId, code) { self._peerCancelled(streamId, 'stream_reset', code); });
353
562
  this._h3.on('stop_sending', function (streamId, code) { self._peerCancelled(streamId, 'stop_sending', code); });
354
563
 
564
+ // The server ended the CONNECT stream with a FIN and no
565
+ // CLOSE_WEBTRANSPORT_SESSION: a clean close, code 0 and an empty reason
566
+ // (draft-ietf-webtrans-http3 §5: "semantically equivalent to
567
+ // terminating it with a CLOSE_WEBTRANSPORT_SESSION capsule that has an
568
+ // error code of 0 and an empty error string"). `closed` waited for the
569
+ // QUIC idle timeout, and 'close' never came.
570
+ this._h3.on('http_end', function (streamId) {
571
+ if (streamId === self._sessionId && self._state === 'connected') self._close(self.closeInfo || { closeCode: 0, reason: '' });
572
+ });
573
+
355
574
  // The session is over in HTTP/3 (its CONNECT stream ended): its streams,
356
- // already reset there, end here too.
575
+ // already reset there, end here too. Still 'connected' here means it
576
+ // ended with neither side closing it — no capsule, no FIN (a FIN closes
577
+ // it cleanly just above): the server reset the CONNECT stream. As in
578
+ // the browser, the session was lost, not closed.
357
579
  this._h3.on('wt_session_closed', function (sessionId) {
358
- if (sessionId === self._sessionId) self._destroyStreams();
580
+ if (sessionId !== self._sessionId) return;
581
+ if (self._state === 'connected') self._sessionLost('the server reset the session\'s CONNECT stream');
359
582
  });
360
583
 
361
584
  // Datagrams — QUIC DATAGRAM payload = VarInt(quarter_stream_id) + app data (RFC 9297)
@@ -388,6 +611,7 @@ class WebTransport extends EventEmitter {
388
611
  if (self._streams[streamId] === stream) delete self._streams[streamId];
389
612
  if (self._h3) self._h3.releaseWebTransportStream(streamId);
390
613
  }, prefixLength, this._sched, this._sessionId);
614
+ stream._owner = this; // its sendGroup must be one of this session's
391
615
  this._streams[streamId] = stream;
392
616
  return stream;
393
617
  }
@@ -421,22 +645,33 @@ class WebTransport extends EventEmitter {
421
645
  _incomingStreams(event) {
422
646
  var self = this;
423
647
  var rs = new ReadableStream({
424
- start: function (controller) { self._incoming[event] = controller; }
648
+ start: function (controller) {
649
+ self._incoming[event] = controller;
650
+ if (self._incomingEnd) { if (self._incomingEnd.error) controller.error(self._incomingEnd.error); else controller.close(); }
651
+ }
425
652
  });
426
653
  queueMicrotask(function () { self._handOver(); });
427
654
  return rs;
428
655
  }
429
656
 
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(); }
657
+ // The session is over (the browser's "cleanup"): each stream still open
658
+ // goes with WT_SESSION_GONE (draft-ietf-webtrans-http3 §4.6), not an
659
+ // application code, and its browser views fail with `err` — the session's
660
+ // error when it was lost or never established, the same object `closed`
661
+ // rejects with; an AbortError when we closed it; a WebTransportError when
662
+ // the server did. Closed (`closed` true), the incoming-stream queues and
663
+ // the datagrams' readable close; otherwise they fail with `err` too.
664
+ _destroyStreams(err, closed) {
665
+ if (this._cleanedUp) return;
666
+ this._cleanedUp = true;
667
+ for (var id in this._streams) { this._streams[id]._sessionGone(err); this._streams[id].destroy(); }
668
+ for (var i = 0; i < this._waiting.length; i++) { var w = this._waiting[i][1]; if (w._sessionGone) w._sessionGone(err); w.destroy(); }
435
669
  this._waiting = [];
436
670
  for (var ev in this._incoming) {
437
- if (this._incoming[ev]) { try { this._incoming[ev].close(); } catch (e) {} }
671
+ if (this._incoming[ev]) { try { if (closed) this._incoming[ev].close(); else this._incoming[ev].error(err); } catch (e) {} }
438
672
  }
439
- this.datagrams._close();
673
+ this._incomingEnd = { error: closed ? null : err }; // for a queue first read after this
674
+ this.datagrams._close(closed ? null : err);
440
675
  }
441
676
 
442
677
  // serverCertificateHashes (the browser's way to trust a certificate it
@@ -482,13 +717,14 @@ class WebTransport extends EventEmitter {
482
717
  ':method': 'CONNECT',
483
718
  ':protocol': 'webtransport',
484
719
  ':scheme': 'https',
485
- ':authority': this._hostname + (this._port !== 443 ? ':' + this._port : ''),
720
+ ':authority': this._authority,
486
721
  ':path': this._path
487
722
  };
723
+ for (var f in this._connectFields) headers[f] = this._connectFields[f];
488
724
  // The browser's `protocols`: the application protocols it offers, as a
489
725
  // 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(', ');
726
+ if (this._protocols.length > 0) {
727
+ headers['wt-available-protocols'] = this._protocols.map(sfString).join(', ');
492
728
  }
493
729
 
494
730
  this._h3.sendHeaders(this._sessionId, headers, false);
@@ -501,18 +737,37 @@ class WebTransport extends EventEmitter {
501
737
  // ============================================================
502
738
 
503
739
  // 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.
740
+ // (wt_stream's openWtStream, as the server's). As the browser's
741
+ // createBidirectionalStream / createUnidirectionalStream:
742
+ // a sendGroup of another session's rejects, TypeError;
743
+ // the session over ('closed', 'failed') rejects, InvalidStateError —
744
+ // also when it ends while the stream waits to be opened;
745
+ // still connecting waits for `ready` (it was refused);
746
+ // past the server's limit, without waitUntilAvailable
747
+ // rejects, QuotaExceededError;
748
+ // otherwise resolved once the stream is open — at once if the server allows
749
+ // it, or when it does (waitUntilAvailable).
508
750
  _openStream(bidi, options) {
509
- if (this._state !== 'connected') return Promise.reject(new WebTransportError('the session is not connected', { source: 'session' }));
510
751
  var self = this;
511
- return new Promise(function (resolve, reject) {
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' }));
752
+ options = options || {};
753
+ if (options.sendGroup != null && options.sendGroup._owner !== this) {
754
+ return Promise.reject(new TypeError('options.sendGroup belongs to another WebTransport session'));
755
+ }
756
+ if (this._state === 'closed' || this._state === 'failed') return Promise.reject(invalidState('the session is ' + this._state));
757
+ var up = this._state === 'connected' ? Promise.resolve() : this.ready;
758
+ return up.then(function () {
759
+ if (self._state !== 'connected') throw invalidState('the session is ' + self._state);
760
+ return new Promise(function (resolve, reject) {
761
+ var stream = openWtStream(self._h3, self._quic, self._sessionId, bidi, options,
762
+ function (id, kind, prefix) { return self._newStream(id, kind, prefix); },
763
+ function (err, st) {
764
+ if (!err) resolve(st);
765
+ else reject(self._state !== 'connected' ? invalidState('the session is ' + self._state) : new WebTransportError(err.message, { source: 'session' }));
766
+ });
767
+ if (!stream) reject(new DOMException('the server allows no more streams now', 'QuotaExceededError'));
768
+ });
769
+ }, function () {
770
+ throw invalidState('the session was not established');
516
771
  });
517
772
  }
518
773
 
@@ -558,18 +813,70 @@ class WebTransport extends EventEmitter {
558
813
  return this._incomingUni || (this._incomingUni = this._incomingStreams('unidirectionalStream'));
559
814
  }
560
815
 
561
- /** The browser's WebTransportSendGroup. */
816
+ /** The server's answer to the CONNECT (an immutable Headers) — null until the session is up. */
817
+ get responseHeaders() { return this._responseHeaders; }
818
+
819
+ /** As the browser's: no WebTransport over HTTP/2 (reliable-only) here — HTTP/3 alone. */
820
+ static get supportsReliableOnly() { return false; }
821
+
822
+ /**
823
+ * The browser's exportKeyingMaterial(label, context, outputLength): keying
824
+ * material of this session's own, from the TLS exporter (see wt_stream's
825
+ * exportSessionKeyingMaterial) — a Promise of a Uint8Array. While the
826
+ * session is being set up, it waits for it; a session that is over (or
827
+ * ends meanwhile) is an InvalidStateError.
828
+ */
829
+ exportKeyingMaterial(label, context, outputLength) {
830
+ var self = this, a;
831
+ try { a = exporterArgs(arguments); } catch (e) { return Promise.reject(e); }
832
+ if (this._state === 'closed' || this._state === 'failed') return Promise.reject(invalidState('the session is ' + this._state));
833
+ var up = this._state === 'connected' ? Promise.resolve() : this.ready;
834
+ return up.then(function () {
835
+ if (self._state !== 'connected') throw invalidState('the session is ' + self._state);
836
+ return exportSessionKeyingMaterial(self._quic, self._sessionId, a);
837
+ }, function () { throw invalidState('the session was not established'); });
838
+ }
839
+
840
+ /** The browser's WebTransportSendGroup — of this session's alone. */
562
841
  createSendGroup() {
563
- return createSendGroup();
842
+ return createSendGroup(this);
564
843
  }
565
844
 
566
845
  /**
567
846
  * The browser's getStats(): the connection's figures, as QUIC has them.
847
+ * A session that failed has none: InvalidStateError, as the browser's.
568
848
  */
569
849
  getStats() {
850
+ if (this._state === 'failed') return Promise.reject(invalidState('the session failed'));
570
851
  return sessionStats(this._session); // wt_stream: as the server's res.getStats()
571
852
  }
572
853
 
854
+ // The browser's anticipatedConcurrentIncoming… hints: null or 0 to 65535
855
+ // (a TypeError otherwise, the value unchanged). Given to the constructor,
856
+ // they raise the stream limits we grant the server at the start, if they
857
+ // are above them (see _hintedTransportParams).
858
+ get anticipatedConcurrentIncomingUnidirectionalStreams() { return this._hintUni; }
859
+ set anticipatedConcurrentIncomingUnidirectionalStreams(v) { this._hintUni = streamHint(v, 'anticipatedConcurrentIncomingUnidirectionalStreams'); }
860
+ get anticipatedConcurrentIncomingBidirectionalStreams() { return this._hintBidi; }
861
+ set anticipatedConcurrentIncomingBidirectionalStreams(v) { this._hintBidi = streamHint(v, 'anticipatedConcurrentIncomingBidirectionalStreams'); }
862
+
863
+ // options.transportParams, with the hints applied: the server may open as
864
+ // many streams as anticipated from the start (unidirectional ones on top
865
+ // of the three HTTP/3 keeps for itself). A limit set in transportParams is
866
+ // the caller's and stays.
867
+ _hintedTransportParams() {
868
+ var given = this._options.transportParams || {};
869
+ var base = defaultTransportParams(given);
870
+ var tp = Object.assign({}, given);
871
+ if (this._hintBidi !== null && given.initialMaxStreamsBidi == null && this._hintBidi > base.initial_max_streams_bidi) {
872
+ tp.initialMaxStreamsBidi = this._hintBidi;
873
+ }
874
+ if (this._hintUni !== null && given.initialMaxStreamsUni == null && this._hintUni + 3 > base.initial_max_streams_uni) {
875
+ tp.initialMaxStreamsUni = this._hintUni + 3;
876
+ }
877
+ return tp;
878
+ }
879
+
573
880
  /**
574
881
  * Send an unreliable datagram.
575
882
  */
@@ -595,21 +902,82 @@ class WebTransport extends EventEmitter {
595
902
  /**
596
903
  * Close the WebTransport session.
597
904
  */
905
+ // The QUIC connection is over: 'closing' (the peer's CONNECTION_CLOSE, or
906
+ // ours) — as the browser hears it, at once — or 'close' at the end of the
907
+ // closing/draining period, whichever comes first; the second finds it done.
908
+ _connectionEnded() {
909
+ // Gone before the CONNECT was answered — after the QUIC handshake, so
910
+ // onError did not see it. `ready` must settle (it rejects in the
911
+ // browser too); left pending, the caller would wait forever.
912
+ if (this._state === 'connecting') {
913
+ var err = new Error('WebTransport connection closed before the session was established');
914
+ err.code = 'ERR_WEBTRANSPORT_SESSION_FAILED';
915
+ this._failSetup(err);
916
+ return;
917
+ }
918
+ // Lost while established: neither a CLOSE_WEBTRANSPORT_SESSION, nor
919
+ // the CONNECT stream's FIN, nor our close() — the connection went
920
+ // (an error, the idle timeout, the server gone). `closed` rejects,
921
+ // as the browser's does; it used to resolve { closeCode: 0 }, which
922
+ // is a close the peer asked for.
923
+ if (this._state === 'connected') { this._sessionLost('the connection was lost'); return; }
924
+ this._sessionClosed();
925
+ }
926
+
927
+ // The session ended without being closed — the connection lost, or the
928
+ // CONNECT stream reset. As the browser's: the state is 'failed', `closed`
929
+ // rejects with a WebTransportError (source 'session'), and every stream,
930
+ // the incoming-stream queues and the datagrams fail with that same error
931
+ // object; 'close' is emitted all the same, so a Node listener hears the end
932
+ // either way. The QUIC connection, this session's alone, goes with it.
933
+ _sessionLost(message) {
934
+ if (this._closedDone) return;
935
+ this._state = 'failed';
936
+ this._closedDone = true;
937
+ var err = new WebTransportError('WebTransport session lost: ' + message, { source: 'session' });
938
+ setErrorCode(err, 'ERR_WEBTRANSPORT_SESSION_LOST');
939
+ var pc = this._quic && this._quic.context && this._quic.context.peer_close;
940
+ if (pc) err.quicError = { code: pc.error, application: !!pc.application, reason: pc.reason || '' };
941
+ this._destroyStreams(err, false);
942
+ this._closedReject(err);
943
+ this.emit('close');
944
+ if (this._quic) { try { this._quic.close(0x0100, 'WebTransport session lost', true); } catch (e) {} }
945
+ }
946
+
598
947
  // The session is over: `closed` resolves and 'close' is emitted, once. As
599
948
  // in the browser, closed resolves with the code and reason the session was
600
949
  // closed with — the server's CLOSE_WEBTRANSPORT_SESSION, or ours — and
601
950
  // { closeCode: 0, reason: '' } when there was none. At the session's close,
602
951
  // not the QUIC connection's: that one lingers in its closing period
603
952
  // (RFC 9000 §10.2) for three PTOs after, and `closed` waited it out.
604
- _sessionClosed() {
953
+ // streamError: what the streams' browser views fail with (see close()).
954
+ _sessionClosed(streamError) {
605
955
  if (this._closedDone) return;
606
956
  this._closedDone = true;
607
- this._destroyStreams();
957
+ this._destroyStreams(streamError || new WebTransportError('the session is closed', { source: 'session' }), true);
608
958
  this._closedResolve(this.closeInfo || { closeCode: 0, reason: '' });
609
959
  this.emit('close');
610
960
  }
611
961
 
962
+ // The browser's close(): before the session is up, it is abandoned —
963
+ // `ready` and `closed` reject with a WebTransportError (source 'session')
964
+ // and the connection goes; `ready` used to resolve all the same once the
965
+ // server answered. Once up, CLOSE_WEBTRANSPORT_SESSION with the code and
966
+ // reason, and `closed` resolves with them (the reason as given; what goes
967
+ // on the wire is cut at 1024 bytes); the streams' views fail with an
968
+ // AbortError, as the browser's.
612
969
  close(info) {
970
+ if (this._state === 'closed' || this._state === 'failed') return;
971
+ if (this._state === 'connecting') {
972
+ this._failSetup(new WebTransportError('the session was closed before it was established', { source: 'session' }));
973
+ return;
974
+ }
975
+ this._close(info, new DOMException('the session was closed', 'AbortError'));
976
+ }
977
+
978
+ // Closed: by us (close()), or by the server (its CLOSE_WEBTRANSPORT_SESSION
979
+ // or the CONNECT stream's FIN — then a WebTransportError for the streams).
980
+ _close(info, streamError) {
613
981
  if (this._state === 'closed' || this._state === 'failed') return;
614
982
  this._state = 'closed';
615
983
 
@@ -630,7 +998,7 @@ class WebTransport extends EventEmitter {
630
998
  closeQuic();
631
999
  }
632
1000
  }
633
- this._sessionClosed();
1001
+ this._sessionClosed(streamError || new WebTransportError('the server closed the session', { source: 'session' }));
634
1002
  }
635
1003
 
636
1004
  /**
@@ -644,9 +1012,11 @@ class WebTransport extends EventEmitter {
644
1012
  * URL this session connected to.
645
1013
  */
646
1014
  get url() {
647
- return 'https://' + this._hostname + ':' + this._port + this._path;
1015
+ var h = this._hostname.indexOf(':') >= 0 ? '[' + this._hostname + ']' : this._hostname;
1016
+ return 'https://' + h + ':' + this._port + this._path;
648
1017
  }
649
1018
  }
650
1019
 
651
1020
 
652
- export { WebTransport, WebTransportError };
1021
+ export { WebTransport, WebTransportError, WebTransportBidirectionalStream, WebTransportReceiveStream, WebTransportSendStream,
1022
+ WebTransportWriter, WebTransportSendGroup, WebTransportDatagramsWritable, WebTransportDatagramDuplexStream };