@procedures/client 0.47.2 → 0.48.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 (121) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/adapters/http/client/batch.d.ts.map +1 -1
  4. package/dist/adapters/http/client/batch.js.map +1 -1
  5. package/dist/adapters/http/server/body.d.ts.map +1 -1
  6. package/dist/adapters/http/server/body.js.map +1 -1
  7. package/dist/adapters/http/shared/constants.d.ts +82 -78
  8. package/dist/adapters/http/shared/constants.d.ts.map +1 -1
  9. package/dist/adapters/http/shared/constants.js +78 -80
  10. package/dist/adapters/http/shared/constants.js.map +1 -1
  11. package/dist/adapters/http/shared/errors.d.ts +15 -13
  12. package/dist/adapters/http/shared/errors.d.ts.map +1 -1
  13. package/dist/adapters/http/shared/errors.js +13 -14
  14. package/dist/adapters/http/shared/errors.js.map +1 -1
  15. package/dist/adapters/port/transport.d.ts.map +1 -1
  16. package/dist/adapters/port/transport.js.map +1 -1
  17. package/dist/adapters/sequence.d.ts.map +1 -1
  18. package/dist/adapters/sequence.js.map +1 -1
  19. package/dist/adapters/websocket/client/types.d.ts +16 -6
  20. package/dist/adapters/websocket/client/types.d.ts.map +1 -1
  21. package/dist/adapters/websocket/client/types.js +7 -8
  22. package/dist/adapters/websocket/client/types.js.map +1 -1
  23. package/dist/adapters/websocket/shared/rpc.d.ts.map +1 -1
  24. package/dist/adapters/websocket/shared/rpc.js.map +1 -1
  25. package/dist/browser.d.ts +1 -1
  26. package/dist/browser.d.ts.map +1 -1
  27. package/dist/browser.js.map +1 -1
  28. package/dist/client/batch-executor.d.ts.map +1 -1
  29. package/dist/client/batch-executor.js.map +1 -1
  30. package/dist/client/client.d.ts.map +1 -1
  31. package/dist/client/client.js +3 -4
  32. package/dist/client/client.js.map +1 -1
  33. package/dist/client/errors/codes.d.ts +66 -64
  34. package/dist/client/errors/codes.d.ts.map +1 -1
  35. package/dist/client/errors/codes.js +64 -65
  36. package/dist/client/errors/codes.js.map +1 -1
  37. package/dist/client/errors/http-status.d.ts +69 -67
  38. package/dist/client/errors/http-status.d.ts.map +1 -1
  39. package/dist/client/errors/http-status.js +67 -68
  40. package/dist/client/errors/http-status.js.map +1 -1
  41. package/dist/client/errors/types.d.ts +38 -15
  42. package/dist/client/errors/types.d.ts.map +1 -1
  43. package/dist/client/errors/types.js +17 -19
  44. package/dist/client/errors/types.js.map +1 -1
  45. package/dist/client/middleware/circuit-breaker.d.ts.map +1 -1
  46. package/dist/client/middleware/circuit-breaker.js.map +1 -1
  47. package/dist/client/middleware/lru.d.ts +1 -1
  48. package/dist/client/middleware/lru.d.ts.map +1 -1
  49. package/dist/client/middleware/lru.js.map +1 -1
  50. package/dist/client/middleware/rate-limit.d.ts.map +1 -1
  51. package/dist/client/middleware/rate-limit.js.map +1 -1
  52. package/dist/client/middleware/resilience.d.ts.map +1 -1
  53. package/dist/client/middleware/resilience.js +2 -1
  54. package/dist/client/middleware/resilience.js.map +1 -1
  55. package/dist/client/route-resolver.d.ts.map +1 -1
  56. package/dist/client/route-resolver.js.map +1 -1
  57. package/dist/client/types.d.ts.map +1 -1
  58. package/dist/client/types.js.map +1 -1
  59. package/dist/client/validation/types.d.ts +1 -7
  60. package/dist/client/validation/types.d.ts.map +1 -1
  61. package/dist/client/validation/types.js +4 -7
  62. package/dist/client/validation/types.js.map +1 -1
  63. package/dist/events/bus.d.ts +35 -0
  64. package/dist/events/bus.d.ts.map +1 -1
  65. package/dist/events/bus.js +135 -22
  66. package/dist/events/bus.js.map +1 -1
  67. package/dist/events/index.d.ts +1 -1
  68. package/dist/events/index.d.ts.map +1 -1
  69. package/dist/events/index.js.map +1 -1
  70. package/dist/events/publisher.d.ts.map +1 -1
  71. package/dist/events/publisher.js +4 -7
  72. package/dist/events/publisher.js.map +1 -1
  73. package/dist/events/recheck.d.ts.map +1 -1
  74. package/dist/events/recheck.js.map +1 -1
  75. package/dist/events/remote.d.ts +1 -7
  76. package/dist/events/remote.d.ts.map +1 -1
  77. package/dist/events/remote.js +4 -7
  78. package/dist/events/remote.js.map +1 -1
  79. package/dist/events/types.d.ts +30 -0
  80. package/dist/events/types.d.ts.map +1 -1
  81. package/dist/middleware/compose.d.ts.map +1 -1
  82. package/dist/middleware/compose.js +6 -10
  83. package/dist/middleware/compose.js.map +1 -1
  84. package/dist/procedures/collection/hint-filter.d.ts +1 -7
  85. package/dist/procedures/collection/hint-filter.d.ts.map +1 -1
  86. package/dist/procedures/collection/hint-filter.js +4 -7
  87. package/dist/procedures/collection/hint-filter.js.map +1 -1
  88. package/dist/server/types.d.ts.map +1 -1
  89. package/dist/server/types.js.map +1 -1
  90. package/package.json +7 -4
  91. package/src/adapters/http/client/batch.ts +4 -1
  92. package/src/adapters/http/server/body.ts +4 -1
  93. package/src/adapters/http/shared/constants.ts +84 -78
  94. package/src/adapters/http/shared/errors.ts +16 -13
  95. package/src/adapters/port/transport.ts +4 -1
  96. package/src/adapters/sequence.ts +5 -1
  97. package/src/adapters/websocket/client/types.ts +19 -6
  98. package/src/adapters/websocket/shared/rpc.ts +15 -3
  99. package/src/browser.ts +1 -0
  100. package/src/client/batch-executor.ts +5 -1
  101. package/src/client/client.ts +3 -4
  102. package/src/client/errors/README.md +12 -12
  103. package/src/client/errors/codes.ts +67 -64
  104. package/src/client/errors/http-status.ts +70 -67
  105. package/src/client/errors/types.ts +44 -15
  106. package/src/client/middleware/circuit-breaker.ts +7 -2
  107. package/src/client/middleware/lru.ts +7 -2
  108. package/src/client/middleware/rate-limit.ts +4 -1
  109. package/src/client/middleware/resilience.ts +2 -1
  110. package/src/client/route-resolver.ts +5 -1
  111. package/src/client/types.ts +7 -2
  112. package/src/client/validation/types.ts +13 -6
  113. package/src/events/bus.ts +152 -23
  114. package/src/events/index.ts +1 -0
  115. package/src/events/publisher.ts +22 -9
  116. package/src/events/recheck.ts +4 -1
  117. package/src/events/remote.ts +33 -12
  118. package/src/events/types.ts +34 -0
  119. package/src/middleware/compose.ts +6 -22
  120. package/src/procedures/collection/hint-filter.ts +13 -6
  121. package/src/server/types.ts +7 -2
