quico 0.4.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.
@@ -12,294 +12,340 @@
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';
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, setErrorCode,
23
+ exporterArgs, exportSessionKeyingMaterial,
24
+ WebTransportError, WebTransportBidirectionalStream, WebTransportReceiveStream, WebTransportSendStream,
25
+ WebTransportWriter, WebTransportSendGroup, WebTransportDatagramsWritable, WebTransportDatagramDuplexStream } from './wt_stream.js';
26
+ import { defaultTransportParams } from './tls_bridge.js';
27
+ import { initialCongestionWindow, checkConnectionOptions } from './quic_connection.js';
28
+ import { sfString, parseSfString, validateHeaderName, validateHeaderValue, isConnectionSpecific } from './http_common.js';
29
+ import { parseVersionList } from './quic_version.js';
21
30
 
22
31
 
23
32
  // ============================================================
24
- // WebTransportBidiStream — Duplex + WHATWG .readable/.writable
33
+ // WebTransport — Client
34
+ //
35
+ // The browser's WebTransport API (W3C), complete — code written for the
36
+ // browser runs here unchanged — and a Node-style API on the same object:
37
+ //
38
+ // Browser API Node API
39
+ // ----------- --------
40
+ // wt.ready / wt.closed / wt.draining 'ready' / 'close' / 'draining'
41
+ // wt.close({ closeCode, reason })
42
+ // wt.datagrams.readable / .writable / wt.sendDatagram(buf), 'datagram'
43
+ // .createWritable() / .maxDatagramSize wt.maxDatagramSize()
44
+ // wt.incomingBidirectionalStreams 'stream' (a Duplex)
45
+ // wt.incomingUnidirectionalStreams 'unidirectionalStream' (a Readable)
46
+ // await wt.createBidirectionalStream() → a Duplex that also has
47
+ // .readable / .writable (WHATWG)
48
+ // await wt.createUnidirectionalStream() → a WritableStream (WHATWG) that
49
+ // also has write() / end()
50
+ // wt.createSendGroup(), wt.getStats(), wt.reliability, wt.congestionControl,
51
+ // wt.protocol, WebTransportError
25
52
  //
26
- // Node.js style:
27
- // stream.write(data);
28
- // stream.on('data', chunk => { ... });
29
- // stream.pipe(dest);
53
+ // Options: the browser's — serverCertificateHashes, protocols,
54
+ // congestionControl, allowPooling, requireUnreliable, anticipatedConcurrent…
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),
58
+ // ca, key / cert / passphrase (a client certificate), and session (one
59
+ // saved from the 'session' event, to resume the TLS session). The
60
+ // server's certificate is verified against the platform roots
61
+ // unless `ca` is given, or serverCertificateHashes pins it as a browser
62
+ // does.
30
63
  //
31
- // WHATWG style:
32
- // const writer = stream.writable.getWriter();
33
- // const reader = stream.readable.getReader();
64
+ // A server stream goes to a Node listener if there is one, otherwise to the
65
+ // browser API's incoming…Streams once that is read; it waits for either.
34
66
  // ============================================================
35
67
 
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;
68
+ // What the client's unidirectional stream (a WritableStream) carries over
69
+ // from the Node Writable underneath it — see createUnidirectionalStream.
70
+ var UNI_METHODS = ['write', 'end', 'destroy', 'cork', 'uncork', 'setDefaultEncoding', 'resetStream', 'getStats',
71
+ 'on', 'once', 'off', 'addListener', 'removeListener', 'removeAllListeners', 'prependListener',
72
+ 'prependOnceListener', 'emit', 'listeners', 'listenerCount', 'eventNames'];
73
+ var UNI_STATE = ['_streamId', 'id', 'sessionId', 'sendState', 'pending', 'streamErrorCode', 'destroyed', 'writableLength', 'writableHighWaterMark',
74
+ 'writableNeedDrain', 'writableEnded', 'writableFinished', 'writableCorked', 'writableObjectMode', 'closed', 'errored'];
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');
49
102
  }
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;
103
+ var out = [];
104
+ if (typeof Headers !== 'undefined' && h instanceof Headers) {
105
+ h.forEach(function (value, name) { out.push([name, value]); });
106
+ return out;
71
107
  }
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
- });
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]]);
86
116
  }
87
- return this._whatwgWritable;
117
+ return out;
88
118
  }
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();
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]);
108
126
  }
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);
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');
117
138
  }
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);
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;
128
143
  }
129
-
130
- _endData() {
131
- this.push(null);
144
+ if (options.origin != null) {
145
+ var origin = String(options.origin).replace(HTTP_WHITESPACE, '');
146
+ validateHeaderValue('origin', origin);
147
+ out['origin'] = origin;
132
148
  }
149
+ return out;
133
150
  }
