@procedures/client 0.7.0 → 0.8.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 (104) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +20 -3
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/adapters/http/server/transport.d.ts +6 -0
  5. package/dist/adapters/http/server/transport.d.ts.map +1 -1
  6. package/dist/adapters/http/server/transport.js +10 -1
  7. package/dist/adapters/http/server/transport.js.map +1 -1
  8. package/dist/adapters/websocket/client/transport.d.ts +7 -3
  9. package/dist/adapters/websocket/client/transport.d.ts.map +1 -1
  10. package/dist/adapters/websocket/client/transport.js +9 -3
  11. package/dist/adapters/websocket/client/transport.js.map +1 -1
  12. package/dist/adapters/websocket/server/transport.d.ts +10 -0
  13. package/dist/adapters/websocket/server/transport.d.ts.map +1 -1
  14. package/dist/adapters/websocket/server/transport.js +22 -2
  15. package/dist/adapters/websocket/server/transport.js.map +1 -1
  16. package/dist/browser.d.ts +4 -4
  17. package/dist/browser.d.ts.map +1 -1
  18. package/dist/browser.js +1 -1
  19. package/dist/browser.js.map +1 -1
  20. package/dist/client/errors/registry.d.ts.map +1 -1
  21. package/dist/client/errors/registry.js +1 -0
  22. package/dist/client/errors/registry.js.map +1 -1
  23. package/dist/client/middleware/retry.d.ts +4 -0
  24. package/dist/client/middleware/retry.d.ts.map +1 -1
  25. package/dist/client/middleware/retry.js +86 -78
  26. package/dist/client/middleware/retry.js.map +1 -1
  27. package/dist/events/bus.d.ts +32 -14
  28. package/dist/events/bus.d.ts.map +1 -1
  29. package/dist/events/bus.js +119 -65
  30. package/dist/events/bus.js.map +1 -1
  31. package/dist/events/index.d.ts +3 -2
  32. package/dist/events/index.d.ts.map +1 -1
  33. package/dist/events/index.js.map +1 -1
  34. package/dist/events/publisher.d.ts.map +1 -1
  35. package/dist/events/publisher.js +20 -6
  36. package/dist/events/publisher.js.map +1 -1
  37. package/dist/events/remote.d.ts +80 -18
  38. package/dist/events/remote.d.ts.map +1 -1
  39. package/dist/events/remote.js +448 -58
  40. package/dist/events/remote.js.map +1 -1
  41. package/dist/events/types.d.ts +13 -2
  42. package/dist/events/types.d.ts.map +1 -1
  43. package/dist/procedures/caller.d.ts +33 -0
  44. package/dist/procedures/caller.d.ts.map +1 -0
  45. package/dist/procedures/caller.js +27 -0
  46. package/dist/procedures/caller.js.map +1 -0
  47. package/dist/procedures/collection/index.d.ts +2 -2
  48. package/dist/procedures/collection/index.d.ts.map +1 -1
  49. package/dist/procedures/collection/index.js +1 -1
  50. package/dist/procedures/collection/index.js.map +1 -1
  51. package/dist/procedures/collection/procedures.d.ts.map +1 -1
  52. package/dist/procedures/collection/procedures.js +21 -1
  53. package/dist/procedures/collection/procedures.js.map +1 -1
  54. package/dist/procedures/collection/schemas.d.ts +9 -0
  55. package/dist/procedures/collection/schemas.d.ts.map +1 -1
  56. package/dist/procedures/collection/schemas.js +28 -0
  57. package/dist/procedures/collection/schemas.js.map +1 -1
  58. package/dist/procedures/collection/storage.d.ts +6 -0
  59. package/dist/procedures/collection/storage.d.ts.map +1 -1
  60. package/dist/procedures/index.d.ts +3 -1
  61. package/dist/procedures/index.d.ts.map +1 -1
  62. package/dist/procedures/index.js +2 -0
  63. package/dist/procedures/index.js.map +1 -1
  64. package/dist/procedures/invoke.d.ts.map +1 -1
  65. package/dist/procedures/invoke.js +3 -0
  66. package/dist/procedures/invoke.js.map +1 -1
  67. package/dist/procedures/types.d.ts +50 -0
  68. package/dist/procedures/types.d.ts.map +1 -1
  69. package/dist/procedures/types.js.map +1 -1
  70. package/dist/protocol.d.ts +1 -1
  71. package/dist/protocol.d.ts.map +1 -1
  72. package/dist/protocol.js +1 -0
  73. package/dist/protocol.js.map +1 -1
  74. package/dist/server/procedure-server.d.ts +37 -3
  75. package/dist/server/procedure-server.d.ts.map +1 -1
  76. package/dist/server/procedure-server.js +86 -4
  77. package/dist/server/procedure-server.js.map +1 -1
  78. package/dist/timers.d.ts +5 -0
  79. package/dist/timers.d.ts.map +1 -1
  80. package/dist/timers.js +3 -1
  81. package/dist/timers.js.map +1 -1
  82. package/package.json +1 -1
  83. package/src/adapters/http/server/transport.ts +11 -1
  84. package/src/adapters/websocket/client/transport.ts +9 -3
  85. package/src/adapters/websocket/server/transport.ts +23 -2
  86. package/src/browser.ts +6 -0
  87. package/src/client/errors/registry.ts +1 -0
  88. package/src/client/middleware/retry.ts +89 -78
  89. package/src/events/bus.ts +141 -73
  90. package/src/events/index.ts +3 -0
  91. package/src/events/publisher.ts +23 -6
  92. package/src/events/remote.ts +575 -73
  93. package/src/events/types.ts +15 -2
  94. package/src/procedures/caller.ts +45 -0
  95. package/src/procedures/collection/index.ts +3 -0
  96. package/src/procedures/collection/procedures.ts +22 -0
  97. package/src/procedures/collection/schemas.ts +37 -0
  98. package/src/procedures/collection/storage.ts +6 -0
  99. package/src/procedures/index.ts +4 -0
  100. package/src/procedures/invoke.ts +2 -0
  101. package/src/procedures/types.ts +53 -0
  102. package/src/protocol.ts +3 -1
  103. package/src/server/procedure-server.ts +103 -6
  104. package/src/timers.ts +8 -1
