@trevrpc/trevrpc-js 1.0.0 → 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 +57 -18
- package/bin/protoc-gen-trevrpc-js.js +1 -1
- package/package.json +7 -10
- package/src/call.js +269 -36
- package/src/channel.js +152 -17
- package/src/client.js +481 -103
- package/src/datagram.js +336 -0
- package/src/framing.js +108 -111
- package/src/generator.js +41 -7
- package/src/index.d.ts +162 -17
- 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 +1 -1
- package/src/varint.js +73 -0
- package/src/wire.js +17 -30
package/src/channel.js
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
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";
|
|
2
10
|
|
|
3
11
|
/** WebTransport constructor options passed through unchanged. */
|
|
4
12
|
const WebTransportOptionKeys = [
|
|
@@ -10,29 +18,35 @@ const WebTransportOptionKeys = [
|
|
|
10
18
|
|
|
11
19
|
const Backoff = Object.freeze({ initialDelayMs: 100, maxDelayMs: 30_000, jitter: 0.2 });
|
|
12
20
|
|
|
21
|
+
const DefaultTransports = Object.freeze(["webtransport", "websocket"]);
|
|
22
|
+
const DefaultFallbackDelayMs = 300;
|
|
23
|
+
const DefaultReprobeIntervalMs = 5 * 60_000;
|
|
24
|
+
|
|
13
25
|
/**
|
|
14
|
-
* A
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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.
|
|
17
30
|
*/
|
|
18
31
|
export class Channel {
|
|
19
32
|
#dial;
|
|
20
33
|
#onStateChange;
|
|
21
34
|
#session;
|
|
35
|
+
#transport;
|
|
22
36
|
#state = "ready";
|
|
23
37
|
#waiters = [];
|
|
24
38
|
#stopped = new AbortController();
|
|
25
39
|
|
|
26
40
|
/** Opens a channel and waits for its first session; use connect() instead. */
|
|
27
41
|
static async connect(url, options = {}) {
|
|
28
|
-
const dial = (
|
|
42
|
+
const dial = sessionDialer(url, options);
|
|
29
43
|
return new Channel(await dial(options.signal), dial, options.onStateChange);
|
|
30
44
|
}
|
|
31
45
|
|
|
32
|
-
constructor(
|
|
46
|
+
constructor(dialed, dial, onStateChange) {
|
|
33
47
|
this.#dial = dial;
|
|
34
48
|
this.#onStateChange = onStateChange;
|
|
35
|
-
this.#adopt(
|
|
49
|
+
this.#adopt(dialed);
|
|
36
50
|
}
|
|
37
51
|
|
|
38
52
|
/** "ready", "reconnecting" while the session is being re-established, or "closed". */
|
|
@@ -40,6 +54,11 @@ export class Channel {
|
|
|
40
54
|
return this.#state;
|
|
41
55
|
}
|
|
42
56
|
|
|
57
|
+
/** The transport of the current or last session: "webtransport" or "websocket". */
|
|
58
|
+
get transport() {
|
|
59
|
+
return this.#transport;
|
|
60
|
+
}
|
|
61
|
+
|
|
43
62
|
/** Opens a bidirectional stream on the current session. */
|
|
44
63
|
createBidirectionalStream() {
|
|
45
64
|
if (this.#state !== "ready") {
|
|
@@ -48,6 +67,22 @@ export class Channel {
|
|
|
48
67
|
return this.#session.createBidirectionalStream();
|
|
49
68
|
}
|
|
50
69
|
|
|
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}`));
|
|
79
|
+
}
|
|
80
|
+
const session = this.#session;
|
|
81
|
+
return session
|
|
82
|
+
.createBidirectionalStream()
|
|
83
|
+
.then((stream) => ({ stream, datagrams: session.datagrams }));
|
|
84
|
+
}
|
|
85
|
+
|
|
51
86
|
/** Resolves once the channel is ready, immediately if it already is. */
|
|
52
87
|
waitUntilReady() {
|
|
53
88
|
if (this.#state === "ready") {
|
|
@@ -74,8 +109,9 @@ export class Channel {
|
|
|
74
109
|
closeQuietly(session, closeInfo);
|
|
75
110
|
}
|
|
76
111
|
|
|
77
|
-
#adopt(session) {
|
|
112
|
+
#adopt({ session, transport }) {
|
|
78
113
|
this.#session = session;
|
|
114
|
+
this.#transport = transport;
|
|
79
115
|
const lost = (reason) => this.#lost(session, reason);
|
|
80
116
|
session.closed.then(lost, lost);
|
|
81
117
|
}
|
|
@@ -100,13 +136,13 @@ export class Channel {
|
|
|
100
136
|
}
|
|
101
137
|
|
|
102
138
|
try {
|
|
103
|
-
const
|
|
139
|
+
const dialed = await this.#dial(signal);
|
|
104
140
|
if (signal.aborted) {
|
|
105
|
-
closeQuietly(session);
|
|
141
|
+
closeQuietly(dialed.session);
|
|
106
142
|
return;
|
|
107
143
|
}
|
|
108
|
-
this.#adopt(
|
|
109
|
-
this.#transition("ready", { attempt });
|
|
144
|
+
this.#adopt(dialed);
|
|
145
|
+
this.#transition("ready", { attempt, transport: dialed.transport });
|
|
110
146
|
for (const waiter of this.#waiters.splice(0)) {
|
|
111
147
|
waiter.resolve();
|
|
112
148
|
}
|
|
@@ -128,13 +164,70 @@ export class Channel {
|
|
|
128
164
|
}
|
|
129
165
|
}
|
|
130
166
|
|
|
131
|
-
/** Opens a channel to a
|
|
167
|
+
/** Opens a channel to a TrevRPC server and waits for its session to be ready. */
|
|
132
168
|
export function connect(url, options = {}) {
|
|
133
169
|
return Channel.connect(url, options);
|
|
134
170
|
}
|
|
135
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}"`);
|
|
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");
|
|
200
|
+
}
|
|
201
|
+
const delayMs = options.fallbackDelayMs ?? DefaultFallbackDelayMs;
|
|
202
|
+
const reprobeIntervalMs = options.reprobeIntervalMs ?? DefaultReprobeIntervalMs;
|
|
203
|
+
let fallbackWonAt = null;
|
|
204
|
+
|
|
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);
|
|
212
|
+
}
|
|
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
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
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;
|
|
227
|
+
}
|
|
228
|
+
|
|
136
229
|
/** Establishes a ready WebTransport session; aborting the signal abandons the attempt. */
|
|
137
|
-
|
|
230
|
+
function dialWebTransport(url, options, signal) {
|
|
138
231
|
const WebTransportConstructor = options.WebTransport ?? globalThis.WebTransport;
|
|
139
232
|
if (typeof WebTransportConstructor !== "function") {
|
|
140
233
|
throw unavailable("WebTransport is not available in this JavaScript runtime");
|
|
@@ -142,8 +235,49 @@ async function dialSession(url, options, signal) {
|
|
|
142
235
|
if (signal?.aborted) {
|
|
143
236
|
throw abortError(signal);
|
|
144
237
|
}
|
|
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
|
+
);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
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;
|
|
268
|
+
try {
|
|
269
|
+
session = new QMuxWebSocketSession(url, { WebSocket: options.WebSocket });
|
|
270
|
+
} catch (error) {
|
|
271
|
+
throw statusFromTransportError(error);
|
|
272
|
+
}
|
|
273
|
+
return establish(session, "WebSocket", signal);
|
|
274
|
+
}
|
|
145
275
|
|
|
146
|
-
|
|
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) {
|
|
147
281
|
const abandon = () => closeQuietly(session);
|
|
148
282
|
signal?.addEventListener("abort", abandon, { once: true });
|
|
149
283
|
try {
|
|
@@ -152,9 +286,10 @@ async function dialSession(url, options, signal) {
|
|
|
152
286
|
await Promise.race([
|
|
153
287
|
session.ready,
|
|
154
288
|
session.closed.then(() => {
|
|
155
|
-
throw unavailable(
|
|
289
|
+
throw unavailable(`${name} session closed before becoming ready`);
|
|
156
290
|
}),
|
|
157
291
|
]);
|
|
292
|
+
verify?.(session);
|
|
158
293
|
} catch (error) {
|
|
159
294
|
closeQuietly(session);
|
|
160
295
|
throw signal?.aborted ? abortError(signal) : statusFromTransportError(error);
|
|
@@ -169,7 +304,7 @@ async function dialSession(url, options, signal) {
|
|
|
169
304
|
}
|
|
170
305
|
|
|
171
306
|
function transportOptions(options) {
|
|
172
|
-
const picked = {};
|
|
307
|
+
const picked = { protocols: [WebTransportProtocol] };
|
|
173
308
|
for (const key of WebTransportOptionKeys) {
|
|
174
309
|
if (options[key] !== undefined) {
|
|
175
310
|
picked[key] = options[key];
|