@trevrpc/trevrpc-js 0.2.2 → 1.0.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.
Files changed (42) hide show
  1. package/README.md +89 -47
  2. package/bin/protoc-gen-trevrpc-js.js +1 -1
  3. package/package.json +11 -41
  4. package/src/bytes.js +10 -0
  5. package/src/call.js +287 -0
  6. package/src/channel.js +131 -296
  7. package/src/client.js +203 -1292
  8. package/src/framing.js +125 -519
  9. package/src/generator.js +26 -103
  10. package/src/index.d.ts +79 -452
  11. package/src/index.js +4 -3
  12. package/src/status.js +24 -70
  13. package/src/wire.js +4 -59
  14. package/examples/greeter/README.md +0 -35
  15. package/examples/greeter/client.js +0 -172
  16. package/examples/greeter/greeter.node.trevrpc.d.ts +0 -53
  17. package/examples/greeter/greeter.node.trevrpc.js +0 -9
  18. package/examples/greeter/greeter.proto +0 -18
  19. package/examples/greeter/greeter.trevrpc.d.ts +0 -55
  20. package/examples/greeter/greeter.trevrpc.js +0 -103
  21. package/examples/greeter/index.html +0 -140
  22. package/examples/greeter/static-server.js +0 -168
  23. package/src/advanced.d.ts +0 -29
  24. package/src/advanced.js +0 -1
  25. package/src/browser-channel.js +0 -49
  26. package/src/connect.js +0 -6
  27. package/src/connect.node.js +0 -6
  28. package/src/index.node.d.ts +0 -10
  29. package/src/index.node.js +0 -3
  30. package/src/native-loader.js +0 -118
  31. package/src/node-advanced.d.ts +0 -27
  32. package/src/node-advanced.js +0 -1
  33. package/src/node-generated.d.ts +0 -24
  34. package/src/node-generated.js +0 -126
  35. package/src/node-index.d.ts +0 -1
  36. package/src/node-index.js +0 -7
  37. package/src/node.d.ts +0 -298
  38. package/src/node.js +0 -1456
  39. package/src/runtime.js +0 -66
  40. package/src/server-response.js +0 -49
  41. package/src/timer.js +0 -29
  42. package/src/webtransport.js +0 -639