134
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
+ }
135
164
 
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;
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');
148
173
  }
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);
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');
162
180
  }
163
- callback();
181
+ out.push(p);
164
182
  }
183
+ return out;
184
+ }
165
185
 
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();
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 }');
174
194
  }
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
- });
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');
190
204
  }
191
- return this._whatwgWritable;
192
205
  }
193
206
  }
194
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
+ }
195
219
 
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
- });
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
224
244
  }
225
- return this._whatwgReadable;
226
245
  }
246
+ return new ImmutableHeaders(pairs);
227
247
  }
228
248
 
229
-
230
- // ============================================================
231
- // WebTransport — Client
232
- //
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();
250
- //
251
- // // Datagrams
252
- // wt.sendDatagram(Buffer.from('ping'));
253
- // wt.on('datagram', data => console.log('pong:', data));
254
- //
255
- // // Incoming streams
256
- // wt.on('stream', stream => { ... }); // bidi from server
257
- // wt.on('unidirectionalStream', stream => { ... }); // uni from server
258
- //
259
- // wt.close();
260
- // ============================================================
249
+ function invalidState(message) { return new DOMException(message, 'InvalidStateError'); }
261
250
 
262
251
  class WebTransport extends EventEmitter {
263
252
 
264
253
  constructor(url, options) {
265
254
  super();
266
255
 
267
- // Parse URL
268
- var parsed = new URL(url);
269
- this._hostname = parsed.hostname;
270
- 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);
271
261
  this._path = parsed.pathname + (parsed.search || '');
272
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');
273
+ // Checked here, as the browser checks its options: a bad value throws now,
274
+ // not later inside the connection.
275
+ defaultTransportParams(this._options.transportParams);
276
+ this._transportParams = this._hintedTransportParams();
277
+ if (this._options.ecdhCurve != null) lemonTls.parseEcdhCurve(this._options.ecdhCurve);
278
+ if (this._options.sigalgs != null) lemonTls.parseSigalgs(this._options.sigalgs);
279
+ initialCongestionWindow(this._options.initialCongestionWindow);
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);
281
+ parseVersionList(this._options.quicVersions, 'quicVersions');
282
+ this._connectFields = connectRequestFields(this._options);
273
283
 
274
284
  // State
275
- this._state = 'connecting'; // connecting → connected → closed
285
+ this._state = 'connecting'; // connecting → connected → closed, or connecting → failed
276
286
  this._sessionId = null; // stream ID of the CONNECT request
277
287
  this._quic = null;
278
288
  this._h3 = null;
279
289
  this._udpSocket = null;
280
290
 
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)
291
+ // This session's streams, ours and the server's: streamId → stream.
292
+ this._streams = {};
293
+ // The server's streams not handed over yet: those that arrived before
294
+ // its 200 (a packet can overtake another), and those nobody listens for
295
+ // yet — the server opens streams with its 200, before `await wt.ready`
296
+ // has even returned to the code that will listen. As the browser's
297
+ // incomingUnidirectionalStreams holds them until read, they wait here for
298
+ // a 'stream' / 'unidirectionalStream' listener. Emitted at once, they
299
+ // were lost.
300
+ this._waiting = []; // [event, stream]
301
+
302
+ // The browser API's promises. A rejection nobody awaits is logged by a
303
+ // browser; in Node it would end the process — so each has a handler of
304
+ // its own, and a caller that awaits it still sees the rejection.
295
305
  var self = this;
296
306
  this.ready = new Promise(function (resolve, reject) {
297
307
  self._readyResolve = resolve;
298
308
  self._readyReject = reject;
299
309
  });