@@ -1,82 +1,85 @@
1
1
  /**
2
2
  * HTTP Status Code Constants
3
3
  *
4
- * Comprehensive enum of HTTP status codes.
4
+ * All HTTP status codes.
5
5
  * NO MAGIC NUMBERS - use these constants everywhere!
6
6
  */
7
7
 
8
- export enum HTTPStatus {
8
+ export const HTTPStatus = {
9
9
  // 1xx Informational
10
- CONTINUE = 100,
11
- SWITCHING_PROTOCOLS = 101,
12
- PROCESSING = 102,
13
- EARLY_HINTS = 103,
10
+ CONTINUE: 100,
11
+ SWITCHING_PROTOCOLS: 101,
12
+ PROCESSING: 102,
13
+ EARLY_HINTS: 103,
14
14
 
15
15
  // 2xx Success
16
- OK = 200,
17
- CREATED = 201,
18
- ACCEPTED = 202,
19
- NON_AUTHORITATIVE_INFORMATION = 203,
20
- NO_CONTENT = 204,
21
- RESET_CONTENT = 205,
22
- PARTIAL_CONTENT = 206,
23
- MULTI_STATUS = 207,
24
- ALREADY_REPORTED = 208,
25
- IM_USED = 226,
16
+ OK: 200,
17
+ CREATED: 201,
18
+ ACCEPTED: 202,
19
+ NON_AUTHORITATIVE_INFORMATION: 203,
20
+ NO_CONTENT: 204,
21
+ RESET_CONTENT: 205,
22
+ PARTIAL_CONTENT: 206,
23
+ MULTI_STATUS: 207,
24
+ ALREADY_REPORTED: 208,
25
+ IM_USED: 226,
26
26
 
27
27
  // 3xx Redirection
28
- MULTIPLE_CHOICES = 300,
29
- MOVED_PERMANENTLY = 301,
30
- FOUND = 302,
31
- SEE_OTHER = 303,
32
- NOT_MODIFIED = 304,
33
- USE_PROXY = 305,
34
- TEMPORARY_REDIRECT = 307,
35
- PERMANENT_REDIRECT = 308,
28
+ MULTIPLE_CHOICES: 300,
29
+ MOVED_PERMANENTLY: 301,
30
+ FOUND: 302,
31
+ SEE_OTHER: 303,
32
+ NOT_MODIFIED: 304,
33
+ USE_PROXY: 305,
34
+ TEMPORARY_REDIRECT: 307,
35
+ PERMANENT_REDIRECT: 308,
36
36
 
37
37
  // 4xx Client Errors
38
- BAD_REQUEST = 400,
39
- UNAUTHORIZED = 401,
40
- PAYMENT_REQUIRED = 402,
41
- FORBIDDEN = 403,
42
- NOT_FOUND = 404,
43
- METHOD_NOT_ALLOWED = 405,
44
- NOT_ACCEPTABLE = 406,
45
- PROXY_AUTHENTICATION_REQUIRED = 407,
46
- REQUEST_TIMEOUT = 408,
47
- CONFLICT = 409,
48
- GONE = 410,
49
- LENGTH_REQUIRED = 411,
50
- PRECONDITION_FAILED = 412,
51
- PAYLOAD_TOO_LARGE = 413,
52
- URI_TOO_LONG = 414,
53
- UNSUPPORTED_MEDIA_TYPE = 415,
54
- RANGE_NOT_SATISFIABLE = 416,
55
- EXPECTATION_FAILED = 417,
56
- IM_A_TEAPOT = 418,
57
- MISDIRECTED_REQUEST = 421,
58
- UNPROCESSABLE_ENTITY = 422,
59
- LOCKED = 423,
60
- FAILED_DEPENDENCY = 424,
61
- TOO_EARLY = 425,
62
- UPGRADE_REQUIRED = 426,
63
- PRECONDITION_REQUIRED = 428,
64
- TOO_MANY_REQUESTS = 429,
65
- REQUEST_HEADER_FIELDS_TOO_LARGE = 431,
66
- UNAVAILABLE_FOR_LEGAL_REASONS = 451,
67
- CLIENT_CLOSED_REQUEST = 499, // nginx-specific
38
+ BAD_REQUEST: 400,
39
+ UNAUTHORIZED: 401,
40
+ PAYMENT_REQUIRED: 402,
41
+ FORBIDDEN: 403,
42
+ NOT_FOUND: 404,
43
+ METHOD_NOT_ALLOWED: 405,
44
+ NOT_ACCEPTABLE: 406,
45
+ PROXY_AUTHENTICATION_REQUIRED: 407,
46
+ REQUEST_TIMEOUT: 408,
47
+ CONFLICT: 409,
48
+ GONE: 410,
49
+ LENGTH_REQUIRED: 411,
50
+ PRECONDITION_FAILED: 412,
51
+ PAYLOAD_TOO_LARGE: 413,
52
+ URI_TOO_LONG: 414,
53
+ UNSUPPORTED_MEDIA_TYPE: 415,
54
+ RANGE_NOT_SATISFIABLE: 416,
55
+ EXPECTATION_FAILED: 417,
56
+ IM_A_TEAPOT: 418,
57
+ MISDIRECTED_REQUEST: 421,
58
+ UNPROCESSABLE_ENTITY: 422,
59
+ LOCKED: 423,
60
+ FAILED_DEPENDENCY: 424,
61
+ TOO_EARLY: 425,
62
+ UPGRADE_REQUIRED: 426,
63
+ PRECONDITION_REQUIRED: 428,
64
+ TOO_MANY_REQUESTS: 429,
65
+ REQUEST_HEADER_FIELDS_TOO_LARGE: 431,
66
+ UNAVAILABLE_FOR_LEGAL_REASONS: 451,
67
+ CLIENT_CLOSED_REQUEST: 499, // nginx-specific
68
68
 
69
69
  // 5xx Server Errors
70
- INTERNAL_SERVER_ERROR = 500,
71
- NOT_IMPLEMENTED = 501,
72
- BAD_GATEWAY = 502,
73
- SERVICE_UNAVAILABLE = 503,
74
- GATEWAY_TIMEOUT = 504,
75
- HTTP_VERSION_NOT_SUPPORTED = 505,
76
- VARIANT_ALSO_NEGOTIATES = 506,
77
- INSUFFICIENT_STORAGE = 507,
78
- LOOP_DETECTED = 508,
79
- NOT_EXTENDED = 510,
80
- NETWORK_AUTHENTICATION_REQUIRED = 511,
81
- NETWORK_CONNECT_TIMEOUT = 599, // non-standard
82
- }
70
+ INTERNAL_SERVER_ERROR: 500,
71
+ NOT_IMPLEMENTED: 501,
72
+ BAD_GATEWAY: 502,
73
+ SERVICE_UNAVAILABLE: 503,
74
+ GATEWAY_TIMEOUT: 504,
75
+ HTTP_VERSION_NOT_SUPPORTED: 505,
76
+ VARIANT_ALSO_NEGOTIATES: 506,
77
+ INSUFFICIENT_STORAGE: 507,
78
+ LOOP_DETECTED: 508,
79
+ NOT_EXTENDED: 510,
80
+ NETWORK_AUTHENTICATION_REQUIRED: 511,
81
+ NETWORK_CONNECT_TIMEOUT: 599, // non-standard
82
+ } as const;
83
+
84
+ /** This type is the union of the values of `HTTPStatus`. */
85
+ export type HTTPStatus = (typeof HTTPStatus)[keyof typeof HTTPStatus];
@@ -8,39 +8,68 @@
8
8
  /**
9
9
  * Error severity levels.
10
10
  */
