@procedures/client 0.4.0 → 0.6.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 (110) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +46 -1
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/adapters/http/client/transport.d.ts +2 -1
  5. package/dist/adapters/http/client/transport.d.ts.map +1 -1
  6. package/dist/adapters/http/client/transport.js +9 -2
  7. package/dist/adapters/http/client/transport.js.map +1 -1
  8. package/dist/adapters/http/server/transport.d.ts +3 -1
  9. package/dist/adapters/http/server/transport.d.ts.map +1 -1
  10. package/dist/adapters/http/server/transport.js +6 -3
  11. package/dist/adapters/http/server/transport.js.map +1 -1
  12. package/dist/adapters/local/client/transport.d.ts +2 -1
  13. package/dist/adapters/local/client/transport.d.ts.map +1 -1
  14. package/dist/adapters/local/client/transport.js +8 -2
  15. package/dist/adapters/local/client/transport.js.map +1 -1
  16. package/dist/adapters/local/client/types.d.ts +6 -0
  17. package/dist/adapters/local/client/types.d.ts.map +1 -1
  18. package/dist/adapters/local/client/types.js.map +1 -1
  19. package/dist/adapters/sequence.d.ts +36 -0
  20. package/dist/adapters/sequence.d.ts.map +1 -0
  21. package/dist/adapters/sequence.js +55 -0
  22. package/dist/adapters/sequence.js.map +1 -0
  23. package/dist/adapters/websocket/client/transport.d.ts +39 -1
  24. package/dist/adapters/websocket/client/transport.d.ts.map +1 -1
  25. package/dist/adapters/websocket/client/transport.js +102 -7
  26. package/dist/adapters/websocket/client/transport.js.map +1 -1
  27. package/dist/adapters/websocket/client/types.d.ts +12 -3
  28. package/dist/adapters/websocket/client/types.d.ts.map +1 -1
  29. package/dist/adapters/websocket/client/types.js.map +1 -1
  30. package/dist/adapters/websocket/server/transport.d.ts +22 -1
  31. package/dist/adapters/websocket/server/transport.d.ts.map +1 -1
  32. package/dist/adapters/websocket/server/transport.js +29 -1
  33. package/dist/adapters/websocket/server/transport.js.map +1 -1
  34. package/dist/adapters/websocket/server/types.d.ts +6 -0
  35. package/dist/adapters/websocket/server/types.d.ts.map +1 -1
  36. package/dist/adapters/websocket/shared/rpc.d.ts +4 -3
  37. package/dist/adapters/websocket/shared/rpc.d.ts.map +1 -1
  38. package/dist/adapters/websocket/shared/rpc.js +18 -9
  39. package/dist/adapters/websocket/shared/rpc.js.map +1 -1
  40. package/dist/browser.d.ts +3 -0
  41. package/dist/browser.d.ts.map +1 -1
  42. package/dist/browser.js +4 -0
  43. package/dist/browser.js.map +1 -1
  44. package/dist/client/errors/registry.d.ts.map +1 -1
  45. package/dist/client/errors/registry.js +1 -0
  46. package/dist/client/errors/registry.js.map +1 -1
  47. package/dist/events/index.d.ts +4 -0
  48. package/dist/events/index.d.ts.map +1 -1
  49. package/dist/events/index.js +3 -0
  50. package/dist/events/index.js.map +1 -1
  51. package/dist/events/publisher.d.ts +95 -0
  52. package/dist/events/publisher.d.ts.map +1 -0
  53. package/dist/events/publisher.js +268 -0
  54. package/dist/events/publisher.js.map +1 -0
  55. package/dist/events/remote.d.ts +164 -0
  56. package/dist/events/remote.d.ts.map +1 -0
  57. package/dist/events/remote.js +250 -0
  58. package/dist/events/remote.js.map +1 -0
  59. package/dist/procedures/collection/index.d.ts +1 -1
  60. package/dist/procedures/collection/index.d.ts.map +1 -1
  61. package/dist/procedures/collection/index.js.map +1 -1
  62. package/dist/procedures/collection/procedures.d.ts.map +1 -1
  63. package/dist/procedures/collection/procedures.js +6 -1
  64. package/dist/procedures/collection/procedures.js.map +1 -1
  65. package/dist/procedures/collection/schemas.d.ts +16 -1
  66. package/dist/procedures/collection/schemas.d.ts.map +1 -1
  67. package/dist/procedures/collection/schemas.js +13 -2
  68. package/dist/procedures/collection/schemas.js.map +1 -1
  69. package/dist/procedures/collection/storage.d.ts +14 -2
  70. package/dist/procedures/collection/storage.d.ts.map +1 -1
  71. package/dist/procedures/invoke.d.ts +4 -2
  72. package/dist/procedures/invoke.d.ts.map +1 -1
  73. package/dist/procedures/invoke.js +1 -1
  74. package/dist/procedures/invoke.js.map +1 -1
  75. package/dist/protocol.d.ts +1 -1
  76. package/dist/protocol.d.ts.map +1 -1
  77. package/dist/protocol.js +1 -0
  78. package/dist/protocol.js.map +1 -1
  79. package/dist/server/host-policy.d.ts +11 -0
  80. package/dist/server/host-policy.d.ts.map +1 -1
  81. package/dist/server/host-policy.js +5 -0
  82. package/dist/server/host-policy.js.map +1 -1
  83. package/dist/server/procedure-server.d.ts +24 -2
  84. package/dist/server/procedure-server.d.ts.map +1 -1
  85. package/dist/server/procedure-server.js +34 -4
  86. package/dist/server/procedure-server.js.map +1 -1
  87. package/package.json +3 -3
  88. package/src/adapters/http/client/transport.ts +9 -2
  89. package/src/adapters/http/server/transport.ts +6 -3
  90. package/src/adapters/local/client/transport.ts +8 -2
  91. package/src/adapters/local/client/types.ts +7 -0
  92. package/src/adapters/sequence.ts +55 -0
  93. package/src/adapters/websocket/client/transport.ts +108 -7
  94. package/src/adapters/websocket/client/types.ts +13 -3
  95. package/src/adapters/websocket/server/transport.ts +43 -1
  96. package/src/adapters/websocket/server/types.ts +7 -0
  97. package/src/adapters/websocket/shared/rpc.ts +19 -9
  98. package/src/browser.ts +15 -0
  99. package/src/client/errors/registry.ts +1 -0
  100. package/src/events/index.ts +16 -0
  101. package/src/events/publisher.ts +314 -0
  102. package/src/events/remote.ts +369 -0
  103. package/src/procedures/collection/index.ts +1 -0
  104. package/src/procedures/collection/procedures.ts +6 -1
  105. package/src/procedures/collection/schemas.ts +28 -2
  106. package/src/procedures/collection/storage.ts +14 -2
  107. package/src/procedures/invoke.ts +4 -2
  108. package/src/protocol.ts +3 -1
  109. package/src/server/host-policy.ts +14 -0
  110. package/src/server/procedure-server.ts +41 -6