300
-
301
- this.closed = new Promise(function (resolve) {
310
+ this.ready.catch(function () {});
311
+ this.closed = new Promise(function (resolve, reject) {
302
312
  self._closedResolve = resolve;
313
+ self._closedReject = reject;
314
+ });
315
+ this.closed.catch(function () {});
316
+ this.draining = new Promise(function (resolve) { self._drainingResolve = resolve; });
317
+
318
+ // The browser API's attributes.
319
+ var o = this._options;
320
+ this.reliability = 'pending'; // then 'supports-unreliable' or 'reliable-only'
321
+ this.congestionControl = 'default';
322
+ this.protocol = ''; // the server's choice among options.protocols
323
+ this._responseHeaders = null; // responseHeaders: the server's, once the session is up
324
+ this._incoming = { stream: null, unidirectionalStream: null }; // the browser API's queues, once read
325
+ // The session, as wt_stream's createDatagrams / sessionStats take it —
326
+ // the server's res hands in the same.
327
+ var self = this;
328
+ this._dgStats = { droppedIncoming: 0, expiredIncoming: 0 };
329
+ this._dgOwner = { maxAge: null, expired: 0 };
330
+ this._session = {
331
+ get quic() { return self._quic; },
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; },
336
+ send: function (u8, options) { return self.sendDatagram(u8, options); },
337
+ maxDatagramSize: function () { return self.maxDatagramSize(); },
338
+ owner: this._dgOwner,
339
+ dgStats: this._dgStats
340
+ };
341
+ this.datagrams = createDatagrams(this._session);
342
+
343
+ // A listener for the server's streams: those waiting go to it — once it
344
+ // is in place ('newListener' comes just before).
345
+ this.on('newListener', function (event) {
346
+ if ((event === 'stream' || event === 'unidirectionalStream') && self._waiting.length > 0) {
347
+ queueMicrotask(function () { self._handOver(); });
348
+ }
303
349
  });
304
350
 
305
351
  // Start connection
@@ -309,16 +355,58 @@ class WebTransport extends EventEmitter {
309
355
 
310
356
  // ---- Connection setup ----
311
357
 
358
+ // The session could not be established. `ready` rejects — that is how the
359
+ // browser API reports it, and how a caller written against it (try/await
360
+ // ready) expects to hear. 'error' is emitted as well, but only to listeners:
361
+ // an unheard 'error' would throw from inside a socket callback and take the
362
+ // process down where the browser would have rejected a promise.
363
+ //
364
+ // As in the browser the state becomes 'failed' and the connection is torn
365
+ // down: a session refused by the server (a CONNECT answered 403, say) left
366
+ // its QUIC connection open until the idle timeout.
367
+ _failSetup(err) {
368
+ if (this._state !== 'connecting') return;
369
+ this._state = 'failed';
370
+ this._attempt = (this._attempt || 0) + 1; // whatever is still on its way is ignored from now
371
+ // As the browser's: a WebTransportError, source 'session' — keeping the
372
+ // cause's message and code (node:tls's, say) for a Node caller.
373
+ if (!(err instanceof WebTransportError)) {
374
+ var we = new WebTransportError(err.message, { source: 'session' });
375
+ if (err.code) setErrorCode(we, err.code);
376
+ we.cause = err;
377
+ err = we;
378
+ }
379
+ this._readyReject(err);
380
+ this._closedReject(err);
381
+ this._destroyStreams(err, false);
382
+ if (this.listenerCount('error') > 0) this.emit('error', err);
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
390
+ }
391
+
312
392
  _connect() {
313
393
  var host = this._hostname;
314
394
  var self = this;
315
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
+
316
403
  if (/^[\d.]+$/.test(host) || host.indexOf(':') >= 0 || host === 'localhost') {
317
404
  var ip = (host === 'localhost') ? '127.0.0.1' : host;
318
405
  this._startQuic(ip);
319
406
  } else {
320
407
  dns.lookup(host, function (err, address) {
321
- if (err) { self._readyReject(err); self.emit('error', err); return; }
408
+ if (self._state !== 'connecting') return; // closed meanwhile
409
+ if (err) { self._failSetup(err); return; }
322
410
  self._startQuic(address);
323
411
  });
324
412
  }
@@ -327,29 +415,73 @@ class WebTransport extends EventEmitter {
327
415
  _startQuic(remoteIp) {
328
416
  var self = this;
329
417
  var remotePort = this._port;
418
+ // Encrypted Client Hello: options.ech (the server's ECHConfigList), or
419
+ // GREASE ECH — as a browser sends — unless echGrease is false. Rejected
420
+ // with new keys, the session is attempted once more with them; an
421
+ // attempt given up for another is ignored from then on.
422
+ var attempt = this._attempt = (this._attempt || 0) + 1;
423
+ var ech = this._echConfig || this._options.ech || null;
330
424
 
331
425
  this._udpSocket = createQuicClientSocket({
332
426
  remoteIp: remoteIp,
333
427
  remotePort: remotePort,
334
428
  hostname: this._hostname,
429
+ // serverCertificateHashes pins the certificate instead of a CA (checked
430
+ // in onConnect, before anything is sent on the connection).
431
+ rejectUnauthorized: this._options.serverCertificateHashes ? false : this._options.rejectUnauthorized,
432
+ key: this._options.key, cert: this._options.cert, passphrase: this._options.passphrase,
433
+ ca: this._options.ca,
434
+ // A saved session ('session' event) to resume with, as tls.connect
435
+ // takes it. Not with serverCertificateHashes: a resumed handshake has
436
+ // no certificate to pin, and the pin is the only check there is.
437
+ session: this._options.serverCertificateHashes ? null : (this._options.session || null),
438
+ onSocket: function (q) {
439
+ if (attempt === self._attempt) self._handshaking = q;
440
+ q.on('session', function (buf) { self.emit('session', buf); });
441
+ },
442
+ transportParams: this._transportParams,
443
+ quicVersions: this._options.quicVersions,
444
+ initialCongestionWindow: this._options.initialCongestionWindow,
445
+ startupCap: this._options.startupCap,
446
+ pmtuDiscovery: this._options.pmtuDiscovery,
447
+ maxUdpPayloadSize: this._options.maxUdpPayloadSize,
448
+ initialRtt: this._options.initialRtt,
449
+ reorderWindow: this._options.reorderWindow,
450
+ ech: ech,
451
+ echGrease: !ech && this._options.echGrease !== false,
452
+ // The key-exchange groups to offer (tls.connect's ecdhCurve; checked
453
+ // in the constructor).
454
+ ecdhCurve: this._options.ecdhCurve,
455
+ sigalgs: this._options.sigalgs,
335
456
 
336
457
  onError: function (err) {
337
- if (self._state === 'connecting') self._readyReject(err);
338
- self.emit('error', err);
458
+ if (attempt !== self._attempt) return;
459
+ if (self._state === 'connecting' && err && err.code === 'ERR_ECH_REJECTED' && err.retryConfigs && !self._echRetried) {
460
+ self._echRetried = true;
461
+ self._echConfig = err.retryConfigs;
462
+ self._startQuic(remoteIp);
463
+ return;
464
+ }
465
+ if (self._state === 'connecting') { self._failSetup(err); return; }
466
+ if (self.listenerCount('error') > 0) self.emit('error', err);
339
467
  },
340
468
 
341
469
  onConnect: function (q, s) {
470
+ if (attempt !== self._attempt) { try { q.close(0x100, 'superseded', true); } catch (e) {} return; }
342
471
  self._quic = q;
472
+ self._handshaking = null;
343
473
  self._udpSocket = s;
474
+ q.on('closing', function () { if (attempt === self._attempt) self._connectionEnded(); });
475
+ var pinErr = self._checkCertificateHashes();
476
+ if (pinErr) { self._failSetup(pinErr); return; }
344
477
  self._h3 = new H3Connection({ quicConnection: self._quic, isServer: false, enableWebTransport: true });
345
478
  self._setupH3Handlers();
346
479
  self._sendConnect();
347
480
  },
348
481
 
349
482
  onClose: function () {
350
- self._state = 'closed';
351
- self._closedResolve();
352
- self.emit('close');
483
+ if (attempt !== self._attempt) return;
484
+ self._connectionEnded();
353
485
  // The helper closes the UDP socket.
354
486
  }
355
487
  });
@@ -361,31 +493,92 @@ class WebTransport extends EventEmitter {
361
493
  _setupH3Handlers() {
362
494
  var self = this;
363
495
 
496
+ // The CONNECT stream after the 200: capsules. The server's
497
+ // CLOSE_WEBTRANSPORT_SESSION ends the session, its DRAIN asks us to wind
498
+ // down ('draining').
499
+ var readCapsules = createCapsuleReader(function (info) {
500
+ if (!self.closeInfo) self.closeInfo = info;
501
+ self._close(info);
502
+ }, function () { self._drainingResolve(); self.emit('draining'); });
503
+ this._h3.on('http_body', function (streamId, data) {
504
+ if (streamId === self._sessionId && self._state === 'connected') readCapsules(data);
505
+ });
506
+
364
507
  // CONNECT response headers
365
508
  this._h3.on('http_headers', function (streamId, headers) {
366
509
  if (streamId === self._sessionId) {
367
510
  var status = parseInt(headers[':status']) || 0;
368
- if (status === 200) {
511
+ // 1xx (103 Early Hints) is not the answer to the CONNECT: it follows.
512
+ if (status >= 100 && status < 200) return;
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) {
516
+ var reliability = self._quic.maxDatagramSize() > 0 ? 'supports-unreliable' : 'reliable-only';
517
+ if (self._options.requireUnreliable && reliability !== 'supports-unreliable') {
518
+ self._failSetup(new Error('the server does not take datagrams (requireUnreliable)'));
519
+ return;
520
+ }
369
521
  self._state = 'connected';
522
+ self.reliability = reliability;
523
+ // The server's choice among options.protocols (WT-Protocol, a
524
+ // structured-field string), if it made one from them.
525
+ var chosen = parseSfString(headers['wt-protocol']);
526
+ if (chosen !== null && self._protocols.indexOf(chosen) >= 0) self.protocol = chosen;
527
+ self._responseHeaders = responseHeadersOf(headers);
370
528
  self._readyResolve();
371
529
  self.emit('ready');
530
+ self._handOver();
531
+ self.datagrams._flush(); // what was written before `ready`
372
532
  } else {
373
- var err = new Error('WebTransport CONNECT failed: HTTP ' + status);
374
- self._readyReject(err);
375
- self.emit('error', err);
533
+ self._failSetup(new Error('WebTransport CONNECT failed: HTTP ' + status));
534
+ return;
376
535
  }
377
536
  }
378
537
  });
379
538
 
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;
539
+ // The server's streams. h3.js recognises them by their prefix — 0x41 or
540
+ // 0x54 and this session's ID — exactly as it does the client's on the
541
+ // server, and hands them over. They were picked out here by stream number
542
+ // (3, 7, 11 taken for HTTP/3's own, 15 and up for WebTransport), true only
543
+ // while the server opened its streams in that order and no others; and
544
+ // their bytes were read past HTTP/3's back, uncredited and unbounded.
545
+ this._h3.on('wt_stream', function (sessionId, streamId, data, fin, isBidi) {
546
+ if (sessionId !== self._sessionId) return;
547
+ var stream = self._newStream(streamId, isBidi ? 'bidi' : 'uni-in');
548
+ if (data.byteLength > 0) stream._pushData(data);
549
+ if (fin) stream._endData();
550
+ self._waiting.push([isBidi ? 'stream' : 'unidirectionalStream', stream]);
551
+ self._handOver();
552
+ });
553
+ this._h3.on('wt_data', function (sessionId, streamId, data, fin) {
554
+ var stream = self._streams[streamId];
555
+ if (!stream) return;
556
+ if (data.byteLength > 0) stream._pushData(data);
557
+ if (fin) stream._endData();
558
+ });
559
+
560
+ // The server gave up on a stream: it ends with the server's code.
561
+ this._h3.on('stream_reset', function (streamId, code) { self._peerCancelled(streamId, 'stream_reset', code); });
562
+ this._h3.on('stop_sending', function (streamId, code) { self._peerCancelled(streamId, 'stop_sending', code); });
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
+ });
387
573
 
388
- self._handleStreamData(streamId, data, fin);
574
+ // The session is over in HTTP/3 (its CONNECT stream ended): its streams,
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.
579
+ this._h3.on('wt_session_closed', function (sessionId) {
580
+ if (sessionId !== self._sessionId) return;
581
+ if (self._state === 'connected') self._sessionLost('the server reset the session\'s CONNECT stream');
389
582
  });
390
583
 
391
584
  // Datagrams — QUIC DATAGRAM payload = VarInt(quarter_stream_id) + app data (RFC 9297)
@@ -396,125 +589,115 @@ class WebTransport extends EventEmitter {
396
589
  var result = readVarInt(rawData, 0);
397
590
  if (!result) return;
398
591
  var qsid = result.value;
399
- var payload = rawData.slice(result.byteLength);
400
592
 
401
593
  var expectedQsid = Math.floor(self._sessionId / 4);
402
594
  if (self._sessionId !== null && qsid === expectedQsid) {
403
- self.emit('datagram', Buffer.from(payload));
595
+ // One copy, into a buffer of its own: exact size, byteOffset 0 — not
596
+ // slice() + Buffer.from(), which copied twice and put anything under
597
+ // 4KB into Node's shared 8KB Buffer pool (see h3_server.js).
598
+ var payload = new Uint8Array(rawData.byteLength - result.byteLength);
599
+ payload.set(rawData.subarray(result.byteLength));
600
+ self.datagrams._push(payload);
601
+ self.emit('datagram', Buffer.from(payload.buffer));
404
602
  }
405
603
  });
406
604
  }
407
605
 
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
- }
606
+ // A stream of this session, ours or the server's (wt_stream.js), forgotten
607
+ // when it closes.
608
+ _newStream(streamId, kind, prefixLength) {
609
+ var self = this;
610
+ var stream = createWtStream(this._quic, streamId, kind, function () {
611
+ if (self._streams[streamId] === stream) delete self._streams[streamId];
612
+ if (self._h3) self._h3.releaseWebTransportStream(streamId);
613
+ }, prefixLength, this._sched, this._sessionId);
614
+ stream._owner = this; // its sendGroup must be one of this session's
615
+ this._streams[streamId] = stream;
616
+ return stream;
431
617
  }
432
618
 
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
- }
619
+ // The peer gave up on one direction of a stream: that direction ends (the
620
+ // stream is forgotten when it closes — see _newStream).
621
+ _peerCancelled(streamId, how, code) {
622
+ var stream = this._streams[streamId];
623
+ if (stream) stream._peerCancelled(how, code);
624
+ }
441
625
 
442
- // New incoming stream — need to parse session_id prefix
443
- if (!(streamId in this._incomingBuffers)) {
444
- this._incomingBuffers[streamId] = { chunks: [], totalLen: 0 };
626
+ // Hand the server's waiting streams to whoever listens for them, in
627
+ // order; the rest keep waiting (see _waiting).
628
+ // A Node listener first; otherwise the browser API's queue, once it is
629
+ // read (incomingBidirectionalStreams yields the Duplex, which has the
630
+ // browser's .readable and .writable; incomingUnidirectionalStreams yields
631
+ // the stream's ReadableStream, as the browser does).
632
+ _handOver() {
633
+ if (this._state !== 'connected') return;
634
+ var keep = [];
635
+ for (var i = 0; i < this._waiting.length; i++) {
636
+ var w = this._waiting[i];
637
+ if (w[1].destroyed) continue;
638
+ if (this.listenerCount(w[0]) > 0) this.emit(w[0], w[1]);
639
+ else if (this._incoming[w[0]]) this._incoming[w[0]].enqueue(w[0] === 'stream' ? w[1] : w[1].readable);
640
+ else keep.push(w);
445
641
  }
642
+ this._waiting = keep;
643
+ }
446
644
 
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;
645
+ _incomingStreams(event) {
646
+ var self = this;
647
+ var rs = new ReadableStream({
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(); }
472
651
  }
473
- }
474
-
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;
652
+ });
653
+ queueMicrotask(function () { self._handOver(); });
654
+ return rs;
655
+ }
482
656
 
