@procedures/client 0.8.0 → 0.8.1

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 (188) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +95 -8
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/adapters/http/client/transport.d.ts +4 -0
  5. package/dist/adapters/http/client/transport.d.ts.map +1 -1
  6. package/dist/adapters/http/client/transport.js +11 -1
  7. package/dist/adapters/http/client/transport.js.map +1 -1
  8. package/dist/adapters/http/client/types.d.ts +19 -0
  9. package/dist/adapters/http/client/types.d.ts.map +1 -1
  10. package/dist/adapters/http/server/cors.d.ts +64 -0
  11. package/dist/adapters/http/server/cors.d.ts.map +1 -0
  12. package/dist/adapters/http/server/cors.js +113 -0
  13. package/dist/adapters/http/server/cors.js.map +1 -0
  14. package/dist/adapters/http/server/handler.d.ts +136 -0
  15. package/dist/adapters/http/server/handler.d.ts.map +1 -0
  16. package/dist/adapters/http/server/handler.js +495 -0
  17. package/dist/adapters/http/server/handler.js.map +1 -0
  18. package/dist/adapters/http/server/strategies.d.ts +14 -5
  19. package/dist/adapters/http/server/strategies.d.ts.map +1 -1
  20. package/dist/adapters/http/server/strategies.js.map +1 -1
  21. package/dist/adapters/http/server/transport.d.ts +30 -46
  22. package/dist/adapters/http/server/transport.d.ts.map +1 -1
  23. package/dist/adapters/http/server/transport.js +48 -432
  24. package/dist/adapters/http/server/transport.js.map +1 -1
  25. package/dist/adapters/http/server/types.d.ts +13 -0
  26. package/dist/adapters/http/server/types.d.ts.map +1 -1
  27. package/dist/adapters/websocket/server/transport.d.ts +46 -1
  28. package/dist/adapters/websocket/server/transport.d.ts.map +1 -1
  29. package/dist/adapters/websocket/server/transport.js +152 -15
  30. package/dist/adapters/websocket/server/transport.js.map +1 -1
  31. package/dist/adapters/websocket/server/types.d.ts +10 -3
  32. package/dist/adapters/websocket/server/types.d.ts.map +1 -1
  33. package/dist/adapters/websocket/server/upgrade-router.d.ts +28 -0
  34. package/dist/adapters/websocket/server/upgrade-router.d.ts.map +1 -0
  35. package/dist/adapters/websocket/server/upgrade-router.js +62 -0
  36. package/dist/adapters/websocket/server/upgrade-router.js.map +1 -0
  37. package/dist/adapters/websocket/server/upgrade.d.ts +68 -0
  38. package/dist/adapters/websocket/server/upgrade.d.ts.map +1 -0
  39. package/dist/adapters/websocket/server/upgrade.js +49 -0
  40. package/dist/adapters/websocket/server/upgrade.js.map +1 -0
  41. package/dist/adapters/websocket/shared/rpc.d.ts +2 -0
  42. package/dist/adapters/websocket/shared/rpc.d.ts.map +1 -1
  43. package/dist/adapters/websocket/shared/rpc.js +8 -2
  44. package/dist/adapters/websocket/shared/rpc.js.map +1 -1
  45. package/dist/browser.d.ts +12 -4
  46. package/dist/browser.d.ts.map +1 -1
  47. package/dist/browser.js +11 -2
  48. package/dist/browser.js.map +1 -1
  49. package/dist/client/client.d.ts +43 -0
  50. package/dist/client/client.d.ts.map +1 -1
  51. package/dist/client/client.js +69 -1
  52. package/dist/client/client.js.map +1 -1
  53. package/dist/events/publisher.d.ts +14 -0
  54. package/dist/events/publisher.d.ts.map +1 -1
  55. package/dist/events/publisher.js +42 -4
  56. package/dist/events/publisher.js.map +1 -1
  57. package/dist/events/rows.d.ts +31 -0
  58. package/dist/events/rows.d.ts.map +1 -0
  59. package/dist/events/rows.js +39 -0
  60. package/dist/events/rows.js.map +1 -0
  61. package/dist/index.d.ts +1 -1
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +1 -1
  64. package/dist/index.js.map +1 -1
  65. package/dist/node.d.ts +14 -0
  66. package/dist/node.d.ts.map +1 -1
  67. package/dist/node.js +9 -0
  68. package/dist/node.js.map +1 -1
  69. package/dist/procedures/capability.d.ts +6 -0
  70. package/dist/procedures/capability.d.ts.map +1 -1
  71. package/dist/procedures/capability.js +25 -0
  72. package/dist/procedures/capability.js.map +1 -1
  73. package/dist/procedures/collection/changes.d.ts +94 -0
  74. package/dist/procedures/collection/changes.d.ts.map +1 -0
  75. package/dist/procedures/collection/changes.js +182 -0
  76. package/dist/procedures/collection/changes.js.map +1 -0
  77. package/dist/procedures/collection/index.d.ts +8 -3
  78. package/dist/procedures/collection/index.d.ts.map +1 -1
  79. package/dist/procedures/collection/index.js +6 -1
  80. package/dist/procedures/collection/index.js.map +1 -1
  81. package/dist/procedures/collection/procedures.d.ts +39 -2
  82. package/dist/procedures/collection/procedures.d.ts.map +1 -1
  83. package/dist/procedures/collection/procedures.js +111 -8
  84. package/dist/procedures/collection/procedures.js.map +1 -1
  85. package/dist/procedures/collection/query.d.ts +246 -0
  86. package/dist/procedures/collection/query.d.ts.map +1 -0
  87. package/dist/procedures/collection/query.js +854 -0
  88. package/dist/procedures/collection/query.js.map +1 -0
  89. package/dist/procedures/collection/schemas.d.ts +50 -0
  90. package/dist/procedures/collection/schemas.d.ts.map +1 -1
  91. package/dist/procedures/collection/schemas.js +144 -6
  92. package/dist/procedures/collection/schemas.js.map +1 -1
  93. package/dist/procedures/collection/storage.d.ts +58 -6
  94. package/dist/procedures/collection/storage.d.ts.map +1 -1
  95. package/dist/procedures/collection/storage.js +23 -0
  96. package/dist/procedures/collection/storage.js.map +1 -1
  97. package/dist/procedures/elicit.d.ts +190 -0
  98. package/dist/procedures/elicit.d.ts.map +1 -0
  99. package/dist/procedures/elicit.js +127 -0
  100. package/dist/procedures/elicit.js.map +1 -0
  101. package/dist/procedures/hints.d.ts +43 -0
  102. package/dist/procedures/hints.d.ts.map +1 -0
  103. package/dist/procedures/hints.js +74 -0
  104. package/dist/procedures/hints.js.map +1 -0
  105. package/dist/procedures/index.d.ts +9 -2
  106. package/dist/procedures/index.d.ts.map +1 -1
  107. package/dist/procedures/index.js +5 -1
  108. package/dist/procedures/index.js.map +1 -1
  109. package/dist/procedures/invoke.d.ts +20 -0
  110. package/dist/procedures/invoke.d.ts.map +1 -1
  111. package/dist/procedures/invoke.js +13 -0
  112. package/dist/procedures/invoke.js.map +1 -1
  113. package/dist/procedures/progress.d.ts +24 -0
  114. package/dist/procedures/progress.d.ts.map +1 -0
  115. package/dist/procedures/progress.js +11 -0
  116. package/dist/procedures/progress.js.map +1 -0
  117. package/dist/procedures/registry.d.ts.map +1 -1
  118. package/dist/procedures/registry.js +5 -0
  119. package/dist/procedures/registry.js.map +1 -1
  120. package/dist/procedures/types.d.ts +42 -0
  121. package/dist/procedures/types.d.ts.map +1 -1
  122. package/dist/procedures/types.js.map +1 -1
  123. package/dist/procedures/untrusted.d.ts +28 -0
  124. package/dist/procedures/untrusted.d.ts.map +1 -0
  125. package/dist/procedures/untrusted.js +32 -0
  126. package/dist/procedures/untrusted.js.map +1 -0
  127. package/dist/server/browser-guard.d.ts +12 -2
  128. package/dist/server/browser-guard.d.ts.map +1 -1
  129. package/dist/server/browser-guard.js +43 -6
  130. package/dist/server/browser-guard.js.map +1 -1
  131. package/dist/server/host-policy.d.ts +10 -1
  132. package/dist/server/host-policy.d.ts.map +1 -1
  133. package/dist/server/host-policy.js +5 -0
  134. package/dist/server/host-policy.js.map +1 -1
  135. package/dist/server/index.d.ts +1 -1
  136. package/dist/server/index.d.ts.map +1 -1
  137. package/dist/server/index.js.map +1 -1
  138. package/dist/server/procedure-server.d.ts +38 -6
  139. package/dist/server/procedure-server.d.ts.map +1 -1
  140. package/dist/server/procedure-server.js +65 -13
  141. package/dist/server/procedure-server.js.map +1 -1
  142. package/dist/server/request-auth.d.ts +51 -4
  143. package/dist/server/request-auth.d.ts.map +1 -1
  144. package/dist/server/request-auth.js +116 -5
  145. package/dist/server/request-auth.js.map +1 -1
  146. package/dist/server/types.d.ts +73 -5
  147. package/dist/server/types.d.ts.map +1 -1
  148. package/dist/server/types.js.map +1 -1
  149. package/package.json +5 -1
  150. package/src/adapters/http/client/transport.ts +11 -1
  151. package/src/adapters/http/client/types.ts +22 -0
  152. package/src/adapters/http/server/cors.ts +144 -0
  153. package/src/adapters/http/server/handler.ts +624 -0
  154. package/src/adapters/http/server/strategies.ts +16 -6
  155. package/src/adapters/http/server/transport.ts +55 -478
  156. package/src/adapters/http/server/types.ts +15 -0
  157. package/src/adapters/websocket/server/transport.ts +166 -16
  158. package/src/adapters/websocket/server/types.ts +11 -3
  159. package/src/adapters/websocket/server/upgrade-router.ts +77 -0
  160. package/src/adapters/websocket/server/upgrade.ts +88 -0
  161. package/src/adapters/websocket/shared/rpc.ts +9 -2
  162. package/src/browser.ts +79 -2
  163. package/src/client/client.ts +83 -1
  164. package/src/events/publisher.ts +48 -4
  165. package/src/events/rows.ts +48 -0
  166. package/src/index.ts +1 -1
  167. package/src/node.ts +21 -0
  168. package/src/procedures/capability.ts +21 -0
  169. package/src/procedures/collection/changes.ts +245 -0
  170. package/src/procedures/collection/index.ts +53 -1
  171. package/src/procedures/collection/procedures.ts +163 -7
  172. package/src/procedures/collection/query.ts +1009 -0
  173. package/src/procedures/collection/schemas.ts +196 -7
  174. package/src/procedures/collection/storage.ts +78 -6
  175. package/src/procedures/elicit.ts +320 -0
  176. package/src/procedures/hints.ts +91 -0
  177. package/src/procedures/index.ts +78 -2
  178. package/src/procedures/invoke.ts +29 -0
  179. package/src/procedures/progress.ts +25 -0
  180. package/src/procedures/registry.ts +4 -0
  181. package/src/procedures/types.ts +49 -0
  182. package/src/procedures/untrusted.ts +50 -0
  183. package/src/server/browser-guard.ts +48 -6
  184. package/src/server/host-policy.ts +13 -1
  185. package/src/server/index.ts +3 -0
  186. package/src/server/procedure-server.ts +82 -14
  187. package/src/server/request-auth.ts +128 -6
  188. package/src/server/types.ts +82 -5
