@procedures/client 0.3.0 → 0.5.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 (196) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +62 -7
  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 +8 -6
  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 +12 -4
  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/node.d.ts +30 -0
  24. package/dist/adapters/websocket/client/node.d.ts.map +1 -0
  25. package/dist/adapters/websocket/client/node.js +32 -0
  26. package/dist/adapters/websocket/client/node.js.map +1 -0
  27. package/dist/adapters/websocket/client/transport.d.ts +39 -1
  28. package/dist/adapters/websocket/client/transport.d.ts.map +1 -1
  29. package/dist/adapters/websocket/client/transport.js +119 -16
  30. package/dist/adapters/websocket/client/transport.js.map +1 -1
  31. package/dist/adapters/websocket/client/types.d.ts +15 -5
  32. package/dist/adapters/websocket/client/types.d.ts.map +1 -1
  33. package/dist/adapters/websocket/client/types.js.map +1 -1
  34. package/dist/adapters/websocket/server/transport.d.ts +22 -1
  35. package/dist/adapters/websocket/server/transport.d.ts.map +1 -1
  36. package/dist/adapters/websocket/server/transport.js +29 -1
  37. package/dist/adapters/websocket/server/transport.js.map +1 -1
  38. package/dist/adapters/websocket/server/types.d.ts +6 -0
  39. package/dist/adapters/websocket/server/types.d.ts.map +1 -1
  40. package/dist/adapters/websocket/shared/rpc.d.ts +4 -3
  41. package/dist/adapters/websocket/shared/rpc.d.ts.map +1 -1
  42. package/dist/adapters/websocket/shared/rpc.js +18 -9
  43. package/dist/adapters/websocket/shared/rpc.js.map +1 -1
  44. package/dist/browser.d.ts +3 -0
  45. package/dist/browser.d.ts.map +1 -1
  46. package/dist/browser.js +4 -0
  47. package/dist/browser.js.map +1 -1
  48. package/dist/client/errors/http-errors.d.ts.map +1 -1
  49. package/dist/client/errors/http-errors.js +6 -0
  50. package/dist/client/errors/http-errors.js.map +1 -1
  51. package/dist/client/errors/index.d.ts +1 -1
  52. package/dist/client/errors/index.js +1 -1
  53. package/dist/client/errors/registry.d.ts +2 -1
  54. package/dist/client/errors/registry.d.ts.map +1 -1
  55. package/dist/client/errors/registry.js +73 -22
  56. package/dist/client/errors/registry.js.map +1 -1
  57. package/dist/client/middleware/rate-limit.d.ts.map +1 -1
  58. package/dist/client/middleware/rate-limit.js +0 -1
  59. package/dist/client/middleware/rate-limit.js.map +1 -1
  60. package/dist/client/middleware/timeout.d.ts.map +1 -1
  61. package/dist/client/middleware/timeout.js +2 -9
  62. package/dist/client/middleware/timeout.js.map +1 -1
  63. package/dist/events/index.d.ts +4 -0
  64. package/dist/events/index.d.ts.map +1 -1
  65. package/dist/events/index.js +3 -0
  66. package/dist/events/index.js.map +1 -1
  67. package/dist/events/publisher.d.ts +95 -0
  68. package/dist/events/publisher.d.ts.map +1 -0
  69. package/dist/events/publisher.js +268 -0
  70. package/dist/events/publisher.js.map +1 -0
  71. package/dist/events/remote.d.ts +164 -0
  72. package/dist/events/remote.d.ts.map +1 -0
  73. package/dist/events/remote.js +250 -0
  74. package/dist/events/remote.js.map +1 -0
  75. package/dist/index.d.ts +4 -0
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +4 -0
  78. package/dist/index.js.map +1 -1
  79. package/dist/node.d.ts +11 -0
  80. package/dist/node.d.ts.map +1 -0
  81. package/dist/node.js +10 -0
  82. package/dist/node.js.map +1 -0
  83. package/dist/procedures/collection/index.d.ts +1 -1
  84. package/dist/procedures/collection/index.d.ts.map +1 -1
  85. package/dist/procedures/collection/index.js.map +1 -1
  86. package/dist/procedures/collection/procedures.d.ts.map +1 -1
  87. package/dist/procedures/collection/procedures.js +6 -1
  88. package/dist/procedures/collection/procedures.js.map +1 -1
  89. package/dist/procedures/collection/schemas.d.ts +16 -1
  90. package/dist/procedures/collection/schemas.d.ts.map +1 -1
  91. package/dist/procedures/collection/schemas.js +13 -2
  92. package/dist/procedures/collection/schemas.js.map +1 -1
  93. package/dist/procedures/collection/storage.d.ts +14 -2
  94. package/dist/procedures/collection/storage.d.ts.map +1 -1
  95. package/dist/procedures/core/array.d.ts +1 -1
  96. package/dist/procedures/core/array.d.ts.map +1 -1
  97. package/dist/procedures/core/array.js +56 -15
  98. package/dist/procedures/core/array.js.map +1 -1
  99. package/dist/procedures/core/index.d.ts.map +1 -1
  100. package/dist/procedures/core/index.js +9 -2
  101. package/dist/procedures/core/index.js.map +1 -1
  102. package/dist/procedures/core/logic.js +1 -1
  103. package/dist/procedures/core/logic.js.map +1 -1
  104. package/dist/procedures/core/math.d.ts.map +1 -1
  105. package/dist/procedures/core/math.js +5 -1
  106. package/dist/procedures/core/math.js.map +1 -1
  107. package/dist/procedures/core/meta.d.ts.map +1 -1
  108. package/dist/procedures/core/meta.js +41 -7
  109. package/dist/procedures/core/meta.js.map +1 -1
  110. package/dist/procedures/core/type.d.ts.map +1 -1
  111. package/dist/procedures/core/type.js +4 -2
  112. package/dist/procedures/core/type.js.map +1 -1
  113. package/dist/procedures/discovery.d.ts +4 -6
  114. package/dist/procedures/discovery.d.ts.map +1 -1
  115. package/dist/procedures/discovery.js +4 -6
  116. package/dist/procedures/discovery.js.map +1 -1
  117. package/dist/procedures/invoke.d.ts +4 -2
  118. package/dist/procedures/invoke.d.ts.map +1 -1
  119. package/dist/procedures/invoke.js +1 -1
  120. package/dist/procedures/invoke.js.map +1 -1
  121. package/dist/procedures/ref.d.ts +4 -2
  122. package/dist/procedures/ref.d.ts.map +1 -1
  123. package/dist/procedures/ref.js +2 -1
  124. package/dist/procedures/ref.js.map +1 -1
  125. package/dist/protocol.d.ts +10 -3
  126. package/dist/protocol.d.ts.map +1 -1
  127. package/dist/protocol.js +13 -2
  128. package/dist/protocol.js.map +1 -1
  129. package/dist/server/host-policy.d.ts +11 -0
  130. package/dist/server/host-policy.d.ts.map +1 -1
  131. package/dist/server/host-policy.js +5 -0
  132. package/dist/server/host-policy.js.map +1 -1
  133. package/dist/server/procedure-server.d.ts +26 -3
  134. package/dist/server/procedure-server.d.ts.map +1 -1
  135. package/dist/server/procedure-server.js +38 -6
  136. package/dist/server/procedure-server.js.map +1 -1
  137. package/package.json +7 -6
  138. package/src/adapters/http/client/transport.ts +9 -2
  139. package/src/adapters/http/server/transport.ts +8 -6
  140. package/src/adapters/local/client/transport.ts +12 -4
  141. package/src/adapters/local/client/types.ts +7 -0
  142. package/src/adapters/sequence.ts +55 -0
  143. package/src/adapters/websocket/client/node.ts +34 -0
  144. package/src/adapters/websocket/client/transport.ts +128 -16
  145. package/src/adapters/websocket/client/types.ts +16 -5
  146. package/src/adapters/websocket/server/transport.ts +43 -1
  147. package/src/adapters/websocket/server/types.ts +7 -0
  148. package/src/adapters/websocket/shared/rpc.ts +19 -9
  149. package/src/browser.ts +15 -0
  150. package/src/client/errors/README.md +2 -0
  151. package/src/client/errors/http-errors.ts +5 -0
  152. package/src/client/errors/index.ts +1 -1
  153. package/src/client/errors/registry.ts +80 -22
  154. package/src/client/middleware/rate-limit.ts +0 -2
  155. package/src/client/middleware/timeout.ts +2 -9
  156. package/src/events/index.ts +16 -0
  157. package/src/events/publisher.ts +314 -0
  158. package/src/events/remote.ts +369 -0
  159. package/src/index.ts +4 -0
  160. package/src/node.ts +11 -0
  161. package/src/procedures/collection/index.ts +1 -0
  162. package/src/procedures/collection/procedures.ts +6 -1
  163. package/src/procedures/collection/schemas.ts +28 -2
  164. package/src/procedures/collection/storage.ts +14 -2
  165. package/src/procedures/core/array.ts +53 -16
  166. package/src/procedures/core/index.ts +9 -2
  167. package/src/procedures/core/logic.ts +1 -1
  168. package/src/procedures/core/math.ts +5 -1
  169. package/src/procedures/core/meta.ts +43 -7
  170. package/src/procedures/core/type.ts +4 -2
  171. package/src/procedures/discovery.ts +4 -6
  172. package/src/procedures/invoke.ts +4 -2
  173. package/src/procedures/ref.ts +4 -2
  174. package/src/protocol.ts +16 -3
  175. package/src/server/host-policy.ts +14 -0
  176. package/src/server/procedure-server.ts +45 -8
  177. package/dist/procedures/cli/announce.d.ts +0 -67
  178. package/dist/procedures/cli/announce.d.ts.map +0 -1
  179. package/dist/procedures/cli/announce.js +0 -392
  180. package/dist/procedures/cli/announce.js.map +0 -1
  181. package/dist/procedures/cli/discover.d.ts +0 -66
  182. package/dist/procedures/cli/discover.d.ts.map +0 -1
  183. package/dist/procedures/cli/discover.js +0 -310
  184. package/dist/procedures/cli/discover.js.map +0 -1
  185. package/dist/procedures/cli/index.d.ts +0 -13
  186. package/dist/procedures/cli/index.d.ts.map +0 -1
  187. package/dist/procedures/cli/index.js +0 -203
  188. package/dist/procedures/cli/index.js.map +0 -1
  189. package/dist/procedures/cli/init.d.ts +0 -17
  190. package/dist/procedures/cli/init.d.ts.map +0 -1
  191. package/dist/procedures/cli/init.js +0 -336
  192. package/dist/procedures/cli/init.js.map +0 -1
  193. package/src/procedures/cli/announce.ts +0 -470
  194. package/src/procedures/cli/discover.ts +0 -390
  195. package/src/procedures/cli/index.ts +0 -231
  196. package/src/procedures/cli/init.ts +0 -370