483
- // Verify it matches our session
484
- if (sessionId !== this._sessionId) {
485
- delete this._incomingBuffers[streamId];
486
- return;
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(); }
669
+ this._waiting = [];
670
+ for (var ev in this._incoming) {
671
+ if (this._incoming[ev]) { try { if (closed) this._incoming[ev].close(); else this._incoming[ev].error(err); } catch (e) {} }
487
672
  }
673
+ this._incomingEnd = { error: closed ? null : err }; // for a queue first read after this
674
+ this.datagrams._close(closed ? null : err);
675
+ }
488
676
 
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
- }
677
+ // serverCertificateHashes (the browser's way to trust a certificate it
678
+ // cannot verify by CA): the leaf's SHA-256 must be one of them, and — as
679
+ // the browser requires of such a certificate — it must be valid now, for
680
+ // no more than 14 days. Checked once the handshake is done and before
681
+ // anything is sent: the handshake has already proved the server holds the
682
+ // certificate's key. null when it passes (or none were given).
683
+ _checkCertificateHashes() {
684
+ var hashes = this._options.serverCertificateHashes;
685
+ if (!hashes || !hashes.length) return null;
686
+ var chain = this._quic.getPeerCertificateChain && this._quic.getPeerCertificateChain();
687
+ if (!chain || !chain.length) return new Error('serverCertificateHashes: the server sent no certificate');
688
+ var der = Buffer.from(chain[0]);
689
+ var digest = createHash('sha256').update(der).digest();
690
+ var match = hashes.some(function (h) {
691
+ if (!h || String(h.algorithm).toLowerCase() !== 'sha-256' || !h.value) return false;
692
+ var v = ArrayBuffer.isView(h.value) ? Buffer.from(h.value.buffer, h.value.byteOffset, h.value.byteLength) : Buffer.from(h.value);
693
+ return v.length === digest.length && v.equals(digest);
694
+ });
695
+ if (!match) return new Error('serverCertificateHashes: the certificate matches none of them');
696
+ var cert = new X509Certificate(der), now = Date.now();
697
+ var from = Date.parse(cert.validFrom), to = Date.parse(cert.validTo);
698
+ if (!(now >= from && now <= to)) return new Error('serverCertificateHashes: the certificate is not valid now');
699
+ if (to - from > 14 * 24 * 3600 * 1000) return new Error('serverCertificateHashes: the certificate is valid for more than 14 days');
700
+ return null;
518
701
  }