package/src/channel.js CHANGED
@@ -1,375 +1,210 @@
1
- import { cancelled, deadlineExceeded, unavailable } from "./status.js";
2
-
3
- const ChannelOptionKeys = new Set(["onStateChange", "signal", "timeoutMs"]);
4
-
5
- const DefaultChannelOptions = Object.freeze({
6
- initialDelayMs: 100,
7
- maxDelayMs: 30_000,
8
- multiplier: 2,
9
- jitter: 0.2,
10
- });
11
- const MaxTimerDelayMs = 2_147_483_647;
12
-
13
- /** Shared state machine for reconnecting channels. */
14
- export class ChannelStateMachine extends EventTarget {
15
- #connect;
16
- #connectAbort = null;
17
- #generation = 0;
18
- #observeClosed;
19
- #isConnectionFailure;
1
+ import { cancelled, statusFromTransportError, unavailable } from "./status.js";
2
+
3
+ /** WebTransport constructor options passed through unchanged. */
4
+ const WebTransportOptionKeys = [
5
+ "allowPooling",
6
+ "congestionControl",
7
+ "requireUnreliable",
8
+ "serverCertificateHashes",
9
+ ];
10
+
11
+ const Backoff = Object.freeze({ initialDelayMs: 100, maxDelayMs: 30_000, jitter: 0.2 });
12
+
13
+ /**
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.
17
+ */
18
+ export class Channel {
19
+ #dial;
20
20
  #onStateChange;
21
- #options;
22
- #state = "connecting";
23
- #transport = null;
21
+ #session;
22
+ #state = "ready";
24
23
  #waiters = [];
24
+ #stopped = new AbortController();
25
25
 
26
- constructor(connect, options = {}, observeClosed = null, isConnectionFailure = () => false) {
27
- super();
28
- this.#connect = connect;
29
- this.#observeClosed = observeClosed;
30
- this.#isConnectionFailure = isConnectionFailure;
31
- this.#onStateChange = options.onStateChange;
32
- this.#options = normalizeChannelOptions(options);
33
- queueMicrotask(() => this.#runConnectLoop(false));
26
+ /** Opens a channel and waits for its first session; use connect() instead. */
27
+ static async connect(url, options = {}) {
28
+ const dial = (signal) => dialSession(url, options, signal);
29
+ return new Channel(await dial(options.signal), dial, options.onStateChange);
34
30
  }
35
31
 
36
- /** Reports whether a generation is currently ready to accept new calls. */
37
- get ready() {
38
- return this.#state === "ready";
32
+ constructor(session, dial, onStateChange) {
33
+ this.#dial = dial;
34
+ this.#onStateChange = onStateChange;
35
+ this.#adopt(session);
39
36
  }
40
37
 
41
- /** Reports the channel lifecycle state. */
38
+ /** "ready", "reconnecting" while the session is being re-established, or "closed". */
42
39
  get state() {
43
40
  return this.#state;
44
41
  }
45
42
 
46
- /** Identifies the current successful connection generation, starting at one. */
47
- get generation() {
48
- return this.#generation;
43
+ /** Opens a bidirectional stream on the current session. */
44
+ createBidirectionalStream() {
45
+ if (this.#state !== "ready") {
46
+ return Promise.reject(unavailable(`channel is ${this.#state}`));
47
+ }
48
+ return this.#session.createBidirectionalStream();
49
49
  }
50
50
 
51
- /** Waits for the current or next connection generation to become ready. */
51
+ /** Resolves once the channel is ready, immediately if it already is. */
52
52
  waitUntilReady() {
53
- if (this.ready) {
53
+ if (this.#state === "ready") {
54
54
  return Promise.resolve();
55
55
  }
56
56
  if (this.#state === "closed") {
57
57
  return Promise.reject(unavailable("channel is closed"));
58
58
  }
59
-
60
- return new Promise((resolve, reject) => {
61
- this.#waiters.push({ resolve, reject });
62
- });
63
- }
64
-
65
- /** Sends a unary call on the current generation without replaying it. */
66
- async call(request, options = {}) {
67
- const transport = this.#readyTransport();
68
- try {
69
- return await transport.call(request, options);
70
- } catch (error) {
71
- this.#operationFailed(transport, error);
72
- throw error;
73
- }
74
- }
75
-
76
- /** Starts a streaming call on the current generation without resuming it later. */
77
- async streamingCall(request, requestBody, options = {}) {
78
- const transport = this.#readyTransport();
79
- try {
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;
85
- }
59
+ return new Promise((resolve, reject) => this.#waiters.push({ resolve, reject }));
86
60
  }
87
61
 
88
- /** Stops reconnecting and closes the current generation. */
89
- close(closeInfo = {}) {
62
+ /** Stops reconnecting and closes the current session. */
63
+ close(closeInfo) {
90
64
  if (this.#state === "closed") {
91
65
  return;
92
66
  }
93
-
94
- const transport = this.#transport;
95
- this.#transport = null;
96
- this.#connectAbort?.abort();
97
- this.#connectAbort = null;
67
+ const session = this.#session;
68
+ this.#session = undefined;
69
+ this.#stopped.abort();
98
70
  this.#transition("closed");
99
- this.#rejectWaiters(unavailable("channel is closed"));
100
- safeClose(transport, closeInfo);
71
+ for (const waiter of this.#waiters.splice(0)) {
72
+ waiter.reject(unavailable("channel is closed"));
73
+ }
74
+ closeQuietly(session, closeInfo);
101
75
  }
102
76
 
103
- #readyTransport() {
104
- if (!this.ready || this.#transport == null) {
105
- throw unavailable(`channel is ${this.#state}`);
106
- }
107
- return this.#transport;
77
+ #adopt(session) {
78
+ this.#session = session;
79
+ const lost = (reason) => this.#lost(session, reason);
80
+ session.closed.then(lost, lost);
108
81
  }
109
82
 
110
- async #runConnectLoop(reconnecting, initialError) {
111
- if (this.#state === "closed" || this.#connectAbort != null) {
83
+ #lost(session, reason) {
84
+ if (session !== this.#session) {
112
85
  return;
113
86
  }
87
+ this.#session = undefined;
88
+ closeQuietly(session);
89
+ void this.#reconnect(reason);
90
+ }
114
91
 
115
- const controller = new AbortController();
116
- this.#connectAbort = controller;
117
- let attempt = 0;
118
- let error = initialError;
119
-
120
- while (!controller.signal.aborted) {
121
- attempt += 1;
122
- const delayed = reconnecting || attempt > 1;
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
- }
92
+ async #reconnect(error) {
93
+ const { signal } = this.#stopped;
94
+ for (let attempt = 1; !signal.aborted; attempt += 1) {
95
+ const delayMs = backoffDelay(attempt);
96
+ this.#transition("reconnecting", { attempt, delayMs, error });
97
+ await sleep(delayMs, signal);
98
+ if (signal.aborted) {
99
+ return;
137
100
  }
138
101
 
139
- let transport;
140
102
  try {
141
- transport = await this.#connect(controller.signal);
142
- } catch (connectError) {
143
- if (controller.signal.aborted) {
103
+ const session = await this.#dial(signal);
104
+ if (signal.aborted) {
105
+ closeQuietly(session);
144
106
  return;
145
107
  }
146
- error = connectError;
147
- continue;
148
- }
149
-
150
- if (controller.signal.aborted || this.#state === "closed") {
151
- safeClose(transport);
108
+ this.#adopt(session);
109
+ this.#transition("ready", { attempt });
110
+ for (const waiter of this.#waiters.splice(0)) {
111
+ waiter.resolve();
112
+ }
152
113
  return;
114
+ } catch (dialError) {
115
+ error = dialError;
153
116
  }
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
117
  }
191
118
  }
192
119
 
193
120
  #transition(state, extra = {}) {
194
121
  const previousState = this.#state;
195
122
  this.#state = state;
196
- const detail = Object.freeze({
197
- state,
198
- previousState,
199
- generation: this.#generation,
200
- ...extra,
201
- });
202
-
203
- if (typeof this.#onStateChange === "function") {
204
- try {
205
- this.#onStateChange(detail);
206
- } catch (error) {
207
- globalThis.reportError?.(error);
208
- }
209
- }
210
- this.dispatchEvent(lifecycleEvent("statechange", detail));
211
- this.dispatchEvent(lifecycleEvent(state, detail));
212
- }
213
-
214
- #resolveWaiters() {
215
- for (const waiter of this.#waiters.splice(0)) {
216
- waiter.resolve();
217
- }
218
- }
219
-
220
- #rejectWaiters(error) {
221
- for (const waiter of this.#waiters.splice(0)) {
222
- waiter.reject(error);
123
+ try {
124
+ this.#onStateChange?.({ state, previousState, ...extra });
125
+ } catch (error) {
126
+ globalThis.reportError?.(error);
223
127
  }
224
128
  }
225
129
  }
226
130
 
227
- /** Returns transport options with channel lifecycle settings removed. */
228
- export function stripChannelOptions(options = {}) {
229
- return Object.fromEntries(Object.entries(options).filter(([key]) => !ChannelOptionKeys.has(key)));
131
+ /** Opens a channel to a WebTransport server and waits for its session to be ready. */
132
+ export function connect(url, options = {}) {
133
+ return Channel.connect(url, options);
230
134
  }
231
135
 
232
- /** Waits for initial readiness with caller-controlled cancellation and deadline. */
233
- export async function waitForInitialReady(channel, options = {}) {
234
- const timeoutMs = options.timeoutMs;
235
- if (timeoutMs != null && (!Number.isFinite(timeoutMs) || timeoutMs < 0)) {
236
- channel.close();
237
- throw new TypeError("timeoutMs must be a non-negative finite number");
136
+ /** Establishes a ready WebTransport session; aborting the signal abandons the attempt. */
137
+ async function dialSession(url, options, signal) {
138
+ const WebTransportConstructor = options.WebTransport ?? globalThis.WebTransport;
139
+ if (typeof WebTransportConstructor !== "function") {
140
+ throw unavailable("WebTransport is not available in this JavaScript runtime");
238
141
  }
239
-
240
- const signal = options.signal;
241
142
  if (signal?.aborted) {
242
- channel.close();
243
- throw initialAbortError(signal);
244
- }
245
- if (timeoutMs === 0) {
246
- channel.close();
247
- throw deadlineExceeded("initial connection deadline exceeded");
143
+ throw abortError(signal);
248
144
  }
249
145
 
250
- const timeoutAbort = timeoutMs == null ? null : new AbortController();
251
- let removeAbort = () => {};
146
+ const session = new WebTransportConstructor(url, transportOptions(options));
147
+ const abandon = () => closeQuietly(session);
148
+ signal?.addEventListener("abort", abandon, { once: true });
252
149
  try {
253
- const waits = [channel.waitUntilReady()];
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);
150
+ // A session that fails to connect settles both promises; one that closes first never becomes
151
+ // ready, so readiness must not wait for it.
152
+ await Promise.race([
153
+ session.ready,
154
+ session.closed.then(() => {
155
+ throw unavailable("WebTransport session closed before becoming ready");
156
+ }),
157
+ ]);
275
158
  } catch (error) {
276
- channel.close();
277
- throw error;
159
+ closeQuietly(session);
160
+ throw signal?.aborted ? abortError(signal) : statusFromTransportError(error);
278
161
  } finally {
279
- timeoutAbort?.abort();
280
- removeAbort();
162
+ signal?.removeEventListener("abort", abandon);
281
163
  }
164
+ if (signal?.aborted) {
165
+ closeQuietly(session);
166
+ throw abortError(signal);
167
+ }
168
+ return session;
282
169
  }
283
170
 
284
- function normalizeChannelOptions(options) {
285
- void options;
286
- return DefaultChannelOptions;
287
- }
288
-
289
- function initialAbortError(signal) {
290
- if (signal.reason?.name === "TrevRpcError") {
291
- return signal.reason;
292
- }
293
- const error = cancelled("initial connection cancelled");
294
- if (signal.reason !== undefined) {
295
- Object.defineProperty(error, "cause", { configurable: true, value: signal.reason });
171
+ function transportOptions(options) {
172
+ const picked = {};
173
+ for (const key of WebTransportOptionKeys) {
174
+ if (options[key] !== undefined) {
175
+ picked[key] = options[key];
176
+ }
296
177
  }
297
- return error;
178
+ return picked;
298
179
  }
299
180
 
300
- function reconnectDelay(options, exponent) {
301
- const exponential = options.initialDelayMs * options.multiplier ** exponent;
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));
181
+ function abortError(signal) {
182
+ return cancelled("connection cancelled", signal.reason);
305
183
  }
306
184
 
307
- async function waitForDelay(delayMs, signal) {
308
- let remaining = delayMs;
309
- while (remaining > 0 && !signal.aborted) {
310
- const chunk = Math.min(remaining, MaxTimerDelayMs);
311
- await waitForTimer(chunk, signal);
312
- remaining -= chunk;
313
- }
185
+ /** Exponential backoff with jitter, capped at maxDelayMs. */
186
+ function backoffDelay(attempt) {
187
+ const { initialDelayMs, maxDelayMs, jitter } = Backoff;
188
+ const base = Math.min(maxDelayMs, initialDelayMs * 2 ** (attempt - 1));
189
+ return Math.min(maxDelayMs, base * (1 + (Math.random() * 2 - 1) * jitter));
314
190
  }
315
191
 
316
- function waitForTimer(delayMs, signal) {
192
+ function sleep(delayMs, signal) {
317
193
  return new Promise((resolve) => {
318
- const timer = setTimeout(done, delayMs);
319
- signal.addEventListener("abort", done, { once: true });
320
-
321
- function done() {
194
+ const done = () => {
322
195
  clearTimeout(timer);
323
196
  signal.removeEventListener("abort", done);
324
197
  resolve();
325
- }
198
+ };
199
+ const timer = setTimeout(done, delayMs);
200
+ signal.addEventListener("abort", done, { once: true });
326
201
  });
327
202
  }
328
203
 
329
- function lifecycleEvent(type, detail) {
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) {
204
+ function closeQuietly(session, closeInfo) {
370
205
  try {
371
- transport?.close?.(closeInfo);
206
+ session?.close(closeInfo);
372
207
  } catch {
373
- // The failed generation may already have released its transport resources.
208
+ // The session may already be gone.
374
209
  }
375
210
  }