@trevrpc/trevrpc-js 0.2.3 → 1.1.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 +127 -46
- package/package.json +12 -45
- package/src/bytes.js +10 -0
- package/src/call.js +520 -0
- package/src/channel.js +258 -288
- package/src/client.js +543 -1254
- package/src/datagram.js +336 -0
- package/src/framing.js +151 -548
- package/src/generator.js +66 -109
- package/src/index.d.ts +235 -463
- package/src/index.js +4 -3
- package/src/qmux/frames.js +262 -0
- package/src/qmux/params.js +104 -0
- package/src/qmux/session.js +739 -0
- package/src/race.js +90 -0
- package/src/status.js +24 -70
- package/src/varint.js +73 -0
- package/src/wire.js +21 -89
- package/examples/greeter/README.md +0 -35
- package/examples/greeter/client.js +0 -172
- package/examples/greeter/greeter.node.trevrpc.d.ts +0 -53
- package/examples/greeter/greeter.node.trevrpc.js +0 -9
- package/examples/greeter/greeter.proto +0 -18
- package/examples/greeter/greeter.trevrpc.d.ts +0 -55
- package/examples/greeter/greeter.trevrpc.js +0 -103
- package/examples/greeter/index.html +0 -140
- package/examples/greeter/static-server.js +0 -168
- package/src/advanced.d.ts +0 -29
- package/src/advanced.js +0 -1
- package/src/browser-channel.js +0 -49
- package/src/connect.js +0 -6
- package/src/connect.node.js +0 -6
- package/src/index.node.d.ts +0 -10
- package/src/index.node.js +0 -3
- package/src/native-loader.js +0 -118
- package/src/node-advanced.d.ts +0 -27
- package/src/node-advanced.js +0 -1
- package/src/node-generated.d.ts +0 -24
- package/src/node-generated.js +0 -126
- package/src/node-index.d.ts +0 -1
- package/src/node-index.js +0 -7
- package/src/node.d.ts +0 -298
- package/src/node.js +0 -1456
- package/src/runtime.js +0 -66
- package/src/server-response.js +0 -49
- package/src/timer.js +0 -29
- package/src/webtransport.js +0 -639
package/src/channel.js
CHANGED
|
@@ -1,375 +1,345 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
1
|
+
import { QMuxWebSocketSession } from "./qmux/session.js";
|
|
2
|
+
import { dialRace } from "./race.js";
|
|
3
|
+
import { cancelled, failedPrecondition, statusFromTransportError, unavailable } from "./status.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The WebTransport protocol that names wire version 1, offered through the `protocols` constructor
|
|
7
|
+
* option. It is the protocol version, so callers cannot change it.
|
|
8
|
+
*/
|
|
9
|
+
const WebTransportProtocol = "trevrpc-1";
|
|
10
|
+
|
|
11
|
+
/** WebTransport constructor options passed through unchanged. */
|
|
12
|
+
const WebTransportOptionKeys = [
|
|
13
|
+
"allowPooling",
|
|
14
|
+
"congestionControl",
|
|
15
|
+
"requireUnreliable",
|
|
16
|
+
"serverCertificateHashes",
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const Backoff = Object.freeze({ initialDelayMs: 100, maxDelayMs: 30_000, jitter: 0.2 });
|
|
20
|
+
|
|
21
|
+
const DefaultTransports = Object.freeze(["webtransport", "websocket"]);
|
|
22
|
+
const DefaultFallbackDelayMs = 300;
|
|
23
|
+
const DefaultReprobeIntervalMs = 5 * 60_000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A session that reconnects in the background after it is lost: WebTransport, or QMux over
|
|
27
|
+
* WebSocket when WebTransport is unavailable or blocked. Calls open their streams on the current
|
|
28
|
+
* session and fail fast with Unavailable while it reconnects; a call that was in flight when the
|
|
29
|
+
* session was lost is never replayed.
|
|
30
|
+
*/
|
|
31
|
+
export class Channel {
|
|
32
|
+
#dial;
|
|
20
33
|
#onStateChange;
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
34
|
+
#session;
|
|
35
|
+
#transport;
|
|
36
|
+
#state = "ready";
|
|
24
37
|
#waiters = [];
|
|
38
|
+
#stopped = new AbortController();
|
|
25
39
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
this.#isConnectionFailure = isConnectionFailure;
|
|
31
|
-
this.#onStateChange = options.onStateChange;
|
|
32
|
-
this.#options = normalizeChannelOptions(options);
|
|
33
|
-
queueMicrotask(() => this.#runConnectLoop(false));
|
|
40
|
+
/** Opens a channel and waits for its first session; use connect() instead. */
|
|
41
|
+
static async connect(url, options = {}) {
|
|
42
|
+
const dial = sessionDialer(url, options);
|
|
43
|
+
return new Channel(await dial(options.signal), dial, options.onStateChange);
|
|
34
44
|
}
|
|
35
45
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
46
|
+
constructor(dialed, dial, onStateChange) {
|
|
47
|
+
this.#dial = dial;
|
|
48
|
+
this.#onStateChange = onStateChange;
|
|
49
|
+
this.#adopt(dialed);
|
|
39
50
|
}
|
|
40
51
|
|
|
41
|
-
/**
|
|
52
|
+
/** "ready", "reconnecting" while the session is being re-established, or "closed". */
|
|
42
53
|
get state() {
|
|
43
54
|
return this.#state;
|
|
44
55
|
}
|
|
45
56
|
|
|
46
|
-
/**
|
|
47
|
-
get
|
|
48
|
-
return this.#
|
|
57
|
+
/** The transport of the current or last session: "webtransport" or "websocket". */
|
|
58
|
+
get transport() {
|
|
59
|
+
return this.#transport;
|
|
49
60
|
}
|
|
50
61
|
|
|
51
|
-
/**
|
|
52
|
-
|
|
53
|
-
if (this
|
|
54
|
-
return Promise.
|
|
55
|
-
}
|
|
56
|
-
if (this.#state === "closed") {
|
|
57
|
-
return Promise.reject(unavailable("channel is closed"));
|
|
62
|
+
/** Opens a bidirectional stream on the current session. */
|
|
63
|
+
createBidirectionalStream() {
|
|
64
|
+
if (this.#state !== "ready") {
|
|
65
|
+
return Promise.reject(unavailable(`channel is ${this.#state}`));
|
|
58
66
|
}
|
|
59
|
-
|
|
60
|
-
return new Promise((resolve, reject) => {
|
|
61
|
-
this.#waiters.push({ resolve, reject });
|
|
62
|
-
});
|
|
67
|
+
return this.#session.createBidirectionalStream();
|
|
63
68
|
}
|
|
64
69
|
|
|
65
|
-
/**
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Opens a bidirectional stream on the current session, together with that session's WebTransport
|
|
72
|
+
* `datagrams` object, or undefined if the session has none (QMux over WebSocket). Calls that may
|
|
73
|
+
* use datagrams open their stream here, so their datagram flow belongs to the session that
|
|
74
|
+
* carries them even if the channel reconnects meanwhile.
|
|
75
|
+
*/
|
|
76
|
+
createBidirectionalStreamWithDatagrams() {
|
|
77
|
+
if (this.#state !== "ready") {
|
|
78
|
+
return Promise.reject(unavailable(`channel is ${this.#state}`));
|
|
73
79
|
}
|
|
80
|
+
const session = this.#session;
|
|
81
|
+
return session
|
|
82
|
+
.createBidirectionalStream()
|
|
83
|
+
.then((stream) => ({ stream, datagrams: session.datagrams }));
|
|
74
84
|
}
|
|
75
85
|
|
|
76
|
-
/**
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const stream = await transport.streamingCall(request, requestBody, options);
|
|
81
|
-
return observedStream(stream, (error) => this.#operationFailed(transport, error));
|
|
82
|
-
} catch (error) {
|
|
83
|
-
this.#operationFailed(transport, error);
|
|
84
|
-
throw error;
|
|
86
|
+
/** Resolves once the channel is ready, immediately if it already is. */
|
|
87
|
+
waitUntilReady() {
|
|
88
|
+
if (this.#state === "ready") {
|
|
89
|
+
return Promise.resolve();
|
|
85
90
|
}
|
|
91
|
+
if (this.#state === "closed") {
|
|
92
|
+
return Promise.reject(unavailable("channel is closed"));
|
|
93
|
+
}
|
|
94
|
+
return new Promise((resolve, reject) => this.#waiters.push({ resolve, reject }));
|
|
86
95
|
}
|
|
87
96
|
|
|
88
|
-
/** Stops reconnecting and closes the current
|
|
89
|
-
close(closeInfo
|
|
97
|
+
/** Stops reconnecting and closes the current session. */
|
|
98
|
+
close(closeInfo) {
|
|
90
99
|
if (this.#state === "closed") {
|
|
91
100
|
return;
|
|
92
101
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
this.#
|
|
96
|
-
this.#connectAbort?.abort();
|
|
97
|
-
this.#connectAbort = null;
|
|
102
|
+
const session = this.#session;
|
|
103
|
+
this.#session = undefined;
|
|
104
|
+
this.#stopped.abort();
|
|
98
105
|
this.#transition("closed");
|
|
99
|
-
this.#
|
|
100
|
-
|
|
106
|
+
for (const waiter of this.#waiters.splice(0)) {
|
|
107
|
+
waiter.reject(unavailable("channel is closed"));
|
|
108
|
+
}
|
|
109
|
+
closeQuietly(session, closeInfo);
|
|
101
110
|
}
|
|
102
111
|
|
|
103
|
-
#
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
112
|
+
#adopt({ session, transport }) {
|
|
113
|
+
this.#session = session;
|
|
114
|
+
this.#transport = transport;
|
|
115
|
+
const lost = (reason) => this.#lost(session, reason);
|
|
116
|
+
session.closed.then(lost, lost);
|
|
108
117
|
}
|
|
109
118
|
|
|
110
|
-
|
|
111
|
-
if (
|
|
119
|
+
#lost(session, reason) {
|
|
120
|
+
if (session !== this.#session) {
|
|
112
121
|
return;
|
|
113
122
|
}
|
|
123
|
+
this.#session = undefined;
|
|
124
|
+
closeQuietly(session);
|
|
125
|
+
void this.#reconnect(reason);
|
|
126
|
+
}
|
|
114
127
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
let attempt =
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const delayMs = delayed
|
|
124
|
-
? reconnectDelay(this.#options, reconnecting ? attempt - 1 : attempt - 2)
|
|
125
|
-
: 0;
|
|
126
|
-
this.#transition(reconnecting ? "reconnecting" : "connecting", {
|
|
127
|
-
attempt,
|
|
128
|
-
delayMs,
|
|
129
|
-
error,
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
if (delayed) {
|
|
133
|
-
await waitForDelay(delayMs, controller.signal);
|
|
134
|
-
if (controller.signal.aborted) {
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
128
|
+
async #reconnect(error) {
|
|
129
|
+
const { signal } = this.#stopped;
|
|
130
|
+
for (let attempt = 1; !signal.aborted; attempt += 1) {
|
|
131
|
+
const delayMs = backoffDelay(attempt);
|
|
132
|
+
this.#transition("reconnecting", { attempt, delayMs, error });
|
|
133
|
+
await sleep(delayMs, signal);
|
|
134
|
+
if (signal.aborted) {
|
|
135
|
+
return;
|
|
137
136
|
}
|
|
138
137
|
|
|
139
|
-
let transport;
|
|
140
138
|
try {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
139
|
+
const dialed = await this.#dial(signal);
|
|
140
|
+
if (signal.aborted) {
|
|
141
|
+
closeQuietly(dialed.session);
|
|
144
142
|
return;
|
|
145
143
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
safeClose(transport);
|
|
144
|
+
this.#adopt(dialed);
|
|
145
|
+
this.#transition("ready", { attempt, transport: dialed.transport });
|
|
146
|
+
for (const waiter of this.#waiters.splice(0)) {
|
|
147
|
+
waiter.resolve();
|
|
148
|
+
}
|
|
152
149
|
return;
|
|
150
|
+
} catch (dialError) {
|
|
151
|
+
error = dialError;
|
|
153
152
|
}
|
|
154
|
-
|
|
155
|
-
this.#connectAbort = null;
|
|
156
|
-
this.#transport = transport;
|
|
157
|
-
this.#generation += 1;
|
|
158
|
-
this.#transition("ready", { attempt });
|
|
159
|
-
this.#resolveWaiters();
|
|
160
|
-
this.#watchTransport(transport);
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
#watchTransport(transport) {
|
|
166
|
-
const closed = this.#observeClosed?.(transport);
|
|
167
|
-
if (closed == null || typeof closed.then !== "function") {
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
Promise.resolve(closed).then(
|
|
172
|
-
(closeInfo) => this.#transportFailed(transport, closeInfo),
|
|
173
|
-
(error) => this.#transportFailed(transport, error),
|
|
174
|
-
);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
#transportFailed(transport, error) {
|
|
178
|
-
if (this.#state !== "ready" || transport !== this.#transport) {
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
this.#transport = null;
|
|
183
|
-
safeClose(transport);
|
|
184
|
-
void this.#runConnectLoop(true, error);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
#operationFailed(transport, error) {
|
|
188
|
-
if (this.#isConnectionFailure(error)) {
|
|
189
|
-
this.#transportFailed(transport, error);
|
|
190
153
|
}
|
|
191
154
|
}
|
|
192
155
|
|
|
193
156
|
#transition(state, extra = {}) {
|
|
194
157
|
const previousState = this.#state;
|
|
195
158
|
this.#state = state;
|
|
196
|
-
|
|
197
|
-
state,
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
...extra,
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
if (typeof this.#onStateChange === "function") {
|
|
204
|
-
try {
|
|
205
|
-
this.#onStateChange(detail);
|
|
206
|
-
} catch (error) {
|
|
207
|
-
globalThis.reportError?.(error);
|
|
208
|
-
}
|
|
159
|
+
try {
|
|
160
|
+
this.#onStateChange?.({ state, previousState, ...extra });
|
|
161
|
+
} catch (error) {
|
|
162
|
+
globalThis.reportError?.(error);
|
|
209
163
|
}
|
|
210
|
-
this.dispatchEvent(lifecycleEvent("statechange", detail));
|
|
211
|
-
this.dispatchEvent(lifecycleEvent(state, detail));
|
|
212
164
|
}
|
|
165
|
+
}
|
|
213
166
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
167
|
+
/** Opens a channel to a TrevRPC server and waits for its session to be ready. */
|
|
168
|
+
export function connect(url, options = {}) {
|
|
169
|
+
return Channel.connect(url, options);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Returns `dial(signal)`, which establishes the next session as `{ session, transport }`.
|
|
174
|
+
*
|
|
175
|
+
* WebTransport is preferred. QMux over WebSocket starts in parallel once WebTransport fails or has
|
|
176
|
+
* not connected within `fallbackDelayMs`, and the first ready session wins. After WebSocket wins,
|
|
177
|
+
* reconnects use it directly until `reprobeIntervalMs` has passed.
|
|
178
|
+
*/
|
|
179
|
+
function sessionDialer(url, options) {
|
|
180
|
+
const webSocketUrl = options.webSocketUrl ?? defaultWebSocketUrl(url);
|
|
181
|
+
const candidates = {
|
|
182
|
+
webtransport: {
|
|
183
|
+
transport: "webtransport",
|
|
184
|
+
dial: (signal) => dialWebTransport(url, options, signal),
|
|
185
|
+
},
|
|
186
|
+
websocket: {
|
|
187
|
+
transport: "websocket",
|
|
188
|
+
dial: (signal) => dialWebSocket(webSocketUrl, options, signal),
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
const [primary, fallback, ...rest] = (options.transports ?? DefaultTransports).map((name) => {
|
|
192
|
+
const candidate = candidates[name];
|
|
193
|
+
if (candidate === undefined) {
|
|
194
|
+
throw new TypeError(`unknown transport "${name}"`);
|
|
217
195
|
}
|
|
196
|
+
return candidate;
|
|
197
|
+
});
|
|
198
|
+
if (primary === undefined || rest.length > 0 || primary === fallback) {
|
|
199
|
+
throw new TypeError("transports must list one or two distinct transports");
|
|
218
200
|
}
|
|
201
|
+
const delayMs = options.fallbackDelayMs ?? DefaultFallbackDelayMs;
|
|
202
|
+
const reprobeIntervalMs = options.reprobeIntervalMs ?? DefaultReprobeIntervalMs;
|
|
203
|
+
let fallbackWonAt = null;
|
|
219
204
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
205
|
+
const dialOne = async (candidate, signal) => ({
|
|
206
|
+
session: await candidate.dial(signal),
|
|
207
|
+
transport: candidate.transport,
|
|
208
|
+
});
|
|
209
|
+
return async (signal) => {
|
|
210
|
+
if (fallback === undefined) {
|
|
211
|
+
return dialOne(primary, signal);
|
|
223
212
|
}
|
|
224
|
-
|
|
213
|
+
if (fallbackWonAt !== null && Date.now() - fallbackWonAt < reprobeIntervalMs) {
|
|
214
|
+
return dialOne(fallback, signal);
|
|
215
|
+
}
|
|
216
|
+
const dialed = await dialRace(primary, fallback, { delayMs, signal });
|
|
217
|
+
fallbackWonAt = dialed.transport === fallback.transport ? Date.now() : null;
|
|
218
|
+
return dialed;
|
|
219
|
+
};
|
|
225
220
|
}
|
|
226
221
|
|
|
227
|
-
/**
|
|
228
|
-
|
|
229
|
-
|
|
222
|
+
/** The `wss://` URL of the same endpoint as an `https://` URL. */
|
|
223
|
+
function defaultWebSocketUrl(url) {
|
|
224
|
+
const webSocketUrl = new URL(url);
|
|
225
|
+
webSocketUrl.protocol = webSocketUrl.protocol === "http:" ? "ws:" : "wss:";
|
|
226
|
+
return webSocketUrl.href;
|
|
230
227
|
}
|
|
231
228
|
|
|
232
|
-
/**
|
|
233
|
-
|
|
234
|
-
const
|
|
235
|
-
if (
|
|
236
|
-
|
|
237
|
-
throw new TypeError("timeoutMs must be a non-negative finite number");
|
|
229
|
+
/** Establishes a ready WebTransport session; aborting the signal abandons the attempt. */
|
|
230
|
+
function dialWebTransport(url, options, signal) {
|
|
231
|
+
const WebTransportConstructor = options.WebTransport ?? globalThis.WebTransport;
|
|
232
|
+
if (typeof WebTransportConstructor !== "function") {
|
|
233
|
+
throw unavailable("WebTransport is not available in this JavaScript runtime");
|
|
238
234
|
}
|
|
239
|
-
|
|
240
|
-
const signal = options.signal;
|
|
241
235
|
if (signal?.aborted) {
|
|
242
|
-
|
|
243
|
-
throw initialAbortError(signal);
|
|
236
|
+
throw abortError(signal);
|
|
244
237
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
238
|
+
return establish(
|
|
239
|
+
new WebTransportConstructor(url, transportOptions(options)),
|
|
240
|
+
"WebTransport",
|
|
241
|
+
signal,
|
|
242
|
+
requireWireVersion,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Fails a ready WebTransport session whose server selected another protocol than version 1. An
|
|
248
|
+
* empty or missing `protocol` means version 1: older browsers do not support the option, and the
|
|
249
|
+
* Rust server cannot echo a selection. A mismatch is FailedPrecondition rather than Unavailable
|
|
250
|
+
* because retrying the same server cannot fix it; the channel still treats it like any failed
|
|
251
|
+
* dial, so the WebSocket fallback and the reconnect loop carry on.
|
|
252
|
+
*/
|
|
253
|
+
function requireWireVersion(session) {
|
|
254
|
+
const { protocol } = session;
|
|
255
|
+
if (typeof protocol === "string" && protocol !== "" && protocol !== WebTransportProtocol) {
|
|
256
|
+
throw failedPrecondition(
|
|
257
|
+
`server selected WebTransport protocol "${protocol}", but this client speaks "${WebTransportProtocol}"`,
|
|
258
|
+
);
|
|
248
259
|
}
|
|
260
|
+
}
|
|
249
261
|
|
|
250
|
-
|
|
251
|
-
|
|
262
|
+
/** Establishes a ready QMux over WebSocket session; aborting the signal abandons the attempt. */
|
|
263
|
+
function dialWebSocket(url, options, signal) {
|
|
264
|
+
if (signal?.aborted) {
|
|
265
|
+
throw abortError(signal);
|
|
266
|
+
}
|
|
267
|
+
let session;
|
|
252
268
|
try {
|
|
253
|
-
|
|
254
|
-
if (signal != null) {
|
|
255
|
-
waits.push(
|
|
256
|
-
new Promise((_, reject) => {
|
|
257
|
-
const rejectCancelled = () => reject(initialAbortError(signal));
|
|
258
|
-
if (signal.aborted) {
|
|
259
|
-
rejectCancelled();
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
signal.addEventListener("abort", rejectCancelled, { once: true });
|
|
263
|
-
removeAbort = () => signal.removeEventListener("abort", rejectCancelled);
|
|
264
|
-
}),
|
|
265
|
-
);
|
|
266
|
-
}
|
|
267
|
-
if (timeoutMs != null) {
|
|
268
|
-
waits.push(
|
|
269
|
-
waitForDelay(timeoutMs, timeoutAbort.signal).then(() => {
|
|
270
|
-
throw deadlineExceeded("initial connection deadline exceeded");
|
|
271
|
-
}),
|
|
272
|
-
);
|
|
273
|
-
}
|
|
274
|
-
await Promise.race(waits);
|
|
269
|
+
session = new QMuxWebSocketSession(url, { WebSocket: options.WebSocket });
|
|
275
270
|
} catch (error) {
|
|
276
|
-
|
|
277
|
-
throw error;
|
|
278
|
-
} finally {
|
|
279
|
-
timeoutAbort?.abort();
|
|
280
|
-
removeAbort();
|
|
271
|
+
throw statusFromTransportError(error);
|
|
281
272
|
}
|
|
273
|
+
return establish(session, "WebSocket", signal);
|
|
282
274
|
}
|
|
283
275
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
276
|
+
/**
|
|
277
|
+
* Waits for a new session to become ready, closing it if it fails or the signal aborts. `verify`
|
|
278
|
+
* may reject the ready session by throwing.
|
|
279
|
+
*/
|
|
280
|
+
async function establish(session, name, signal, verify) {
|
|
281
|
+
const abandon = () => closeQuietly(session);
|
|
282
|
+
signal?.addEventListener("abort", abandon, { once: true });
|
|
283
|
+
try {
|
|
284
|
+
// A session that fails to connect settles both promises; one that closes first never becomes
|
|
285
|
+
// ready, so readiness must not wait for it.
|
|
286
|
+
await Promise.race([
|
|
287
|
+
session.ready,
|
|
288
|
+
session.closed.then(() => {
|
|
289
|
+
throw unavailable(`${name} session closed before becoming ready`);
|
|
290
|
+
}),
|
|
291
|
+
]);
|
|
292
|
+
verify?.(session);
|
|
293
|
+
} catch (error) {
|
|
294
|
+
closeQuietly(session);
|
|
295
|
+
throw signal?.aborted ? abortError(signal) : statusFromTransportError(error);
|
|
296
|
+
} finally {
|
|
297
|
+
signal?.removeEventListener("abort", abandon);
|
|
298
|
+
}
|
|
299
|
+
if (signal?.aborted) {
|
|
300
|
+
closeQuietly(session);
|
|
301
|
+
throw abortError(signal);
|
|
302
|
+
}
|
|
303
|
+
return session;
|
|
287
304
|
}
|
|
288
305
|
|
|
289
|
-
function
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
Object.defineProperty(error, "cause", { configurable: true, value: signal.reason });
|
|
306
|
+
function transportOptions(options) {
|
|
307
|
+
const picked = { protocols: [WebTransportProtocol] };
|
|
308
|
+
for (const key of WebTransportOptionKeys) {
|
|
309
|
+
if (options[key] !== undefined) {
|
|
310
|
+
picked[key] = options[key];
|
|
311
|
+
}
|
|
296
312
|
}
|
|
297
|
-
return
|
|
313
|
+
return picked;
|
|
298
314
|
}
|
|
299
315
|
|
|
300
|
-
function
|
|
301
|
-
|
|
302
|
-
const base = Math.min(options.maxDelayMs, exponential);
|
|
303
|
-
const jitter = 1 + (Math.random() * 2 - 1) * options.jitter;
|
|
304
|
-
return Math.min(options.maxDelayMs, Math.max(0, base * jitter));
|
|
316
|
+
function abortError(signal) {
|
|
317
|
+
return cancelled("connection cancelled", signal.reason);
|
|
305
318
|
}
|
|
306
319
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
remaining -= chunk;
|
|
313
|
-
}
|
|
320
|
+
/** Exponential backoff with jitter, capped at maxDelayMs. */
|
|
321
|
+
function backoffDelay(attempt) {
|
|
322
|
+
const { initialDelayMs, maxDelayMs, jitter } = Backoff;
|
|
323
|
+
const base = Math.min(maxDelayMs, initialDelayMs * 2 ** (attempt - 1));
|
|
324
|
+
return Math.min(maxDelayMs, base * (1 + (Math.random() * 2 - 1) * jitter));
|
|
314
325
|
}
|
|
315
326
|
|
|
316
|
-
function
|
|
327
|
+
function sleep(delayMs, signal) {
|
|
317
328
|
return new Promise((resolve) => {
|
|
318
|
-
const
|
|
319
|
-
signal.addEventListener("abort", done, { once: true });
|
|
320
|
-
|
|
321
|
-
function done() {
|
|
329
|
+
const done = () => {
|
|
322
330
|
clearTimeout(timer);
|
|
323
331
|
signal.removeEventListener("abort", done);
|
|
324
332
|
resolve();
|
|
325
|
-
}
|
|
333
|
+
};
|
|
334
|
+
const timer = setTimeout(done, delayMs);
|
|
335
|
+
signal.addEventListener("abort", done, { once: true });
|
|
326
336
|
});
|
|
327
337
|
}
|
|
328
338
|
|
|
329
|
-
function
|
|
330
|
-
const event = new Event(type);
|
|
331
|
-
Object.defineProperty(event, "detail", { value: detail });
|
|
332
|
-
return event;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
function observedStream(stream, onFailure) {
|
|
336
|
-
const iterator = stream[Symbol.asyncIterator]();
|
|
337
|
-
const observed = {
|
|
338
|
-
[Symbol.asyncIterator]() {
|
|
339
|
-
return this;
|
|
340
|
-
},
|
|
341
|
-
next(...args) {
|
|
342
|
-
return observeResult(() => iterator.next(...args), onFailure);
|
|
343
|
-
},
|
|
344
|
-
};
|
|
345
|
-
|
|
346
|
-
for (const method of ["nextBatch", "nextBodyBatch"]) {
|
|
347
|
-
if (typeof iterator[method] === "function") {
|
|
348
|
-
observed[method] = (...args) => observeResult(() => iterator[method](...args), onFailure);
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
if (typeof iterator.return === "function") {
|
|
352
|
-
observed.return = (...args) => iterator.return(...args);
|
|
353
|
-
}
|
|
354
|
-
if (typeof iterator.throw === "function") {
|
|
355
|
-
observed.throw = (...args) => iterator.throw(...args);
|
|
356
|
-
}
|
|
357
|
-
return observed;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
async function observeResult(operation, onFailure) {
|
|
361
|
-
try {
|
|
362
|
-
return await operation();
|
|
363
|
-
} catch (error) {
|
|
364
|
-
onFailure(error);
|
|
365
|
-
throw error;
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
function safeClose(transport, closeInfo) {
|
|
339
|
+
function closeQuietly(session, closeInfo) {
|
|
370
340
|
try {
|
|
371
|
-
|
|
341
|
+
session?.close(closeInfo);
|
|
372
342
|
} catch {
|
|
373
|
-
// The
|
|
343
|
+
// The session may already be gone.
|
|
374
344
|
}
|
|
375
345
|
}
|