519
702
 
520
703
  // ---- Send CONNECT ----
@@ -523,19 +706,29 @@ class WebTransport extends EventEmitter {
523
706
  // Send H3 control streams (SETTINGS)
524
707
  this._h3.sendControlStreams();
525
708
 
526
- // CONNECT uses stream 0 (first client bidi)
527
- this._sessionId = 0;
709
+ // The CONNECT goes on the first bidirectional stream; its number is the
710
+ // session's ID, and the server's streams for it carry that ID — so they
711
+ // are recognised from now on, even one that overtakes the 200.
712
+ this._sessionId = this._quic.nextLocalStreamId(true);
713
+ this._sched = wtScheduling(this._quic, this._sessionId);
528
714
 
529
715
  // Send Extended CONNECT — no FIN (session stays open)
530
716
  var headers = {
531
717
  ':method': 'CONNECT',
532
718
  ':protocol': 'webtransport',
533
719
  ':scheme': 'https',
534
- ':authority': this._hostname + (this._port !== 443 ? ':' + this._port : ''),
720
+ ':authority': this._authority,
535
721
  ':path': this._path
536
722
  };
723
+ for (var f in this._connectFields) headers[f] = this._connectFields[f];
724
+ // The browser's `protocols`: the application protocols it offers, as a
725
+ // structured-field list of strings; the server answers with WT-Protocol.
726
+ if (this._protocols.length > 0) {
727
+ headers['wt-available-protocols'] = this._protocols.map(sfString).join(', ');
728
+ }
537
729
 
538
730
  this._h3.sendHeaders(this._sessionId, headers, false);
731
+ this._h3.registerWebTransportSession(this._sessionId);
539
732
  }
