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.
- package/README.md +1987 -160
- package/index.d.ts +1034 -77
- package/index.js +59 -36
- package/package.json +8 -5
- package/src/agent.js +306 -36
- package/src/client.js +447 -120
- package/src/client_profile.js +266 -0
- package/src/connect_udp.js +578 -0
- package/src/connect_udp_client.js +779 -0
- package/src/crypto.js +240 -140
- package/src/h1.js +953 -0
- package/src/h1_client.js +731 -0
- package/src/h1_server.js +666 -0
- package/src/h2.js +1325 -0
- package/src/h2_client.js +675 -0
- package/src/h2_fingerprint.js +403 -0
- package/src/h2_frames.js +582 -0
- package/src/h2_server.js +911 -0
- package/src/h3.js +1986 -520
- package/src/h3_client.js +484 -46
- package/src/h3_server.js +1448 -98
- package/src/hpack.js +455 -0
- package/src/hpack_primitives.js +559 -0
- package/src/http_common.js +908 -0
- package/src/ja4h.js +256 -0
- package/src/qpack.js +555 -0
- package/src/quic_connection.js +6702 -665
- package/src/quic_server.js +858 -82
- package/src/quic_socket.js +134 -9
- package/src/quic_version.js +143 -0
- package/src/server.js +1097 -125
- package/src/server_stats.js +93 -0
- package/src/streams.js +542 -43
- package/src/tcp_listener.js +568 -0
- package/src/tls_bridge.js +478 -73
- package/src/tls_options.js +108 -0
- package/src/transport.js +455 -46
- package/src/utils.js +94 -12
- package/src/webtransport.js +784 -417
- package/src/ws.js +744 -0
- package/src/ws_client.js +621 -0
- package/src/ws_server.js +815 -0
- package/src/ws_stream.js +250 -0
- package/src/wt_stream.js +1075 -0
package/src/webtransport.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
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
|
-
//
|
|
32
|
-
//
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
117
|
+
return out;
|
|
88
118
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
//
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
if (
|
|
153
|
-
|
|
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
|
-
|
|
181
|
+
out.push(p);
|
|
164
182
|
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
165
185
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
//
|
|
268
|
-
var parsed =
|
|
269
|
-
this._hostname = parsed.hostname;
|
|
270
|
-
this.
|
|
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
|
-
//
|
|
282
|
-
this._streams = {};
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
//
|
|
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 (
|
|
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 (
|
|
338
|
-
self.
|
|
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.
|
|
351
|
-
self.
|
|
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
|
-
|
|
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
|
-
|
|
374
|
-
|
|
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
|
-
//
|
|
381
|
-
//
|
|
382
|
-
//
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
476
|
-
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
if (
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
this._streams[streamId] = stream;
|
|
514
|
-
if (remaining.byteLength > 0) stream._pushData(remaining);
|
|
515
|
-
if (fin) { stream._endData(); delete this._streams[streamId]; }
|
|
516
|
-
this.emit('unidirectionalStream', stream);
|
|
517
|
-
}
|
|
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
|
|
527
|
-
|
|
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.
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
-
|
|
558
|
-
|
|
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
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
|
|
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
|
-
|
|
574
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
582
|
-
*
|
|
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
|
-
|
|
585
|
-
var self = this;
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
840
|
+
/** The browser's WebTransportSendGroup — of this session's alone. */
|
|
841
|
+
createSendGroup() {
|
|
842
|
+
return createSendGroup(this);
|
|
843
|
+
}
|
|
597
844
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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
|
-
//
|
|
633
|
-
//
|
|
634
|
-
|
|
635
|
-
|
|
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
|
-
|
|
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,
|
|
1021
|
+
export { WebTransport, WebTransportError, WebTransportBidirectionalStream, WebTransportReceiveStream, WebTransportSendStream,
|
|
1022
|
+
WebTransportWriter, WebTransportSendGroup, WebTransportDatagramsWritable, WebTransportDatagramDuplexStream };
|