@@ -10,15 +10,23 @@
10
10
  * - `remoteEvents()` gives an async iterator of the events.
11
11
  * - `subscribeRemote()` calls a handler for each event, and gives the unsubscribe function.
12
12
  *
13
+ * The subscribers of the same topics with the same options on one transport share one call: one
14
+ * wire subscription with a reference count. The last unsubscribe sends the cancel. Each subscriber
15
+ * has its own queue, its own `seq`, its own `ready` and its own end.
16
+ *
13
17
  * After a failure that a retry can repair (a closed connection, `UNAVAILABLE`, `TIMEOUT`), the
14
- * subscriber sends the call again after a backoff. The delivery is at most once: the events
15
- * between the failure and the new confirmation are lost. Each other error ends the subscription.
18
+ * subscriber sends the call again after a backoff, at most `maxAttempts` times without a
19
+ * confirmation. The delivery is at most once: the events between the failure and the new
20
+ * confirmation are lost. Each other error ends the subscription.
16
21
  *
17
- * This file imports only types and the backoff, so a page or a worker that subscribes stays small.
22
+ * This file imports only types, the backoff, the clock and the durations, so a page or a worker
23
+ * that subscribes stays small.
18
24
  */
19
25
 
20
26
  import type { Message, Method, ResponseItem, Transport } from "../client/types.js";
21
27
  import { backoffDelay } from "@mark1russell7/service/backoff";
28
+ import { realClock, type Clock } from "../clock.js";
29
+ import { toMs, type Duration } from "../duration.js";
22
30
 
23
31
  /** The path of the procedure of a subscription. */
24
32
  export const EVENTS_SUBSCRIBE_PATH: readonly ["events", "subscribe"] = Object.freeze(["events", "subscribe"] as const);