540
733
 
541
734
 
@@ -543,97 +736,269 @@ class WebTransport extends EventEmitter {
543
736
  // Public API
544
737
  // ============================================================
545
738
 
546
- /**
547
- * Create a bidirectional stream.
548
- * Returns a Duplex stream (Node.js) with .readable/.writable (WHATWG).
549
- */
550
- createBidirectionalStream() {
739
+ // The browser's stream options — sendOrder, sendGroup, waitUntilAvailable
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).
750
+ _openStream(bidi, options) {
551
751
  var self = this;
552
- return new Promise(function (resolve, reject) {
553
- if (self._state !== 'connected') {
554
- return reject(new Error('WebTransport is not connected'));
555
- }
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');
771
+ });
772
+ }
556
773
 
557
- var streamId = self._nextBidiStreamId;
558
- self._nextBidiStreamId += 4;
774
+ /** Whether a stream could be opened now, within the server's MAX_STREAMS — as the server's res.canOpenStream(). */
775
+ canOpenStream(options) {
776
+ return this._state === 'connected' && !!this._quic && this._quic.canOpenStream(!!(options && options.bidirectional));
777
+ }
559
778
 
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);
779
+ /**
780
+ * A bidirectional stream: a Duplex (wt_stream.js) — and, as the browser's
781
+ * WebTransportBidirectionalStream, it has .readable and .writable.
782
+ */
783
+ createBidirectionalStream(options) {
784
+ return this._openStream(true, options);
785
+ }
563
786
 