11
- export enum ErrorSeverity {
11
+ export const ErrorSeverity = {
12
12
  /** Informational - not an actual error */
13
- INFO = "info",
13
+ INFO: "info",
14
14
  /** Warning - degraded but functional */
15
- WARNING = "warning",
15
+ WARNING: "warning",
16
16
  /** Error - operation failed */
17
- ERROR = "error",
17
+ ERROR: "error",
18
18
  /** Critical - system-level failure */
19
- CRITICAL = "critical",
19
+ CRITICAL: "critical",
20
+ } as const;
21
+
22
+ /** This type is the union of the values of `ErrorSeverity`. */
23
+ export type ErrorSeverity = (typeof ErrorSeverity)[keyof typeof ErrorSeverity];
24
+
25
+ /** These types keep each member of `ErrorSeverity` usable as a type. */
26
+ // eslint-disable-next-line @typescript-eslint/no-namespace
27
+ export declare namespace ErrorSeverity {
28
+ export type INFO = typeof ErrorSeverity.INFO;
29
+ export type WARNING = typeof ErrorSeverity.WARNING;
30
+ export type ERROR = typeof ErrorSeverity.ERROR;
31
+ export type CRITICAL = typeof ErrorSeverity.CRITICAL;
20
32
  }
21
33
 
22
34
  /**
23
35
  * Error categories for classification.
24
36
  */
25
- export enum ErrorCategory {
37
+ export const ErrorCategory = {
26
38
  /** Network-level errors (connectivity, DNS, etc.) */
27
- NETWORK = "network",
39
+ NETWORK: "network",
28
40
  /** Client-side errors (validation, configuration) */
29
- CLIENT = "client",
41
+ CLIENT: "client",
30
42
  /** Server-side errors (5xx, internal errors) */
31
- SERVER = "server",
43
+ SERVER: "server",
32
44
  /** Protocol-level errors (parsing, encoding) */
33
- PROTOCOL = "protocol",
45
+ PROTOCOL: "protocol",
34
46
  /** Authentication/authorization errors */
35
- AUTH = "auth",
47
+ AUTH: "auth",
36
48
  /** Rate limiting and throttling */
37
- RATE_LIMIT = "rate_limit",
49
+ RATE_LIMIT: "rate_limit",
38
50
  /** Timeout errors */
39
- TIMEOUT = "timeout",
51
+ TIMEOUT: "timeout",
40
52
  /** Cancellation errors */
41
- CANCELLED = "cancelled",
53
+ CANCELLED: "cancelled",
42
54
  /** Unknown/uncategorized errors */
43
- UNKNOWN = "unknown",
55
+ UNKNOWN: "unknown",
56
+ } as const;
57
+
58
+ /** This type is the union of the values of `ErrorCategory`. */
59
+ export type ErrorCategory = (typeof ErrorCategory)[keyof typeof ErrorCategory];
60
+
61
+ /** These types keep each member of `ErrorCategory` usable as a type. */
62
+ // eslint-disable-next-line @typescript-eslint/no-namespace
63
+ export declare namespace ErrorCategory {
64
+ export type NETWORK = typeof ErrorCategory.NETWORK;
65
+ export type CLIENT = typeof ErrorCategory.CLIENT;
66
+ export type SERVER = typeof ErrorCategory.SERVER;
67
+ export type PROTOCOL = typeof ErrorCategory.PROTOCOL;
68
+ export type AUTH = typeof ErrorCategory.AUTH;
69
+ export type RATE_LIMIT = typeof ErrorCategory.RATE_LIMIT;
70
+ export type TIMEOUT = typeof ErrorCategory.TIMEOUT;
71
+ export type CANCELLED = typeof ErrorCategory.CANCELLED;
72
+ export type UNKNOWN = typeof ErrorCategory.UNKNOWN;
44
73
  }
45
74
 
46
75
  /**
@@ -116,13 +116,18 @@ export interface CircuitBreakerStats {
116
116
  * Circuit breaker error thrown when circuit is open.
117
117
  */
118
118
  export class CircuitBreakerError extends Error {
119
+ public readonly state: CircuitState;
120
+ public readonly lastError: Error | null;
121
+
119
122
  constructor(
120
- public readonly state: CircuitState,
121
- public readonly lastError: Error | null
123
+ state: CircuitState,
124
+ lastError: Error | null
122
125
  ) {
123
126
  super(
124
127
  `Circuit breaker is ${state}${lastError ? `: ${lastError.message}` : ""}`
125
128
  );
129
+ this.state = state;
130
+ this.lastError = lastError;
126
131
  this.name = "CircuitBreakerError";
127
132
  }
128
133
  }
@@ -8,6 +8,9 @@
8
8
  * `hashMap` of that package).
9
9
  */
10
10
  export class LruMap<K, V> {
11
+ private readonly capacity: number;
12
+ private readonly onEvict?: ((key: K, value: V) => void) | undefined;
13
+
11
14
  private readonly entries = new Map<K, V>();
12
15
 
13
16
  /**
@@ -15,9 +18,11 @@ export class LruMap<K, V> {
15
18
  * @param onEvict - Called for each key that a write removes to keep the capacity.
16
19
  */
17
20
  constructor(
18
- private readonly capacity: number,
19
- private readonly onEvict?: (key: K, value: V) => void
21
+ capacity: number,
22
+ onEvict?: (key: K, value: V) => void
20
23
  ) {
24
+ this.capacity = capacity;
25
+ this.onEvict = onEvict;
21
26
  if (!Number.isInteger(capacity) || capacity < 1) {
22
27
  throw new RangeError(`The capacity of an LRU map must be a positive whole number, not ${capacity}`);
23
28
  }
@@ -89,11 +89,14 @@ export interface RateLimitStats {
89
89
  * Rate limit error thrown when limit exceeded.
90
90
  */
91
91
  export class RateLimitError extends Error {
92
+ public readonly stats: RateLimitStats;
93
+
92
94
  constructor(
93
95
  message: string,
94
- public readonly stats: RateLimitStats
96
+ stats: RateLimitStats
95
97
  ) {
96
98
  super(message);
99
+ this.stats = stats;
97
100
  this.name = "RateLimitError";
98
101
  }
99
102
  }
@@ -16,6 +16,7 @@
16
16
  * call goes to the server as `procedure-timeout` with each try (`client/deadline.ts`).
17
17
  */
18
18
 
19
+ import { compose } from "@mark1russell7/collections/layers";
19
20
  import type { ClientMiddleware, ClientRunner, TypedClientMiddleware } from "../types.js";
20
21
  import type { CacheContext, CircuitBreakerContext, RateLimitContext, RetryContext, TimeoutContext } from "./contexts.js";
21
22
  import { createCacheMiddleware, type CacheOptions } from "./cache.js";
@@ -76,6 +77,6 @@ export function resilience(options: ResilienceOptions): TypedClientMiddleware<Re
76
77
  layers.push(createTimeoutMiddleware({ perAttempt: options.perAttempt, ...(message ? { message: `${message} (per-attempt)` } : {}) }) as ClientMiddleware);
77
78
  }
78
79
  const composed = <TReq, TRes>(next: ClientRunner<TReq, TRes>): ClientRunner<TReq, TRes> =>
79
- layers.reduceRight<ClientRunner<TReq, TRes>>((inner, layer) => (layer as ClientMiddleware<TReq, TRes>)(inner), next);
80
+ compose(...(layers as ClientMiddleware<TReq, TRes>[]))(next);
80
81
  return composed as TypedClientMiddleware<ResilienceContext, {}>;
81
82
  }
@@ -99,7 +99,11 @@ export interface RouteResolutionResult {
99
99
  * ```
100
100
  */
101
101
  export class RouteResolver {
102
- constructor(private readonly registry: ProcedureRegistry) {}
102
+ private readonly registry: ProcedureRegistry;
103
+
104
+ constructor(registry: ProcedureRegistry) {
105
+ this.registry = registry;
106
+ }
103
107
 
104
108
  /**
105
109
  * Resolve a route structure to procedures.
@@ -326,6 +326,9 @@ export type AccumulateContext<
326
326
  * Client error thrown when response has error status.
327
327
  */
328
328
  export class ClientError extends Error {
329
+ public readonly status: Extract<Status, { type: "error" }>;
330
+ public readonly responseId: string;
331
+
329
332
  /**
330
333
  * The metadata of the error response, for example `reason` of a `FORBIDDEN` error, or
331
334
  * `conflicts` of a `VERSION_CONFLICT` error (roadmap CLI-16). Empty when the response has none.
@@ -333,11 +336,13 @@ export class ClientError extends Error {
333
336
  public readonly metadata: Metadata;
334
337
 
335
338
  constructor(
336
- public readonly status: Extract<Status, { type: "error" }>,
337
- public readonly responseId: string,
339
+ status: Extract<Status, { type: "error" }>,
340
+ responseId: string,
338
341
  metadata: Metadata = {},
339
342
  ) {
340
343
  super(status.message);
344
+ this.status = status;
345
+ this.responseId = responseId;
341
346
  this.name = "ClientError";
342
347
  this.metadata = metadata;
343
348
  }
@@ -114,18 +114,25 @@ export type ValidationPhase = "input" | "output";
114
114
  * Error thrown when validation fails.
115
115
  */
116
116
  export class ValidationError extends Error {
117
+ /** Which phase of validation failed */
118
+ public readonly phase: ValidationPhase;
119
+ /** The underlying Zod error */
120
+ public readonly zodError: ZodErrorLike;
121
+ /** The method being called */
122
+ public readonly method: Method;
123
+
117
124
  override readonly name = "ValidationError";
118
125
 
119
126
  constructor(
120
127
  message: string,
121
- /** Which phase of validation failed */
122
- public readonly phase: ValidationPhase,
123
- /** The underlying Zod error */
124
- public readonly zodError: ZodErrorLike,
125
- /** The method being called */
126
- public readonly method: Method,
128
+ phase: ValidationPhase,
129
+ zodError: ZodErrorLike,
130
+ method: Method,
127
131
  ) {
128
132
  super(message);
133
+ this.phase = phase;
134
+ this.zodError = zodError;
135
+ this.method = method;
129
136
 
130
137
  // Maintain proper stack trace in V8 environments
131
138
  if (Error.captureStackTrace) {
package/src/events/bus.ts CHANGED
@@ -11,6 +11,7 @@ import type {
11
11
  EventHandler,
12
12
  Unsubscribe,
13
13
  EventBusOptions,
14
+ EventFanOutOptions,
14
15
  EventListenOptions,
15
16
  EventWaitOptions,
16
17
  } from "./types.js";
@@ -34,6 +35,31 @@ function clearedError(channel: string): Error {
34
35
  return error;
35
36
  }
36
37
 
38
+ /** The default of `fanOut.maxPending`. No bench chose it: a run of a slow channel can change it. */
39
+ const DEFAULT_MAX_PENDING = 1024;
40
+
41
+ /** The default of `fanOut.schedule`: a later task of the event loop. */
42
+ function nextTask(slice: () => void): void {
43
+ if (typeof setImmediate === "function") setImmediate(slice);
44
+ else setTimeout(slice, 0);
45
+ }
46
+
47
+ /** The handlers of a channel, each with the number of its subscription, in the order of these numbers. */
48
+ type ChannelHandlers = Map<EventHandler<unknown>, number>;
49
+
50
+ /** One event that waits for its slices. */
51
+ interface PendingEvent {
52
+ readonly channel: string;
53
+ readonly data: unknown;
54
+ /** The handlers of the channel at `emit()`. A new map of the channel gets no old event. */
55
+ readonly handlers: ChannelHandlers;
56
+ /** The live cursor: a removed handler gets no call, with no copy of the handlers per event. */
57
+ readonly cursor: Iterator<[EventHandler<unknown>, number]>;
58
+ /** The last subscription at `emit()`. A later handler does not get this event. */
59
+ readonly lastSubscription: number;
60
+ done: boolean;
61
+ }
62
+
37
63
  // =============================================================================
38
64
  // Event Bus Implementation
39
65
  // =============================================================================
@@ -43,16 +69,34 @@ function clearedError(channel: string): Error {
43
69
  * Supports pub/sub messaging with async iteration.
44
70
  */
45
71
  export class DefaultEventBus implements EventBus {
46
- private readonly subscribers = new Map<string, Set<EventHandler<unknown>>>();
72
+ private readonly subscribers = new Map<string, ChannelHandlers>();
73
+ /** The number of the last subscription of this bus. */
74
+ private lastSubscription = 0;
75
+ /** The events that wait for their slices, oldest first, from the index `pendingHead`. */
76
+ private pending: PendingEvent[] = [];
77
+ private pendingHead = 0;
78
+ /** For each channel, the number of its events in `pending`. */
79
+ private readonly pendingOn = new Map<string, number>();
80
+ private sliceScheduled = false;
81
+ private drainWaiters: Array<() => void> = [];
47
82
  private readonly buffer = new Map<string, unknown[]>();
48
83
  /**
49
84
  * For each channel, the functions that `clear()` runs: the `onClear` of each handler of `on()`
50
85
  * that has one. Each open stream has one too, so `clear()` ends it.
51
86
  */
52
87
  private readonly clearHandlers = new Map<string, Set<() => void>>();
53
- private readonly options: Required<EventBusOptions>;
88
+ private readonly options: Required<Omit<EventBusOptions, "fanOut">>;
89
+ private readonly fanOut: Required<EventFanOutOptions> | undefined;
54
90
 
55
91
  constructor(options: EventBusOptions = {}) {
92
+ const { fanOut } = options;
93
+ if (fanOut && (!Number.isInteger(fanOut.sliceSize) || fanOut.sliceSize < 1)) {
94
+ throw new RangeError(`The option fanOut.sliceSize must be a whole number from 1 up, not ${fanOut.sliceSize}`);
95
+ }
96
+ if (fanOut?.maxPending !== undefined && (!Number.isInteger(fanOut.maxPending) || fanOut.maxPending < 1)) {
97
+ throw new RangeError(`The option fanOut.maxPending must be a whole number from 1 up, not ${fanOut.maxPending}`);
98
+ }
99
+ this.fanOut = fanOut && { maxPending: DEFAULT_MAX_PENDING, schedule: nextTask, ...fanOut };
56
100
  this.options = {
57
101
  bufferSize: options.bufferSize ?? 0,
58
102
  onError: options.onError ?? ((error, channel) => {
@@ -64,6 +108,12 @@ export class DefaultEventBus implements EventBus {
64
108
 
65
109
  /**
66
110
  * Emit an event on a channel.
111
+ *
112
+ * Without the option `fanOut`, each handler runs before this method returns. With it, a channel
113
+ * with more than `fanOut.sliceSize` handlers delivers later, in slices (roadmap CLI-90). One
114
+ * handler gets the events of a channel in the order of `emit()`. An unsubscribe stops the calls
115
+ * that wait for that handler. A handler that comes after `emit()` does not get the event. The
116
+ * events of two different channels can reach a handler in another order.
67
117
  */
68
118
  emit<T>(channel: string, data: T): void {
69
119
  if (this.options.debug) {
@@ -87,19 +137,104 @@ export class DefaultEventBus implements EventBus {
87
137
  const channelSubs = this.subscribers.get(channel);
88
138
  if (!channelSubs) return;
89
139
 
90
- for (const handler of channelSubs) {
91
- try {
92
- const result = handler(data);
93
- // Handle async handlers
94
- if (result instanceof Promise) {
95
- result.catch((error) => this.options.onError(error, channel));
96
- }
97
- } catch (error) {
98
- this.options.onError(error instanceof Error ? error : new Error(String(error)), channel);
140
+ if (this.fanOut && (channelSubs.size > this.fanOut.sliceSize || this.pendingOn.has(channel))) {
141
+ this.enqueue(channel, data, channelSubs, this.fanOut);
142
+ return;
143
+ }
144
+
145
+ for (const handler of channelSubs.keys()) this.call(handler, data, channel);
146
+ }
147
+
148
+ /** This function calls one handler. An error goes to `onError`, and it stops no other handler. */
149
+ private call(handler: EventHandler<unknown>, data: unknown, channel: string): void {
150
+ try {
151
+ const result = handler(data);
152
+ // Handle async handlers
153
+ if (result instanceof Promise) {
154
+ result.catch((error) => this.options.onError(error, channel));
155
+ }
156
+ } catch (error) {
157
+ this.options.onError(error instanceof Error ? error : new Error(String(error)), channel);
158
+ }
159
+ }
160
+
161
+ /** This function puts an event in the queue of the slices. A full queue finishes its oldest events. */
162
+ private enqueue(channel: string, data: unknown, handlers: ChannelHandlers, fanOut: Required<EventFanOutOptions>): void {
163
+ this.pending.push({ channel, data, handlers, cursor: handlers.entries(), lastSubscription: this.lastSubscription, done: false });
164
+ this.pendingOn.set(channel, (this.pendingOn.get(channel) ?? 0) + 1);
165
+ while (this.pending.length - this.pendingHead > fanOut.maxPending) {
166
+ const oldest = this.pending[this.pendingHead]!;
167
+ this.deliverPart(oldest, Number.POSITIVE_INFINITY);
168
+ this.retire(oldest);
169
+ }
170
+ this.scheduleSlice(fanOut);
171
+ }
172
+
173
+ private scheduleSlice(fanOut: Required<EventFanOutOptions>): void {
174
+ if (this.sliceScheduled || this.pendingHead === this.pending.length) return;
175
+ this.sliceScheduled = true;
176
+ fanOut.schedule(() => {
177
+ this.sliceScheduled = false;
178
+ this.deliver(fanOut.sliceSize);
179
+ this.scheduleSlice(fanOut);
180
+ });
181
+ }
182
+
183
+ /** This function calls at most `limit` handlers of the oldest events. */
184
+ private deliver(limit: number): void {
185
+ let left = limit;
186
+ while (left > 0 && this.pendingHead < this.pending.length) {
187
+ const event = this.pending[this.pendingHead]!;
188
+ left -= this.deliverPart(event, left);
189
+ if (event.done) this.retire(event);
190
+ }
191
+ if (this.pendingHead === this.pending.length) this.drainWaiters.splice(0).forEach((resolve) => resolve());
192
+ }
193
+
194
+ /** This function calls at most `limit` handlers of one event, and returns the count of calls. */
195
+ private deliverPart(event: PendingEvent, limit: number): number {
196
+ let calls = 0;
197
+ while (calls < limit && !event.done) {
198
+ // clear(), or the last unsubscribe: the map of this event is not the map of the channel now
199
+ const step = this.subscribers.get(event.channel) === event.handlers ? event.cursor.next() : undefined;
200
+ if (!step || step.done || step.value[1] > event.lastSubscription) {
201
+ event.done = true;
202
+ break;
99
203
  }
204
+ this.call(step.value[0], event.data, event.channel);
205
+ calls++;
206
+ }
207
+ return calls;
208
+ }
209
+
210
+ /**
211
+ * This function takes a finished event from the queue. A handler in a slice can emit, and that
212
+ * emit can finish the event first, so this function examines the head.
213
+ */
214
+ private retire(event: PendingEvent): void {
215
+ if (this.pending[this.pendingHead] !== event) return;
216
+ this.pendingHead++;
217
+ const left = (this.pendingOn.get(event.channel) ?? 1) - 1;
218
+ if (left === 0) this.pendingOn.delete(event.channel);
219
+ else this.pendingOn.set(event.channel, left);
220
+ if (this.pendingHead === this.pending.length) {
221
+ this.pending = [];
222
+ this.pendingHead = 0;
223
+ } else if (this.pendingHead > 1024 && this.pendingHead * 2 > this.pending.length) {
224
+ this.pending = this.pending.slice(this.pendingHead);
225
+ this.pendingHead = 0;
100
226
  }
101
227
  }
102
228
 
229
+ /**
230
+ * This method resolves when no event waits for a slice. A host calls it before it stops. Without
231
+ * the option `fanOut`, it resolves at once.
232
+ */
233
+ drained(): Promise<void> {
234
+ if (this.pendingHead === this.pending.length) return Promise.resolve();
235
+ return new Promise((resolve) => this.drainWaiters.push(resolve));
236
+ }
237
+
103
238
  /**
104
239
  * Subscribe to events on a channel. The `onClear` of the options runs one time when `clear()`
105
240
  * or `clearAll()` removes the handler, not at an unsubscribe.
@@ -111,11 +246,14 @@ export class DefaultEventBus implements EventBus {
111
246
 
112
247
  let channelSubs = this.subscribers.get(channel);
113
248
  if (!channelSubs) {
114
- channelSubs = new Set();
249
+ channelSubs = new Map();
115
250
  this.subscribers.set(channel, channelSubs);
116
251
  }
117
252
 
118
- channelSubs.add(handler as EventHandler<unknown>);
253
+ // A handler that is already on the channel keeps its place and its number
254
+ if (!channelSubs.has(handler as EventHandler<unknown>)) {
255
+ channelSubs.set(handler as EventHandler<unknown>, ++this.lastSubscription);
256
+ }
119
257
 
120
258
  // The unsubscribe function works once, and removes only the set it added to. (Before, a
121
259
  // repeated call of an old unsubscribe removed the set of a newer subscriber: deep dive CORE-15.)
@@ -144,16 +282,7 @@ export class DefaultEventBus implements EventBus {
144
282
  // Replay buffered events if any
145
283
  const buffered = this.buffer.get(channel);
146
284
  if (buffered && buffered.length > 0) {
147
- for (const data of buffered) {
148
- try {
149
- const result = handler(data as T);
150
- if (result instanceof Promise) {
151
- result.catch((error) => this.options.onError(error, channel));
152
- }
153
- } catch (error) {
154
- this.options.onError(error instanceof Error ? error : new Error(String(error)), channel);
155
- }
156
- }
285
+ for (const data of buffered) this.call(handler as EventHandler<unknown>, data, channel);
157
286
  }
158
287
 
159
288
  return () => {
@@ -40,6 +40,7 @@ export type {
40
40
  ProcedureChannels,
41
41
  DefaultChannels,
42
42
  EventBusOptions,
43
+ EventFanOutOptions,
43
44
  EventListenOptions,
44
45
  EventWaitOptions,
45
46
  } from "./types.js";