@@ -15,6 +15,7 @@ import {
15
15
  } from "./types.js";
16
16
  import { HTTP_ERROR_REGISTRY } from "./http-errors.js";
17
17
  import { ErrorCode } from "./codes.js";
18
+ import { ERROR_CODES, type ProtocolErrorCode } from "../../protocol.js";
18
19
 
19
20
  /**
20
21
  * Create error metadata with defaults.
@@ -47,12 +48,72 @@ function defineError(
47
48
  return result;
48
49
  }
49
50
 
51
+ /** The texts of an entry. For a code of the protocol, `ERROR_CODES` gives the status and the retry flag. */
52
+ type ProtocolErrorTexts = Omit<ErrorMetadata, "code" | "httpStatus" | "retryable">;
53
+
50
54
  /**
51
- * Universal Error Registry
52
- *
53
- * Protocol-agnostic error codes with rich metadata.
55
+ * This function makes the entry of a code of the wire protocol. The HTTP status and the retry
56
+ * flag come from `ERROR_CODES` (`protocol.ts`, `WIRE-PROTOCOL.md`), the source of these facts.
57
+ */
58
+ function defineProtocolError(code: ProtocolErrorCode, texts: ProtocolErrorTexts): ErrorMetadata {
59
+ const info = ERROR_CODES[code];
60
+ return defineError(code, { ...texts, httpStatus: info.http, retryable: info.retryable });
61
+ }
62
+
63
+ /** The category of each code of the protocol, for a code that the registry does not describe. */
64
+ const PROTOCOL_CATEGORIES: Readonly<Record<ProtocolErrorCode, ErrorCategory>> = {
65
+ NOT_FOUND: ErrorCategory.CLIENT,
66
+ NO_HANDLER: ErrorCategory.SERVER,
67
+ NOT_EXPOSED: ErrorCategory.AUTH,
68
+ NOT_DECLARED: ErrorCategory.AUTH,
69
+ FORBIDDEN: ErrorCategory.AUTH,
70
+ UNAUTHORIZED: ErrorCategory.AUTH,
71
+ VALIDATION_ERROR: ErrorCategory.CLIENT,
72
+ INVALID_REQUEST: ErrorCategory.PROTOCOL,
73
+ PROTOCOL_VERSION: ErrorCategory.PROTOCOL,
74
+ OUTPUT_VALIDATION_ERROR: ErrorCategory.SERVER,
75
+ NO_OUTPUT: ErrorCategory.SERVER,
76
+ HANDLER_ERROR: ErrorCategory.SERVER,
77
+ INTERNAL_ERROR: ErrorCategory.SERVER,
78
+ NOT_ACCEPTABLE: ErrorCategory.CLIENT,
79
+ DUPLICATE: ErrorCategory.CLIENT,
80
+ DUPLICATE_REQUEST: ErrorCategory.CLIENT,
81
+ ABORTED: ErrorCategory.CANCELLED,
82
+ TIMEOUT: ErrorCategory.TIMEOUT,
83
+ UNAVAILABLE: ErrorCategory.SERVER,
84
+ CONNECTION_CLOSED: ErrorCategory.NETWORK,
85
+ CONNECTION_NOT_FOUND: ErrorCategory.CLIENT,
86
+ TOPIC_NOT_FOUND: ErrorCategory.CLIENT,
87
+ };
88
+
89
+ /**
90
+ * This function gives the registry with one entry for each code of the protocol. The status and
91
+ * the retry flag of such an entry come from `ERROR_CODES`, so the two lists agree (before, the
92
+ * registry gave `TIMEOUT` the status 408 and two codes another retry flag). A code without texts
93
+ * here gets the meaning of the protocol as its texts.
94
+ */
95
+ function withProtocolCodes(described: ErrorRegistry): ErrorRegistry {
96
+ const registry: ErrorRegistry = { ...described };
97
+ for (const code of Object.keys(ERROR_CODES) as ProtocolErrorCode[]) {
98
+ const info = ERROR_CODES[code];
99
+ const meaning = `${info.meaning}.`;
100
+ const texts: ProtocolErrorTexts = described[code] ?? {
101
+ summary: info.meaning,
102
+ detail: meaning,
103
+ category: PROTOCOL_CATEGORIES[code],
104
+ severity: info.retryable ? ErrorSeverity.WARNING : ErrorSeverity.ERROR,
105
+ userMessage: meaning,
106
+ devMessage: meaning,
107
+ };
108
+ registry[code] = { ...texts, code, httpStatus: info.http, retryable: info.retryable };
109
+ }
110
+ return registry;
111
+ }
112
+
113
+ /**
114
+ * The entries with their own texts. `ERROR_REGISTRY` adds the codes of the protocol.
54
115
  */