564
- var stream = new WebTransportBidiStream({
565
- streamId: streamId,
566
- quic: self._quic,
567
- sessionId: self._sessionId,
568
- prefixSent: false
787
+ /**
788
+ * A unidirectional stream: as the browser's, a WritableStream
789
+ * (getWriter(), pipeTo() into it) — and Node's Writable on the same object,
790
+ * whole: write() / 'drain', end(), resetStream(), its events, the
791
+ * writable* state, and readable.pipe() into it, as the server's stream is.
792
+ * Only write(), end() and a few events were carried over: there was no
793
+ * writableLength, no destroyed, and pipe() failed on the missing emit().
794
+ */
795
+ createUnidirectionalStream(options) {
796
+ return this._openStream(false, options).then(function (stream) {
797
+ var w = stream.writable;
798
+ UNI_METHODS.forEach(function (m) {
799
+ if (!(m in w) || m === 'getStats') w[m] = stream[m].bind(stream);
800
+ });
801
+ UNI_STATE.forEach(function (k) {
802
+ Object.defineProperty(w, k, { configurable: true, get: function () { return stream[k]; } });
569
803
  });
804
+ return w;
805
+ });
806
+ }
570
807
 
571
- self._streams[streamId] = stream;
808
+ /** The browser's incoming streams, as ReadableStreams of streams. */
809
+ get incomingBidirectionalStreams() {
810
+ return this._incomingBidi || (this._incomingBidi = this._incomingStreams('stream'));
811
+ }
812
+ get incomingUnidirectionalStreams() {
813
+ return this._incomingUni || (this._incomingUni = this._incomingStreams('unidirectionalStream'));
814
+ }
572
815
 
573
- // Listen for incoming data on this bidi stream
574
- // (already handled by _handleStreamData via QUIC stream event)
816
+ /** The server's answer to the CONNECT (an immutable Headers) — null until the session is up. */
817
+ get responseHeaders() { return this._responseHeaders; }
575
818
 
576
- resolve(stream);
577
- });
578
- }
819
+ /** As the browser's: no WebTransport over HTTP/2 (reliable-only) here — HTTP/3 alone. */
820
+ static get supportsReliableOnly() { return false; }
579
821
 
580
822
  /**
581
- * Create a unidirectional stream (outgoing only).
582
- * Returns a Writable stream (Node.js) with .writable (WHATWG).
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.
583
828
  */
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;
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
+ }
593
839
 
594
- // Claim this stream
595
- self._claimedStreams.add(streamId);
596
- if (self._h3 && self._h3.claimStream) self._h3.claimStream(streamId);
840
+ /** The browser's WebTransportSendGroup — of this session's alone. */
841
+ createSendGroup() {
842
+ return createSendGroup(this);
843
+ }
597
844
 
598
- var stream = new WebTransportUniWriteStream({
599
- streamId: streamId,
600
- quic: self._quic,
601
- sessionId: self._sessionId
602
- });
845
+ /**
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.
848
+ */
849
+ getStats() {
850
+ if (this._state === 'failed') return Promise.reject(invalidState('the session failed'));
851
+ return sessionStats(this._session); // wt_stream: as the server's res.getStats()
852
+ }
603
853
 