@@ -0,0 +1,314 @@
1
+ /**
2
+ * The publisher of the remote events (`documentation/EVENTS.md`).
3
+ *
4
+ * An `EventPublisher` serves the procedure `events.subscribe` from an event bus. A host gives it
5
+ * to `ProcedureServer` (the option `events`) or to `LocalTransport`. Each subscription is a stream
6
+ * without an end: its first item confirms the listeners, and each other item is one event of the
7
+ * bus.
8
+ *
9
+ * - The topic rule (`topics`) is the expose rule of the topics. A topic outside it gives
10
+ * `TOPIC_NOT_FOUND`.
11
+ * - `allow` examines each topic with the capability of the call. A refusal gives `FORBIDDEN` with
12
+ * the reason `topic-not-allowed`.
13
+ * - The procedure declares that it reads the resource `events`, so the capability of the host
14
+ * applies to it as to each procedure.
15
+ *
16
+ * The delivery is at most once. The queue of a subscription holds at most `buffer` events. When
17
+ * it is full, the publisher drops the oldest event, and the next event tells the number of lost
18
+ * events (`dropped`). The bus never waits for a subscriber.
19
+ */
20
+
21
+ import { z } from "zod";
22
+ import { createProcedure } from "../procedures/define.js";
23
+ import { InvocationError } from "../procedures/invoke.js";
24
+ import type { AnyProcedure, Capability, ProcedureContext } from "../procedures/types.js";
25
+ import type { EventBus, Unsubscribe } from "./types.js";
26
+ import { getGlobalEventBus } from "./bus.js";
27
+ import { EVENTS_SUBSCRIBE_PATH, type EventItem, type SubscribeInput } from "./remote.js";
28
+
29
+ /** The number of topics that one subscription can name. */
30
+ export const MAX_SUBSCRIPTION_TOPICS = 64;
31
+
32
+ /** The number of characters of a topic name. */
33
+ export const MAX_TOPIC_LENGTH = 256;
34
+
35
+ /**
36
+ * The topics that a publisher serves: a list of patterns, or a function. A pattern is an exact
37
+ * name (`orders.created`), a prefix that ends with `*` (`orders.*`), or `*` for each topic.
38
+ */
39
+ export type TopicRule = readonly string[] | ((topic: string) => boolean);
40
+
41
+ /** What `allow` knows about the call of a subscription. */
42
+ export interface TopicCall {
43
+ /** The capability of the call: its clearance, its permitted effects and its deadline. */
44
+ readonly capability: Capability;
45
+ /** The metadata of the call: the connection id and the headers of a network request. */
46
+ readonly metadata: Readonly<Record<string, unknown>>;
47
+ }
48
+
49
+ /** The options of an `EventPublisher`. */
50
+ export interface EventPublisherOptions {
51
+ /** The bus that the subscriptions read. The default is the global bus of `getGlobalEventBus()`. */
52
+ bus?: EventBus | undefined;
53
+ /** The topics that a peer can subscribe to. A topic outside the rule gives `TOPIC_NOT_FOUND`. */
54
+ topics: TopicRule;
55
+ /**
56
+ * This function tells if a call can subscribe to a topic of the rule, for example from the
57
+ * clearance of its capability. A refusal gives `FORBIDDEN` with the reason `topic-not-allowed`.
58
+ * Without it, each call can subscribe to each topic of the rule.
59
+ */
60
+ allow?: ((topic: string, call: TopicCall) => boolean) | undefined;
61
+ /**
62
+ * The number of events that one subscription holds for a slow subscriber. When the queue is
63
+ * full, the publisher drops the oldest event.
64
+ * @default 1024
65
+ */
66
+ buffer?: number | undefined;
67
+ }
68
+
69
+ /** This function gives the test of a topic rule. */
70
+ export function topicMatcher(rule: TopicRule): (topic: string) => boolean {
71
+ if (typeof rule === "function") return rule;
72
+ const exact = new Set<string>();
73
+ const prefixes: string[] = [];
74
+ let all = false;
75
+ for (const pattern of rule) {
76
+ if (pattern === "*") all = true;
77
+ else if (pattern.endsWith("*")) prefixes.push(pattern.slice(0, -1));
78
+ else exact.add(pattern);
79
+ }
80
+ return (topic) => all || exact.has(topic) || prefixes.some((prefix) => topic.startsWith(prefix));
81
+ }
82
+
83
+ /** The error of a topic outside the topic rule. */
84
+ class TopicNotFoundError extends Error {
85
+ override readonly name = "TopicNotFoundError";
86
+ readonly code = "TOPIC_NOT_FOUND";
87
+ readonly retryable = false;
88
+ }
89
+
90
+ /** The error that ends each subscription when the publisher stops. A resubscribe can succeed later. */
91
+ function stoppedError(): Error {
92
+ return Object.assign(new Error("The publisher of the events stops"), { code: "UNAVAILABLE", retryable: true });
93
+ }
94
+
95
+ const topicSchema = z.string().min(1).max(MAX_TOPIC_LENGTH);
96
+
97
+ const subscribeInput = z.object({
98
+ topics: z.array(topicSchema).min(1).max(MAX_SUBSCRIPTION_TOPICS).describe("The topics of the subscription"),
99
+ });
100
+
101
+ const eventItemSchema = z.discriminatedUnion("type", [
102
+ z.object({
103
+ type: z.literal("subscribed"),
104
+ topics: z.array(z.string()).describe("The topics that the publisher listens to for this subscription"),
105
+ }),
106
+ z.object({
107
+ type: z.literal("event"),
108
+ topic: z.string(),
109
+ data: z.unknown(),
110
+ seq: z.number().int().describe("The number of the event in the subscription, from 1"),
111
+ dropped: z.number().int().optional().describe("The events that the publisher dropped immediately before this one"),
112
+ }),
113
+ ]);
114
+
115
+ /**
116
+ * One subscription: the listeners on the bus and the queue of its events. It is the async
117
+ * iterator of the stream of the call. `return()`, the abort of the signal of the call and
118
+ * `fail()` remove the listeners.
119
+ */
120
+ class TopicSubscription implements AsyncIterableIterator<EventItem> {
121
+ private confirmation: EventItem | undefined;
122
+ private readonly queue: Array<Extract<EventItem, { type: "event" }>> = [];
123
+ private seq = 0;
124
+ private dropped = 0;
125
+ private waiter: { resolve: (result: IteratorResult<EventItem>) => void; reject: (error: unknown) => void } | undefined;
126
+ private closed = false;
127
+ private failure: unknown;
128
+ private readonly unsubscribes: Unsubscribe[];
129
+
130
+ constructor(
131
+ bus: EventBus,
132
+ topics: readonly string[],
133
+ private readonly buffer: number,
134
+ private readonly signal: AbortSignal | undefined,
135
+ private readonly onClose: (subscription: TopicSubscription) => void,
136
+ ) {
137
+ this.confirmation = { type: "subscribed", topics };
138
+ this.unsubscribes = topics.map((topic) => bus.on(topic, (data) => this.push(topic, data)));
139
+ signal?.addEventListener("abort", this.onAbort, { once: true });
140
+ }
141
+
142
+ /** This function closes a subscription whose call aborted before it started. */
143
+ closeIfAborted(): this {
144
+ if (this.signal?.aborted) this.close();
145
+ return this;
146
+ }
147
+
148
+ private readonly onAbort = (): void => this.close();
149
+
150
+ /** This function takes one event of the bus. It never waits. */
151
+ private push(topic: string, data: unknown): void {
152
+ if (this.closed) return;
153
+ const event = { type: "event" as const, topic, data, seq: ++this.seq };
154
+ const waiter = this.waiter;
155
+ if (waiter && this.queue.length === 0) {
156
+ this.waiter = undefined;
157
+ waiter.resolve({ value: this.withDropped(event), done: false });
158
+ return;
159
+ }
160
+ if (this.queue.length >= this.buffer) {
161
+ // A slow subscriber: the oldest event goes, and the next event tells the loss
162
+ this.queue.shift();
163
+ this.dropped++;
164
+ }
165
+ this.queue.push(event);
166
+ }
167
+
168
+ /** The event with the number of events that the publisher dropped before it. */
169
+ private withDropped(event: Extract<EventItem, { type: "event" }>): EventItem {
170
+ if (this.dropped === 0) return event;
171
+ const dropped = this.dropped;
172
+ this.dropped = 0;
173
+ return { ...event, dropped };
174
+ }
175
+
176
+ next(): Promise<IteratorResult<EventItem>> {
177
+ if (this.confirmation) {
178
+ const confirmation = this.confirmation;
179
+ this.confirmation = undefined;
180
+ return Promise.resolve({ value: confirmation, done: false });
181
+ }
182
+ const event = this.queue.shift();
183
+ if (event) return Promise.resolve({ value: this.withDropped(event), done: false });
184
+ if (this.failure !== undefined) {
185
+ const failure = this.failure;
186
+ this.failure = undefined;
187
+ return Promise.reject(failure);
188
+ }
189
+ if (this.closed) return Promise.resolve({ value: undefined, done: true });
190
+ return new Promise((resolve, reject) => {
191
+ this.waiter = { resolve, reject };
192
+ });
193
+ }
194
+
195
+ return(): Promise<IteratorResult<EventItem>> {
196
+ this.close();
197
+ return Promise.resolve({ value: undefined, done: true });
198
+ }
199
+
200
+ [Symbol.asyncIterator](): AsyncIterableIterator<EventItem> {
201
+ return this;
202
+ }
203
+
204
+ /** This function ends the subscription with an error: the reader gets it at its next item. */
205
+ fail(error: unknown): void {
206
+ if (this.closed) return;
207
+ const waiter = this.waiter;
208
+ this.waiter = undefined;
209
+ this.release();
210
+ this.queue.length = 0;
211
+ this.confirmation = undefined;
212
+ if (waiter) waiter.reject(error);
213
+ else this.failure = error;
214
+ }
215
+
216
+ /** This function ends the subscription without an error. */
217
+ close(): void {
218
+ if (this.closed) return;
219
+ const waiter = this.waiter;
220
+ this.waiter = undefined;
221
+ this.release();
222
+ this.queue.length = 0;
223
+ this.confirmation = undefined;
224
+ waiter?.resolve({ value: undefined, done: true });
225
+ }
226
+
227
+ /** This function removes the listeners from the bus and from the signal. */
228
+ private release(): void {
229
+ this.closed = true;
230
+ for (const unsubscribe of this.unsubscribes) unsubscribe();
231
+ this.signal?.removeEventListener("abort", this.onAbort);
232
+ this.onClose(this);
233
+ }
234
+ }
235
+
236
+ /**
237
+ * The publisher of the remote events: it serves `events.subscribe` from a bus, under a topic
238
+ * rule. `ProcedureServer` makes one from its option `events`.
239
+ *
240
+ * @example
241
+ * ```typescript
242
+ * const publisher = new EventPublisher({ bus, topics: ["orders.*"] });
243
+ * const server = new ProcedureServer({ registry, autoRegister: true, events: publisher });
244
+ * publisher.publish("orders.created", { order: 1 });
245
+ * ```
246
+ */
247
+ export class EventPublisher {
248
+ /** The bus that the subscriptions read. */
249
+ readonly bus: EventBus;
250
+ /** The procedure `events.subscribe` of this publisher. A host pins it, outside its registry. */
251
+ readonly procedure: AnyProcedure;
252
+ private readonly known: (topic: string) => boolean;
253
+ private readonly allow: EventPublisherOptions["allow"];
254
+ private readonly buffer: number;
255
+ private readonly open = new Set<TopicSubscription>();
256
+
257
+ constructor(options: EventPublisherOptions) {
258
+ this.bus = options.bus ?? getGlobalEventBus();
259
+ this.known = topicMatcher(options.topics);
260
+ this.allow = options.allow;
261
+ this.buffer = Math.max(1, Math.floor(options.buffer ?? 1024));
262
+ this.procedure = createProcedure()
263
+ .path([...EVENTS_SUBSCRIBE_PATH])
264
+ .input(subscribeInput)
265
+ .output(eventItemSchema)
266
+ .meta({
267
+ description: "A subscription to topics of the event bus of the host: a stream without an end, with one item for each event",
268
+ effects: { reads: ["events"], writes: [] },
269
+ stream: { unbounded: true },
270
+ })
271
+ .handler((async (input: SubscribeInput, ctx: ProcedureContext) => this.subscribe(input.topics, ctx)) as never)
272
+ .build() as AnyProcedure;
273
+ }
274
+
275
+ /** This function publishes an event on the bus: each subscription of the topic gets it. */
276
+ publish<T>(topic: string, data: T): void {
277
+ this.bus.emit(topic, data);
278
+ }
279
+
280
+ /** The number of open subscriptions. */
281
+ get subscriptions(): number {
282
+ return this.open.size;
283
+ }
284
+
285
+ /**
286
+ * This function ends each open subscription with a retryable `UNAVAILABLE`: the publisher stops.
287
+ * A subscriber resubscribes after its backoff. `ProcedureServer.stop()` calls it first.
288
+ */
289
+ end(): void {
290
+ for (const subscription of [...this.open]) subscription.fail(stoppedError());
291
+ }
292
+
293
+ /**
294
+ * The stream of one subscription. The publisher examines each topic before it adds a listener:
295
+ * the topic rule first, then `allow`. A call with one bad topic makes no subscription.
296
+ */
297
+ private subscribe(topics: readonly string[], ctx: ProcedureContext): TopicSubscription {
298
+ const unique = [...new Set(topics)];
299
+ for (const topic of unique) {
300
+ if (!this.known(topic)) throw new TopicNotFoundError(`The host has no topic "${topic}"`);
301
+ }
302
+ if (this.allow) {
303
+ const call: TopicCall = { capability: ctx.capability, metadata: ctx.metadata };
304
+ for (const topic of unique) {
305
+ if (!this.allow(topic, call)) {
306
+ throw new InvocationError("FORBIDDEN", `The capability of the call does not permit the topic "${topic}"`, ctx.path, "topic-not-allowed");
307
+ }
308
+ }
309
+ }
310
+ const subscription = new TopicSubscription(this.bus, unique, this.buffer, ctx.signal, (closed) => this.open.delete(closed));
311
+ this.open.add(subscription);
312
+ return subscription.closeIfAborted();
313
+ }
314
+ }
@@ -0,0 +1,369 @@
1
+ /**
2
+ * The subscriber of the remote events (`documentation/EVENTS.md`).
3
+ *
4
+ * A peer subscribes to the topics of the bus of another peer with a streaming call of the
5
+ * procedure `events.subscribe`. The first item of the stream confirms the subscription, and each
6
+ * other item is one event. The frames are the frames of each call: credit is the backpressure,
7
+ * and a cancel is the unsubscribe. Thus each transport with streams carries a subscription:
8
+ * WebSocket in the two directions, HTTP with NDJSON, a message port and the local transport.
9
+ *
10
+ * - `remoteEvents()` gives an async iterator of the events.
11
+ * - `subscribeRemote()` calls a handler for each event, and gives the unsubscribe function.
12
+ *
13
+ * 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.
16
+ *
17
+ * This file imports only types and the backoff, so a page or a worker that subscribes stays small.
18
+ */
19
+
20
+ import type { Message, Method, ResponseItem, Transport } from "../client/types.js";
21
+ import { backoffDelay } from "@mark1russell7/service/backoff";
22
+
23
+ /** The path of the procedure of a subscription. */
24
+ export const EVENTS_SUBSCRIBE_PATH: readonly ["events", "subscribe"] = Object.freeze(["events", "subscribe"] as const);
25
+
26
+ /** The method of the procedure of a subscription. */
27
+ const SUBSCRIBE_METHOD: Method = Object.freeze({ service: "events", operation: "subscribe" });
28
+
29
+ /** The input of `events.subscribe`. */
30
+ export interface SubscribeInput {
31
+ /** The topics: from 1 to 64 names of 1 to 256 characters. */
32
+ readonly topics: readonly string[];
33
+ }
34
+
35
+ /**
36
+ * An item of the stream of `events.subscribe`. The first item is `subscribed`. Each other item is
37
+ * an `event`. A subscriber ignores an item of another type: it comes from a newer publisher.
38
+ */
39
+ export type EventItem =
40
+ | { readonly type: "subscribed"; readonly topics: readonly string[] }
41
+ | {
42
+ readonly type: "event";
43
+ readonly topic: string;
44
+ readonly data: unknown;
45
+ /** The number of the event in its subscription, from 1. A gap shows lost events. */
46
+ readonly seq: number;
47
+ /** The number of events that the publisher dropped immediately before this one. */
48
+ readonly dropped?: number | undefined;
49
+ };
50
+
51
+ /** One event that a subscriber gets. */
52
+ export interface RemoteEvent<T = unknown> {
53
+ /** The topic (the channel of the bus of the publisher). */
54
+ readonly topic: string;
55
+ /** The data of the event, after a trip as JSON. */
56
+ readonly data: T;
57
+ /** The number of the event in its subscription, from 1. A resubscribe starts again at 1. */
58
+ readonly seq: number;
59
+ /** The number of events that the publisher dropped immediately before this one: 0 without a loss. */
60
+ readonly dropped: number;
61
+ /** The id of the subscription. It stays the same after a resubscribe. */
62
+ readonly subscription: string;
63
+ }
64
+
65
+ /**
66
+ * 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.
68
+ */
69
+ export type RemoteEventHandler<T = unknown> = (data: T, event: RemoteEvent<T>) => void | Promise<void>;
70
+
71
+ /** The options of a subscription. */
72
+ export interface RemoteEventsOptions {
73
+ /** When this signal aborts, the subscription ends: the transport sends a cancel. */
74
+ signal?: AbortSignal | undefined;
75
+ /**
76
+ * True: the subscriber sends the call again after a failure that a retry can repair. False: such
77
+ * a failure ends the subscription.
78
+ * @default true
79
+ */
80
+ resubscribe?: boolean | undefined;
81
+ /**
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.
85
+ */
86
+ backoff?: ((attempt: number) => number) | undefined;
87
+ /** This function gets the topics of each confirmation: the first one, and one after each resubscribe. */
88
+ onSubscribed?: ((topics: readonly string[]) => void) | undefined;
89
+ /** The metadata of the call, for example a trace. */
90
+ metadata?: Record<string, unknown> | undefined;
91
+ }
92
+
93
+ /** The options of `subscribeRemote()`. */
94
+ export interface SubscribeOptions extends RemoteEventsOptions {
95
+ /**
96
+ * 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.
99
+ */
100
+ onError?: ((error: SubscriptionError) => void) | undefined;
101
+ }
102
+
103
+ /**
104
+ * The unsubscribe function of a subscription. A second call does nothing. `ready` resolves at the
105
+ * first confirmation of the publisher. It rejects when the subscription ends before it.
106
+ */
107
+ export interface Subscription {
108
+ (): void;
109
+ /** This promise resolves when the publisher confirms the subscription the first time. */
110
+ readonly ready: Promise<void>;
111
+ /** The id of the subscription: the `subscription` of its events. */
112
+ readonly id: string;
113
+ }
114
+
115
+ /** The error that ends a subscription. */
116
+ export class SubscriptionError extends Error {
117
+ override readonly name = "SubscriptionError";
118
+
119
+ constructor(
120
+ /** The code: a code of the protocol, for example `TOPIC_NOT_FOUND`. */
121
+ readonly code: string,
122
+ message: string,
123
+ /** True when a resubscribe can repair the failure. */
124
+ readonly retryable: boolean,
125
+ /** The reason of a `FORBIDDEN` error, for example `topic-not-allowed`. */
126
+ readonly reason?: string | undefined,
127
+ ) {
128
+ super(message);
129
+ }
130
+ }
131
+
132
+ /**
133
+ * 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.
136
+ */
137
+ export interface SubscriptionHooks {
138
+ /** When this signal aborts, each subscription ends without an error. */
139
+ readonly lifetime?: AbortSignal | undefined;
140
+ /**
141
+ * This function resolves when the transport can send the call. It rejects when the transport
142
+ * closed. After a lost connection, the subscriber waits for it, not for a backoff.
143
+ */
144
+ readonly ready?: ((signal: AbortSignal) => Promise<void>) | undefined;
145
+ /** The backoff, when the options give none. */
146
+ readonly backoff?: ((attempt: number) => number) | undefined;
147
+ /** This function gets each event of each subscription. */
148
+ readonly onEvent?: ((event: RemoteEvent) => void) | undefined;
149
+ /** False: no resubscribe, whatever the options say (a server cannot reach a closed connection). */
150
+ readonly resubscribe?: boolean | undefined;
151
+ }
152
+
153
+ /** The part of a transport that a subscription uses. */
154
+ export type EventTransport = Pick<Transport, "send">;
155
+
156
+ /** The codes after which a resubscribe can succeed, also when the item does not say retryable. */
157
+ const RESUBSCRIBE_CODES: ReadonlySet<string> = new Set(["UNAVAILABLE", "TIMEOUT", "CONNECTION_CLOSED", "ABORTED"]);
158
+
159
+ /** The default backoff: from 250 ms, twice as long at each try, at most 30 s, with full jitter. */
160
+ function defaultBackoff(attempt: number): number {
161
+ return backoffDelay({ base: 250, factor: 2, cap: 30_000, jitter: { kind: "subtract", fraction: 1 } }, attempt);
162
+ }
163
+
164
+ let subscriptions = 0;
165
+
166
+ /** The topics of a subscription as a list. */
167
+ function topicList(topics: string | readonly string[]): readonly string[] {
168
+ return typeof topics === "string" ? [topics] : [...topics];
169
+ }
170
+
171
+ /** A signal that aborts when one of the signals aborts, and a function that removes its listeners. */
172
+ function linkSignals(signals: ReadonlyArray<AbortSignal | undefined>): { signal: AbortSignal; unlink: () => void } {
173
+ const controller = new AbortController();
174
+ const present = signals.filter((signal): signal is AbortSignal => signal !== undefined);
175
+ const abort = (): void => controller.abort();
176
+ if (present.some((signal) => signal.aborted)) controller.abort();
177
+ else for (const signal of present) signal.addEventListener("abort", abort, { once: true });
178
+ return {
179
+ signal: controller.signal,
180
+ unlink: () => {
181
+ for (const signal of present) signal.removeEventListener("abort", abort);
182
+ },
183
+ };
184
+ }
185
+
186
+ /** This function waits for a time, or until the signal aborts. */
187
+ function pause(ms: number, signal: AbortSignal): Promise<void> {
188
+ if (signal.aborted || !(ms > 0)) return Promise.resolve();
189
+ return new Promise((resolve) => {
190
+ const done = (): void => {
191
+ clearTimeout(timer);
192
+ signal.removeEventListener("abort", done);
193
+ resolve();
194
+ };
195
+ const timer = setTimeout(done, ms);
196
+ signal.addEventListener("abort", done, { once: true });
197
+ });
198
+ }
199
+
200
+ /** The error of an error item of the call. */
201
+ function errorOfItem(item: ResponseItem<unknown>): SubscriptionError {
202
+ const status = item.status;
203
+ if (status.type !== "error") return new SubscriptionError("INTERNAL_ERROR", "The item is not an error", false);
204
+ const code = String(status.code);
205
+ const reason = item.metadata?.["reason"];
206
+ return new SubscriptionError(code, status.message, status.retryable === true || RESUBSCRIBE_CODES.has(code), typeof reason === "string" ? reason : undefined);
207
+ }
208
+
209
+ /** The error of a call that failed in the transport: the connection closed. A resubscribe can repair it. */
210
+ function errorOfFailure(error: unknown): SubscriptionError {
211
+ if (error instanceof SubscriptionError) return error;
212
+ const message = error instanceof Error ? error.message : String(error);
213
+ return new SubscriptionError("CONNECTION_CLOSED", message, true);
214
+ }
215
+
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]);
225
+ const resubscribe = hooks.resubscribe !== false && options.resubscribe !== false;
226
+ const backoff = options.backoff ?? hooks.backoff ?? defaultBackoff;
227
+ let attempt = 0;
228
+ let tries = 0;
229
+ try {
230
+ for (;;) {
231
+ if (signal.aborted) return;
232
+ if (hooks.ready) {
233
+ await hooks.ready(signal);
234
+ if (signal.aborted) return;
235
+ }
236
+ const message: Message<SubscribeInput> = {
237
+ id: `${id}.${++tries}`,
238
+ method: SUBSCRIBE_METHOD,
239
+ payload: { topics: [...topics] },
240
+ metadata: { ...(options.metadata ?? {}) },
241
+ signal,
242
+ };
243
+ let failure: { error: SubscriptionError; lost: boolean } | undefined;
244
+ try {
245
+ for await (const item of transport.send<SubscribeInput, EventItem>(message)) {
246
+ if (item.status.type === "error") {
247
+ failure = { error: errorOfItem(item), lost: false };
248
+ break;
249
+ }
250
+ const payload = item.payload as EventItem | undefined;
251
+ if (payload === null || typeof payload !== "object") continue;
252
+ if (payload.type === "subscribed") {
253
+ attempt = 0;
254
+ options.onSubscribed?.(payload.topics);
255
+ } 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;
265
+ }
266
+ }
267
+ } catch (error) {
268
+ failure = { error: errorOfFailure(error), lost: true };
269
+ }
270
+ // An unsubscribe, or the end of the transport: no error
271
+ if (signal.aborted) return;
272
+ // The publisher ended the subscription
273
+ if (!failure) return;
274
+ if (!resubscribe || !failure.error.retryable) throw failure.error;
275
+ attempt++;
276
+ // After a lost connection, the transport waits for its reconnect: no second backoff
277
+ if (!(failure.lost && hooks.ready)) await pause(backoff(attempt), signal);
278
+ }
279
+ } finally {
280
+ unlink();
281
+ }
282
+ }
283
+
284
+ /**
285
+ * 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`.
288
+ *
289
+ * @param transport - The transport to the publisher: HTTP, WebSocket, a message port, local
290
+ * @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
292
+ * @param hooks - The parts that a transport adds (its connection, its backoff, its `onEvent`)
293
+ */
294
+ export function remoteEvents<T = unknown>(
295
+ transport: EventTransport,
296
+ topics: string | readonly string[],
297
+ options: RemoteEventsOptions = {},
298
+ hooks: SubscriptionHooks = {},
299
+ ): AsyncIterable<RemoteEvent<T>> {
300
+ const list = topicList(topics);
301
+ return {
302
+ [Symbol.asyncIterator]: () => eventsOf<T>(transport, list, options, hooks, `events-${++subscriptions}`),
303
+ };
304
+ }
305
+
306
+ /**
307
+ * This function subscribes to topics of the bus of the other side, and calls the handler for each
308
+ * event in order. It gives the unsubscribe function, with the `ready` promise of the first
309
+ * confirmation. An error of the handler goes to `console.error`, and the subscription goes on.
310
+ *
311
+ * @param transport - The transport to the publisher
312
+ * @param topics - One topic, or a list of 1 to 64 topics
313
+ * @param handler - The handler of each event: it gets the data and the event
314
+ * @param options - The options of `remoteEvents()`, and the `onError` handler of the end
315
+ * @param hooks - The parts that a transport adds
316
+ */
317
+ export function subscribeRemote<T = unknown>(
318
+ transport: EventTransport,
319
+ topics: string | readonly string[],
320
+ handler: RemoteEventHandler<T>,
321
+ options: SubscribeOptions = {},
322
+ hooks: SubscriptionHooks = {},
323
+ ): Subscription {
324
+ const controller = new AbortController();
325
+ const id = `events-${++subscriptions}`;
326
+ let confirmed = false;
327
+ let confirm: () => void = () => undefined;
328
+ let refuse: (error: SubscriptionError) => void = () => undefined;
329
+ const ready = new Promise<void>((resolve, reject) => {
330
+ confirm = resolve;
331
+ refuse = reject;
332
+ });
333
+ // A caller that does not wait for `ready` gets no unhandled rejection
334
+ 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
+ };
347
+
348
+ void (async () => {
349
+ try {
350
+ for await (const event of { [Symbol.asyncIterator]: () => eventsOf<T>(transport, topicList(topics), settings, hooks, id) }) {
351
+ try {
352
+ await handler(event.data, event);
353
+ } catch (error) {
354
+ console.error(`[events] The handler of ${event.topic} failed:`, error);
355
+ }
356
+ }
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
+ } finally {
363
+ unlink();
364
+ }
365
+ })();
366
+
367
+ const unsubscribe = (): void => controller.abort();
368
+ return Object.assign(unsubscribe, { ready, id });
369
+ }
@@ -37,6 +37,7 @@ export {
37
37
 
38
38
  export type {
39
39
  GetInput,
40
+ GetFoundOutput,
40
41
  SetInput,
41
42
  DeleteInput,
42
43
  HasInput,