@procedures/client 0.4.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 (110) hide show
  1. package/CHANGELOG.md +39 -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
@@ -33,6 +33,7 @@ import {
33
33
  deleteBatchInputSchema,
34
34
  deleteBatchOutputSchema,
35
35
  type GetInput,
36
+ type GetFoundOutput,
36
37
  type SetInput,
37
38
  type DeleteInput,
38
39
  type HasInput,
@@ -107,7 +108,11 @@ export function createCollectionProcedures(collectionName: string): AnyProcedure
107
108
  },
108
109
  handler: async (input: GetInput, ctx : ProcedureContext) => {
109
110
  const storage = getStorage(ctx);
110
- return storage.get(input.id);
111
+ const value = await storage.get(input.id);
112
+ if (input.found !== true) return value;
113
+ // The output with `found` keeps a stored null apart from a missing id, in one call
114
+ const output: GetFoundOutput = value === undefined ? { found: false } : { found: true, value };
115
+ return output;
111
116
  },
112
117
  }),
113
118
 
@@ -117,6 +117,12 @@ export const valueSchema : ZodLike<unknown> = createSchema<unknown>((data) => {
117
117
  */
118
118
  export interface GetInput {
119
119
  id: string;
120
+ /**
121
+ * True: the output is `{ found, value }` (a `GetFoundOutput`), so a stored `null` and a missing
122
+ * id give different outputs. Without it, the output is the item, or `null` on the wire for a
123
+ * missing id. `ApiStorage` of `@procedures/store` sends it.
124
+ */
125
+ found?: boolean;
120
126
  }
121
127
 
122
128
  export const getInputSchema : ZodLike<GetInput> = createSchema<GetInput>((data) => {
@@ -138,11 +144,31 @@ export const getInputSchema : ZodLike<GetInput> = createSchema<GetInput>((data)
138
144
  },
139
145
  };
140
146
  }
141
- return { success: true, data: { id: data["id"] } };
147
+ const found = data["found"];
148
+ if (found !== undefined && typeof found !== "boolean") {
149
+ return {
150
+ success: false,
151
+ error: {
152
+ message: "found must be a boolean",
153
+ errors: [{ path: ["found"], message: "found must be a boolean" }],
154
+ },
155
+ };
156
+ }
157
+ return { success: true, data: found === true ? { id: data["id"], found: true } : { id: data["id"] } };
142
158
  });
143
159
 
144
160
  /**
145
- * Output for get operation - the item or undefined.
161
+ * The output of `get` with `found: true` in the input. `found` is true when the collection has
162
+ * the id. Then `value` is the item, also when the item is `null`.
163
+ */
164
+ export interface GetFoundOutput {
165
+ found: boolean;
166
+ value?: unknown;
167
+ }
168
+
169
+ /**
170
+ * Output for get operation: the item, or undefined for a missing id. With `found: true` in the
171
+ * input, the output is a `GetFoundOutput`.
146
172
  */