604
- resolve(stream);
605
- });
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;
606
878
  }
607
879
 
608
880
  /**
609
881
  * Send an unreliable datagram.
610
882
  */
611
- sendDatagram(data) {
883
+ // As the server's res.sendDatagram: true when queued (sent under
884
+ // congestion control, never retransmitted), false when it cannot be sent at
885
+ // all — larger than maxDatagramSize(), or the server takes no datagrams.
886
+ // options { sendOrder, sendGroup }: its place among the session's streams
887
+ // (wt_stream's wtScheduling); without them, ahead of every stream.
888
+ sendDatagram(data, options) {
612
889
  if (this._state !== 'connected') throw new Error('WebTransport is not connected');
613
890
  if (typeof data === 'string') data = Buffer.from(data);
614
891
  if (Buffer.isBuffer(data)) data = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
892
+ // VarInt(quarter stream ID) + payload (RFC 9297)
893
+ return this._quic.sendDatagram(wt_datagram_payload(this._sessionId, data), this._sched.forDatagram(options), this._dgOwner);
894
+ }
615
895
 
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
- }
896
+ /** The largest datagram this session can send, in bytes of payload. */
897
+ maxDatagramSize() {
898
+ if (this._state !== 'connected') return 0;
899
+ return Math.max(0, this._quic.maxDatagramSize() - wt_datagram_payload(this._sessionId, new Uint8Array(0)).byteLength);
623
900
  }
624
901
 
625
902
  /**
626
903
  * Close the WebTransport session.
627
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
+
947
+ // The session is over: `closed` resolves and 'close' is emitted, once. As
948
+ // in the browser, closed resolves with the code and reason the session was
949
+ // closed with — the server's CLOSE_WEBTRANSPORT_SESSION, or ours — and
950
+ // { closeCode: 0, reason: '' } when there was none. At the session's close,
951
+ // not the QUIC connection's: that one lingers in its closing period
952
+ // (RFC 9000 §10.2) for three PTOs after, and `closed` waited it out.
953
+ // streamError: what the streams' browser views fail with (see close()).
954
+ _sessionClosed(streamError) {
955
+ if (this._closedDone) return;
956
+ this._closedDone = true;
957
+ this._destroyStreams(streamError || new WebTransportError('the session is closed', { source: 'session' }), true);
958
+ this._closedResolve(this.closeInfo || { closeCode: 0, reason: '' });
959
+ this.emit('close');
960
+ }
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.
628
969
  close(info) {
629
- if (this._state === 'closed') return;
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) {
981
+ if (this._state === 'closed' || this._state === 'failed') return;
630
982
  this._state = 'closed';
631
983
 
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');
984
+ // CLOSE_WEBTRANSPORT_SESSION with the code and reason, and the CONNECT
985
+ // stream's end — so the server learns them, as from a browser's
986
+ // wt.close(). Then the QUIC connection, which is this session's alone,
987
+ // once the capsule has left: closed at once, it was dropped unsent.
988
+ var quic = this._quic;
989
+ if (!this.closeInfo) this.closeInfo = { closeCode: (info && info.closeCode) || 0, reason: (info && info.reason) || '' };
990
+ if (quic) {
991
+ var closeQuic = function () { quic.close(0x0100, 'WebTransport closed', true); }; // H3_NO_ERROR
992
+ if (this._h3 && this._sessionId !== null) {
993
+ this._h3.sendBody(this._sessionId, wtCloseCapsule(this.closeInfo.closeCode, this.closeInfo.reason), true);
994
+ quic.whenStreamSent(this._sessionId, 0, closeQuic);
995
+ var t = setTimeout(closeQuic, 1000); // a capsule that cannot leave does not hold the close
996
+ if (t.unref) t.unref();
997
+ } else {
998
+ closeQuic();
999
+ }
636
1000
  }
1001
+ this._sessionClosed(streamError || new WebTransportError('the server closed the session', { source: 'session' }));
637
1002
  }
638
1003
 
639
1004
  /**
@@ -647,9 +1012,11 @@ class WebTransport extends EventEmitter {
647
1012
  * URL this session connected to.
648
1013
  */
649
1014
  get url() {
650
- 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;
651
1017
  }
652
1018
  }
653
1019
 
654
1020
 
655
- export { WebTransport, WebTransportBidiStream, WebTransportUniWriteStream, WebTransportUniReadStream };
1021
+ export { WebTransport, WebTransportError, WebTransportBidirectionalStream, WebTransportReceiveStream, WebTransportSendStream,
1022
+ WebTransportWriter, WebTransportSendGroup, WebTransportDatagramsWritable, WebTransportDatagramDuplexStream };