@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/src/channel.js CHANGED
@@ -1,4 +1,14 @@
1
- import { cancelled, statusFromTransportError, unavailable } from "./status.js";
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 WebTransport session that reconnects in the background after it is lost. Calls open their
15
- * streams on the current session and fail fast with Unavailable while it reconnects; a call that
16
- * was in flight when the session was lost is never replayed.
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 = (signal) => dialSession(url, options, signal);
29
- return new Channel(await dial(options.signal), dial, options.onStateChange);
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(session, dial, onStateChange) {
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(session);
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 session = await this.#dial(signal);
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(session);
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 WebTransport server and waits for its session to be ready. */
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
- async function dialSession(url, options, signal) {
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
- const session = new WebTransportConstructor(url, transportOptions(options));
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("WebTransport session closed before becoming ready");
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];