147
173
  export const getOutputSchema : ZodLike<unknown> = createSchema<unknown>((data) => {
148
174
  // Accept any value (item) or undefined
@@ -9,9 +9,16 @@
9
9
  * the collections package (roadmap 3.3).
10
10
  *
11
11
  * Each method gives a Promise: a caller cannot tell a local storage from a remote one.
12
+ *
13
+ * The contract (the suite of `@procedures/collections/testing` tests it for each storage):
14
+ * - A storage keeps a copy of each value that a write takes, and each read gives a new copy.
15
+ * - A stored `null` reads back as `null`. A missing id reads back as `undefined`.
16
+ * - A delta of `getChangesSince` follows the order of the last change of each id.
17
+ * - After `close()`, each operation rejects with an error whose message says "closed". A second
18
+ * `close()` does nothing.
12
19
  */
13
20
  export interface CollectionStorage<T> {
14
- /** The item with this id, or undefined. */
21
+ /** The item with this id, or undefined. A stored null is null. */
15
22
  get(id: string): Promise<T | undefined>;
16
23
  /** Every item of the collection. */
17
24
  getAll(): Promise<T[]>;
@@ -50,7 +57,10 @@ export interface CollectionStorage<T> {
50
57
  * that keeps no writes does not have this method. `ProcedureServer.useStorage` calls it.
51
58
  */
52
59
  flush?(): Promise<void>;
53
- /** Release the resources of the storage. */
60
+ /**
61
+ * This method releases the resources of the storage. After it, each operation rejects. A second
62
+ * `close()` does nothing.
63
+ */
54
64
  close(): Promise<void>;
55
65
  /** The kind, the size and the statistics of the storage. */
56
66
  getMetadata?(): Promise<StorageMetadata>;
@@ -65,6 +75,8 @@ export interface CollectionStorage<T> {
65
75
  * the first call, a version of an older run, or a change log that dropped old deletes.
66
76
  * - When `full` is false, `changed` has the items that changed after the version, and `deleted`
67
77
  * has the ids that were deleted after it.
78
+ * - The order of a full result is the order of the entries. The order of a delta is the order of
79
+ * the last change of each id.
68
80
  */
69
81
  export interface StorageChanges<T> {
70
82
  /** The version of the storage that includes these changes. */
@@ -68,8 +68,10 @@ export type InvocationErrorCode =
68
68
  * of `@procedures/fs`).
69
69
  * - `host-not-allowed`: the input has a URL whose host the host does not allow, or a redirect goes
70
70
  * to such a host (`createHttpModule({ allow })` of `@procedures/http`).
71
+ * - `topic-not-allowed`: the `allow` function of an `EventPublisher` refuses a topic of a
72
+ * subscription (`documentation/EVENTS.md`).
71
73
  */
72
- export type ForbiddenReason = CapabilityRefusalReason | "outside-root" | "host-not-allowed";
74
+ export type ForbiddenReason = CapabilityRefusalReason | "outside-root" | "host-not-allowed" | "topic-not-allowed";
73
75
 
74
76
  /** An error of the invocation itself. An error that the handler throws passes through unchanged. */
75
77
  export class InvocationError extends Error {
@@ -79,7 +81,7 @@ export class InvocationError extends Error {
79
81
  readonly retryable: boolean = false;
80
82
  /**
81
83
  * The reason of a `FORBIDDEN` error: `effects-undeclared`, `effects-exceeded`, `clearance`,
82
- * `deadline`, `outside-root` or `host-not-allowed`.
84
+ * `deadline`, `outside-root`, `host-not-allowed` or `topic-not-allowed`.
83
85
  */
84
86
  readonly reason: ForbiddenReason | undefined;
85
87
 
package/src/protocol.ts CHANGED
@@ -46,7 +46,8 @@ export type ProtocolErrorCode =
46
46
  | "TIMEOUT"
47
47
  | "UNAVAILABLE"
48
48
  | "CONNECTION_CLOSED"
49
- | "CONNECTION_NOT_FOUND";
49
+ | "CONNECTION_NOT_FOUND"
50
+ | "TOPIC_NOT_FOUND";
50
51
 
51
52
  /**
52
53
  * The error codes of the protocol, on each face (HTTP, WebSocket, MCP, local). An error that a
@@ -76,6 +77,7 @@ export const ERROR_CODES: Readonly<Record<ProtocolErrorCode, ErrorCodeInfo>> = {
76
77
  UNAVAILABLE: { http: 503, retryable: true, meaning: "The server cannot take the call now: it stops, or it is busy" },
77
78
  CONNECTION_CLOSED: { http: 503, retryable: true, meaning: "The connection closed before the call left" },
78
79
  CONNECTION_NOT_FOUND: { http: 404, retryable: false, meaning: "The server has no connection with this id" },
80
+ TOPIC_NOT_FOUND: { http: 404, retryable: false, meaning: "The topic rule of the host has no topic with this name" },
79
81
  };
80
82
 
81
83
  /** True when a code is an error code of the protocol. */
@@ -14,6 +14,8 @@
14
14
  * - `token`, `browser`: the checks of the network transports (`client-server` applies them).
15
15
  * - `capability`, `readOnly`, `clearanceOrder`, `resources`, `admitItem`: the capability of each
16
16
  * call and what the host gives for it (CAPABILITIES-2026-10).
17
+ * - `events`: the remote events of the host (`documentation/EVENTS.md`). The server pins
18
+ * `events.subscribe` with this topic rule, and its procedures get the bus as `ctx.bus`.
17
19
  */
18
20
 
19
21
  import type { Capability, ClearanceOrder, ProcedurePath, ResourcesOf } from "../procedures/types.js";
@@ -24,6 +26,7 @@ import { timeoutInterceptor } from "../procedures/interceptors.js";
24
26
  import type { BrowserGuardOptions } from "./browser-guard.js";
25
27
  import type { ProcedureRegistry } from "../procedures/registry.js";
26
28
  import type { ProcedureServerOptions } from "./procedure-server.js";
29
+ import type { EventPublisherOptions } from "../events/publisher.js";
27
30
  import { toMs, type Duration } from "../duration.js";
28
31
 
29
32
  /** The procedures of a host that are tools: namespaces (first path segments), less some keys. */
@@ -86,6 +89,12 @@ export interface HostPolicyOptions {
86
89
  resources?: ((invocation: Invocation) => ResourcesOf) | undefined;
87
90
  /** The filter of the output that leaves the host. */
88
91
  admitItem?: AdmitItem | undefined;
92
+ /**
93
+ * The remote events of the host (`documentation/EVENTS.md`): the topic rule, with an optional
94
+ * bus, `allow` function and buffer. The default bus is the global bus, which is also the default
95
+ * `ctx.bus`. Without this option, the host does not serve `events.subscribe`.
96
+ */
97
+ events?: EventPublisherOptions | undefined;
89
98
  }
90
99
 
91
100
  /** What a host lets its callers do. */
@@ -100,6 +109,8 @@ export interface HostPolicy {
100
109
  readonly clearanceOrder: ClearanceOrder | undefined;
101
110
  readonly resources: ((invocation: Invocation) => ResourcesOf) | undefined;
102
111
  readonly admitItem: AdmitItem | undefined;
112
+ /** The remote events: each server of the policy makes its own publisher from these options. */
113
+ readonly events: EventPublisherOptions | undefined;
103
114
  }
104
115
 
105
116
  /** The capability function of a policy: the capability of the options, narrowed by `readOnly`. */
@@ -145,6 +156,7 @@ export function hostPolicy(options: HostPolicyOptions = {}): HostPolicy {
145
156
  clearanceOrder: options.clearanceOrder,
146
157
  resources: options.resources,
147
158
  admitItem: options.admitItem,
159
+ events: options.events,
148
160
  };
149
161
  }
150
162
 
@@ -162,5 +174,7 @@ export function procedureServerOptions(policy: HostPolicy, registry?: ProcedureR
162
174
  ...(policy.clearanceOrder ? { clearanceOrder: policy.clearanceOrder } : {}),
163
175
  ...(policy.resources ? { resources: policy.resources } : {}),
164
176
  ...(policy.admitItem ? { admitItem: policy.admitItem } : {}),
177
+ // The remote events: the server makes its publisher and pins events.subscribe (EVENTS.md)
178
+ ...(policy.events ? { events: policy.events } : {}),
165
179
  };
166
180
  }
@@ -25,6 +25,8 @@ import { optionalMs, type Duration } from "../duration.js";
25
25
  import type { CollectionStorage } from "../procedures/collection/storage.js";
26
26
  import { TIMER_DEFAULTS } from "../timers.js";
27
27
  import { errorCodeOf } from "../protocol.js";
28
+ import { EventPublisher, type EventPublisherOptions } from "../events/publisher.js";
29
+ import { getGlobalEventBus } from "../events/bus.js";
28
30
 
29
31
  // =============================================================================
30
32
  // Types
@@ -119,6 +121,14 @@ export interface ProcedureServerOptions extends ServerOptions {
119
121
  * `request.principal`.
120
122
  */
121
123
  authenticate?: ((request: ServerRequest<unknown>) => Principal | undefined | Promise<Principal | undefined>) | undefined;
124
+
125
+ /**
126
+ * The remote events of the server (`documentation/EVENTS.md`): a bus and its topic rule, or an
127
+ * `EventPublisher`. The server pins the procedure `events.subscribe`, outside its registry, so a
128
+ * peer can subscribe to the topics. The procedures of the server get this bus as `ctx.bus`.
129
+ * `stop()` ends each subscription first, with `UNAVAILABLE`.
130
+ */
131
+ events?: EventPublisherOptions | EventPublisher | undefined;
122
132
  }
123
133
 
124
134
  /**
@@ -288,6 +298,8 @@ export class ProcedureServer extends Server implements RepositoryProvider {
288
298
  private readonly authenticate: ProcedureServerOptions["authenticate"];
289
299
  /** The functions that `stop()` waits on: each one runs when no call is in flight */
290
300
  private idleWaiters: Array<() => void> = [];
301
+ /** The publisher of the remote events, from the option `events`, or undefined. */
302
+ readonly publisher: EventPublisher | undefined;
291
303
 
292
304
  constructor(options: ProcedureServerOptions = {}) {
293
305
  super(options);
@@ -305,6 +317,9 @@ export class ProcedureServer extends Server implements RepositoryProvider {
305
317
  this.idempotency = options.idempotency === false ? undefined : new IdempotencyCache(options.idempotency ?? {});
306
318
  this.token = options.token;
307
319
  this.authenticate = options.authenticate;
320
+ // The remote events: the server pins events.subscribe, as it pins host.health (EVENTS.md)
321
+ this.publisher = options.events instanceof EventPublisher ? options.events : options.events ? new EventPublisher(options.events) : undefined;
322
+ if (this.publisher) this.registerProcedure(this.publisher.procedure);
308
323
 
309
324
  // Configure collections
310
325
  if (options.collections) {
@@ -645,6 +660,8 @@ export class ProcedureServer extends Server implements RepositoryProvider {
645
660
  clearanceOrder,
646
661
  resources,
647
662
  admitItem,
663
+ // The bus of the remote events is the bus of the procedures (EVENTS.md)
664
+ ...(this.publisher ? { bus: this.publisher.bus } : {}),
648
665
  });
649
666
  if (output.kind === "stream") {
650
667
  // The faces that collect a stream read this mark (roadmap R15)
@@ -723,23 +740,41 @@ export class ProcedureServer extends Server implements RepositoryProvider {
723
740
  }
724
741
 
725
742
  /**
726
- * Close all storage backends.
743
+ * This function publishes an event on the bus of the server: the bus of the option `events`, or
744
+ * the global bus. Each subscription of the topic gets it (`documentation/EVENTS.md`).
745
+ *
746
+ * @param topic - The topic: a channel of the bus
747
+ * @param data - The data of the event. It crosses the wire as JSON.
748
+ */
749
+ publish<T>(topic: string, data: T): void {
750
+ (this.publisher?.bus ?? getGlobalEventBus()).emit(topic, data);
751
+ }
752
+
753
+ /**
754
+ * This method closes each storage one time, also a storage of two collections, and removes the
755
+ * storages from the server. A closed storage rejects each operation, so the server does not keep
756
+ * it. Before a new `start()`, give the storages again with `useStorage()`.
727
757
  */
728
758
  async closeStorages(): Promise<void> {
729
- const closePromises = Array.from(this.storages.values()).map((s) =>
730
- s.close()
731
- );
732
- await Promise.all(closePromises);
759
+ const storages = new Set(this.storages.values());
760
+ this.storages.clear();
761
+ const results = await Promise.allSettled(Array.from(storages, (storage) => storage.close()));
762
+ const failed = results.find((result): result is PromiseRejectedResult => result.status === "rejected");
763
+ if (failed) throw failed.reason;
733
764
  }
734
765
 
735
766
  /**
736
- * Stop server and close storages.
767
+ * This method stops the server, then closes the storages and removes them (`closeStorages()`).
768
+ * The server can start again, with new storages from `useStorage()`. Before, a server that
769
+ * started again used the closed storages.
737
770
  */
738
771
  override async stop(options: StopOptions = {}): Promise<void> {
739
772
  const graceMs = optionalMs(options.grace, "grace") ?? TIMER_DEFAULTS.server.stopGraceMs;
740
773
  // A graceful stop: new calls get UNAVAILABLE, and the calls in flight have the grace time
741
774
  // to end (roadmap R14). Before, the transports aborted every call at once.
742
775
  this.draining = true;
776
+ // A subscription has no end: it ends now, with a retryable UNAVAILABLE (EVENTS.md)
777
+ this.publisher?.end();
743
778
  if (graceMs > 0 && this.inFlight > 0) {
744
779
  await new Promise<void>((resolve) => {
745
780
  const timer = setTimeout(resolve, graceMs);