@procedures/client 0.3.0 → 0.4.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 (120) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +16 -6
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/adapters/http/server/transport.d.ts.map +1 -1
  5. package/dist/adapters/http/server/transport.js +2 -3
  6. package/dist/adapters/http/server/transport.js.map +1 -1
  7. package/dist/adapters/local/client/transport.d.ts.map +1 -1
  8. package/dist/adapters/local/client/transport.js +4 -2
  9. package/dist/adapters/local/client/transport.js.map +1 -1
  10. package/dist/adapters/websocket/client/node.d.ts +30 -0
  11. package/dist/adapters/websocket/client/node.d.ts.map +1 -0
  12. package/dist/adapters/websocket/client/node.js +32 -0
  13. package/dist/adapters/websocket/client/node.js.map +1 -0
  14. package/dist/adapters/websocket/client/transport.d.ts.map +1 -1
  15. package/dist/adapters/websocket/client/transport.js +17 -9
  16. package/dist/adapters/websocket/client/transport.js.map +1 -1
  17. package/dist/adapters/websocket/client/types.d.ts +3 -2
  18. package/dist/adapters/websocket/client/types.d.ts.map +1 -1
  19. package/dist/adapters/websocket/client/types.js.map +1 -1
  20. package/dist/client/errors/http-errors.d.ts.map +1 -1
  21. package/dist/client/errors/http-errors.js +6 -0
  22. package/dist/client/errors/http-errors.js.map +1 -1
  23. package/dist/client/errors/index.d.ts +1 -1
  24. package/dist/client/errors/index.js +1 -1
  25. package/dist/client/errors/registry.d.ts +2 -1
  26. package/dist/client/errors/registry.d.ts.map +1 -1
  27. package/dist/client/errors/registry.js +72 -22
  28. package/dist/client/errors/registry.js.map +1 -1
  29. package/dist/client/middleware/rate-limit.d.ts.map +1 -1
  30. package/dist/client/middleware/rate-limit.js +0 -1
  31. package/dist/client/middleware/rate-limit.js.map +1 -1
  32. package/dist/client/middleware/timeout.d.ts.map +1 -1
  33. package/dist/client/middleware/timeout.js +2 -9
  34. package/dist/client/middleware/timeout.js.map +1 -1
  35. package/dist/index.d.ts +4 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +4 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/node.d.ts +11 -0
  40. package/dist/node.d.ts.map +1 -0
  41. package/dist/node.js +10 -0
  42. package/dist/node.js.map +1 -0
  43. package/dist/procedures/core/array.d.ts +1 -1
  44. package/dist/procedures/core/array.d.ts.map +1 -1
  45. package/dist/procedures/core/array.js +56 -15
  46. package/dist/procedures/core/array.js.map +1 -1
  47. package/dist/procedures/core/index.d.ts.map +1 -1
  48. package/dist/procedures/core/index.js +9 -2
  49. package/dist/procedures/core/index.js.map +1 -1
  50. package/dist/procedures/core/logic.js +1 -1
  51. package/dist/procedures/core/logic.js.map +1 -1
  52. package/dist/procedures/core/math.d.ts.map +1 -1
  53. package/dist/procedures/core/math.js +5 -1
  54. package/dist/procedures/core/math.js.map +1 -1
  55. package/dist/procedures/core/meta.d.ts.map +1 -1
  56. package/dist/procedures/core/meta.js +41 -7
  57. package/dist/procedures/core/meta.js.map +1 -1
  58. package/dist/procedures/core/type.d.ts.map +1 -1
  59. package/dist/procedures/core/type.js +4 -2
  60. package/dist/procedures/core/type.js.map +1 -1
  61. package/dist/procedures/discovery.d.ts +4 -6
  62. package/dist/procedures/discovery.d.ts.map +1 -1
  63. package/dist/procedures/discovery.js +4 -6
  64. package/dist/procedures/discovery.js.map +1 -1
  65. package/dist/procedures/ref.d.ts +4 -2
  66. package/dist/procedures/ref.d.ts.map +1 -1
  67. package/dist/procedures/ref.js +2 -1
  68. package/dist/procedures/ref.js.map +1 -1
  69. package/dist/protocol.d.ts +9 -2
  70. package/dist/protocol.d.ts.map +1 -1
  71. package/dist/protocol.js +12 -2
  72. package/dist/protocol.js.map +1 -1
  73. package/dist/server/procedure-server.d.ts +2 -1
  74. package/dist/server/procedure-server.d.ts.map +1 -1
  75. package/dist/server/procedure-server.js +4 -2
  76. package/dist/server/procedure-server.js.map +1 -1
  77. package/package.json +5 -4
  78. package/src/adapters/http/server/transport.ts +2 -3
  79. package/src/adapters/local/client/transport.ts +4 -2
  80. package/src/adapters/websocket/client/node.ts +34 -0
  81. package/src/adapters/websocket/client/transport.ts +20 -9
  82. package/src/adapters/websocket/client/types.ts +3 -2
  83. package/src/client/errors/README.md +2 -0
  84. package/src/client/errors/http-errors.ts +5 -0
  85. package/src/client/errors/index.ts +1 -1
  86. package/src/client/errors/registry.ts +79 -22
  87. package/src/client/middleware/rate-limit.ts +0 -2
  88. package/src/client/middleware/timeout.ts +2 -9
  89. package/src/index.ts +4 -0
  90. package/src/node.ts +11 -0
  91. package/src/procedures/core/array.ts +53 -16
  92. package/src/procedures/core/index.ts +9 -2
  93. package/src/procedures/core/logic.ts +1 -1
  94. package/src/procedures/core/math.ts +5 -1
  95. package/src/procedures/core/meta.ts +43 -7
  96. package/src/procedures/core/type.ts +4 -2
  97. package/src/procedures/discovery.ts +4 -6
  98. package/src/procedures/ref.ts +4 -2
  99. package/src/protocol.ts +13 -2
  100. package/src/server/procedure-server.ts +4 -2
  101. package/dist/procedures/cli/announce.d.ts +0 -67
  102. package/dist/procedures/cli/announce.d.ts.map +0 -1
  103. package/dist/procedures/cli/announce.js +0 -392
  104. package/dist/procedures/cli/announce.js.map +0 -1
  105. package/dist/procedures/cli/discover.d.ts +0 -66
  106. package/dist/procedures/cli/discover.d.ts.map +0 -1
  107. package/dist/procedures/cli/discover.js +0 -310
  108. package/dist/procedures/cli/discover.js.map +0 -1
  109. package/dist/procedures/cli/index.d.ts +0 -13
  110. package/dist/procedures/cli/index.d.ts.map +0 -1
  111. package/dist/procedures/cli/index.js +0 -203
  112. package/dist/procedures/cli/index.js.map +0 -1
  113. package/dist/procedures/cli/init.d.ts +0 -17
  114. package/dist/procedures/cli/init.d.ts.map +0 -1
  115. package/dist/procedures/cli/init.js +0 -336
  116. package/dist/procedures/cli/init.js.map +0 -1
  117. package/src/procedures/cli/announce.ts +0 -470
  118. package/src/procedures/cli/discover.ts +0 -390
  119. package/src/procedures/cli/index.ts +0 -231
  120. package/src/procedures/cli/init.ts +0 -370
