@procedures/client 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +20 -3
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/adapters/http/server/transport.d.ts +6 -0
  5. package/dist/adapters/http/server/transport.d.ts.map +1 -1
  6. package/dist/adapters/http/server/transport.js +10 -1
  7. package/dist/adapters/http/server/transport.js.map +1 -1
  8. package/dist/adapters/websocket/client/transport.d.ts +7 -3
  9. package/dist/adapters/websocket/client/transport.d.ts.map +1 -1
  10. package/dist/adapters/websocket/client/transport.js +9 -3
  11. package/dist/adapters/websocket/client/transport.js.map +1 -1
  12. package/dist/adapters/websocket/server/transport.d.ts +10 -0
  13. package/dist/adapters/websocket/server/transport.d.ts.map +1 -1
  14. package/dist/adapters/websocket/server/transport.js +22 -2
  15. package/dist/adapters/websocket/server/transport.js.map +1 -1
  16. package/dist/browser.d.ts +4 -4
  17. package/dist/browser.d.ts.map +1 -1
  18. package/dist/browser.js +1 -1
  19. package/dist/browser.js.map +1 -1
  20. package/dist/client/errors/registry.d.ts.map +1 -1
  21. package/dist/client/errors/registry.js +1 -0
  22. package/dist/client/errors/registry.js.map +1 -1
  23. package/dist/client/middleware/retry.d.ts +4 -0
  24. package/dist/client/middleware/retry.d.ts.map +1 -1
  25. package/dist/client/middleware/retry.js +86 -78
  26. package/dist/client/middleware/retry.js.map +1 -1
  27. package/dist/events/bus.d.ts +32 -14
  28. package/dist/events/bus.d.ts.map +1 -1
  29. package/dist/events/bus.js +119 -65
  30. package/dist/events/bus.js.map +1 -1
  31. package/dist/events/index.d.ts +3 -2
  32. package/dist/events/index.d.ts.map +1 -1
  33. package/dist/events/index.js.map +1 -1
  34. package/dist/events/publisher.d.ts.map +1 -1
  35. package/dist/events/publisher.js +20 -6
  36. package/dist/events/publisher.js.map +1 -1
  37. package/dist/events/remote.d.ts +80 -18
  38. package/dist/events/remote.d.ts.map +1 -1
  39. package/dist/events/remote.js +448 -58
  40. package/dist/events/remote.js.map +1 -1
  41. package/dist/events/types.d.ts +13 -2
  42. package/dist/events/types.d.ts.map +1 -1
  43. package/dist/procedures/caller.d.ts +33 -0
  44. package/dist/procedures/caller.d.ts.map +1 -0
  45. package/dist/procedures/caller.js +27 -0
  46. package/dist/procedures/caller.js.map +1 -0
  47. package/dist/procedures/collection/index.d.ts +2 -2
  48. package/dist/procedures/collection/index.d.ts.map +1 -1
  49. package/dist/procedures/collection/index.js +1 -1
  50. package/dist/procedures/collection/index.js.map +1 -1
  51. package/dist/procedures/collection/procedures.d.ts.map +1 -1
  52. package/dist/procedures/collection/procedures.js +21 -1
  53. package/dist/procedures/collection/procedures.js.map +1 -1
  54. package/dist/procedures/collection/schemas.d.ts +9 -0
  55. package/dist/procedures/collection/schemas.d.ts.map +1 -1
  56. package/dist/procedures/collection/schemas.js +28 -0
  57. package/dist/procedures/collection/schemas.js.map +1 -1
  58. package/dist/procedures/collection/storage.d.ts +6 -0
  59. package/dist/procedures/collection/storage.d.ts.map +1 -1
  60. package/dist/procedures/index.d.ts +3 -1
  61. package/dist/procedures/index.d.ts.map +1 -1
  62. package/dist/procedures/index.js +2 -0
  63. package/dist/procedures/index.js.map +1 -1
  64. package/dist/procedures/invoke.d.ts.map +1 -1
  65. package/dist/procedures/invoke.js +3 -0
  66. package/dist/procedures/invoke.js.map +1 -1
  67. package/dist/procedures/types.d.ts +50 -0
  68. package/dist/procedures/types.d.ts.map +1 -1
  69. package/dist/procedures/types.js.map +1 -1
  70. package/dist/protocol.d.ts +1 -1
  71. package/dist/protocol.d.ts.map +1 -1
  72. package/dist/protocol.js +1 -0
  73. package/dist/protocol.js.map +1 -1
  74. package/dist/server/procedure-server.d.ts +37 -3
  75. package/dist/server/procedure-server.d.ts.map +1 -1
  76. package/dist/server/procedure-server.js +86 -4
  77. package/dist/server/procedure-server.js.map +1 -1
  78. package/dist/timers.d.ts +5 -0
  79. package/dist/timers.d.ts.map +1 -1
  80. package/dist/timers.js +3 -1
  81. package/dist/timers.js.map +1 -1
  82. package/package.json +1 -1
  83. package/src/adapters/http/server/transport.ts +11 -1
  84. package/src/adapters/websocket/client/transport.ts +9 -3
  85. package/src/adapters/websocket/server/transport.ts +23 -2
  86. package/src/browser.ts +6 -0
  87. package/src/client/errors/registry.ts +1 -0
  88. package/src/client/middleware/retry.ts +89 -78
  89. package/src/events/bus.ts +141 -73
  90. package/src/events/index.ts +3 -0
  91. package/src/events/publisher.ts +23 -6
  92. package/src/events/remote.ts +575 -73
  93. package/src/events/types.ts +15 -2
  94. package/src/procedures/caller.ts +45 -0
  95. package/src/procedures/collection/index.ts +3 -0
  96. package/src/procedures/collection/procedures.ts +22 -0
  97. package/src/procedures/collection/schemas.ts +37 -0
  98. package/src/procedures/collection/storage.ts +6 -0
  99. package/src/procedures/index.ts +4 -0
  100. package/src/procedures/invoke.ts +2 -0
  101. package/src/procedures/types.ts +53 -0
  102. package/src/protocol.ts +3 -1
  103. package/src/server/procedure-server.ts +103 -6
  104. package/src/timers.ts +8 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package",
