@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/src/channel.js CHANGED
@@ -1,4 +1,12 @@
1
- import { cancelled, statusFromTransportError, unavailable } from "./status.js";
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 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.
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 = (signal) => dialSession(url, options, signal);
42
+ const dial = sessionDialer(url, options);
29
43
  return new Channel(await dial(options.signal), dial, options.onStateChange);
30
44
  }
31
45
 
32
- constructor(session, dial, onStateChange) {
46
+ constructor(dialed, dial, onStateChange) {
33
47
  this.#dial = dial;
34
48
  this.#onStateChange = onStateChange;
35
- this.#adopt(session);
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 session = await this.#dial(signal);
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(session);
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 WebTransport server and waits for its session to be ready. */
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
- async function dialSession(url, options, signal) {
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
- const session = new WebTransportConstructor(url, transportOptions(options));
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("WebTransport session closed before becoming ready");
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];