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