@trevrpc/trevrpc-js 1.0.0 → 1.2.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 +86 -18
- package/bin/protoc-gen-trevrpc-js.js +1 -1
- package/package.json +17 -12
- package/src/call.js +616 -40
- package/src/channel.js +190 -18
- package/src/client.js +555 -106
- package/src/compression.js +330 -0
- package/src/connection-metadata.js +114 -0
- package/src/datagram.js +357 -0
- package/src/framing.js +124 -110
- package/src/generator.js +147 -16
- package/src/index.d.ts +223 -17
- package/src/qmux/frames.js +262 -0
- package/src/qmux/params.js +104 -0
- package/src/qmux/session.js +740 -0
- package/src/race.js +90 -0
- package/src/status.js +1 -1
- package/src/stream-compression.js +298 -0
- package/src/varint.js +73 -0
- package/src/wire.js +47 -28
- package/src/zstd.d.ts +35 -0
- package/src/zstd.js +407 -0
package/src/channel.js
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ConnectionMetadataKey, ConnectionMetadataSource } from "./connection-metadata.js";
|
|
2
|
+
import { QMuxWebSocketSession } from "./qmux/session.js";
|
|
3
|
+
import { dialRace } from "./race.js";
|
|
4
|
+
import { cancelled, failedPrecondition, statusFromTransportError, unavailable } from "./status.js";
|
|
5
|
+
import { StreamBudget, StreamBudgetKey } from "./stream-compression.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The WebTransport protocol that names wire version 1, offered through the `protocols` constructor
|
|
9
|
+
* option. It is the protocol version, so callers cannot change it.
|
|
10
|
+
*/
|
|
11
|
+
const WebTransportProtocol = "trevrpc-1";
|
|
2
12
|
|
|
3
13
|
/** WebTransport constructor options passed through unchanged. */
|
|
4
14
|
const WebTransportOptionKeys = [
|
|
@@ -10,29 +20,57 @@ const WebTransportOptionKeys = [
|
|
|
10
20
|
|
|
11
21
|
const Backoff = Object.freeze({ initialDelayMs: 100, maxDelayMs: 30_000, jitter: 0.2 });
|
|
12
22
|
|
|
23
|
+
const DefaultTransports = Object.freeze(["webtransport", "websocket"]);
|
|
24
|
+
const DefaultFallbackDelayMs = 300;
|
|
25
|
+
const DefaultReprobeIntervalMs = 5 * 60_000;
|
|
26
|
+
|
|
13
27
|
/**
|
|
14
|
-
* A
|
|
15
|
-
*
|
|
16
|
-
*
|
|
28
|
+
* A session that reconnects in the background after it is lost: WebTransport, or QMux over
|
|
29
|
+
* WebSocket when WebTransport is unavailable or blocked. Calls open their streams on the current
|
|
30
|
+
* session and fail fast with Unavailable while it reconnects; a call that was in flight when the
|
|
31
|
+
* session was lost is never replayed.
|
|
17
32
|
*/
|
|
18
33
|
export class Channel {
|
|
19
34
|
#dial;
|
|
35
|
+
#compression;
|
|
20
36
|
#onStateChange;
|
|
21
37
|
#session;
|
|
38
|
+
#transport;
|
|
22
39
|
#state = "ready";
|
|
23
40
|
#waiters = [];
|
|
24
41
|
#stopped = new AbortController();
|
|
25
42
|
|
|
26
43
|
/** Opens a channel and waits for its first session; use connect() instead. */
|
|
27
44
|
static async connect(url, options = {}) {
|
|
28
|
-
const dial = (
|
|
29
|
-
|
|
45
|
+
const dial = sessionDialer(url, options);
|
|
46
|
+
// Checked before dialing, so an invalid option never leaves a session behind.
|
|
47
|
+
const streamBudget = new StreamBudget(options.streamCompressionMemory);
|
|
48
|
+
const connectionMetadata = new ConnectionMetadataSource(options.connectionMetadata);
|
|
49
|
+
return new Channel(await dial(options.signal), dial, {
|
|
50
|
+
...options,
|
|
51
|
+
streamBudget,
|
|
52
|
+
connectionMetadata,
|
|
53
|
+
});
|
|
30
54
|
}
|
|
31
55
|
|
|
32
|
-
constructor(
|
|
56
|
+
constructor(
|
|
57
|
+
dialed,
|
|
58
|
+
dial,
|
|
59
|
+
{
|
|
60
|
+
compression,
|
|
61
|
+
streamBudget = new StreamBudget(),
|
|
62
|
+
connectionMetadata = new ConnectionMetadataSource(),
|
|
63
|
+
onStateChange,
|
|
64
|
+
} = {},
|
|
65
|
+
) {
|
|
33
66
|
this.#dial = dial;
|
|
67
|
+
this.#compression = compression;
|
|
68
|
+
// The memory budget of the stream compression contexts of this channel's calls.
|
|
69
|
+
this[StreamBudgetKey] = streamBudget;
|
|
70
|
+
// The connection metadata every call of this channel carries.
|
|
71
|
+
this[ConnectionMetadataKey] = connectionMetadata;
|
|
34
72
|
this.#onStateChange = onStateChange;
|
|
35
|
-
this.#adopt(
|
|
73
|
+
this.#adopt(dialed);
|
|
36
74
|
}
|
|
37
75
|
|
|
38
76
|
/** "ready", "reconnecting" while the session is being re-established, or "closed". */
|
|
@@ -40,6 +78,25 @@ export class Channel {
|
|
|
40
78
|
return this.#state;
|
|
41
79
|
}
|
|
42
80
|
|
|
81
|
+
/** The transport of the current or last session: "webtransport" or "websocket". */
|
|
82
|
+
get transport() {
|
|
83
|
+
return this.#transport;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The compression settings from the connect options, or undefined. */
|
|
87
|
+
get compression() {
|
|
88
|
+
return this.#compression;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Replaces the channel's connection metadata, which every call carries; empty or missing
|
|
93
|
+
* metadata removes it. Calls started after this use the new metadata. Throws InvalidArgument if
|
|
94
|
+
* it is invalid, and then keeps the old.
|
|
95
|
+
*/
|
|
96
|
+
setConnectionMetadata(metadata) {
|
|
97
|
+
this[ConnectionMetadataKey].set(metadata);
|
|
98
|
+
}
|
|
99
|
+
|
|
43
100
|
/** Opens a bidirectional stream on the current session. */
|
|
44
101
|
createBidirectionalStream() {
|
|
45
102
|
if (this.#state !== "ready") {
|
|
@@ -48,6 +105,21 @@ export class Channel {
|
|
|
48
105
|
return this.#session.createBidirectionalStream();
|
|
49
106
|
}
|
|
50
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Opens a bidirectional stream on the current session, together with that session: the
|
|
110
|
+
* WebTransport or QMux over WebSocket session, whose `datagrams` are undefined for QMux. Calls
|
|
111
|
+
* whose setup depends on their session (dictionary negotiation, datagram flows) open their stream
|
|
112
|
+
* here, so that setup belongs to the session that carries them even if the channel reconnects
|
|
113
|
+
* meanwhile.
|
|
114
|
+
*/
|
|
115
|
+
createBidirectionalStreamWithSession() {
|
|
116
|
+
if (this.#state !== "ready") {
|
|
117
|
+
return Promise.reject(unavailable(`channel is ${this.#state}`));
|
|
118
|
+
}
|
|
119
|
+
const session = this.#session;
|
|
120
|
+
return session.createBidirectionalStream().then((stream) => ({ stream, session }));
|
|
121
|
+
}
|
|
122
|
+
|
|
51
123
|
/** Resolves once the channel is ready, immediately if it already is. */
|
|
52
124
|
waitUntilReady() {
|
|
53
125
|
if (this.#state === "ready") {
|
|
@@ -74,8 +146,9 @@ export class Channel {
|
|
|
74
146
|
closeQuietly(session, closeInfo);
|
|
75
147
|
}
|
|
76
148
|
|
|
77
|
-
#adopt(session) {
|
|
149
|
+
#adopt({ session, transport }) {
|
|
78
150
|
this.#session = session;
|
|
151
|
+
this.#transport = transport;
|
|
79
152
|
const lost = (reason) => this.#lost(session, reason);
|
|
80
153
|
session.closed.then(lost, lost);
|
|
81
154
|
}
|
|
@@ -100,13 +173,13 @@ export class Channel {
|
|
|
100
173
|
}
|
|
101
174
|
|
|
102
175
|
try {
|
|
103
|
-
const
|
|
176
|
+
const dialed = await this.#dial(signal);
|
|
104
177
|
if (signal.aborted) {
|
|
105
|
-
closeQuietly(session);
|
|
178
|
+
closeQuietly(dialed.session);
|
|
106
179
|
return;
|
|
107
180
|
}
|
|
108
|
-
this.#adopt(
|
|
109
|
-
this.#transition("ready", { attempt });
|
|
181
|
+
this.#adopt(dialed);
|
|
182
|
+
this.#transition("ready", { attempt, transport: dialed.transport });
|
|
110
183
|
for (const waiter of this.#waiters.splice(0)) {
|
|
111
184
|
waiter.resolve();
|
|
112
185
|
}
|
|
@@ -128,13 +201,70 @@ export class Channel {
|
|
|
128
201
|
}
|
|
129
202
|
}
|
|
130
203
|
|
|
131
|
-
/** Opens a channel to a
|
|
204
|
+
/** Opens a channel to a TrevRPC server and waits for its session to be ready. */
|
|
132
205
|
export function connect(url, options = {}) {
|
|
133
206
|
return Channel.connect(url, options);
|
|
134
207
|
}
|
|
135
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Returns `dial(signal)`, which establishes the next session as `{ session, transport }`.
|
|
211
|
+
*
|
|
212
|
+
* WebTransport is preferred. QMux over WebSocket starts in parallel once WebTransport fails or has
|
|
213
|
+
* not connected within `fallbackDelayMs`, and the first ready session wins. After WebSocket wins,
|
|
214
|
+
* reconnects use it directly until `reprobeIntervalMs` has passed.
|
|
215
|
+
*/
|
|
216
|
+
function sessionDialer(url, options) {
|
|
217
|
+
const webSocketUrl = options.webSocketUrl ?? defaultWebSocketUrl(url);
|
|
218
|
+
const candidates = {
|
|
219
|
+
webtransport: {
|
|
220
|
+
transport: "webtransport",
|
|
221
|
+
dial: (signal) => dialWebTransport(url, options, signal),
|
|
222
|
+
},
|
|
223
|
+
websocket: {
|
|
224
|
+
transport: "websocket",
|
|
225
|
+
dial: (signal) => dialWebSocket(webSocketUrl, options, signal),
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
const [primary, fallback, ...rest] = (options.transports ?? DefaultTransports).map((name) => {
|
|
229
|
+
const candidate = candidates[name];
|
|
230
|
+
if (candidate === undefined) {
|
|
231
|
+
throw new TypeError(`unknown transport "${name}"`);
|
|
232
|
+
}
|
|
233
|
+
return candidate;
|
|
234
|
+
});
|
|
235
|
+
if (primary === undefined || rest.length > 0 || primary === fallback) {
|
|
236
|
+
throw new TypeError("transports must list one or two distinct transports");
|
|
237
|
+
}
|
|
238
|
+
const delayMs = options.fallbackDelayMs ?? DefaultFallbackDelayMs;
|
|
239
|
+
const reprobeIntervalMs = options.reprobeIntervalMs ?? DefaultReprobeIntervalMs;
|
|
240
|
+
let fallbackWonAt = null;
|
|
241
|
+
|
|
242
|
+
const dialOne = async (candidate, signal) => ({
|
|
243
|
+
session: await candidate.dial(signal),
|
|
244
|
+
transport: candidate.transport,
|
|
245
|
+
});
|
|
246
|
+
return async (signal) => {
|
|
247
|
+
if (fallback === undefined) {
|
|
248
|
+
return dialOne(primary, signal);
|
|
249
|
+
}
|
|
250
|
+
if (fallbackWonAt !== null && Date.now() - fallbackWonAt < reprobeIntervalMs) {
|
|
251
|
+
return dialOne(fallback, signal);
|
|
252
|
+
}
|
|
253
|
+
const dialed = await dialRace(primary, fallback, { delayMs, signal });
|
|
254
|
+
fallbackWonAt = dialed.transport === fallback.transport ? Date.now() : null;
|
|
255
|
+
return dialed;
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** The `wss://` URL of the same endpoint as an `https://` URL. */
|
|
260
|
+
function defaultWebSocketUrl(url) {
|
|
261
|
+
const webSocketUrl = new URL(url);
|
|
262
|
+
webSocketUrl.protocol = webSocketUrl.protocol === "http:" ? "ws:" : "wss:";
|
|
263
|
+
return webSocketUrl.href;
|
|
264
|
+
}
|
|
265
|
+
|
|
136
266
|
/** Establishes a ready WebTransport session; aborting the signal abandons the attempt. */
|
|
137
|
-
|
|
267
|
+
function dialWebTransport(url, options, signal) {
|
|
138
268
|
const WebTransportConstructor = options.WebTransport ?? globalThis.WebTransport;
|
|
139
269
|
if (typeof WebTransportConstructor !== "function") {
|
|
140
270
|
throw unavailable("WebTransport is not available in this JavaScript runtime");
|
|
@@ -142,8 +272,49 @@ async function dialSession(url, options, signal) {
|
|
|
142
272
|
if (signal?.aborted) {
|
|
143
273
|
throw abortError(signal);
|
|
144
274
|
}
|
|
275
|
+
return establish(
|
|
276
|
+
new WebTransportConstructor(url, transportOptions(options)),
|
|
277
|
+
"WebTransport",
|
|
278
|
+
signal,
|
|
279
|
+
requireWireVersion,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Fails a ready WebTransport session whose server selected another protocol than version 1. An
|
|
285
|
+
* empty or missing `protocol` means version 1: older browsers do not support the option, and the
|
|
286
|
+
* Rust server cannot echo a selection. A mismatch is FailedPrecondition rather than Unavailable
|
|
287
|
+
* because retrying the same server cannot fix it; the channel still treats it like any failed
|
|
288
|
+
* dial, so the WebSocket fallback and the reconnect loop carry on.
|
|
289
|
+
*/
|
|
290
|
+
function requireWireVersion(session) {
|
|
291
|
+
const { protocol } = session;
|
|
292
|
+
if (typeof protocol === "string" && protocol !== "" && protocol !== WebTransportProtocol) {
|
|
293
|
+
throw failedPrecondition(
|
|
294
|
+
`server selected WebTransport protocol "${protocol}", but this client speaks "${WebTransportProtocol}"`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Establishes a ready QMux over WebSocket session; aborting the signal abandons the attempt. */
|
|
300
|
+
function dialWebSocket(url, options, signal) {
|
|
301
|
+
if (signal?.aborted) {
|
|
302
|
+
throw abortError(signal);
|
|
303
|
+
}
|
|
304
|
+
let session;
|
|
305
|
+
try {
|
|
306
|
+
session = new QMuxWebSocketSession(url, { WebSocket: options.WebSocket });
|
|
307
|
+
} catch (error) {
|
|
308
|
+
throw statusFromTransportError(error);
|
|
309
|
+
}
|
|
310
|
+
return establish(session, "WebSocket", signal);
|
|
311
|
+
}
|
|
145
312
|
|
|
146
|
-
|
|
313
|
+
/**
|
|
314
|
+
* Waits for a new session to become ready, closing it if it fails or the signal aborts. `verify`
|
|
315
|
+
* may reject the ready session by throwing.
|
|
316
|
+
*/
|
|
317
|
+
async function establish(session, name, signal, verify) {
|
|
147
318
|
const abandon = () => closeQuietly(session);
|
|
148
319
|
signal?.addEventListener("abort", abandon, { once: true });
|
|
149
320
|
try {
|
|
@@ -152,9 +323,10 @@ async function dialSession(url, options, signal) {
|
|
|
152
323
|
await Promise.race([
|
|
153
324
|
session.ready,
|
|
154
325
|
session.closed.then(() => {
|
|
155
|
-
throw unavailable(
|
|
326
|
+
throw unavailable(`${name} session closed before becoming ready`);
|
|
156
327
|
}),
|
|
157
328
|
]);
|
|
329
|
+
verify?.(session);
|
|
158
330
|
} catch (error) {
|
|
159
331
|
closeQuietly(session);
|
|
160
332
|
throw signal?.aborted ? abortError(signal) : statusFromTransportError(error);
|
|
@@ -169,7 +341,7 @@ async function dialSession(url, options, signal) {
|
|
|
169
341
|
}
|
|
170
342
|
|
|
171
343
|
function transportOptions(options) {
|
|
172
|
-
const picked = {};
|
|
344
|
+
const picked = { protocols: [WebTransportProtocol] };
|
|
173
345
|
for (const key of WebTransportOptionKeys) {
|
|
174
346
|
if (options[key] !== undefined) {
|
|
175
347
|
picked[key] = options[key];
|