3
3
  "name": "@procedures/client",
4
- "version": "0.7.0",
4
+ "version": "0.8.0",
5
5
  "description": "Procedures as data for TypeScript: define typed procedures once and call them in process, over HTTP, WebSocket, the CLI or MCP. Streaming, middleware, retries, caching and a JSON program format.",
6
6
  "keywords": [
7
7
  "rpc",
@@ -566,7 +566,7 @@ export class HttpServerTransport implements ServerTransport {
566
566
 
567
567
  server.listen(this.options.port, this.options.host, () => {
568
568
  console.log(
569
- `[${this.name}] Server listening on http://${this.options.host}:${this.options.port}`
569
+ `[${this.name}] Server listening on http://${this.options.host}:${this.port() ?? this.options.port}`
570
570
  );
571
571
  resolve();
572
572
  });
@@ -610,4 +610,14 @@ export class HttpServerTransport implements ServerTransport {
610
610
  isRunning(): boolean {
611
611
  return !this.stopped && this.httpServer !== null && this.httpServer.listening;
612
612
  }
613
+
614
+ /**
615
+ * The port that the server of the transport listens on. With the option `port` 0, the system
616
+ * gives a free port, and this function returns that port. Before `start()` and after `stop()`,
617
+ * the function returns undefined.
618
+ */
619
+ port(): number | undefined {
620
+ const address = this.httpServer?.listening ? this.httpServer.address() : null;
621
+ return typeof address === "object" && address !== null ? address.port : undefined;
622
+ }
613
623
  }
@@ -874,11 +874,12 @@ export class WebSocketTransport implements Transport {
874
874
  /**
875
875
  * This function subscribes to topics of the bus of the server (`documentation/EVENTS.md`). The
876
876
  * handler gets each event in order. After a drop, the transport sends the subscription again on
877
- * the new connection. The events between the drop and the new confirmation are lost.
877
+ * the new connection. The events between the drop and the new confirmation are lost. The
878
+ * subscribers of the same topics with the same options share one call.
878
879
  *
879
880
  * @param topics - One topic, or a list of 1 to 64 topics
880
881
  * @param handler - The handler of each event. A promise that it gives holds the next event.
881
- * @param options - `onSubscribed`, `onError`, a signal, the metadata of the call
882
+ * @param options - `onSubscribed`, `onError`, a signal, the metadata of the call, the resubscribe rules
882
883
  * @returns The unsubscribe function, with the `ready` promise of the first confirmation
883
884
  *
884
885
  * @example
@@ -904,12 +905,17 @@ export class WebSocketTransport implements Transport {
904
905
  return remoteEvents<T>(this, topics, options, this.subscriptionHooks());
905
906
  }
906
907
 
907
- /** The parts of the subscriptions of this transport: its connection, its backoff, its close and `onEvent`. */
908
+ /**
909
+ * The parts of the subscriptions of this transport: its connection, its backoff, its
910
+ * `maxAttempts`, its clock, its close and `onEvent`.
911
+ */
908
912
  private subscriptionHooks(): SubscriptionHooks {
909
913
  return {
910
914
  lifetime: this.subscriptions.signal,
911
915
  ready: (signal) => this.whenConnected(signal),
912
916
  backoff: (attempt) => reconnectDelay(attempt, this.options.reconnect),
917
+ maxAttempts: this.options.reconnect.maxAttempts,
918
+ clock: this.clock,
913
919
  onEvent: (event) => {
914
920
  if (!this.options.onEvent) return;
915
921
  try {
@@ -90,6 +90,12 @@ export class WebSocketServerTransport implements ServerTransport {
90
90
  */
91
91
  private readonly channels = new Map<string, { incoming: IncomingCalls; outgoing: OutgoingCalls }>();
92
92
 
93
+ /**
94
+ * The transport of the remote events of each connection: one object for each connection, so the
95
+ * subscriptions of one connection to the same topics share one call (EVENTS.md).
96
+ */
97
+ private readonly eventTransports = new Map<string, Transport>();
98
+
93
99
  /** The browser checks of each connection (see `server/browser-guard.ts`) */
94
100
  private readonly guard: BrowserGuardOptions;
95
101
 
@@ -303,6 +309,7 @@ export class WebSocketServerTransport implements ServerTransport {
303
309
  channel?.incoming.abortAll();
304
310
  channel?.outgoing.failAll("WebSocket connection closed");
305
311
  this.channels.delete(connectionId);
312
+ this.eventTransports.delete(connectionId);
306
313
  const conn = this.trackedConnections.get(connectionId);
307
314
  if (conn) {
308
315
  this.trackedConnections.delete(connectionId);
@@ -552,7 +559,7 @@ export class WebSocketServerTransport implements ServerTransport {
552
559
  handler: RemoteEventHandler<T>,
553
560
  options: SubscribeOptions = {},
554
561
  ): Subscription {
555
- return subscribeRemote<T>(this.transportFor(connectionId), topics, handler, options, { resubscribe: false });
562
+ return subscribeRemote<T>(this.eventTransportFor(connectionId), topics, handler, options, { resubscribe: false });
556
563
  }
557
564
 
558
565
  /**
@@ -564,7 +571,21 @@ export class WebSocketServerTransport implements ServerTransport {
564
571
  * @param options - A signal, `onSubscribed`, the metadata of the call
565
572
  */
566
573
  events<T = unknown>(connectionId: string, topics: string | readonly string[], options: RemoteEventsOptions = {}): AsyncIterable<RemoteEvent<T>> {
567
- return remoteEvents<T>(this.transportFor(connectionId), topics, options, { resubscribe: false });
574
+ return remoteEvents<T>(this.eventTransportFor(connectionId), topics, options, { resubscribe: false });
575
+ }
576
+
577
+ /**
578
+ * The transport of the remote events of a connection: the same object for each subscription of
579
+ * a tracked connection. The close of the connection removes it.
580
+ */
581
+ private eventTransportFor(connectionId: string): Transport {
582
+ if (!this.trackedConnections.has(connectionId)) return this.transportFor(connectionId);
583
+ let transport = this.eventTransports.get(connectionId);
584
+ if (!transport) {
585
+ transport = this.transportFor(connectionId);
586
+ this.eventTransports.set(connectionId, transport);
587
+ }
588
+ return transport;
568
589
  }
569
590
 
570
591
  /**
package/src/browser.ts CHANGED
@@ -128,6 +128,7 @@ export {
128
128
  registerModules,
129
129
  createRegistry,
130
130
  registerClientProcedures,
131
+ callerOf,
131
132
  // Procedure reference system (procedure-as-data)
132
133
  PROCEDURE_SYMBOL,
133
134
  PROCEDURE_JSON_KEY,
@@ -213,6 +214,7 @@ export {
213
214
 
214
215
  export type {
215
216
  Effects,
217
+ FsRootRules,
216
218
  PermittedEffects,
217
219
  Register,
218
220
  ClearanceOf,
@@ -235,6 +237,8 @@ export type {
235
237
  StorageMetadata,
236
238
  ProcedureModule,
237
239
  ModuleInput,
240
+ Caller,
241
+ CallerOptions,
238
242
  RegistrationOptions,
239
243
  ProcedureRegistryLike,
240
244
  ProcedureDefinition,
@@ -411,6 +415,7 @@ export type {
411
415
  TypedEventBus,
412
416
  EventHandler,
413
417
  EventBusOptions,
418
+ EventListenOptions,
414
419
  EventWaitOptions,
415
420
  ChannelMap,
416
421
  } from "./events/index.js";
@@ -424,6 +429,7 @@ export type {
424
429
  RemoteEvent,
425
430
  RemoteEventHandler,
426
431
  RemoteEventsOptions,
432
+ ResubscribeBackoff,
427
433
  SubscribeOptions,
428
434
  Subscription,
429
435
  } from "./events/index.js";
@@ -84,6 +84,7 @@ const PROTOCOL_CATEGORIES: Readonly<Record<ProtocolErrorCode, ErrorCategory>> =
84
84
  CONNECTION_CLOSED: ErrorCategory.NETWORK,
85
85
  CONNECTION_NOT_FOUND: ErrorCategory.CLIENT,
86
86
  TOPIC_NOT_FOUND: ErrorCategory.CLIENT,
87
+ TOPIC_CLEARED: ErrorCategory.CANCELLED,
87
88
  };
88
89
 
89
90
  /**
@@ -108,6 +108,10 @@ export interface RetryOptions {
108
108
  /**
109
109
  * Hook called after each retry completes (success or failure).
110
110
  *
111
+ * The middleware calls it when the call ends: after the last item, or when the reader stops
112
+ * reading. A `Client` with `throwOnError` stops at the error item. Before, the hook then did
113
+ * not run, so it did not hear of a failure. A thrown error and an abort do not call it.
114
+ *
111
115
  * @param success - Whether the retry succeeded
112
116
  * @param attempt - Attempt number that just completed
113
117
  */
@@ -249,99 +253,106 @@ export function createRetryMiddleware(options: RetryOptions = {}): TypedClientMi
249
253
  const idempotencyKey = idempotencyKeyOf(context.message.metadata) ?? newIdempotencyKey();
250
254
  context.message.metadata[IDEMPOTENCY_KEY_METADATA] = idempotencyKey;
251
255
 
252
- let attempt = 0;
253
- for (;;) {
254
- // Update retry metadata with current state
255
- context.message.metadata.retry = {
256
- ...(typeof given === "object" && given !== null ? (given as object) : {}),
257
- attempt,
258
- maxAttempts: maxRetries + 1,
259
- idempotent,
260
- };
261
- // Each attempt gets its own request id. The id correlates the frames of one attempt
262
- // on the wire: a cancelled attempt can still answer under its id (deep dive TRN-8/9).
263
- context.message.id = attempt === 0 ? baseId : `${baseId}.retry${attempt}`;
256
+ // The outcome of the call for onAfterRetry. The hook runs in `finally`: a reader that stops
257
+ // at an error item (a Client with throwOnError) closes this generator at its yield. Before,
258
+ // the hook came after the yield, so it did not hear of a failure.
259
+ let outcome: { success: boolean; attempt: number } | undefined;
260
+ try {
261
+ let attempt = 0;
262
+ for (;;) {
263
+ // Update retry metadata with current state
264
+ context.message.metadata.retry = {
265
+ ...(typeof given === "object" && given !== null ? (given as object) : {}),
266
+ attempt,
267
+ maxAttempts: maxRetries + 1,
268
+ idempotent,
269
+ };
270
+ // Each attempt gets its own request id. The id correlates the frames of one attempt
271
+ // on the wire: a cancelled attempt can still answer under its id (deep dive TRN-8/9).
272
+ context.message.id = attempt === 0 ? baseId : `${baseId}.retry${attempt}`;
264
273
 
265
- // Check if cancelled before attempt
266
- if (signal?.aborted) {
267
- yield abortedItem<TRes>(context.message.id);
268
- return;
269
- }
274
+ // Check if cancelled before attempt
275
+ if (signal?.aborted) {
276
+ yield abortedItem<TRes>(context.message.id);
277
+ return;
278
+ }
270
279
 
271
- // The items pass through as they arrive (BUGS-2026-07 M2: before, the middleware
272
- // collected the whole stream first). A retry is possible only while no item has
273
- // reached the reader: the first item is a retryable error, or the attempt throws
274
- // before its first item. After that, a retry would repeat items.
275
- let started = false;
276
- let lastItem: ResponseItem<TRes> | undefined;
277
- let retryItem: ResponseItem<TRes> | undefined;
278
- try {
279
- for await (const item of next(context)) {
280
- if (!started && attempt < maxRetries && shouldRetryItem(item, attempt)) {
281
- retryItem = item;
282
- break;
280
+ // The items pass through as they arrive (BUGS-2026-07 M2: before, the middleware
281
+ // collected the whole stream first). A retry is possible only while no item has
282
+ // reached the reader: the first item is a retryable error, or the attempt throws
283
+ // before its first item. After that, a retry would repeat items.
284
+ let started = false;
285
+ let lastItem: ResponseItem<TRes> | undefined;
286
+ let retryItem: ResponseItem<TRes> | undefined;
287
+ try {
288
+ for await (const item of next(context)) {
289
+ if (!started && attempt < maxRetries && shouldRetryItem(item, attempt)) {
290
+ retryItem = item;
291
+ break;
292
+ }
293
+ started = true;
294
+ lastItem = item;
295
+ outcome = { success: item.status.type === "success", attempt };
296
+ yield item;
283
297
  }
284
- started = true;
285
- lastItem = item;
286
- yield item;
287
- }
288
- } catch (error) {
289
- const isAborted = signal?.aborted || (error instanceof Error && error.name === "AbortError");
290
- if (started || isAborted || attempt >= maxRetries) {
291
- throw error;
298
+ } catch (error) {
299
+ // A thrown error does not call onAfterRetry
300
+ outcome = undefined;
301
+ const isAborted = signal?.aborted || (error instanceof Error && error.name === "AbortError");
302
+ if (started || isAborted || attempt >= maxRetries) {
303
+ throw error;
304
+ }
305
+ // A thrown error is retried only when it is a network failure, or when shouldRetry
306
+ // says so. Before, every thrown error was retried: a validation error, an open
307
+ // circuit breaker, a rate limit (deep dive TRN-8).
308
+ const item = thrownItem<TRes>(context.message.id, error);
309
+ if (!shouldRetryItem(item, attempt)) {
310
+ throw error;
311
+ }
312
+ retryItem = item;
292
313
  }
293
- // A thrown error is retried only when it is a network failure, or when shouldRetry
294
- // says so. Before, every thrown error was retried: a validation error, an open
295
- // circuit breaker, a rate limit (deep dive TRN-8).
296
- const item = thrownItem<TRes>(context.message.id, error);
297
- if (!shouldRetryItem(item, attempt)) {
298
- throw error;
314
+
315
+ if (retryItem === undefined) {
316
+ // Success, a non-retryable error, or the last attempt
317
+ outcome = { success: lastItem?.status.type === "success", attempt };
318
+ return;
299
319
  }
300
- retryItem = item;
301
- }
302
320
 
303
- if (retryItem === undefined) {
304
- // Success, a non-retryable error, or the last attempt
305
- if (onAfterRetry) {
306
- await onAfterRetry(lastItem?.status.type === "success", attempt);
321
+ // The budget is empty: the error goes to the caller (R12)
322
+ if (!takeRetry()) {
323
+ outcome = { success: false, attempt };
324
+ yield retryItem;
325
+ return;
307
326
  }
308
- return;
309
- }
310
327
 
311
- // The budget is empty: the error goes to the caller (R12)
312
- if (!takeRetry()) {
313
- yield retryItem;
314
- if (onAfterRetry) await onAfterRetry(false, attempt);
315
- return;
316
- }
328
+ // A server that asks for a wait (Retry-After) gets at least that wait (R12)
329
+ let delay = Math.max(retryDelayOf(attempt, overrides), retryAfterOf(retryItem) ?? 0);
317
330
 
318
- // A server that asks for a wait (Retry-After) gets at least that wait (R12)
319
- let delay = Math.max(retryDelayOf(attempt, overrides), retryAfterOf(retryItem) ?? 0);
331
+ // Before retry hook
332
+ if (onBeforeRetry) {
333
+ const hookResult = await onBeforeRetry(retryItem, attempt);
334
+ if (!hookResult.shouldRetry) {
335
+ // Hook decided not to retry - yield the error and return
336
+ outcome = { success: false, attempt };
337
+ yield retryItem;
338
+ return;
339
+ }
320
340
 
321
- // Before retry hook
322
- if (onBeforeRetry) {
323
- const hookResult = await onBeforeRetry(retryItem, attempt);
324
- if (!hookResult.shouldRetry) {
325
- // Hook decided not to retry - yield the error and return
326
- yield retryItem;
327
- if (onAfterRetry) {
328
- await onAfterRetry(false, attempt);
341
+ // Use custom delay if provided by hook
342
+ if (hookResult.delayMs !== undefined) {
343
+ delay = hookResult.delayMs;
329
344
  }
330
- return;
331
345
  }
332
346
 
333
- // Use custom delay if provided by hook
334
- if (hookResult.delayMs !== undefined) {
335
- delay = hookResult.delayMs;
347
+ // The backoff stops at once when the caller aborts (deep dive TRN-8)
348
+ if (!(await sleep(delay, signal, clock))) {
349
+ yield abortedItem<TRes>(context.message.id);
350
+ return;
336
351
  }
352
+ attempt++;
337
353
  }
338
-
339
- // The backoff stops at once when the caller aborts (deep dive TRN-8)
340
- if (!(await sleep(delay, signal, clock))) {
341
- yield abortedItem<TRes>(context.message.id);
342
- return;
343
- }
344
- attempt++;
354
+ } finally {
355
+ if (outcome && onAfterRetry) await onAfterRetry(outcome.success, outcome.attempt);
345
356
  }
346
357
  };
347
358
  };