@@ -96,6 +96,23 @@ interface PendingRequest {
96
96
  * const client = new Client({ transport });
97
97
  * ```
98
98
  */
99
+ /** The `readyState` of an open socket. The global `WebSocket` and the `ws` package use the same number. */
100
+ const OPEN = 1;
101
+
102
+ /**
103
+ * The error handler of a socket that the transport left. A `WebSocket` of the `ws` package that
104
+ * closes during its handshake emits an error. Without a handler, that error ends the process.
105
+ */
106
+ const ignoreError = (): void => undefined;
107
+
108
+ /** This function removes the handlers of the transport from a socket, and keeps `ignoreError`. */
109
+ function detach(ws: WebSocket): void {
110
+ ws.onopen = null;
111
+ ws.onmessage = null;
112
+ ws.onclose = null;
113
+ ws.onerror = ignoreError;
114
+ }
115
+
99
116
  function errorItem<TRes>(id: string, code: string, message: string, retryable: boolean): ResponseItem<TRes> {
100
117
  return { id, status: { type: "error", code, message, retryable }, payload: null as TRes, metadata: {} };
101
118
  }
@@ -368,10 +385,7 @@ export class WebSocketTransport implements Transport {
368
385
  const ws = this.ws;
369
386
  this.ws = null;
370
387
  if (ws) {
371
- ws.onopen = null;
372
- ws.onmessage = null;
373
- ws.onclose = null;
374
- ws.onerror = null;
388
+ detach(ws);
375
389
  try {
376
390
  // A socket of the ws package ends at once. close() waits for the answer of a peer that can
377
391
  // be dead (finding of roadmap R36).
@@ -500,7 +514,7 @@ export class WebSocketTransport implements Transport {
500
514
  return new IncomingCalls({
501
515
  handler,
502
516
  send: (frame) => ws.send(JSON.stringify(frame)),
503
- isOpen: () => this.ws === ws && ws.readyState === WebSocket.OPEN,
517
+ isOpen: () => this.ws === ws && ws.readyState === OPEN,
504
518
  // A call of the server that this client opened: the client trusts it, so it has no
505
519
  // credentials (a ProcedureServer applies no token to it)
506
520
  requestOf: (frame, signal) => ({
@@ -854,10 +868,7 @@ export class WebSocketTransport implements Transport {
854
868
  if (this.ws) {
855
869
  const ws = this.ws;
856
870
  this.ws = null;
857
- ws.onopen = null;
858
- ws.onmessage = null;
859
- ws.onclose = null;
860
- ws.onerror = null;
871
+ detach(ws);
861
872
  ws.close();
862
873
  }
863
874
 
@@ -170,8 +170,9 @@ export interface WebSocketTransportOptions {
170
170
  onTransition?: (transition: MachineTransition) => void;
171
171
 
172
172
  /**
173
- * The class of the sockets. The default is the global `WebSocket`. In Node, give the `WebSocket`
174
- * of the `ws` package: a drop then ends its socket at once with `terminate()`. The global
173
+ * The class of the sockets. The default is the global `WebSocket`. The `WebSocketTransport` of
174
+ * the Node entry `@procedures/client/node` has the `WebSocket` of the `ws` package as its
175
+ * default. With that class, a drop ends its socket at once with `terminate()`. The global
175
176
  * `WebSocket` has only `close()`, which waits for the answer of the peer. With a dead peer, the
176
177
  * socket stays open until the kernel stops it, at most 15 minutes (the harness cells of R36).
177
178
  */
@@ -213,6 +213,8 @@ console.error(formatError(error, true)); // include stack trace
213
213
  2. Add metadata to `ERROR_REGISTRY` in `registry.ts` or `HTTP_ERROR_REGISTRY` in `http-errors.ts`
214
214
  3. If HTTP-related, add to `HTTP_STATUS_ERROR_MAP` in `http-errors.ts`
215
215
 
216
+ `ERROR_CODES` of the wire protocol (`src/protocol.ts`, `WIRE-PROTOCOL.md`) is the source of the codes of the protocol. `ERROR_REGISTRY` has an entry for each of them, and that entry takes its HTTP status and its retry flag from `ERROR_CODES`. To add a code of the protocol, add it to `ERROR_CODES`. A code without texts in `registry.ts` gets the meaning of the protocol as its texts. `defineProtocolError` gives a code of the protocol its own texts.
217
+
216
218
  Example:
217
219
  ```typescript
218
220
  // 1. Add to ErrorCode enum
@@ -8,6 +8,7 @@
8
8
  import { ErrorCategory, ErrorSeverity, type ErrorMetadata } from "./types.js";
9
9
  import { ErrorCode } from "./codes.js";
10
10
  import { HTTPStatus } from "./http-status.js";
11
+ import { httpStatusOf } from "../../protocol.js";
11
12
 
12
13
  /**
13
14
  * Helper to create HTTP error metadata.
@@ -595,6 +596,10 @@ export function httpStatusToErrorCode(status: number): ErrorCode {
595
596
  * ```
596
597
  */
597
598
  export function errorCodeToHTTPStatus(code: ErrorCode): HTTPStatus {
599
+ // A code of the wire protocol has the status of ERROR_CODES: before, TIMEOUT gave 408 here and
600
+ // 504 on the wire
601
+ const protocolStatus = httpStatusOf(code);
602
+ if (protocolStatus !== undefined) return protocolStatus as HTTPStatus;
598
603
  // Build map from HTTP_STATUS_ERROR_MAP
599
604
  const entry = HTTP_STATUS_ERROR_MAP.find(([, errorCode]) => errorCode === code);
600
605
  return entry?.[0] ?? HTTPStatus.INTERNAL_SERVER_ERROR;
@@ -20,7 +20,7 @@
20
20
  * console.log(error.metadata.userMessage); // User-friendly message
21
21
  * console.log(error.metadata.devMessage); // Developer diagnostic
22
22
  * console.log(error.metadata.suggestions); // Suggested actions
23
- * console.log(error.metadata.retryable); // true
23
+ * console.log(error.metadata.retryable); // false (ERROR_CODES of the protocol)
24
24
  *
25
25
  * // Format for logging
26
26
  * console.log(formatError(error));
@@ -15,6 +15,7 @@ import {
15
15
  } from "./types.js";
16
16
  import { HTTP_ERROR_REGISTRY } from "./http-errors.js";
17
17
  import { ErrorCode } from "./codes.js";
18
+ import { ERROR_CODES, type ProtocolErrorCode } from "../../protocol.js";
18
19
 
19
20
  /**
20
21
  * Create error metadata with defaults.
@@ -47,12 +48,71 @@ function defineError(
47
48
  return result;
48
49
  }
49
50
 
51
+ /** The texts of an entry. For a code of the protocol, `ERROR_CODES` gives the status and the retry flag. */
52
+ type ProtocolErrorTexts = Omit<ErrorMetadata, "code" | "httpStatus" | "retryable">;
53
+
50
54
  /**
51
- * Universal Error Registry
52
- *
53
- * Protocol-agnostic error codes with rich metadata.
55
+ * This function makes the entry of a code of the wire protocol. The HTTP status and the retry
56
+ * flag come from `ERROR_CODES` (`protocol.ts`, `WIRE-PROTOCOL.md`), the source of these facts.
57
+ */
58
+ function defineProtocolError(code: ProtocolErrorCode, texts: ProtocolErrorTexts): ErrorMetadata {
59
+ const info = ERROR_CODES[code];
60
+ return defineError(code, { ...texts, httpStatus: info.http, retryable: info.retryable });
61
+ }
62
+
63
+ /** The category of each code of the protocol, for a code that the registry does not describe. */
64
+ const PROTOCOL_CATEGORIES: Readonly<Record<ProtocolErrorCode, ErrorCategory>> = {
65
+ NOT_FOUND: ErrorCategory.CLIENT,
66
+ NO_HANDLER: ErrorCategory.SERVER,
67
+ NOT_EXPOSED: ErrorCategory.AUTH,
68
+ NOT_DECLARED: ErrorCategory.AUTH,
69
+ FORBIDDEN: ErrorCategory.AUTH,
70
+ UNAUTHORIZED: ErrorCategory.AUTH,
71
+ VALIDATION_ERROR: ErrorCategory.CLIENT,
72
+ INVALID_REQUEST: ErrorCategory.PROTOCOL,
73
+ PROTOCOL_VERSION: ErrorCategory.PROTOCOL,
74
+ OUTPUT_VALIDATION_ERROR: ErrorCategory.SERVER,
75
+ NO_OUTPUT: ErrorCategory.SERVER,
76
+ HANDLER_ERROR: ErrorCategory.SERVER,
77
+ INTERNAL_ERROR: ErrorCategory.SERVER,
78
+ NOT_ACCEPTABLE: ErrorCategory.CLIENT,
79
+ DUPLICATE: ErrorCategory.CLIENT,
80
+ DUPLICATE_REQUEST: ErrorCategory.CLIENT,
81
+ ABORTED: ErrorCategory.CANCELLED,
82
+ TIMEOUT: ErrorCategory.TIMEOUT,
83
+ UNAVAILABLE: ErrorCategory.SERVER,
84
+ CONNECTION_CLOSED: ErrorCategory.NETWORK,
85
+ CONNECTION_NOT_FOUND: ErrorCategory.CLIENT,
86
+ };
87
+
88
+ /**
89
+ * This function gives the registry with one entry for each code of the protocol. The status and
90
+ * the retry flag of such an entry come from `ERROR_CODES`, so the two lists agree (before, the
91
+ * registry gave `TIMEOUT` the status 408 and two codes another retry flag). A code without texts
92
+ * here gets the meaning of the protocol as its texts.
93
+ */
94
+ function withProtocolCodes(described: ErrorRegistry): ErrorRegistry {
95
+ const registry: ErrorRegistry = { ...described };
96
+ for (const code of Object.keys(ERROR_CODES) as ProtocolErrorCode[]) {
97
+ const info = ERROR_CODES[code];
98
+ const meaning = `${info.meaning}.`;
99
+ const texts: ProtocolErrorTexts = described[code] ?? {
100
+ summary: info.meaning,
101
+ detail: meaning,
102
+ category: PROTOCOL_CATEGORIES[code],
103
+ severity: info.retryable ? ErrorSeverity.WARNING : ErrorSeverity.ERROR,
104
+ userMessage: meaning,
105
+ devMessage: meaning,
106
+ };
107
+ registry[code] = { ...texts, code, httpStatus: info.http, retryable: info.retryable };
108
+ }
109
+ return registry;
110
+ }
111
+
112
+ /**
113
+ * The entries with their own texts. `ERROR_REGISTRY` adds the codes of the protocol.
54
114
  */
55
- export const ERROR_REGISTRY: ErrorRegistry = {
115
+ const DESCRIBED_ERRORS = {
56
116
  //
57
117
  // ═══ Network Errors ═══
58
118
  //
@@ -108,16 +168,14 @@ export const ERROR_REGISTRY: ErrorRegistry = {
108
168
  // ═══ Timeout Errors ═══
109
169
  //
110
170
 
111
- TIMEOUT: defineError(ErrorCode.TIMEOUT, {
171
+ TIMEOUT: defineProtocolError("TIMEOUT", {
112
172
  summary: "Request timed out",
113
173
  detail:
114
174
  "The request exceeded the configured timeout duration. This may indicate slow server response, network congestion, or overly aggressive timeout settings.",
115
175
  category: ErrorCategory.TIMEOUT,
116
- retryable: true,
117
176
  severity: ErrorSeverity.WARNING,
118
177
  userMessage: "The request took too long to complete. Please try again.",
119
178
  devMessage: "Request exceeded timeout threshold. Consider increasing timeout or optimizing server response time.",
120
- httpStatus: 408,
121
179
  suggestions: [
122
180
  "Try again with a longer timeout",
123
181
  "Check server performance",
@@ -159,11 +217,10 @@ export const ERROR_REGISTRY: ErrorRegistry = {
159
217
  // ═══ Cancellation Errors ═══
160
218
  //
161
219
 
162
- ABORTED: defineError(ErrorCode.ABORTED, {
220
+ ABORTED: defineProtocolError("ABORTED", {
163
221
  summary: "Request was aborted",
164
222
  detail: "The request was explicitly cancelled by the client via AbortSignal. This is user-initiated.",
165
223
  category: ErrorCategory.CANCELLED,
166
- retryable: false,
167
224
  severity: ErrorSeverity.INFO,
168
225
  userMessage: "The request was cancelled.",
169
226
  devMessage: "Request aborted via AbortSignal. This is expected and not an error condition.",
@@ -269,15 +326,13 @@ export const ERROR_REGISTRY: ErrorRegistry = {
269
326
  ],
270
327
  }),
271
328
 
272
- UNAUTHORIZED: defineError(ErrorCode.UNAUTHORIZED, {
329
+ UNAUTHORIZED: defineProtocolError("UNAUTHORIZED", {
273
330
  summary: "Unauthorized",
274
331
  detail: "Authentication is required but was not provided or is invalid.",
275
332
  category: ErrorCategory.AUTH,
276
- retryable: false,
277
333
  severity: ErrorSeverity.ERROR,
278
334
  userMessage: "Authentication required. Please sign in.",
279
335
  devMessage: "Missing or invalid authentication credentials. Check auth token/API key.",
280
- httpStatus: 401,
281
336
  suggestions: [
282
337
  "Provide valid authentication credentials",
283
338
  "Check token hasn't expired",
@@ -285,15 +340,13 @@ export const ERROR_REGISTRY: ErrorRegistry = {
285
340
  ],
286
341
  }),
287
342
 
288
- FORBIDDEN: defineError(ErrorCode.FORBIDDEN, {
343
+ FORBIDDEN: defineProtocolError("FORBIDDEN", {
289
344
  summary: "Forbidden",
290
345
  detail: "The authenticated user doesn't have permission to access this resource.",
291
346
  category: ErrorCategory.AUTH,
292
- retryable: false,
293
347
  severity: ErrorSeverity.ERROR,
294
348
  userMessage: "You don't have permission to access this resource.",
295
349
  devMessage: "Insufficient permissions. Check user roles and resource ACLs.",
296
- httpStatus: 403,
297
350
  suggestions: [
298
351
  "Verify user has required permissions",
299
352
  "Check resource access control lists",
@@ -301,15 +354,13 @@ export const ERROR_REGISTRY: ErrorRegistry = {
301
354
  ],
302
355
  }),
303
356
 
304
- NOT_FOUND: defineError(ErrorCode.NOT_FOUND, {
357
+ NOT_FOUND: defineProtocolError("NOT_FOUND", {
305
358
  summary: "Not found",
306
359
  detail: "The requested resource does not exist.",
307
360
  category: ErrorCategory.CLIENT,
308
- retryable: false,
309
361
  severity: ErrorSeverity.ERROR,
310
362
  userMessage: "The requested item was not found.",
311
363
  devMessage: "Resource not found. Verify ID/path is correct and resource exists.",
312
- httpStatus: 404,
313
364
  suggestions: [
314
365
  "Verify the resource ID is correct",
315
366
  "Check the resource hasn't been deleted",
@@ -402,17 +453,15 @@ export const ERROR_REGISTRY: ErrorRegistry = {
402
453
  // ═══ Server Errors (5xx) ═══
403
454
  //
404
455
 
405
- INTERNAL_ERROR: defineError(ErrorCode.INTERNAL_ERROR, {
456
+ INTERNAL_ERROR: defineProtocolError("INTERNAL_ERROR", {
406
457
  summary: "Internal server error",
407
458
  detail: "The server encountered an unexpected condition. This is a server-side bug.",
408
459
  category: ErrorCategory.SERVER,
409
- retryable: true,
410
460
  severity: ErrorSeverity.CRITICAL,
411
461
  userMessage: "Server error occurred. Please try again later.",
412
462
  devMessage: "Internal server error. Check server logs for details.",
413
- httpStatus: 500,
463
+ // The protocol says that a retry does not help (ERROR_CODES: retryable false)
414
464
  suggestions: [
415
- "Retry the request",
416
465
  "Check server logs",
417
466
  "Contact server team",
418
467
  ],
@@ -554,6 +603,14 @@ export const ERROR_REGISTRY: ErrorRegistry = {
554
603
  ...HTTP_ERROR_REGISTRY,
555
604
  } as const satisfies ErrorRegistry;
556
605
 
606
+ /**
607
+ * Universal Error Registry
608
+ *
609
+ * Protocol-agnostic error codes with rich metadata. Each code of the wire protocol has an entry,
610
+ * and `ERROR_CODES` gives its HTTP status and its retry flag.
611
+ */
612
+ export const ERROR_REGISTRY: ErrorRegistry = withProtocolCodes(DESCRIBED_ERRORS);
613
+
557
614
  /**
558
615
  * Get error metadata by code.
559
616
  *
@@ -83,7 +83,6 @@ export class RateLimitError extends Error {
83
83
  */
84
84
  interface QueuedRequest {
85
85
  resolve: () => void;
86
- reject: (error: Error) => void;
87
86
  timestamp: number;
88
87
  }
89
88
 
@@ -208,7 +207,6 @@ export function createRateLimitMiddleware(
208
207
  signal?.removeEventListener("abort", onAbort);
209
208
  resolve(true);
210
209
  },
211
- reject: () => resolve(false),
212
210
  timestamp: Date.now(),
213
211
  };
214
212
  // A queued request whose caller aborts leaves the queue (before, it kept its place)
@@ -170,19 +170,12 @@ async function* runWithTimeout<TReq, TRes>(
170
170
  phase = "done";
171
171
  return;
172
172
  }
173
- if (result.value.status.type === "error" && timedOut()) {
174
- // The transport saw the aborted signal and reported ABORTED: it is a timeout
175
- yield timeoutItem();
176
- return;
177
- }
173
+ // An item or an error of the transport comes before the abort. `nextOrAbort` adds its abort
174
+ // listener before it calls `next()`, so a later abort gives ABORTED_FIRST, not an item.
178
175
  yield result.value;
179
176
  }
180
177
  } catch (error) {
181
178
  phase = "done";
182
- if (timedOut()) {
183
- yield timeoutItem();
184
- return;
185
- }
186
179
  throw error;
187
180
  } finally {
188
181
  clearTimeout(timeoutId);
package/src/index.ts CHANGED
@@ -6,6 +6,10 @@
6
6
  *
7
7
  * This entry point is for Node. It exports the browser-safe entry point
8
8
  * ("@procedures/client/browser") and the server transports, which use `http` and `ws`.
9
+ *
10
+ * The `WebSocketTransport` of this entry uses the global `WebSocket`, because some applications
11
+ * bundle this entry for a browser. The Node entry "@procedures/client/node" has a
12
+ * `WebSocketTransport` whose sockets are of the `ws` package: a drop ends them at once.
9
13
  */
10
14
 
11
15
  export * from "./browser.js";
package/src/node.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The Node entry of `@procedures/client`: `@procedures/client/node`.
3
+ *
4
+ * This entry exports the client transports with the defaults of Node. Its `WebSocketTransport`
5
+ * uses the `WebSocket` class of the `ws` package, so a drop ends a socket at once with
6
+ * `terminate()` (finding of roadmap R36). The other entries keep the global `WebSocket`: a browser
7
+ * bundle imports `@procedures/client/browser`, and that entry does not import `ws`.
8
+ */
9
+
10
+ export { WebSocketTransport } from "./adapters/websocket/client/node.js";
11
+ export type { WebSocketTransportOptions, WebSocketClass } from "./adapters/websocket/client/types.js";
@@ -10,6 +10,7 @@ import { anyOutput, arrayOutput, booleanOutput, numberOutput } from "./outputs.j
10
10
  import { defineProcedure, namespace } from "../define.js";
11
11
  import type { AnyProcedure, Procedure } from "../types.js";
12
12
  import { PURE_EFFECTS, withEffects } from "../effects.js";
13
+ import { isPlainObject } from "../ref.js";
13
14
 
14
15
  /**
15
16
  * This function gives the own field `key` of an item, or undefined. A field of the prototype
@@ -301,10 +302,47 @@ interface UniqueInput {
301
302
  }
302
303
 
303
304
  const UniqueInputSchema = z.object({
304
- items: z.array(z.unknown()).describe("The array"),
305
- key: z.string().optional().describe("The key (a field name of the items)"),
305
+ items: z.array(z.unknown()).describe("The array. Equal JSON values are duplicates: objects and arrays compare by value."),
306
+ key: z.string().optional().describe("The key (a field name of the items). The values of the field compare by value."),
306
307
  });
307
308
 
309
+ /**
310
+ * This function gives a function that makes a text key for a value: two equal JSON values get
311
+ * the same key. An array compares by its items in order, and a plain object by its own fields in
312
+ * any order. A number and its string get different keys. Another object (a `Date`, a `Map`) and
313
+ * a symbol compare by identity.
314
+ */
315
+ function valueKeys(): (value: unknown) => string {
316
+ const identities = new Map<unknown, number>();
317
+ const identity = (value: unknown): string => {
318
+ let id = identities.get(value);
319
+ if (id === undefined) {
320
+ id = identities.size;
321
+ identities.set(value, id);
322
+ }
323
+ return `#${id}`;
324
+ };
325
+ const keyOf = (value: unknown, parents: Set<unknown>): string => {
326
+ if (typeof value === "string") return JSON.stringify(value);
327
+ // -0 and 0 are one value, and NaN is one value, as in a Set
328
+ if (typeof value === "number") return Object.is(value, -0) ? "0" : String(value);
329
+ if (typeof value === "bigint") return `${value}n`;
330
+ if (value === null || value === undefined || typeof value === "boolean") return String(value);
331
+ // A cycle is not JSON: its object compares by identity
332
+ if (parents.has(value) || (!Array.isArray(value) && !isPlainObject(value))) return identity(value);
333
+ parents.add(value);
334
+ const text = Array.isArray(value)
335
+ ? `[${value.map((item) => keyOf(item, parents)).join(",")}]`
336
+ : `{${Object.keys(value)
337
+ .sort()
338
+ .map((field) => `${JSON.stringify(field)}:${keyOf(value[field], parents)}`)
339
+ .join(",")}}`;
340
+ parents.delete(value);
341
+ return text;
342
+ };
343
+ return (value) => keyOf(value, new Set());
344
+ }
345
+
308
346
  type UniqueProcedure = Procedure<UniqueInput, unknown[], { description: string; tags: string[] }>;
309
347
 
310
348
  const uniqueProcedure: UniqueProcedure = defineProcedure({
@@ -312,20 +350,19 @@ const uniqueProcedure: UniqueProcedure = defineProcedure({
312
350
  input: UniqueInputSchema,
313
351
  output: arrayOutput("The items without duplicates"),
314
352
  metadata: {
315
- description: "Remove duplicate values (optionally by key)",
353
+ description: "Remove duplicate values (optionally by key). It compares by value and keeps the first item of each value.",
316
354
  tags: ["core", "array"],
317
355
  },
318
356
  handler: async (input: UniqueInput): Promise<unknown[]> => {
319
- if (input.key) {
320
- const seen = new Set<unknown>();
321
- return input.items.filter((item) => {
322
- const val = fieldOf(item, input.key!);
323
- if (seen.has(val)) return false;
324
- seen.add(val);
325
- return true;
326
- });
327
- }
328
- return [...new Set(input.items)];
357
+ // Before, objects compared by reference, so two equal objects both stayed (catalog test, R29)
358
+ const keyOf = valueKeys();
359
+ const seen = new Set<string>();
360
+ return input.items.filter((item) => {
361
+ const key = keyOf(input.key ? fieldOf(item, input.key) : item);
362
+ if (seen.has(key)) return false;
363
+ seen.add(key);
364
+ return true;
365
+ });
329
366
  },
330
367
  });
331
368
 
@@ -623,8 +660,8 @@ interface FilterInput {
623
660
  value?: unknown | undefined;
624
661
  /** Values to match (any of these) */
625
662
  values?: unknown[] | undefined;
626
- /** Match mode: 'equals', 'not', 'in', 'notIn', 'contains', 'startsWith', 'endsWith' */
627
- match?: 'equals' | 'not' | 'in' | 'notIn' | 'contains' | 'startsWith' | 'endsWith' | 'gt' | 'gte' | 'lt' | 'lte' | undefined;
663
+ /** Match mode: 'equals', 'not', 'in', 'notIn', 'contains', 'startsWith', 'endsWith', 'gt', 'gte', 'lt', 'lte' */
664
+ match?:'equals' | 'not' | 'in' | 'notIn' | 'contains' | 'startsWith' | 'endsWith' | 'gt' | 'gte' | 'lt' | 'lte' | undefined;
628
665
  /** Invert the filter */
629
666
  invert?: boolean | undefined;
630
667
  }
@@ -634,7 +671,7 @@ const FilterInputSchema = z.object({
634
671
  key: z.string().optional().describe("Property to match against"),
635
672
  value: z.unknown().optional().describe("Value to match (equality check)"),
636
673
  values: z.array(z.unknown()).optional().describe("Values to match (any of these)"),
637
- match: z.enum(["equals", "not", "in", "notIn", "contains", "startsWith", "endsWith", "gt", "gte", "lt", "lte"]).optional().describe("Match mode: 'equals', 'not', 'in', 'notIn', 'contains', 'startsWith', 'endsWith'"),
674
+ match: z.enum(["equals", "not", "in", "notIn", "contains", "startsWith", "endsWith", "gt", "gte", "lt", "lte"]).optional().describe("Match mode (default 'equals'): 'equals' and 'not' compare with value, 'in' and 'notIn' with values. 'contains', 'startsWith' and 'endsWith' compare strings. 'gt', 'gte', 'lt' and 'lte' compare numbers."),
638
675
  invert: z.boolean().optional().describe("Invert the filter"),
639
676
  });
640
677
 
@@ -84,9 +84,16 @@ const ConditionalInputSchema = z.looseObject({
84
84
  const LogicInputSchema = z.looseObject({
85
85
  values: z.array(z.unknown()).describe("The operands, in order: values or calls. The evaluation stops at the deciding value."),
86
86
  });
87
- const NotInputSchema = z.looseObject({ value: operand("The value or call to negate") });
87
+ // not, all, any and none take values (roadmap R29, the owner's choice). They do not run a call in
88
+ // their operands: a call is an object, thus truthy. In a program, the calls of their input run
89
+ // first, as for each procedure that is not control flow. client.and and client.or run calls.
90
+ const NotInputSchema = z.looseObject({
91
+ value: operand("The value to negate. This procedure does not run a call: a call is an object, thus truthy."),
92
+ });
88
93
  const VariadicBoolInputSchema = z.looseObject({
89
- values: z.array(z.unknown()).describe("The values or calls to evaluate"),
94
+ values: z
95
+ .array(z.unknown())
96
+ .describe("The values to evaluate. This procedure does not run calls: a call is an object, thus truthy. client.and and client.or run calls."),
90
97
  });
91
98
  const MapInputSchema = z.looseObject({
92
99
  items: operand("The array, or a call that gives it"),
@@ -184,7 +184,7 @@ export const orMetadata: LogicMetadata = {
184
184
  * Metadata for the NOT operator.
185
185
  */
186
186
  export const notMetadata: LogicMetadata = {
187
- description: "Logical NOT",
187
+ description: "Logical NOT of a value",
188
188
  tags: ["core", "logic"],
189
189
  arity: "unary",
190
190
  commutative: false, // n/a for unary
@@ -151,10 +151,14 @@ const modProcedure: ModProcedure = defineProcedure({
151
151
  input: ModInputSchema,
152
152
  output: numberOutput("The remainder of a divided by b"),
153
153
  metadata: {
154
- description: "Modulo (remainder of a / b)",
154
+ description: "Modulo (remainder of a / b). It refuses b = 0, as client.divide does.",
155
155
  tags: ["core", "math"],
156
156
  },
157
157
  handler: async (input: ModInput): Promise<number> => {
158
+ // Before, b = 0 gave NaN, and client.divide refused it (catalog test, roadmap R29)
159
+ if (input.b === 0) {
160
+ throw new Error("Division by zero");
161
+ }
158
162
  return input.a % input.b;
159
163
  },
160
164
  });
@@ -14,6 +14,7 @@ import {
14
14
  parseProcedureJson,
15
15
  stringifyProcedureJson,
16
16
  isAnyProcedureRef,
17
+ isProcedureRef,
17
18
  isOutputRef,
18
19
  isPlainObject,
19
20
  resolveOutputRef,
@@ -58,18 +59,37 @@ const ImportOutputSchema = z.looseObject({
58
59
 
59
60
  type ImportProcedure = Procedure<ImportInput, ImportOutput, { description: string; tags: string[] }>;
60
61
 
62
+ /**
63
+ * This function gives true for the runtime form of a procedure reference whose path is a list of
64
+ * texts. `client.import` accepts only such a value at the top level.
65
+ */
66
+ function isImportableRef(value: unknown): boolean {
67
+ if (!isProcedureRef(value)) return false;
68
+ const path = (value as { path?: unknown }).path;
69
+ return Array.isArray(path) && path.length > 0 && path.every((segment) => typeof segment === "string");
70
+ }
71
+
61
72
  const importProcedure: ImportProcedure = defineProcedure({
62
73
  path: ["import"],
63
74
  input: ImportInputSchema,
64
75
  output: ImportOutputSchema,
65
76
  metadata: {
66
- description: "Parse procedure aggregation from JSON string",
77
+ description:
78
+ "Parse a procedure reference ({ $proc, input }) from JSON text. Other JSON, for example a number or a list, gives success false.",
67
79
  tags: ["core", "meta"],
68
80
  },
69
81
  handler: async (input: ImportInput): Promise<ImportOutput> => {
70
82
  try {
71
83
  if (input.json) {
72
84
  const procedure = parseProcedureJson(input.json);
85
+ // Before, each JSON value gave success true, for example 42 (catalog test, roadmap R29)
86
+ if (!isImportableRef(procedure)) {
87
+ return {
88
+ procedure: null,
89
+ success: false,
90
+ error: "The JSON is not a procedure reference. A reference is an object whose $proc is a list of texts.",
91
+ };
92
+ }
73
93
  return { procedure, success: true };
74
94
  }
75
95
  if (input.path) {
@@ -107,8 +127,19 @@ interface ExportInput {
107
127
  pretty?: boolean | undefined;
108
128
  }
109
129
 
130
+ /**
131
+ * This function gives a schema of a required value: undefined has no JSON text, so the input
132
+ * schema refuses it with `VALIDATION_ERROR`. Before, the call failed with
133
+ * `OUTPUT_VALIDATION_ERROR` (catalog test, roadmap R29).
134
+ */
135
+ const definedValue = (description: string) =>
136
+ z
137
+ .unknown()
138
+ .refine((value) => value !== undefined, { message: "The value is missing: undefined has no JSON text" })
139
+ .describe(description);
140
+
110
141
  const ExportInputSchema = z.object({
111
- procedure: z.unknown().optional().describe("Procedure reference to serialize"),
142
+ procedure: definedValue("The procedure reference to serialize. The procedure refuses a missing value (undefined) with VALIDATION_ERROR, because undefined has no JSON text."),
112
143
  pretty: z.boolean().optional().describe("Pretty print JSON output"),
113
144
  });
114
145
 
@@ -134,13 +165,15 @@ const exportProcedure: ExportProcedure = defineProcedure({
134
165
  input: ExportInputSchema,
135
166
  output: ExportOutputSchema,
136
167
  metadata: {
137
- description: "Serialize procedure aggregation to JSON string",
168
+ description: "Serialize a procedure reference to JSON text. It refuses a missing procedure (undefined) with VALIDATION_ERROR.",
138
169
  // The refs of the input are data: exec() must not run them (deep dive CORE-5)
139
170
  tags: ["core", "meta", RAW_INPUT_TAG],
140
171
  },
141
172
  handler: async (input: ExportInput): Promise<ExportOutput> => {
142
173
  try {
143
- const json = stringifyProcedureJson(input.procedure, input.pretty ? 2 : undefined);
174
+ const json = stringifyProcedureJson(input.procedure, input.pretty ? 2 : undefined) as string | undefined;
175
+ // A function or a symbol has no JSON text either: an error result, not an invalid output
176
+ if (json === undefined) return { json: "", success: false, error: "The procedure has no JSON text" };
144
177
  return { json, success: true };
145
178
  } catch (error) {
146
179
  return {
@@ -268,7 +301,7 @@ interface StringifyJsonInput {
268
301
  }
269
302
 
270
303
  const StringifyJsonInputSchema = z.object({
271
- value: z.unknown().optional().describe("Value to stringify"),
304
+ value: definedValue("The value to write as JSON. The procedure refuses a missing value (undefined) with VALIDATION_ERROR, because undefined has no JSON text."),
272
305
  pretty: z.boolean().optional().describe("Pretty print with indentation"),
273
306
  indent: z.union([z.number(), z.string()]).optional().describe("Custom indentation (number of spaces or string)"),
274
307
  });
@@ -280,13 +313,16 @@ const stringifyJsonProcedure: StringifyJsonProcedure = defineProcedure({
280
313
  input: StringifyJsonInputSchema,
281
314
  output: stringOutput("The JSON text"),
282
315
  metadata: {
283
- description: "Stringify value to JSON (with procedure ref support)",
316
+ description: "Stringify value to JSON (with procedure ref support). It refuses a missing value (undefined) with VALIDATION_ERROR.",
284
317
  // The refs of the input are data: exec() must not run them (deep dive CORE-5)
285
318
  tags: ["core", "meta", RAW_INPUT_TAG],
286
319
  },
287
320
  handler: async (input: StringifyJsonInput): Promise<string> => {
288
321
  const indent = input.pretty ? (input.indent ?? 2) : input.indent;
289
- return stringifyProcedureJson(input.value, indent as number);
322
+ const json = stringifyProcedureJson(input.value, indent as number) as string | undefined;
323
+ // A function or a symbol has no JSON text either: a clear error, not an invalid output
324
+ if (json === undefined) throw new Error("The value has no JSON text");
325
+ return json;
290
326
  },
291
327
  });
292
328