@@ -54,9 +62,12 @@ export interface RemoteEvent<T = unknown> {
54
62
  readonly topic: string;
55
63
  /** The data of the event, after a trip as JSON. */
56
64
  readonly data: T;
57
- /** The number of the event in its subscription, from 1. A resubscribe starts again at 1. */
65
+ /**
66
+ * The number of the event for this subscriber, from 1. A resubscribe starts again at 1. A
67
+ * subscriber that joins a shared subscription also starts at 1.
68
+ */
58
69
  readonly seq: number;
59
- /** The number of events that the publisher dropped immediately before this one: 0 without a loss. */
70
+ /** The number of events that this subscriber lost immediately before this one: 0 without a loss. */
60
71
  readonly dropped: number;
61
72
  /** The id of the subscription. It stays the same after a resubscribe. */
62
73
  readonly subscription: string;
@@ -64,10 +75,39 @@ export interface RemoteEvent<T = unknown> {
64
75
 
65
76
  /**
66
77
  * The handler of the events of a subscription. When it gives a promise, the subscriber waits for
67
- * it before the next event, so a slow handler holds the credit of its own subscription only.
78
+ * it before the next event. A slow handler holds the credit of its subscription only when no
79
+ * other subscriber of its shared subscription waits.
68
80
  */
69
81
  export type RemoteEventHandler<T = unknown> = (data: T, event: RemoteEvent<T>) => void | Promise<void>;
70
82
 
83
+ /**
84
+ * The backoff before each resubscribe try: an exponential backoff with jitter. The names are the
85
+ * names of the `reconnect` options of `WebSocketTransport`. Each time is a `Duration`.
86
+ */
87
+ export interface ResubscribeBackoff {
88
+ /**
89
+ * The delay before the first resubscribe try, for example `ms(250)`.
90
+ * @default ms(250)
91
+ */
92
+ initialDelay?: Duration | undefined;
93
+ /**
94
+ * The longest delay before a resubscribe try.
95
+ * @default seconds(30)
96
+ */
97
+ maxDelay?: Duration | undefined;
98
+ /**
99
+ * The factor of the delay from one try to the next.
100
+ * @default 2
101
+ */
102
+ backoffMultiplier?: number | undefined;
103
+ /**
104
+ * The random part of each delay, from 0 to 1. With 1 (full jitter), the delay is a random time
105
+ * from 0 to the backoff. 0 gives the backoff exactly.
106
+ * @default 1
107
+ */
108
+ jitter?: number | undefined;
109
+ }
110
+
71
111
  /** The options of a subscription. */
72
112
  export interface RemoteEventsOptions {
73
113
  /** When this signal aborts, the subscription ends: the transport sends a cancel. */
@@ -79,11 +119,24 @@ export interface RemoteEventsOptions {
79
119
  */
80
120
  resubscribe?: boolean | undefined;
81
121
  /**
82
- * This function gives the time before resubscribe try `attempt` (from 1), in milliseconds. The
83
- * default is an exponential backoff from 250 ms to 30 s with full jitter. The WebSocket
84
- * transport uses its reconnect backoff.
122
+ * The number of resubscribe tries after a failure, one after the other without a confirmation.
123
+ * Each confirmation starts the count again. When the tries run out, the subscription ends with a
124
+ * `SubscriptionError` that has the code of the last failure. 0 is the same as `resubscribe: false`.
125
+ * The WebSocket transport uses the `reconnect.maxAttempts` of its options (default `Infinity`).
126
+ * @default 10 (with the default backoff, at most approximately two minutes of waits)
127
+ */
128
+ maxAttempts?: number | undefined;
129
+ /**
130
+ * The backoff before each resubscribe try. A plain number or a function throws a `TypeError`.
131
+ * The WebSocket transport uses its reconnect backoff when the options give none.
132
+ * @default { initialDelay: ms(250), maxDelay: seconds(30), backoffMultiplier: 2, jitter: 1 }
133
+ */
134
+ backoff?: ResubscribeBackoff | undefined;
135
+ /**
136
+ * The clock of the backoff. A test gives the `fakeClock` of `@mark1russell7/service/testing`.
137
+ * The default is the clock of the platform. The WebSocket transport uses its own clock.
85
138
  */
86
- backoff?: ((attempt: number) => number) | undefined;
139
+ clock?: Clock | undefined;
87
140
  /** This function gets the topics of each confirmation: the first one, and one after each resubscribe. */
88
141
  onSubscribed?: ((topics: readonly string[]) => void) | undefined;
89
142
  /** The metadata of the call, for example a trace. */
@@ -94,8 +147,9 @@ export interface RemoteEventsOptions {
94
147
  export interface SubscribeOptions extends RemoteEventsOptions {
95
148
  /**
96
149
  * This function gets the error that ends the subscription: a refusal (`TOPIC_NOT_FOUND`,
97
- * `FORBIDDEN`), an older publisher (`NOT_FOUND`), or a transport that closed. An unsubscribe is
98
- * not an error.
150
+ * `FORBIDDEN`), an older publisher (`NOT_FOUND`), a topic that the host cleared
151
+ * (`TOPIC_CLEARED`), a transport that closed, or the last failure after `maxAttempts` tries. An
152
+ * unsubscribe is not an error.
99
153
  */
100
154
  onError?: ((error: SubscriptionError) => void) | undefined;
101
155
  }
@@ -131,8 +185,9 @@ export class SubscriptionError extends Error {
131
185
 
132
186
  /**
133
187
  * The parts that a transport adds to its subscriptions. The WebSocket transport waits for its
134
- * connection, uses its reconnect backoff, ends its subscriptions at `close()`, and gives each
135
- * event to its `onEvent` option.
188
+ * connection, uses its reconnect backoff, its `maxAttempts` and its clock, ends its subscriptions
189
+ * at `close()`, and gives each event to its `onEvent` option. A shared subscription uses the
190
+ * hooks of its first subscriber.
136
191
  */
137
192
  export interface SubscriptionHooks {
138
193
  /** When this signal aborts, each subscription ends without an error. */
@@ -142,8 +197,12 @@ export interface SubscriptionHooks {
142
197
  * closed. After a lost connection, the subscriber waits for it, not for a backoff.
143
198
  */
144
199
  readonly ready?: ((signal: AbortSignal) => Promise<void>) | undefined;
145
- /** The backoff, when the options give none. */
200
+ /** The backoff in milliseconds before resubscribe try `attempt` (from 1), when the options give none. */
146
201
  readonly backoff?: ((attempt: number) => number) | undefined;
202
+ /** The number of resubscribe tries, when the options give none. */
203
+ readonly maxAttempts?: number | undefined;
204
+ /** The clock of the backoff, when the options give none. */
205
+ readonly clock?: Clock | undefined;
147
206
  /** This function gets each event of each subscription. */
148
207
  readonly onEvent?: ((event: RemoteEvent) => void) | undefined;
149
208
  /** False: no resubscribe, whatever the options say (a server cannot reach a closed connection). */
@@ -156,9 +215,26 @@ export type EventTransport = Pick<Transport, "send">;
156
215
  /** The codes after which a resubscribe can succeed, also when the item does not say retryable. */
157
216
  const RESUBSCRIBE_CODES: ReadonlySet<string> = new Set(["UNAVAILABLE", "TIMEOUT", "CONNECTION_CLOSED", "ABORTED"]);
158
217
 
159
- /** The default backoff: from 250 ms, twice as long at each try, at most 30 s, with full jitter. */
218
+ /** The default number of resubscribe tries (EVENTS.md). */
219
+ const DEFAULT_MAX_ATTEMPTS = 10;
220
+
221
+ /** The default backoff, in milliseconds: from 250 ms, twice as long at each try, at most 30 s, with full jitter. */
222
+ const DEFAULT_BACKOFF = { initialDelayMs: 250, maxDelayMs: 30_000, backoffMultiplier: 2, jitter: 1 } as const;
223
+
224
+ /**
225
+ * The number of events that the queue of one subscriber holds when another subscriber of its
226
+ * shared subscription reads faster. When it is full, the oldest event goes, as at the publisher.
227
+ */
228
+ const SUBSCRIBER_BUFFER = 1024;
229
+
230
+ /** The default backoff of `DEFAULT_BACKOFF`. */
160
231
  function defaultBackoff(attempt: number): number {
161
- return backoffDelay({ base: 250, factor: 2, cap: 30_000, jitter: { kind: "subtract", fraction: 1 } }, attempt);
232
+ return exponentialBackoff([DEFAULT_BACKOFF.initialDelayMs, DEFAULT_BACKOFF.maxDelayMs, DEFAULT_BACKOFF.backoffMultiplier, DEFAULT_BACKOFF.jitter], attempt);
233
+ }
234
+
235
+ /** The delay before try `attempt`: an exponential backoff of `[base, cap, factor, jitter]`. */
236
+ function exponentialBackoff([base, cap, factor, jitter]: readonly [number, number, number, number], attempt: number): number {
237
+ return backoffDelay({ base, factor, cap, jitter: { kind: "subtract", fraction: jitter } }, attempt);
162
238
  }
163
239
 
164
240
  let subscriptions = 0;
@@ -183,16 +259,17 @@ function linkSignals(signals: ReadonlyArray<AbortSignal | undefined>): { signal:
183
259
  };
184
260
  }
185
261
 
186
- /** This function waits for a time, or until the signal aborts. */
187
- function pause(ms: number, signal: AbortSignal): Promise<void> {
262
+ /** This function waits for a time on the clock, or until the signal aborts. */
263
+ function pause(ms: number, signal: AbortSignal, clock: Clock): Promise<void> {
188
264
  if (signal.aborted || !(ms > 0)) return Promise.resolve();
189
265
  return new Promise((resolve) => {
266
+ let cancel: (() => void) | undefined;
190
267
  const done = (): void => {
191
- clearTimeout(timer);
268
+ cancel?.();
192
269
  signal.removeEventListener("abort", done);
193
270
  resolve();
194
271
  };
195
- const timer = setTimeout(done, ms);
272
+ cancel = clock.after(ms, done);
196
273
  signal.addEventListener("abort", done, { once: true });
197
274
  });
198
275
  }
@@ -213,17 +290,112 @@ function errorOfFailure(error: unknown): SubscriptionError {
213
290
  return new SubscriptionError("CONNECTION_CLOSED", message, true);
214
291
  }
215
292
 
216
- /** The events of a subscription, with each resubscribe. The generator ends at the unsubscribe. */
217
- async function* eventsOf<T>(
218
- transport: EventTransport,
219
- topics: readonly string[],
220
- options: RemoteEventsOptions,
221
- hooks: SubscriptionHooks,
222
- id: string,
223
- ): AsyncGenerator<RemoteEvent<T>, void, undefined> {
224
- const { signal, unlink } = linkSignals([options.signal, hooks.lifetime]);
293
+ /** The error that ends a subscription when its resubscribe tries ran out. It keeps the code of the last failure. */
294
+ function exhaustedError(last: SubscriptionError, tries: number): SubscriptionError {
295
+ return new SubscriptionError(
296
+ last.code,
297
+ `The subscription stopped after ${tries} resubscribe tries without a confirmation: ${last.message}`,
298
+ last.retryable,
299
+ last.reason,
300
+ );
301
+ }
302
+
303
+ // =============================================================================
304
+ // The resubscribe rules
305
+ // =============================================================================
306
+
307
+ /** The resubscribe rules of a subscription, from its options and the hooks of its transport. */
308
+ interface ResubscribePolicy {
309
+ readonly resubscribe: boolean;
310
+ readonly maxAttempts: number;
311
+ /** The delay in milliseconds before try `attempt` (from 1). */
312
+ readonly backoff: (attempt: number) => number;
313
+ readonly clock: Clock;
314
+ /** The part of the key of a shared subscription that these rules give. */
315
+ readonly key: unknown;
316
+ }
317
+
318
+ /** The ids of the clocks of the options, for the keys of the shared subscriptions. */
319
+ const clockIds = new WeakMap<Clock, number>();
320
+ let clocks = 0;
321
+
322
+ /** The id of the clock of the options: 0 without one. */
323
+ function clockIdOf(clock: Clock | undefined): number {
324
+ if (!clock) return 0;
325
+ let id = clockIds.get(clock);
326
+ if (id === undefined) {
327
+ id = ++clocks;
328
+ clockIds.set(clock, id);
329
+ }
330
+ return id;
331
+ }
332
+
333
+ /** The milliseconds of a backoff of the options. A plain number or a function throws a `TypeError`. */
334
+ function backoffSettings(backoff: ResubscribeBackoff): [number, number, number, number] {
335
+ if (backoff === null || typeof backoff !== "object") {
336
+ const given = typeof backoff === "function" ? "a function" : JSON.stringify(backoff);
337
+ throw new TypeError(
338
+ `backoff must be an object of durations such as { initialDelay: ms(250), maxDelay: seconds(30) }, got ${given}. The backoff is no longer a function of milliseconds (EVENTS.md).`,
339
+ );
340
+ }
341
+ const base = backoff.initialDelay === undefined ? DEFAULT_BACKOFF.initialDelayMs : toMs(backoff.initialDelay, "backoff.initialDelay");
342
+ const cap = backoff.maxDelay === undefined ? DEFAULT_BACKOFF.maxDelayMs : toMs(backoff.maxDelay, "backoff.maxDelay");
343
+ const factor = backoff.backoffMultiplier ?? DEFAULT_BACKOFF.backoffMultiplier;
344
+ const jitter = Math.min(1, Math.max(0, backoff.jitter ?? DEFAULT_BACKOFF.jitter));
345
+ return [base, cap, factor, jitter];
346
+ }
347
+
348
+ /** The resubscribe rules of the options and the hooks. A bad option throws a `TypeError` at once. */
349
+ function policyOf(options: RemoteEventsOptions, hooks: SubscriptionHooks): ResubscribePolicy {
350
+ const maxAttempts = options.maxAttempts ?? hooks.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
351
+ if (typeof maxAttempts !== "number" || Number.isNaN(maxAttempts) || maxAttempts < 0) {
352
+ throw new TypeError(`maxAttempts must be a number from 0 (Infinity for no limit), got ${JSON.stringify(maxAttempts)}`);
353
+ }
354
+ let backoff: (attempt: number) => number;
355
+ let backoffKey: unknown;
356
+ if (options.backoff !== undefined) {
357
+ const settings = backoffSettings(options.backoff);
358
+ backoff = (attempt) => exponentialBackoff(settings, attempt);
359
+ backoffKey = settings;
360
+ } else {
361
+ backoff = hooks.backoff ?? defaultBackoff;
362
+ backoffKey = "transport";
363
+ }
225
364
  const resubscribe = hooks.resubscribe !== false && options.resubscribe !== false;
226
- const backoff = options.backoff ?? hooks.backoff ?? defaultBackoff;
365
+ return {
366
+ resubscribe,
367
+ maxAttempts,
368
+ backoff,
369
+ clock: options.clock ?? hooks.clock ?? realClock,
370
+ key: [resubscribe, maxAttempts === Number.POSITIVE_INFINITY ? "Infinity" : maxAttempts, backoffKey, clockIdOf(options.clock)],
371
+ };
372
+ }
373
+
374
+ // =============================================================================
375
+ // The wire subscription
376
+ // =============================================================================
377
+
378
+ /** An event item of the stream of `events.subscribe`. */
379
+ type WireEvent = Extract<EventItem, { type: "event" }>;
380
+
381
+ /** What the call of a wire subscription needs besides the transport. */
382
+ interface WireCall {
383
+ readonly topics: readonly string[];
384
+ readonly metadata: Record<string, unknown>;
385
+ readonly policy: ResubscribePolicy;
386
+ readonly hooks: SubscriptionHooks;
387
+ /** The id of the subscription: the prefix of the id of each call. */
388
+ readonly id: string;
389
+ /** This function gets the topics of each `subscribed` item. */
390
+ readonly onSubscribed: (topics: readonly string[]) => void;
391
+ /** This function runs before each resubscribe try. */
392
+ readonly onRetry: () => void;
393
+ }
394
+
395
+ /** The events of a wire subscription, with each resubscribe. The generator ends when `stop` aborts. */
396
+ async function* wireEvents(transport: EventTransport, call: WireCall, stop: AbortSignal): AsyncGenerator<WireEvent, void, undefined> {
397
+ const { policy, hooks } = call;
398
+ const { signal, unlink } = linkSignals([stop, hooks.lifetime]);
227
399
  let attempt = 0;
228
400
  let tries = 0;
229
401
  try {
@@ -234,10 +406,10 @@ async function* eventsOf<T>(
234
406
  if (signal.aborted) return;
235
407
  }
236
408
  const message: Message<SubscribeInput> = {
237
- id: `${id}.${++tries}`,
409
+ id: `${call.id}.${++tries}`,
238
410
  method: SUBSCRIBE_METHOD,
239
- payload: { topics: [...topics] },
240
- metadata: { ...(options.metadata ?? {}) },
411
+ payload: { topics: [...call.topics] },
412
+ metadata: { ...call.metadata },
241
413
  signal,
242
414
  };
243
415
  let failure: { error: SubscriptionError; lost: boolean } | undefined;
@@ -251,17 +423,9 @@ async function* eventsOf<T>(
251
423
  if (payload === null || typeof payload !== "object") continue;
252
424
  if (payload.type === "subscribed") {
253
425
  attempt = 0;
254
- options.onSubscribed?.(payload.topics);
426
+ call.onSubscribed(payload.topics);
255
427
  } else if (payload.type === "event") {
256
- const event: RemoteEvent<T> = {
257
- topic: payload.topic,
258
- data: payload.data as T,
259
- seq: payload.seq,
260
- dropped: payload.dropped ?? 0,
261
- subscription: id,
262
- };
263
- hooks.onEvent?.(event as RemoteEvent);
264
- yield event;
428
+ yield payload;
265
429
  }
266
430
  }
267
431
  } catch (error) {
@@ -271,25 +435,318 @@ async function* eventsOf<T>(
271
435
  if (signal.aborted) return;
272
436
  // The publisher ended the subscription
273
437
  if (!failure) return;
274
- if (!resubscribe || !failure.error.retryable) throw failure.error;
438
+ if (!policy.resubscribe || !failure.error.retryable || policy.maxAttempts === 0) throw failure.error;
439
+ // The tries ran out: before, HTTP resubscribed forever
440
+ if (attempt >= policy.maxAttempts) throw exhaustedError(failure.error, attempt);
275
441
  attempt++;
442
+ call.onRetry();
276
443
  // After a lost connection, the transport waits for its reconnect: no second backoff
277
- if (!(failure.lost && hooks.ready)) await pause(backoff(attempt), signal);
444
+ if (!(failure.lost && hooks.ready)) await pause(policy.backoff(attempt), signal, policy.clock);
278
445
  }
279
446
  } finally {
280
447
  unlink();
281
448
  }
282
449
  }
283
450
 
451
+ // =============================================================================
452
+ // The shared subscriptions
453
+ // =============================================================================
454
+
455
+ /** An entry of the queue of one subscriber: a confirmation, or an event with its own numbers. */
456
+ type Entry =
457
+ | { readonly kind: "subscribed"; readonly topics: readonly string[] }
458
+ | { readonly kind: "event"; readonly topic: string; readonly data: unknown; readonly seq: number; dropped: number };
459
+
460
+ /**
461
+ * One subscriber of a shared subscription: its queue, and its numbers. Its `seq` counts from the
462
+ * last event that the shared subscription got before the subscriber joined.
463
+ */
464
+ class Subscriber {
465
+ /** The `seq` of the wire subscription before the first event of this subscriber. */
466
+ base = 0;
467
+ /** The error that ends this subscriber, after the entries of its queue. */
468
+ failure: SubscriptionError | undefined;
469
+ /** This function tells the shared subscription that the subscriber waits. */
470
+ onWait: () => void = () => undefined;
471
+ private readonly queue: Entry[] = [];
472
+ private events = 0;
473
+ private waiter: ((entry: Entry | undefined) => void) | undefined;
474
+ private ended = false;
475
+
476
+ constructor(readonly id: string) {}
477
+
478
+ /** True when the subscriber waits for an entry. */
479
+ get waiting(): boolean {
480
+ return this.waiter !== undefined;
481
+ }
482
+
483
+ /** This function gives the subscriber one event of the wire, with its own `seq`. */
484
+ pushEvent(event: WireEvent): void {
485
+ this.push({ kind: "event", topic: event.topic, data: event.data, seq: event.seq - this.base, dropped: event.dropped ?? 0 });
486
+ }
487
+
488
+ /** This function gives the subscriber one entry. A full queue drops its oldest event. */
489
+ push(entry: Entry): void {
490
+ if (this.ended) return;
491
+ const waiter = this.waiter;
492
+ if (waiter) {
493
+ this.waiter = undefined;
494
+ waiter(entry);
495
+ return;
496
+ }
497
+ if (entry.kind === "event") {
498
+ if (this.events >= SUBSCRIBER_BUFFER) {
499
+ // The next event of the same wire stream tells the loss. After a confirmation, a new stream starts.
500
+ const index = this.queue.findIndex((queued) => queued.kind === "event");
501
+ const lost = this.queue.splice(index, 1)[0] as Extract<Entry, { kind: "event" }>;
502
+ this.events--;
503
+ const next = index < this.queue.length ? this.queue[index]! : entry;
504
+ if (next.kind === "event") next.dropped += 1 + lost.dropped;
505
+ }
506
+ this.events++;
507
+ }
508
+ this.queue.push(entry);
509
+ }
510
+
511
+ /** The next entry, or undefined at the end. */
512
+ take(): Promise<Entry | undefined> {
513
+ const entry = this.queue.shift();
514
+ if (entry) {
515
+ if (entry.kind === "event") this.events--;
516
+ return Promise.resolve(entry);
517
+ }
518
+ if (this.ended) return Promise.resolve(undefined);
519
+ return new Promise((resolve) => {
520
+ this.waiter = resolve;
521
+ this.onWait();
522
+ });
523
+ }
524
+
525
+ /**
526
+ * This function ends the subscriber. Without `discard`, it first gives the entries of its queue.
527
+ * The shared subscription ends it with its error, and an unsubscribe ends it with `discard`.
528
+ */
529
+ end(failure?: SubscriptionError, discard = false): void {
530
+ if (this.ended) return;
531
+ this.ended = true;
532
+ this.failure = failure;
533
+ if (discard) {
534
+ this.queue.length = 0;
535
+ this.events = 0;
536
+ }
537
+ const waiter = this.waiter;
538
+ this.waiter = undefined;
539
+ waiter?.(undefined);
540
+ }
541
+ }
542
+
543
+ /**
544
+ * One wire subscription and its subscribers. It reads the next item of the wire only when a
545
+ * subscriber waits, so a single slow subscriber holds the credit, as before the share. It ends
546
+ * when its last subscriber leaves, or with the end of the wire subscription for each subscriber.
547
+ */
548
+ class SharedSubscription {
549
+ private readonly members = new Set<Subscriber>();
550
+ private readonly stop = new AbortController();
551
+ /** The topics of the current confirmation, or undefined before it and during a resubscribe. */
552
+ private confirmed: readonly string[] | undefined;
553
+ /** The `seq` of the last event of the wire. */
554
+ private lastSeq = 0;
555
+ private demand: (() => void) | undefined;
556
+ private started = false;
557
+ private finished = false;
558
+
559
+ constructor(
560
+ private readonly shares: Map<string, SharedSubscription> | undefined,
561
+ private readonly key: string,
562
+ private readonly transport: EventTransport,
563
+ private readonly call: Omit<WireCall, "onSubscribed" | "onRetry">,
564
+ ) {}
565
+
566
+ /** True while a new subscriber can join. */
567
+ get open(): boolean {
568
+ return !this.finished && !this.stop.signal.aborted;
569
+ }
570
+
571
+ /** This function adds a subscriber. On a confirmed subscription, it gets the confirmation at once. */
572
+ join(subscriber: Subscriber): void {
573
+ this.members.add(subscriber);
574
+ subscriber.base = this.lastSeq;
575
+ subscriber.onWait = () => this.wake();
576
+ if (this.confirmed) subscriber.push({ kind: "subscribed", topics: this.confirmed });
577
+ if (!this.started) {
578
+ this.started = true;
579
+ void this.run();
580
+ }
581
+ }
582
+
583
+ /** This function removes a subscriber. The last one ends the wire subscription: the transport sends the cancel. */
584
+ leave(subscriber: Subscriber): void {
585
+ if (!this.members.delete(subscriber) || this.members.size > 0) return;
586
+ this.forget();
587
+ this.stop.abort();
588
+ this.wake();
589
+ }
590
+
591
+ private wake(): void {
592
+ const demand = this.demand;
593
+ this.demand = undefined;
594
+ demand?.();
595
+ }
596
+
597
+ /** This function removes the subscription from the shares of its transport. */
598
+ private forget(): void {
599
+ if (this.shares?.get(this.key) === this) this.shares.delete(this.key);
600
+ }
601
+
602
+ private stopped(): boolean {
603
+ return this.stop.signal.aborted || this.call.hooks.lifetime?.aborted === true;
604
+ }
605
+
606
+ private wanted(): boolean {
607
+ for (const member of this.members) if (member.waiting) return true;
608
+ return false;
609
+ }
610
+
611
+ private async run(): Promise<void> {
612
+ const events = wireEvents(
613
+ this.transport,
614
+ {
615
+ ...this.call,
616
+ onSubscribed: (topics) => {
617
+ this.confirmed = topics;
618
+ this.lastSeq = 0;
619
+ for (const member of this.members) {
620
+ member.base = 0;
621
+ member.push({ kind: "subscribed", topics });
622
+ }
623
+ },
624
+ onRetry: () => {
625
+ this.confirmed = undefined;
626
+ },
627
+ },
628
+ this.stop.signal,
629
+ );
630
+ // The end of the transport ends the wait for a subscriber too
631
+ const lifetime = this.call.hooks.lifetime;
632
+ const onLifetime = (): void => this.wake();
633
+ lifetime?.addEventListener("abort", onLifetime, { once: true });
634
+ let failure: SubscriptionError | undefined;
635
+ try {
636
+ for (;;) {
637
+ while (!this.stopped() && !this.wanted()) {
638
+ await new Promise<void>((resolve) => {
639
+ this.demand = resolve;
640
+ });
641
+ }
642
+ if (this.stopped()) break;
643
+ const result = await events.next();
644
+ if (result.done) break;
645
+ this.lastSeq = result.value.seq;
646
+ for (const member of this.members) member.pushEvent(result.value);
647
+ }
648
+ } catch (error) {
649
+ failure = errorOfFailure(error);
650
+ } finally {
651
+ this.finished = true;
652
+ this.forget();
653
+ lifetime?.removeEventListener("abort", onLifetime);
654
+ await events.return(undefined).catch(() => undefined);
655
+ }
656
+ // The end of the wire subscription goes to each subscriber
657
+ for (const member of [...this.members]) member.end(failure);
658
+ this.members.clear();
659
+ }
660
+ }
661
+
662
+ /** The shared subscriptions of each transport, by key. */
663
+ const sharesOf = new WeakMap<EventTransport, Map<string, SharedSubscription>>();
664
+
665
+ /** The key of a shared subscription: the set of topics, the metadata and the resubscribe rules. */
666
+ function keyOf(topics: readonly string[], metadata: Record<string, unknown>, policy: ResubscribePolicy): string | undefined {
667
+ try {
668
+ return JSON.stringify([[...new Set(topics)].sort(), metadata, policy.key]);
669
+ } catch {
670
+ // Metadata that is not JSON: the subscription is not shared
671
+ return undefined;
672
+ }
673
+ }
674
+
675
+ /** The shared subscription of a new subscriber: an open one with the same key, or a new one with the id of the subscriber. */
676
+ function sharedFor(
677
+ transport: EventTransport,
678
+ topics: readonly string[],
679
+ options: RemoteEventsOptions,
680
+ hooks: SubscriptionHooks,
681
+ policy: ResubscribePolicy,
682
+ id: string,
683
+ ): SharedSubscription {
684
+ const metadata = { ...(options.metadata ?? {}) };
685
+ const key = keyOf(topics, metadata, policy);
686
+ let shares = sharesOf.get(transport);
687
+ if (!shares) {
688
+ shares = new Map();
689
+ sharesOf.set(transport, shares);
690
+ }
691
+ const existing = key === undefined ? undefined : shares.get(key);
692
+ if (existing?.open) return existing;
693
+ const shared = new SharedSubscription(key === undefined ? undefined : shares, key ?? "", transport, { topics, metadata, policy, hooks, id });
694
+ if (key !== undefined) shares.set(key, shared);
695
+ return shared;
696
+ }
697
+
698
+ /** The event that a subscriber gets from an entry of its queue. */
699
+ function remoteEventOf<T>(entry: Extract<Entry, { kind: "event" }>, id: string): RemoteEvent<T> {
700
+ return { topic: entry.topic, data: entry.data as T, seq: entry.seq, dropped: entry.dropped, subscription: id };
701
+ }
702
+
703
+ /** This function calls `onSubscribed`. An error of it goes to `console.error`, and the subscription goes on. */
704
+ function confirmTo(options: RemoteEventsOptions, topics: readonly string[]): void {
705
+ try {
706
+ options.onSubscribed?.(topics);
707
+ } catch (error) {
708
+ console.error("[events] The onSubscribed handler failed:", error);
709
+ }
710
+ }
711
+
712
+ /** This function joins a new subscriber to its shared subscription, and gives the function that leaves it. */
713
+ function attach(
714
+ subscriber: Subscriber,
715
+ transport: EventTransport,
716
+ topics: readonly string[],
717
+ options: RemoteEventsOptions,
718
+ hooks: SubscriptionHooks,
719
+ policy: ResubscribePolicy,
720
+ ): () => void {
721
+ if (options.signal?.aborted) {
722
+ subscriber.end(undefined, true);
723
+ return () => undefined;
724
+ }
725
+ const shared = sharedFor(transport, topics, options, hooks, policy, subscriber.id);
726
+ let left = false;
727
+ const leave = (): void => {
728
+ if (left) return;
729
+ left = true;
730
+ options.signal?.removeEventListener("abort", leave);
731
+ shared.leave(subscriber);
732
+ subscriber.end(undefined, true);
733
+ };
734
+ options.signal?.addEventListener("abort", leave, { once: true });
735
+ shared.join(subscriber);
736
+ return leave;
737
+ }
738
+
284
739
  /**
285
740
  * This function gives the events of a subscription to topics of the bus of the other side. The
286
- * iteration sends the call. A `break` of the reader, or the abort of the signal, sends the cancel.
287
- * An error that ends the subscription ends the iteration with a `SubscriptionError`.
741
+ * iteration joins the subscription, so it sends the call when no subscriber shares one. A `break`
742
+ * of the reader, or the abort of the signal, leaves it: the last subscriber sends the cancel. An
743
+ * error that ends the subscription ends the iteration with a `SubscriptionError`.
288
744
  *
289
745
  * @param transport - The transport to the publisher: HTTP, WebSocket, a message port, local
290
746
  * @param topics - One topic, or a list of 1 to 64 topics
291
- * @param options - The signal, the resubscribe, the backoff and the confirmation handler
747
+ * @param options - The signal, the resubscribe rules, the clock and the confirmation handler
292
748
  * @param hooks - The parts that a transport adds (its connection, its backoff, its `onEvent`)
749
+ * @throws TypeError - When `backoff` or `maxAttempts` is not valid, for example a plain number of milliseconds
293
750
  */
294
751
  export function remoteEvents<T = unknown>(
295
752
  transport: EventTransport,
@@ -298,8 +755,52 @@ export function remoteEvents<T = unknown>(
298
755
  hooks: SubscriptionHooks = {},
299
756
  ): AsyncIterable<RemoteEvent<T>> {
300
757
  const list = topicList(topics);
758
+ const policy = policyOf(options, hooks);
301
759
  return {
302
- [Symbol.asyncIterator]: () => eventsOf<T>(transport, list, options, hooks, `events-${++subscriptions}`),
760
+ [Symbol.asyncIterator]: (): AsyncIterableIterator<RemoteEvent<T>> => {
761
+ const subscriber = new Subscriber(`events-${++subscriptions}`);
762
+ let leave: (() => void) | undefined;
763
+ let stopped = false;
764
+ const stop = (): void => {
765
+ stopped = true;
766
+ if (leave) leave();
767
+ else subscriber.end(undefined, true);
768
+ };
769
+ const iterator: AsyncIterableIterator<RemoteEvent<T>> = {
770
+ async next(): Promise<IteratorResult<RemoteEvent<T>>> {
771
+ if (!leave && !stopped) leave = attach(subscriber, transport, list, options, hooks, policy);
772
+ for (;;) {
773
+ const entry = await subscriber.take();
774
+ if (!entry) {
775
+ const failure = subscriber.failure;
776
+ subscriber.failure = undefined;
777
+ stop();
778
+ if (failure) throw failure;
779
+ return { value: undefined, done: true };
780
+ }
781
+ if (entry.kind === "subscribed") {
782
+ confirmTo(options, entry.topics);
783
+ continue;
784
+ }
785
+ const event = remoteEventOf<T>(entry, subscriber.id);
786
+ hooks.onEvent?.(event as RemoteEvent);
787
+ return { value: event, done: false };
788
+ }
789
+ },
790
+ async return(): Promise<IteratorResult<RemoteEvent<T>>> {
791
+ stop();
792
+ return { value: undefined, done: true };
793
+ },
794
+ async throw(error?: unknown): Promise<IteratorResult<RemoteEvent<T>>> {
795
+ stop();
796
+ throw error;
797
+ },
798
+ [Symbol.asyncIterator]() {
799
+ return iterator;
800
+ },
801
+ };
802
+ return iterator;
803
+ },
303
804
  };
304
805
  }
305
806
 
@@ -307,12 +808,14 @@ export function remoteEvents<T = unknown>(
307
808
  * This function subscribes to topics of the bus of the other side, and calls the handler for each
308
809
  * event in order. It gives the unsubscribe function, with the `ready` promise of the first
309
810
  * confirmation. An error of the handler goes to `console.error`, and the subscription goes on.
811
+ * The subscribers of the same topics with the same options on one transport share one call.
310
812
  *
311
813
  * @param transport - The transport to the publisher
312
814
  * @param topics - One topic, or a list of 1 to 64 topics
313
815
  * @param handler - The handler of each event: it gets the data and the event
314
816
  * @param options - The options of `remoteEvents()`, and the `onError` handler of the end
315
817
  * @param hooks - The parts that a transport adds
818
+ * @throws TypeError - When `backoff` or `maxAttempts` is not valid, for example a plain number of milliseconds
316
819
  */
317
820
  export function subscribeRemote<T = unknown>(
318
821
  transport: EventTransport,
@@ -321,8 +824,9 @@ export function subscribeRemote<T = unknown>(
321
824
  options: SubscribeOptions = {},
322
825
  hooks: SubscriptionHooks = {},
323
826
  ): Subscription {
324
- const controller = new AbortController();
325
- const id = `events-${++subscriptions}`;
827
+ const list = topicList(topics);
828
+ const policy = policyOf(options, hooks);
829
+ const subscriber = new Subscriber(`events-${++subscriptions}`);
326
830
  let confirmed = false;
327
831
  let confirm: () => void = () => undefined;
328
832
  let refuse: (error: SubscriptionError) => void = () => undefined;
@@ -332,38 +836,36 @@ export function subscribeRemote<T = unknown>(
332
836
  });
333
837
  // A caller that does not wait for `ready` gets no unhandled rejection
334
838
  ready.catch(() => undefined);
335
- const { signal, unlink } = linkSignals([options.signal, controller.signal]);
336
- const settings: RemoteEventsOptions = {
337
- ...options,
338
- signal,
339
- onSubscribed: (list) => {
340
- if (!confirmed) {
341
- confirmed = true;
342
- confirm();
343
- }
344
- options.onSubscribed?.(list);
345
- },
346
- };
839
+ const leave = attach(subscriber, transport, list, options, hooks, policy);
347
840
 
348
841
  void (async () => {
349
842
  try {
350
- for await (const event of { [Symbol.asyncIterator]: () => eventsOf<T>(transport, topicList(topics), settings, hooks, id) }) {
843
+ for (;;) {
844
+ const entry = await subscriber.take();
845
+ if (!entry) break;
846
+ if (entry.kind === "subscribed") {
847
+ if (!confirmed) {
848
+ confirmed = true;
849
+ confirm();
850
+ }
851
+ confirmTo(options, entry.topics);
852
+ continue;
853
+ }
854
+ const event = remoteEventOf<T>(entry, subscriber.id);
855
+ hooks.onEvent?.(event as RemoteEvent);
351
856
  try {
352
857
  await handler(event.data, event);
353
858
  } catch (error) {
354
859
  console.error(`[events] The handler of ${event.topic} failed:`, error);
355
860
  }
356
861
  }
357
- if (!confirmed) refuse(new SubscriptionError("ABORTED", "The subscription ended before its confirmation", false));
358
- } catch (error) {
359
- const failure = errorOfFailure(error);
360
- if (!confirmed) refuse(failure);
361
- options.onError?.(failure);
362
862
  } finally {
363
- unlink();
863
+ leave();
364
864
  }
865
+ const failure = subscriber.failure;
866
+ if (!confirmed) refuse(failure ?? new SubscriptionError("ABORTED", "The subscription ended before its confirmation", false));
867
+ if (failure) options.onError?.(failure);
365
868
  })();
366
869
 
367
- const unsubscribe = (): void => controller.abort();
368
- return Object.assign(unsubscribe, { ready, id });
870
+ return Object.assign(leave, { ready, id: subscriber.id });
369
871
  }