@@ -30,6 +30,7 @@ import type {
30
30
  Method,
31
31
  Metadata,
32
32
  Message,
33
+ ResponseItem,
33
34
  ClientMiddleware,
34
35
  ClientContext,
35
36
  ClientRunner,
@@ -56,8 +57,19 @@ import { RouteResolver, type ResolvedRoute, type RouteResolutionResult } from ".
56
57
  import { BatchExecutor, type ExecutionContext } from "./batch-executor.js";
57
58
  import type { ProcedureRegistry } from "../procedures/registry.js";
58
59
  import type { EventBus } from "../events/types.js";
60
+ import {
61
+ remoteEvents,
62
+ subscribeRemote,
63
+ type EventTransport,
64
+ type RemoteEvent,
65
+ type RemoteEventHandler,
66
+ type RemoteEventsOptions,
67
+ type SubscribeOptions,
68
+ type Subscription,
69
+ } from "../events/remote.js";
59
70
  import { narrowCapability } from "../procedures/capability.js";
60
- import { TIMEOUT_METADATA_KEY, absorbTimeout, writeRemainingTime } from "./deadline.js";
71
+ import { DEADLINE_METADATA_KEY, TIMEOUT_METADATA_KEY, absorbTimeout, writeRemainingTime } from "./deadline.js";
72
+ import { ms } from "../duration.js";
61
73
  import type { Capability, ClearanceOrder, NestedCallOptions } from "../procedures/types.js";
62
74
  import { PROCEDURE_REGISTRY } from "../procedures/registry.js";
63
75
  import { validateSchema } from "../procedures/schema.js";
@@ -169,6 +181,12 @@ function callerOf(options: ExecOptions): CallerContext {
169
181
  return caller;
170
182
  }
171
183
 
184
+ /**
185
+ * The timeouts of a subscription: none. The timeout middleware takes 0 as "no timeout", so the
186
+ * timeouts of the client do not end a subscription, which has no end (roadmap CLI-15).
187
+ */
188
+ const NO_SUBSCRIPTION_TIMEOUT = Object.freeze({ overall: ms(0), perAttempt: ms(0) });
189
+
172
190
  /**
173
191
  * Generate a unique message ID.
174
192
  */
@@ -221,6 +239,8 @@ export class Client<TContext = {}> {
221
239
  private readonly throwOnError: boolean;
222
240
  /** The bus of the local procedures. Undefined: the global bus. */
223
241
  private bus: EventBus | undefined;
242
+ /** The transport of the subscriptions through the middleware, made at the first subscription */
243
+ private subscriptionTransport: EventTransport | undefined;
224
244
  /** The interceptors of the local invocations (roadmap 1.2) */
225
245
  private interceptors: readonly Interceptor[] = [];
226
246
  /** The capability of the local invocations (CAPABILITIES-2026-10). Undefined: no limits. */
@@ -779,6 +799,68 @@ export class Client<TContext = {}> {
779
799
  }
780
800
  }
781
801
 
802
+ /**
803
+ * This method subscribes to topics of the event bus of the server (`documentation/EVENTS.md`).
804
+ * The call of `events.subscribe` goes through the middleware and the context of this client, as
805
+ * `stream()` does. Thus a token of the auth middleware goes with it. The subscriber resubscribes
806
+ * after a failure that a retry can repair, and the subscribers of the same topics share one call.
807
+ *
808
+ * A subscription has no deadline: the timeouts of the client (its timeout middleware, the
809
+ * `timeout` of its context and a `procedure-timeout` of its metadata) do not apply to it. For a
810
+ * deadline, give it in the metadata of the options, for example
811
+ * `{ metadata: { timeout: { overall: minutes(5) } } }`.
812
+ *
813
+ * @param topics - One topic, or a list of 1 to 64 topics
814
+ * @param handler - The handler of each event. A promise that it gives holds the next event.
815
+ * @param options - `onSubscribed`, `onError`, a signal, the metadata of the call, the resubscribe rules
816
+ * @returns The unsubscribe function, with the `ready` promise of the first confirmation
817
+ *
818
+ * @example
819
+ * ```typescript
820
+ * const unsubscribe = client.subscribe("collections.users", (event) => console.log(event));
821
+ * await unsubscribe.ready;
822
+ * unsubscribe();
823
+ * ```
824
+ */
825
+ subscribe<T = unknown>(topics: string | readonly string[], handler: RemoteEventHandler<T>, options: SubscribeOptions = {}): Subscription {
826
+ return subscribeRemote<T>(this.eventTransport(), topics, handler, options);
827
+ }
828
+
829
+ /**
830
+ * This method gives the events of topics of the event bus of the server as an async iterator,
831
+ * through the middleware of this client. A `break` or the signal ends the subscription. As for
832
+ * `subscribe()`, the timeouts of the client do not apply: the metadata of the options can give one.
833
+ *
834
+ * @param topics - One topic, or a list of 1 to 64 topics
835
+ * @param options - A signal, `onSubscribed`, the metadata of the call, the resubscribe rules
836
+ */
837
+ events<T = unknown>(topics: string | readonly string[], options: RemoteEventsOptions = {}): AsyncIterable<RemoteEvent<T>> {
838
+ return remoteEvents<T>(this.eventTransport(), topics, options);
839
+ }
840
+
841
+ /**
842
+ * The transport of the subscriptions of this client: each call goes through the middleware, with
843
+ * the context of the client in its metadata. The subscriptions of one client share it, so they
844
+ * share their calls. The timeouts and the deadline of the client stay out: only the metadata of
845
+ * the subscription can give them.
846
+ */
847
+ private eventTransport(): EventTransport {
848
+ if (!this.subscriptionTransport) {
849
+ const send = <TReq, TRes>(message: Message<TReq>): AsyncIterable<ResponseItem<TRes>> => {
850
+ const metadata: Metadata = { ...this.defaultMetadata, ...(this.getEffectiveContext() as object) };
851
+ delete metadata["timeout"];
852
+ delete metadata[TIMEOUT_METADATA_KEY];
853
+ delete metadata[DEADLINE_METADATA_KEY];
854
+ metadata["timeout"] = NO_SUBSCRIPTION_TIMEOUT as unknown as NonNullable<Metadata["timeout"]>;
855
+ Object.assign(metadata, message.metadata);
856
+ absorbTimeout(metadata);
857
+ return this.composeMiddleware<TReq, TRes>()({ message: { ...message, metadata } });
858
+ };
859
+ this.subscriptionTransport = { send };
860
+ }
861
+ return this.subscriptionTransport;
862
+ }
863
+
782
864
  /**
783
865
  * Compose middleware chain using universal middleware composition.
784
866
  *
@@ -8,11 +8,17 @@
8
8
  *
9
9
  * - The topic rule (`topics`) is the expose rule of the topics. A topic outside it gives
10
10
  * `TOPIC_NOT_FOUND`.
11
+ * - `reads` gives the resources that a subscriber of a topic reads. The capability of the call
12
+ * must permit them, as for a procedure that reads them (roadmap CLI-15). By default, a topic
13
+ * `collections.<name>` reads `collection:<name>`.
11
14
  * - `allow` examines each topic with the capability of the call. A refusal gives `FORBIDDEN` with
12
15
  * the reason `topic-not-allowed`.
13
16
  * - The procedure declares that it reads the resource `events`, so the capability of the host
14
17
  * applies to it as to each procedure.
15
18
  *
19
+ * - A value with rows (`withRows()`) reaches each subscriber through the output filter of its host
20
+ * (`ctx.admit`): each row passes the filter, and the subscriber gets only the admitted rows.
21
+ *
16
22
  * The delivery is at most once. The queue of a subscription holds at most `buffer` events. When
17
23
  * it is full, the publisher drops the oldest event, and the next event tells the number of lost
18
24
  * events (`dropped`). The bus never waits for a subscriber.
@@ -21,10 +27,13 @@
21
27
  import { z } from "zod";
22
28
  import { createProcedure } from "../procedures/define.js";
23
29
  import { InvocationError } from "../procedures/invoke.js";
30
+ import { capabilityRefusal } from "../procedures/capability.js";
31
+ import { collectionTopicReads } from "../procedures/collection/changes.js";
24
32
  import type { AnyProcedure, Capability, ProcedureContext } from "../procedures/types.js";
25
33
  import type { EventBus, Unsubscribe } from "./types.js";
26
34
  import { getGlobalEventBus } from "./bus.js";
27
35
  import { EVENTS_SUBSCRIBE_PATH, type EventItem, type SubscribeInput } from "./remote.js";
36
+ import { admittedValue, rowsOf } from "./rows.js";
28
37
 
29
38
  /** The number of topics that one subscription can name. */
30
39
  export const MAX_SUBSCRIPTION_TOPICS = 64;
@@ -64,6 +73,18 @@ export interface EventPublisherOptions {
64
73
  * @default 1024
65
74
  */
66
75
  buffer?: number | undefined;
76
+ /**
77
+ * The resources that a subscriber of a topic reads (roadmap CLI-15). The publisher refuses a
78
+ * topic whose reads the capability of the call does not permit, as for a procedure that reads
79
+ * them: `FORBIDDEN` with the reason `effects-exceeded`.
80
+ * @default `collectionTopicReads`: a topic `collections.<name>` reads `collection:<name>`
81
+ */
82
+ reads?: ((topic: string) => readonly string[]) | undefined;
83
+ }
84
+
85
+ /** A procedure that reads resources, for the admission of a topic under a capability. */
86
+ function topicReader(reads: readonly string[]): AnyProcedure {
87
+ return { path: [...EVENTS_SUBSCRIBE_PATH], metadata: { effects: { reads: [...reads], writes: [] } } } as unknown as AnyProcedure;
67
88
  }
68
89
 
69
90
  /** This function gives the test of a topic rule. */
@@ -131,7 +152,9 @@ class TopicSubscription implements AsyncIterableIterator<EventItem> {
131
152
  private readonly queue: Array<Extract<EventItem, { type: "event" }>> = [];
132
153
  private seq = 0;
133
154
  private dropped = 0;
134
- private waiter: { resolve: (result: IteratorResult<EventItem>) => void; reject: (error: unknown) => void } | undefined;
155
+ private waiter:
156
+ | { resolve: (result: IteratorResult<EventItem> | PromiseLike<IteratorResult<EventItem>>) => void; reject: (error: unknown) => void }
157
+ | undefined;
135
158
  private closed = false;
136
159
  private failure: unknown;
137
160
  private readonly unsubscribes: Unsubscribe[];
@@ -142,6 +165,8 @@ class TopicSubscription implements AsyncIterableIterator<EventItem> {
142
165
  private readonly buffer: number,
143
166
  private readonly signal: AbortSignal | undefined,
144
167
  private readonly onClose: (subscription: TopicSubscription) => void,
168
+ /** The output filter of the host for this subscriber: each row of a value passes it */
169
+ private readonly admit: ((item: unknown) => Promise<boolean>) | undefined,
145
170
  ) {
146
171
  this.confirmation = { type: "subscribed", topics };
147
172
  // Before, clear() removed the listeners, and the subscriber saw no end (EVENTS.md)
@@ -166,7 +191,7 @@ class TopicSubscription implements AsyncIterableIterator<EventItem> {
166
191
  const waiter = this.waiter;
167
192
  if (waiter && this.queue.length === 0) {
168
193
  this.waiter = undefined;
169
- waiter.resolve({ value: this.withDropped(event), done: false });
194
+ waiter.resolve(this.deliver(event));
170
195
  return;
171
196
  }
172
197
  if (this.queue.length >= this.buffer) {
@@ -177,6 +202,16 @@ class TopicSubscription implements AsyncIterableIterator<EventItem> {
177
202
  this.queue.push(event);
178
203
  }
179
204
 
205
+ /**
206
+ * The item of an event for the subscriber. The rows of its data pass the output filter of the
207
+ * host for this subscriber, and the subscriber gets only the admitted rows.
208
+ */
209
+ private deliver(event: Extract<EventItem, { type: "event" }>): Promise<IteratorResult<EventItem>> {
210
+ const item = this.withDropped(event) as Extract<EventItem, { type: "event" }>;
211
+ if (!this.admit || !rowsOf(item.data)) return Promise.resolve({ value: item, done: false });
212
+ return admittedValue(item.data, this.admit).then((data): IteratorResult<EventItem> => ({ value: { ...item, data }, done: false }));
213
+ }
214
+
180
215
  /** The event with the number of events that the publisher dropped before it. */
181
216
  private withDropped(event: Extract<EventItem, { type: "event" }>): EventItem {
182
217
  if (this.dropped === 0) return event;
@@ -192,7 +227,7 @@ class TopicSubscription implements AsyncIterableIterator<EventItem> {
192
227
  return Promise.resolve({ value: confirmation, done: false });
193
228
  }
194
229
  const event = this.queue.shift();
195
- if (event) return Promise.resolve({ value: this.withDropped(event), done: false });
230
+ if (event) return this.deliver(event);
196
231
  if (this.failure !== undefined) {
197
232
  const failure = this.failure;
198
233
  this.failure = undefined;
@@ -268,6 +303,7 @@ export class EventPublisher {
268
303
  readonly procedure: AnyProcedure;
269
304
  private readonly known: (topic: string) => boolean;
270
305
  private readonly allow: EventPublisherOptions["allow"];
306
+ private readonly reads: (topic: string) => readonly string[];
271
307
  private readonly buffer: number;
272
308
  private readonly open = new Set<TopicSubscription>();
273
309
 
@@ -275,6 +311,7 @@ export class EventPublisher {
275
311
  this.bus = options.bus ?? getGlobalEventBus();
276
312
  this.known = topicMatcher(options.topics);
277
313
  this.allow = options.allow;
314
+ this.reads = options.reads ?? collectionTopicReads;
278
315
  this.buffer = Math.max(1, Math.floor(options.buffer ?? 1024));
279
316
  this.procedure = createProcedure()
280
317
  .path([...EVENTS_SUBSCRIBE_PATH])
@@ -316,6 +353,13 @@ export class EventPublisher {
316
353
  for (const topic of unique) {
317
354
  if (!this.known(topic)) throw new TopicNotFoundError(`The host has no topic "${topic}"`);
318
355
  }
356
+ // A subscriber of a topic reads its resources: the capability of the call must permit them
357
+ for (const topic of unique) {
358
+ const reads = this.reads(topic);
359
+ if (reads.length === 0) continue;
360
+ const refusal = capabilityRefusal(topicReader(reads), ctx.capability);
361
+ if (refusal) throw new InvocationError("FORBIDDEN", `The topic "${topic}": ${refusal.message}`, ctx.path, refusal.reason);
362
+ }
319
363
  if (this.allow) {
320
364
  const call: TopicCall = { capability: ctx.capability, metadata: ctx.metadata };
321
365
  for (const topic of unique) {
@@ -324,7 +368,7 @@ export class EventPublisher {
324
368
  }
325
369
  }
326
370
  }
327
- const subscription = new TopicSubscription(this.bus, unique, this.buffer, ctx.signal, (closed) => this.open.delete(closed));
371
+ const subscription = new TopicSubscription(this.bus, unique, this.buffer, ctx.signal, (closed) => this.open.delete(closed), ctx.admit);
328
372
  this.open.add(subscription);
329
373
  return subscription.closeIfAborted();
330
374
  }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The rows of a published value (roadmap CLI-15).
3
+ *
4
+ * A value on the bus can hold rows of data: the values of a collection change event, for example.
5
+ * The publisher of the remote events gives each row to the output filter of the host (`admitItem`)
6
+ * for each subscriber, and each subscriber gets only the rows that its filter admits. Thus a row
7
+ * reaches a subscriber only through the same filter as a read.
8
+ *
9
+ * The rows are on a symbol key, so they stay in this process: JSON does not take them.
10
+ */
11
+
12
+ /** The rows of a published value, and the function that makes the value of the admitted rows. */
13
+ export interface PublishedRows {
14
+ /** The rows that the output filter examines, one at a time. */
15
+ readonly rows: readonly unknown[];
16
+ /** This function gives the value for a subscriber: `admitted[i]` tells if row `i` stays. */
17
+ keep(admitted: readonly boolean[]): unknown;
18
+ }
19
+
20
+ /** The key of the rows of a published value. */
21
+ const ROWS: unique symbol = Symbol.for("procedures.publishedRows");
22
+
23
+ /**
24
+ * This function gives the value with its rows, for the filter of each subscriber. The rows are not
25
+ * enumerable: a copy or a comparison of the value does not see them.
26
+ */
27
+ export function withRows<T extends object>(value: T, rows: PublishedRows): T {
28
+ return Object.defineProperty(value, ROWS, { value: rows, enumerable: false });
29
+ }
30
+
31
+ /** The rows of a published value, or undefined for a value without rows. */
32
+ export function rowsOf(value: unknown): PublishedRows | undefined {
33
+ if (typeof value !== "object" || value === null) return undefined;
34
+ const rows = (value as { [ROWS]?: unknown })[ROWS];
35
+ return typeof rows === "object" && rows !== null ? (rows as PublishedRows) : undefined;
36
+ }
37
+
38
+ /**
39
+ * The value of a published value for one subscriber: each row passes the filter, and the value
40
+ * keeps the admitted rows. An error of the filter refuses the row. Without rows or a filter, the
41
+ * value stays as it is.
42
+ */
43
+ export async function admittedValue(value: unknown, admit: ((item: unknown) => Promise<boolean>) | undefined): Promise<unknown> {
44
+ const rows = rowsOf(value);
45
+ if (!rows || !admit) return value;
46
+ const admitted = await Promise.all(rows.rows.map((row) => admit(row).catch(() => false)));
47
+ return rows.keep(admitted);
48
+ }
package/src/index.ts CHANGED
@@ -27,7 +27,7 @@ export type {
27
27
  HttpUrlStrategy,
28
28
  } from "./server/index.js";
29
29
  export { WebSocketServerTransport } from "./server/index.js";
30
- export { checkBrowserRequest, isLoopbackHost, isLoopbackAddress, hostNameOf } from "./server/browser-guard.js";
30
+ export { checkBrowserRequest, hasAmbientCredentials, isLoopbackHost, isLoopbackAddress, hostNameOf } from "./server/browser-guard.js";
31
31
  export type { BrowserGuardOptions, GuardedRequest, GuardResult } from "./server/browser-guard.js";
32
32
  export type {
33
33
  WebSocketServerTransportOptions,
package/src/node.ts CHANGED
@@ -9,9 +9,30 @@
9
9
  * This entry also exports `writeNdjsonStream`: the writer of an NDJSON stream response of wire
10
10
  * protocol v1, for a `ServerResponse` of `node:http`. `HttpServerTransport` uses it, so a server
11
11
  * without Express writes the same bytes.
12
+ *
13
+ * The procedure faces of Node, without a framework (CLI-3): `procedureHandler(server, options)`
14
+ * gives a request handler `(req, res) => boolean`, and `upgradeHandler(server, options)` gives an
15
+ * upgrade handler `(req, socket, head) => boolean`. Each one takes the requests of its path and
16
+ * gives `false` for the others, so the routes of the application share the listener.
12
17
  */
13
18
 
14
19
  export { WebSocketTransport } from "./adapters/websocket/client/node.js";
15
20
  export type { WebSocketTransportOptions, WebSocketClass } from "./adapters/websocket/client/types.js";
16
21
  export { writeNdjsonStream } from "./adapters/http/server/ndjson.js";
17
22
  export type { WriteNdjsonStreamOptions, NdjsonError } from "./adapters/http/server/ndjson.js";
23
+ export { procedureHandler } from "./adapters/http/server/handler.js";
24
+ export type { ProcedureHandlerOptions, NodeProcedureHandler, NodeUrlStrategy, UrlRequest } from "./adapters/http/server/handler.js";
25
+ export type { CorsOptions } from "./adapters/http/server/cors.js";
26
+ export type { RoutedRequest } from "./adapters/http/server/strategies.js";
27
+ export { defaultServerUrlStrategy, rpcServerUrlStrategy } from "./adapters/http/server/strategies.js";
28
+ export { upgradeHandler } from "./adapters/websocket/server/upgrade.js";
29
+ export type { UpgradeHandlerOptions, NodeUpgradeHandler } from "./adapters/websocket/server/upgrade.js";
30
+ export { WebSocketServerTransport } from "./adapters/websocket/server/transport.js";
31
+ export type {
32
+ WebSocketServerTransportOptions,
33
+ WebSocketAuthHandler,
34
+ WebSocketConnectionHandler,
35
+ WebSocketMessage,
36
+ TrackedConnection,
37
+ ConnectionEventHandler,
38
+ } from "./adapters/websocket/server/types.js";
@@ -178,6 +178,27 @@ export function narrowCapability<C>(
178
178
  return Object.freeze(result) as Capability<C>;
179
179
  }
180
180
 
181
+ /**
182
+ * This function joins two requests for a narrower capability into one request that asks for
183
+ * both: the meet of the clearances, the intersection of the effects and the shorter time. Two
184
+ * different clearances need an order.
185
+ */
186
+ export function joinNarrowing<C>(first: CapabilityRequest<C>, second: CapabilityRequest<C>, order?: ClearanceOrder<C>): CapabilityRequest<C> {
187
+ const result: CapabilityRequest<C> = {};
188
+ if (first.clearance === undefined || second.clearance === undefined || Object.is(first.clearance, second.clearance)) {
189
+ const clearance = first.clearance ?? second.clearance;
190
+ if (clearance !== undefined) result.clearance = clearance;
191
+ } else {
192
+ if (!order) throw new Error("Two different clearances need a clearance order: give clearanceOrder to the host (hostPolicy)");
193
+ result.clearance = order.meet(first.clearance, second.clearance);
194
+ }
195
+ const effects = first.effects === undefined ? second.effects : narrowEffects(first.effects, second.effects);
196
+ if (effects !== undefined) result.effects = effects;
197
+ const times = [first.timeoutMs, second.timeoutMs].filter((time): time is number => typeof time === "number" && Number.isFinite(time) && time >= 0);
198
+ if (times.length > 0) result.timeoutMs = Math.min(...times);
199
+ return result;
200
+ }
201
+
181
202
  // =============================================================================
182
203
  // Admission
183
204
  // =============================================================================
@@ -0,0 +1,245 @@
1
+ /**
2
+ * The change events of the collection procedures (roadmap CLI-15).
3
+ *
4
+ * Each write of a collection procedure publishes one `CollectionChangeEvent` on the bus of its
5
+ * context (`ctx.bus`), under the topic `collections.<name>`. A peer gets the events with
6
+ * `events.subscribe` (`documentation/EVENTS.md`). The delivery is at most once, so an event is a
7
+ * hint of a change, and a subscriber reads the changes when it misses an event.
8
+ *
9
+ * - `since` and `version` are versions of `getChangesSince()` of the storage. The events of one
10
+ * storage and one topic make a chain: the `since` of an event is the `version` of the event
11
+ * before it. A subscriber whose cursor is `since` applies the event without a read.
12
+ * - The changes of an event come from the change log of the storage, not from the input of the
13
+ * write. Thus an event also has the writes that did not go through the procedures.
14
+ * - By default, an event has only the ids and the versions: a value reaches a caller only through
15
+ * a read, with its checks. With the option `values`, an event has the values too, and each row
16
+ * `{ id, value }` passes the output filter of the host for each subscriber (`withRows()`).
17
+ * - A subscriber of the topic must have a capability that reads `collection:<name>`, as for a
18
+ * read (`collectionTopicReads()`, the default `reads` of `EventPublisher`).
19
+ * - An event without `since` only tells that the collection changed: the storage has no change
20
+ * log, the chain starts, or the log dropped the version. The subscriber then reads the changes.
21
+ * - The chain starts at the version of a call of `changes` (a pull of a subscriber). While no one
22
+ * listens to the topic, the procedures publish nothing, and the chain stops.
23
+ * - The publishes of one storage and one topic run one at a time, in the order of the writes. A
24
+ * write resolves after its event is on the bus. A failed publish never fails the write.
25
+ */
26
+
27
+ import type { EventBus } from "../../events/types.js";
28
+ import { withRows } from "../../events/rows.js";
29
+ import type { StorageChanges } from "./storage.js";
30
+
31
+ /**
32
+ * The change event of the writes of a collection, on the topic `collections.<name>` (roadmap
33
+ * CLI-15). The data crosses the wire as JSON.
34
+ */
35
+ export interface CollectionChangeEvent {
36
+ /**
37
+ * The version of the storage before the changes. A subscriber whose cursor is this version
38
+ * applies the changes without a read. Without it, the event is only a hint: read the changes.
39
+ */
40
+ readonly since?: string | undefined;
41
+ /** The version of the storage after the changes, when the storage has a change log. */
42
+ readonly version?: string | undefined;
43
+ /** The ids of the items that changed, in the order of the last change of each id. */
44
+ readonly changed: readonly string[];
45
+ /** The ids that were deleted. */
46
+ readonly deleted: readonly string[];
47
+ /**
48
+ * With the option `values`: the changed items that the output filter of the subscriber admits.
49
+ * Without the option, the event has no values: read them with `changes`.
50
+ */
51
+ readonly values?: ReadonlyArray<{ readonly id: string; readonly value: unknown }> | undefined;
52
+ /** With the option `values`: the changed ids whose values the filter refused. Read them with `changes`. */
53
+ readonly withheld?: readonly string[] | undefined;
54
+ /** The origin of the writes, when one origin made all the changes of the event. */
55
+ readonly origin?: string | undefined;
56
+ }
57
+
58
+ /**
59
+ * This function gives the topic of the change events of a collection: `collections.<name>`. The
60
+ * partition of roadmap CLI-17 comes after the name, with the option `topic` of the procedures.
61
+ */
62
+ export function collectionChangeTopic(collection: string): string {
63
+ return `collections.${collection}`;
64
+ }
65
+
66
+ /**
67
+ * This function gives the resources that a subscriber of a topic reads: `collection:<name>` for
68
+ * the topic `collections.<name>` and for a topic of a partition, `collections.<name>.<partition>`.
69
+ * Another topic gives no resource. `EventPublisher` uses it as its default `reads`.
70
+ */
71
+ export function collectionTopicReads(topic: string): readonly string[] {
72
+ const match = /^collections\.([^.]+)(?:\.|$)/.exec(topic);
73
+ return match ? [`collection:${match[1]}`] : [];
74
+ }
75
+
76
+ /** The part of a storage that the change events read. */
77
+ interface ChangeLog {
78
+ getChangesSince?(since?: string): Promise<StorageChanges<unknown>>;
79
+ }
80
+
81
+ /** A write that ended, and that no event told yet. */
82
+ interface EndedWrite {
83
+ /** The ids of the write, or "*" for a clear. */
84
+ readonly ids: readonly string[] | "*";
85
+ readonly origin: string | undefined;
86
+ /** True when the chain had a version when the write started. */
87
+ readonly anchored: boolean;
88
+ }
89
+
90
+ /** The chain of the events of one storage and one topic. */
91
+ interface ChangeChain {
92
+ /** The `version` of the last event, or of a call of `changes`: the `since` of the next event. */
93
+ anchor: string | undefined;
94
+ /** The publishes, one at a time. */
95
+ tail: Promise<void>;
96
+ /** The writes that ended since the last publish. */
97
+ readonly ended: EndedWrite[];
98
+ }
99
+
100
+ /** The chains of each storage, by topic. A storage that goes away takes its chains with it. */
101
+ const CHAINS = new WeakMap<object, Map<string, ChangeChain>>();
102
+
103
+ function chainOf(storage: object, topic: string): ChangeChain {
104
+ let topics = CHAINS.get(storage);
105
+ if (!topics) {
106
+ topics = new Map();
107
+ CHAINS.set(storage, topics);
108
+ }
109
+ let chain = topics.get(topic);
110
+ if (!chain) {
111
+ chain = { anchor: undefined, tail: Promise.resolve(), ended: [] };
112
+ topics.set(topic, chain);
113
+ }
114
+ return chain;
115
+ }
116
+
117
+ /** The origin of an event: the one origin of its writes, when the changes are only of these writes. */
118
+ function originOf(writes: readonly EndedWrite[], changes: StorageChanges<unknown> | undefined): string | undefined {
119
+ const origin = writes[0]?.origin;
120
+ if (origin === undefined || writes.some((write) => write.origin !== origin)) return undefined;
121
+ if (!changes || writes.some((write) => write.ids === "*")) return origin;
122
+ const ids = new Set<string>();
123
+ for (const write of writes) for (const id of write.ids as readonly string[]) ids.add(id);
124
+ const own = changes.changed.every(([id]) => ids.has(id)) && changes.deleted.every((id) => ids.has(id));
125
+ return own ? origin : undefined;
126
+ }
127
+
128
+ /** A hint: the collection changed, and the subscriber reads the changes. */
129
+ function hint(version: string | undefined, origin: string | undefined): CollectionChangeEvent {
130
+ return {
131
+ ...(version !== undefined && { version }),
132
+ changed: [],
133
+ deleted: [],
134
+ ...(origin !== undefined && { origin }),
135
+ };
136
+ }
137
+
138
+ /**
139
+ * The event of a delta with the values of the changed items. Each row `{ id, value }` passes the
140
+ * output filter of each subscriber, and a refused row goes from `values` to `withheld`.
141
+ */
142
+ function withValues(event: CollectionChangeEvent, entries: ReadonlyArray<{ readonly id: string; readonly value: unknown }>): CollectionChangeEvent {
143
+ return withRows(
144
+ { ...event, values: entries },
145
+ {
146
+ rows: entries,
147
+ keep: (admitted) => {
148
+ const withheld = entries.filter((_, index) => !admitted[index]).map(({ id }) => id);
149
+ return {
150
+ ...event,
151
+ values: entries.filter((_, index) => admitted[index]),
152
+ ...(withheld.length > 0 && { withheld }),
153
+ };
154
+ },
155
+ },
156
+ );
157
+ }
158
+
159
+ /** One publish: the event of the writes that ended since the last publish. */
160
+ async function publish(chain: ChangeChain, storage: ChangeLog, bus: EventBus, topic: string, values: boolean): Promise<void> {
161
+ const writes = chain.ended.splice(0);
162
+ // An earlier publish told these writes
163
+ if (writes.length === 0) return;
164
+ if (!bus.hasSubscribers(topic)) {
165
+ // No one listens: the procedures do not read the log. The next call of `changes` starts the chain again.
166
+ chain.anchor = undefined;
167
+ return;
168
+ }
169
+ const since = chain.anchor;
170
+ if (!storage.getChangesSince || since === undefined) {
171
+ bus.emit(topic, hint(undefined, originOf(writes, undefined)));
172
+ return;
173
+ }
174
+ let changes: StorageChanges<unknown>;
175
+ try {
176
+ changes = await storage.getChangesSince(since);
177
+ } catch {
178
+ // The subscribers read the changes themselves. The chain keeps its version.
179
+ bus.emit(topic, hint(undefined, originOf(writes, undefined)));
180
+ return;
181
+ }
182
+ chain.anchor = changes.version;
183
+ // A full result: the log does not know the version of the chain any more
184
+ if (changes.full || writes.some((write) => !write.anchored)) {
185
+ bus.emit(topic, hint(changes.version, originOf(writes, undefined)));
186
+ return;
187
+ }
188
+ if (changes.changed.length === 0 && changes.deleted.length === 0) return;
189
+ const origin = originOf(writes, changes);
190
+ const event: CollectionChangeEvent = {
191
+ since,
192
+ version: changes.version,
193
+ changed: changes.changed.map(([id]) => id),
194
+ deleted: changes.deleted,
195
+ ...(origin !== undefined && { origin }),
196
+ };
197
+ bus.emit(topic, values ? withValues(event, changes.changed.map(([id, value]) => ({ id, value }))) : event);
198
+ }
199
+
200
+ /**
201
+ * This function runs a write of a collection procedure, and then publishes the change event of
202
+ * the write on the bus. Without a bus or a topic, it only runs the write. A failed write
203
+ * publishes too, because a batch can change a part of the storage. The error of the publish
204
+ * never reaches the caller.
205
+ *
206
+ * @param bus - The bus of the context, or undefined
207
+ * @param storage - The storage of the collection: the chain of the events belongs to it
208
+ * @param topic - The topic of the events, or undefined for no event
209
+ * @param ids - The ids of the write, or "*" for a clear
210
+ * @param origin - The origin of the write
211
+ * @param write - The write
212
+ * @param values - True: the events carry the values, for the output filter of each subscriber
213
+ */
214
+ export async function writeWithChangeEvent<R>(
215
+ bus: EventBus | undefined,
216
+ storage: object & ChangeLog,
217
+ topic: string | undefined,
218
+ ids: readonly string[] | "*",
219
+ origin: string | undefined,
220
+ write: () => Promise<R>,
221
+ values = false,
222
+ ): Promise<R> {
223
+ if (bus === undefined || topic === undefined) return write();
224
+ const chain = chainOf(storage, topic);
225
+ const started: EndedWrite = { ids, origin, anchored: chain.anchor !== undefined };
226
+ try {
227
+ return await write();
228
+ } finally {
229
+ chain.ended.push(started);
230
+ const run = chain.tail.then(() => publish(chain, storage, bus, topic, values));
231
+ chain.tail = run.catch(() => undefined);
232
+ await chain.tail;
233
+ }
234
+ }
235
+
236
+ /**
237
+ * This function starts the chain of the events of a storage at the version of a call of
238
+ * `changes`, when the chain has no version. The subscriber that made the call then applies the
239
+ * next event without a read.
240
+ */
241
+ export function startChangeChain(bus: EventBus | undefined, storage: object, topic: string | undefined, version: string): void {
242
+ if (bus === undefined || topic === undefined) return;
243
+ const chain = chainOf(storage, topic);
244
+ if (chain.anchor === undefined) chain.anchor = version;
245
+ }