55
- export const ERROR_REGISTRY: ErrorRegistry = {
116
+ const DESCRIBED_ERRORS = {
56
117
  //
57
118
  // ═══ Network Errors ═══
58
119
  //
@@ -108,16 +169,14 @@ export const ERROR_REGISTRY: ErrorRegistry = {
108
169
  // ═══ Timeout Errors ═══
109
170
  //
110
171
 
111
- TIMEOUT: defineError(ErrorCode.TIMEOUT, {
172
+ TIMEOUT: defineProtocolError("TIMEOUT", {
112
173
  summary: "Request timed out",
113
174
  detail:
114
175
  "The request exceeded the configured timeout duration. This may indicate slow server response, network congestion, or overly aggressive timeout settings.",
115
176
  category: ErrorCategory.TIMEOUT,
116
- retryable: true,
117
177
  severity: ErrorSeverity.WARNING,
118
178
  userMessage: "The request took too long to complete. Please try again.",
119
179
  devMessage: "Request exceeded timeout threshold. Consider increasing timeout or optimizing server response time.",
120
- httpStatus: 408,
121
180
  suggestions: [
122
181
  "Try again with a longer timeout",
123
182
  "Check server performance",
@@ -159,11 +218,10 @@ export const ERROR_REGISTRY: ErrorRegistry = {
159
218
  // ═══ Cancellation Errors ═══
160
219
  //
161
220
 
162
- ABORTED: defineError(ErrorCode.ABORTED, {
221
+ ABORTED: defineProtocolError("ABORTED", {
163
222
  summary: "Request was aborted",
164
223
  detail: "The request was explicitly cancelled by the client via AbortSignal. This is user-initiated.",
165
224
  category: ErrorCategory.CANCELLED,
166
- retryable: false,
167
225
  severity: ErrorSeverity.INFO,
168
226
  userMessage: "The request was cancelled.",
169
227
  devMessage: "Request aborted via AbortSignal. This is expected and not an error condition.",
@@ -269,15 +327,13 @@ export const ERROR_REGISTRY: ErrorRegistry = {
269
327
  ],
270
328
  }),
271
329
 
272
- UNAUTHORIZED: defineError(ErrorCode.UNAUTHORIZED, {
330
+ UNAUTHORIZED: defineProtocolError("UNAUTHORIZED", {
273
331
  summary: "Unauthorized",
274
332
  detail: "Authentication is required but was not provided or is invalid.",
275
333
  category: ErrorCategory.AUTH,
276
- retryable: false,
277
334
  severity: ErrorSeverity.ERROR,
278
335
  userMessage: "Authentication required. Please sign in.",
279
336
  devMessage: "Missing or invalid authentication credentials. Check auth token/API key.",
280
- httpStatus: 401,
281
337
  suggestions: [
282
338
  "Provide valid authentication credentials",
283
339
  "Check token hasn't expired",
@@ -285,15 +341,13 @@ export const ERROR_REGISTRY: ErrorRegistry = {
285
341
  ],
286
342
  }),
287
343
 
288
- FORBIDDEN: defineError(ErrorCode.FORBIDDEN, {
344
+ FORBIDDEN: defineProtocolError("FORBIDDEN", {
289
345
  summary: "Forbidden",
290
346
  detail: "The authenticated user doesn't have permission to access this resource.",
291
347
  category: ErrorCategory.AUTH,
292
- retryable: false,
293
348
  severity: ErrorSeverity.ERROR,
294
349
  userMessage: "You don't have permission to access this resource.",
295
350
  devMessage: "Insufficient permissions. Check user roles and resource ACLs.",
296
- httpStatus: 403,
297
351
  suggestions: [
298
352
  "Verify user has required permissions",
299
353
  "Check resource access control lists",
@@ -301,15 +355,13 @@ export const ERROR_REGISTRY: ErrorRegistry = {
301
355
  ],
302
356
  }),
303
357
 
304
- NOT_FOUND: defineError(ErrorCode.NOT_FOUND, {
358
+ NOT_FOUND: defineProtocolError("NOT_FOUND", {
305
359
  summary: "Not found",
306
360
  detail: "The requested resource does not exist.",
307
361
  category: ErrorCategory.CLIENT,
308
- retryable: false,
309
362
  severity: ErrorSeverity.ERROR,
310
363
  userMessage: "The requested item was not found.",
311
364
  devMessage: "Resource not found. Verify ID/path is correct and resource exists.",
312
- httpStatus: 404,
313
365
  suggestions: [
314
366
  "Verify the resource ID is correct",
315
367
  "Check the resource hasn't been deleted",
@@ -402,17 +454,15 @@ export const ERROR_REGISTRY: ErrorRegistry = {
402
454
  // ═══ Server Errors (5xx) ═══
403
455
  //
404
456
 
405
- INTERNAL_ERROR: defineError(ErrorCode.INTERNAL_ERROR, {
457
+ INTERNAL_ERROR: defineProtocolError("INTERNAL_ERROR", {
406
458
  summary: "Internal server error",
407
459
  detail: "The server encountered an unexpected condition. This is a server-side bug.",
408
460
  category: ErrorCategory.SERVER,
409
- retryable: true,
410
461
  severity: ErrorSeverity.CRITICAL,
411
462
  userMessage: "Server error occurred. Please try again later.",
412
463
  devMessage: "Internal server error. Check server logs for details.",
413
- httpStatus: 500,
464
+ // The protocol says that a retry does not help (ERROR_CODES: retryable false)
414
465
  suggestions: [
415
- "Retry the request",
416
466
  "Check server logs",
417
467
  "Contact server team",
418
468
  ],
@@ -554,6 +604,14 @@ export const ERROR_REGISTRY: ErrorRegistry = {
554
604
  ...HTTP_ERROR_REGISTRY,
555
605
  } as const satisfies ErrorRegistry;
556
606
 
607
+ /**
608
+ * Universal Error Registry
609
+ *
610
+ * Protocol-agnostic error codes with rich metadata. Each code of the wire protocol has an entry,
611
+ * and `ERROR_CODES` gives its HTTP status and its retry flag.
612
+ */
613
+ export const ERROR_REGISTRY: ErrorRegistry = withProtocolCodes(DESCRIBED_ERRORS);
614
+
557
615
  /**
558
616
  * Get error metadata by code.
559
617
  *
@@ -83,7 +83,6 @@ export class RateLimitError extends Error {
83
83
  */
84
84
  interface QueuedRequest {
85
85
  resolve: () => void;
86
- reject: (error: Error) => void;
87
86
  timestamp: number;
88
87
  }
89
88
 
@@ -208,7 +207,6 @@ export function createRateLimitMiddleware(
208
207
  signal?.removeEventListener("abort", onAbort);
209
208
  resolve(true);
210
209
  },
211
- reject: () => resolve(false),
212
210
  timestamp: Date.now(),
213
211
  };
214
212
  // A queued request whose caller aborts leaves the queue (before, it kept its place)
@@ -170,19 +170,12 @@ async function* runWithTimeout<TReq, TRes>(
170
170
  phase = "done";
171
171
  return;
172
172
  }
173
- if (result.value.status.type === "error" && timedOut()) {
174
- // The transport saw the aborted signal and reported ABORTED: it is a timeout
175
- yield timeoutItem();
176
- return;
177
- }
173
+ // An item or an error of the transport comes before the abort. `nextOrAbort` adds its abort
174
+ // listener before it calls `next()`, so a later abort gives ABORTED_FIRST, not an item.
178
175
  yield result.value;
179
176
  }
180
177
  } catch (error) {
181
178
  phase = "done";
182
- if (timedOut()) {
183
- yield timeoutItem();
184
- return;
185
- }
186
179
  throw error;
187
180
  } finally {
188
181
  clearTimeout(timeoutId);
@@ -55,6 +55,22 @@ export {
55
55
  withEventBusSignal,
56
56
  } from "./bus.js";
57
57
 
58
+ // The remote events: a peer subscribes to the topics of the bus of another peer (EVENTS.md)
59
+ export { EventPublisher, topicMatcher, MAX_SUBSCRIPTION_TOPICS, MAX_TOPIC_LENGTH } from "./publisher.js";
60
+ export type { EventPublisherOptions, TopicRule, TopicCall } from "./publisher.js";
61
+ export { EVENTS_SUBSCRIBE_PATH, SubscriptionError, remoteEvents, subscribeRemote } from "./remote.js";
62
+ export type {
63
+ EventItem,
64
+ EventTransport,
65
+ RemoteEvent,
66
+ RemoteEventHandler,
67
+ RemoteEventsOptions,
68
+ SubscribeInput,
69
+ SubscribeOptions,
70
+ Subscription,
71
+ SubscriptionHooks,
72
+ } from "./remote.js";
73
+
58
74
  // Utilities
59
75
  export {
60
76
  createChannelPrefix,
@@ -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
+ }