@procedures/client 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +62 -7
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/adapters/http/client/transport.d.ts +2 -1
  5. package/dist/adapters/http/client/transport.d.ts.map +1 -1
  6. package/dist/adapters/http/client/transport.js +9 -2
  7. package/dist/adapters/http/client/transport.js.map +1 -1
  8. package/dist/adapters/http/server/transport.d.ts +3 -1
  9. package/dist/adapters/http/server/transport.d.ts.map +1 -1
  10. package/dist/adapters/http/server/transport.js +8 -6
  11. package/dist/adapters/http/server/transport.js.map +1 -1
  12. package/dist/adapters/local/client/transport.d.ts +2 -1
  13. package/dist/adapters/local/client/transport.d.ts.map +1 -1
  14. package/dist/adapters/local/client/transport.js +12 -4
  15. package/dist/adapters/local/client/transport.js.map +1 -1
  16. package/dist/adapters/local/client/types.d.ts +6 -0
  17. package/dist/adapters/local/client/types.d.ts.map +1 -1
  18. package/dist/adapters/local/client/types.js.map +1 -1
  19. package/dist/adapters/sequence.d.ts +36 -0
  20. package/dist/adapters/sequence.d.ts.map +1 -0
  21. package/dist/adapters/sequence.js +55 -0
  22. package/dist/adapters/sequence.js.map +1 -0
  23. package/dist/adapters/websocket/client/node.d.ts +30 -0
  24. package/dist/adapters/websocket/client/node.d.ts.map +1 -0
  25. package/dist/adapters/websocket/client/node.js +32 -0
  26. package/dist/adapters/websocket/client/node.js.map +1 -0
  27. package/dist/adapters/websocket/client/transport.d.ts +39 -1
  28. package/dist/adapters/websocket/client/transport.d.ts.map +1 -1
  29. package/dist/adapters/websocket/client/transport.js +119 -16
  30. package/dist/adapters/websocket/client/transport.js.map +1 -1
  31. package/dist/adapters/websocket/client/types.d.ts +15 -5
  32. package/dist/adapters/websocket/client/types.d.ts.map +1 -1
  33. package/dist/adapters/websocket/client/types.js.map +1 -1
  34. package/dist/adapters/websocket/server/transport.d.ts +22 -1
  35. package/dist/adapters/websocket/server/transport.d.ts.map +1 -1
  36. package/dist/adapters/websocket/server/transport.js +29 -1
  37. package/dist/adapters/websocket/server/transport.js.map +1 -1
  38. package/dist/adapters/websocket/server/types.d.ts +6 -0
  39. package/dist/adapters/websocket/server/types.d.ts.map +1 -1
  40. package/dist/adapters/websocket/shared/rpc.d.ts +4 -3
  41. package/dist/adapters/websocket/shared/rpc.d.ts.map +1 -1
  42. package/dist/adapters/websocket/shared/rpc.js +18 -9
  43. package/dist/adapters/websocket/shared/rpc.js.map +1 -1
  44. package/dist/browser.d.ts +3 -0
  45. package/dist/browser.d.ts.map +1 -1
  46. package/dist/browser.js +4 -0
  47. package/dist/browser.js.map +1 -1
  48. package/dist/client/errors/http-errors.d.ts.map +1 -1
  49. package/dist/client/errors/http-errors.js +6 -0
  50. package/dist/client/errors/http-errors.js.map +1 -1
  51. package/dist/client/errors/index.d.ts +1 -1
  52. package/dist/client/errors/index.js +1 -1
  53. package/dist/client/errors/registry.d.ts +2 -1
  54. package/dist/client/errors/registry.d.ts.map +1 -1
  55. package/dist/client/errors/registry.js +73 -22
  56. package/dist/client/errors/registry.js.map +1 -1
  57. package/dist/client/middleware/rate-limit.d.ts.map +1 -1
  58. package/dist/client/middleware/rate-limit.js +0 -1
  59. package/dist/client/middleware/rate-limit.js.map +1 -1
  60. package/dist/client/middleware/timeout.d.ts.map +1 -1
  61. package/dist/client/middleware/timeout.js +2 -9
  62. package/dist/client/middleware/timeout.js.map +1 -1
  63. package/dist/events/index.d.ts +4 -0
  64. package/dist/events/index.d.ts.map +1 -1
  65. package/dist/events/index.js +3 -0
  66. package/dist/events/index.js.map +1 -1
  67. package/dist/events/publisher.d.ts +95 -0
  68. package/dist/events/publisher.d.ts.map +1 -0
  69. package/dist/events/publisher.js +268 -0
  70. package/dist/events/publisher.js.map +1 -0
  71. package/dist/events/remote.d.ts +164 -0
  72. package/dist/events/remote.d.ts.map +1 -0
  73. package/dist/events/remote.js +250 -0
  74. package/dist/events/remote.js.map +1 -0
  75. package/dist/index.d.ts +4 -0
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +4 -0
  78. package/dist/index.js.map +1 -1
  79. package/dist/node.d.ts +11 -0
  80. package/dist/node.d.ts.map +1 -0
  81. package/dist/node.js +10 -0
  82. package/dist/node.js.map +1 -0
  83. package/dist/procedures/collection/index.d.ts +1 -1
  84. package/dist/procedures/collection/index.d.ts.map +1 -1
  85. package/dist/procedures/collection/index.js.map +1 -1
  86. package/dist/procedures/collection/procedures.d.ts.map +1 -1
  87. package/dist/procedures/collection/procedures.js +6 -1
  88. package/dist/procedures/collection/procedures.js.map +1 -1
  89. package/dist/procedures/collection/schemas.d.ts +16 -1
  90. package/dist/procedures/collection/schemas.d.ts.map +1 -1
  91. package/dist/procedures/collection/schemas.js +13 -2
  92. package/dist/procedures/collection/schemas.js.map +1 -1
  93. package/dist/procedures/collection/storage.d.ts +14 -2
  94. package/dist/procedures/collection/storage.d.ts.map +1 -1
  95. package/dist/procedures/core/array.d.ts +1 -1
  96. package/dist/procedures/core/array.d.ts.map +1 -1
  97. package/dist/procedures/core/array.js +56 -15
  98. package/dist/procedures/core/array.js.map +1 -1
  99. package/dist/procedures/core/index.d.ts.map +1 -1
  100. package/dist/procedures/core/index.js +9 -2
  101. package/dist/procedures/core/index.js.map +1 -1
  102. package/dist/procedures/core/logic.js +1 -1
  103. package/dist/procedures/core/logic.js.map +1 -1
  104. package/dist/procedures/core/math.d.ts.map +1 -1
  105. package/dist/procedures/core/math.js +5 -1
  106. package/dist/procedures/core/math.js.map +1 -1
  107. package/dist/procedures/core/meta.d.ts.map +1 -1
  108. package/dist/procedures/core/meta.js +41 -7
  109. package/dist/procedures/core/meta.js.map +1 -1
  110. package/dist/procedures/core/type.d.ts.map +1 -1
  111. package/dist/procedures/core/type.js +4 -2
  112. package/dist/procedures/core/type.js.map +1 -1
  113. package/dist/procedures/discovery.d.ts +4 -6
  114. package/dist/procedures/discovery.d.ts.map +1 -1
  115. package/dist/procedures/discovery.js +4 -6
  116. package/dist/procedures/discovery.js.map +1 -1
  117. package/dist/procedures/invoke.d.ts +4 -2
  118. package/dist/procedures/invoke.d.ts.map +1 -1
  119. package/dist/procedures/invoke.js +1 -1
  120. package/dist/procedures/invoke.js.map +1 -1
  121. package/dist/procedures/ref.d.ts +4 -2
  122. package/dist/procedures/ref.d.ts.map +1 -1
  123. package/dist/procedures/ref.js +2 -1
  124. package/dist/procedures/ref.js.map +1 -1
  125. package/dist/protocol.d.ts +10 -3
  126. package/dist/protocol.d.ts.map +1 -1
  127. package/dist/protocol.js +13 -2
  128. package/dist/protocol.js.map +1 -1
  129. package/dist/server/host-policy.d.ts +11 -0
  130. package/dist/server/host-policy.d.ts.map +1 -1
  131. package/dist/server/host-policy.js +5 -0
  132. package/dist/server/host-policy.js.map +1 -1
  133. package/dist/server/procedure-server.d.ts +26 -3
  134. package/dist/server/procedure-server.d.ts.map +1 -1
  135. package/dist/server/procedure-server.js +38 -6
  136. package/dist/server/procedure-server.js.map +1 -1
  137. package/package.json +7 -6
  138. package/src/adapters/http/client/transport.ts +9 -2
  139. package/src/adapters/http/server/transport.ts +8 -6
  140. package/src/adapters/local/client/transport.ts +12 -4
  141. package/src/adapters/local/client/types.ts +7 -0
  142. package/src/adapters/sequence.ts +55 -0
  143. package/src/adapters/websocket/client/node.ts +34 -0
  144. package/src/adapters/websocket/client/transport.ts +128 -16
  145. package/src/adapters/websocket/client/types.ts +16 -5
  146. package/src/adapters/websocket/server/transport.ts +43 -1
  147. package/src/adapters/websocket/server/types.ts +7 -0
  148. package/src/adapters/websocket/shared/rpc.ts +19 -9
  149. package/src/browser.ts +15 -0
  150. package/src/client/errors/README.md +2 -0
  151. package/src/client/errors/http-errors.ts +5 -0
  152. package/src/client/errors/index.ts +1 -1
  153. package/src/client/errors/registry.ts +80 -22
  154. package/src/client/middleware/rate-limit.ts +0 -2
  155. package/src/client/middleware/timeout.ts +2 -9
  156. package/src/events/index.ts +16 -0
  157. package/src/events/publisher.ts +314 -0
  158. package/src/events/remote.ts +369 -0
  159. package/src/index.ts +4 -0
  160. package/src/node.ts +11 -0
  161. package/src/procedures/collection/index.ts +1 -0
  162. package/src/procedures/collection/procedures.ts +6 -1
  163. package/src/procedures/collection/schemas.ts +28 -2
  164. package/src/procedures/collection/storage.ts +14 -2
  165. package/src/procedures/core/array.ts +53 -16
  166. package/src/procedures/core/index.ts +9 -2
  167. package/src/procedures/core/logic.ts +1 -1
  168. package/src/procedures/core/math.ts +5 -1
  169. package/src/procedures/core/meta.ts +43 -7
  170. package/src/procedures/core/type.ts +4 -2
  171. package/src/procedures/discovery.ts +4 -6
  172. package/src/procedures/invoke.ts +4 -2
  173. package/src/procedures/ref.ts +4 -2
  174. package/src/protocol.ts +16 -3
  175. package/src/server/host-policy.ts +14 -0
  176. package/src/server/procedure-server.ts +45 -8
  177. package/dist/procedures/cli/announce.d.ts +0 -67
  178. package/dist/procedures/cli/announce.d.ts.map +0 -1
  179. package/dist/procedures/cli/announce.js +0 -392
  180. package/dist/procedures/cli/announce.js.map +0 -1
  181. package/dist/procedures/cli/discover.d.ts +0 -66
  182. package/dist/procedures/cli/discover.d.ts.map +0 -1
  183. package/dist/procedures/cli/discover.js +0 -310
  184. package/dist/procedures/cli/discover.js.map +0 -1
  185. package/dist/procedures/cli/index.d.ts +0 -13
  186. package/dist/procedures/cli/index.d.ts.map +0 -1
  187. package/dist/procedures/cli/index.js +0 -203
  188. package/dist/procedures/cli/index.js.map +0 -1
  189. package/dist/procedures/cli/init.d.ts +0 -17
  190. package/dist/procedures/cli/init.d.ts.map +0 -1
  191. package/dist/procedures/cli/init.js +0 -336
  192. package/dist/procedures/cli/init.js.map +0 -1
  193. package/src/procedures/cli/announce.ts +0 -470
  194. package/src/procedures/cli/discover.ts +0 -390
  195. package/src/procedures/cli/index.ts +0 -231
  196. package/src/procedures/cli/init.ts +0 -370
@@ -8,6 +8,7 @@ import type { Message, Method } from "../../../client/types.js";
8
8
  import type { ProcedureRegistry } from "../../../procedures/registry.js";
9
9
  import type { Interceptor } from "../../../procedures/invoke.js";
10
10
  import type { Capability, ClearanceOrder } from "../../../procedures/types.js";
11
+ import type { EventPublisher } from "../../../events/publisher.js";
11
12
 
12
13
  /**
13
14
  * Handler function for a method.
@@ -65,4 +66,10 @@ export interface LocalTransportOptions {
65
66
 
66
67
  /** The order of the clearance values of the capability. */
67
68
  clearanceOrder?: ClearanceOrder | undefined;
69
+
70
+ /**
71
+ * The publisher of the remote events (`documentation/EVENTS.md`). The transport serves its
72
+ * procedure `events.subscribe`, and its procedures get its bus as `ctx.bus`.
73
+ */
74
+ events?: EventPublisher | undefined;
68
75
  }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The sequence numbers of the stream frames (wire protocol v1, `documentation/WIRE-PROTOCOL.md`).
3
+ *
4
+ * - The callee writes `seq` in each `stream` frame of a call: 1 in the first frame, and one more
5
+ * in each next frame. The end frame has a number too. An NDJSON line `item` or `done` has the
6
+ * same field.
7
+ * - The caller drops a frame whose `seq` is not above the last one. Thus a duplicate frame or a
8
+ * late frame gives no item.
9
+ * - A gap is a loss. The delivery is at most once, so the stream continues. The next item has the
10
+ * number of lost frames in its metadata key `stream-gap`, and the caller writes a warning. The
11
+ * caller also gives back the credit of the lost frames.
12
+ * - A frame without `seq` comes from an older peer. The caller takes it as before.
13
+ */
14
+
15
+ /**
16
+ * The metadata key of a stream item after a gap: the number of frames that did not arrive
17
+ * immediately before this item. The receiver sets it. It does not cross the wire.
18
+ */
19
+ export const STREAM_GAP_METADATA_KEY = "stream-gap";
20
+
21
+ /** The order of the stream frames of one call, at the side that receives them. */
22
+ export class StreamSequence {
23
+ private last = 0;
24
+
25
+ /**
26
+ * This function examines the `seq` of a frame. It gives -1 for a frame to drop: its `seq` is
27
+ * not above the last one. Else it gives the number of frames that did not arrive before this
28
+ * frame. A frame without a valid `seq` gives 0.
29
+ */
30
+ next(seq: unknown, id: string): number {
31
+ if (typeof seq !== "number" || !Number.isSafeInteger(seq) || seq < 1) return 0;
32
+ if (seq <= this.last) return -1;
33
+ const gap = seq - this.last - 1;
34
+ this.last = seq;
35
+ if (gap > 0) console.warn(`[stream] Frames lost before seq ${seq} of the call ${id}: ${gap}`);
36
+ return gap;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * This function gives the metadata of a stream item. The key `stream-gap` has the gap of the
42
+ * receiver only: the receiver removes a key of the same name that the peer sent.
43
+ */
44
+ export function itemMetadata(metadata: Record<string, unknown> | undefined, gap: number): Record<string, unknown> {
45
+ const out: Record<string, unknown> = { ...metadata };
46
+ delete out[STREAM_GAP_METADATA_KEY];
47
+ if (gap > 0) out[STREAM_GAP_METADATA_KEY] = gap;
48
+ return out;
49
+ }
50
+
51
+ /** The number of frames that did not arrive immediately before an item: 0 without a gap. */
52
+ export function gapOf(metadata: Record<string, unknown> | undefined): number {
53
+ const gap = metadata?.[STREAM_GAP_METADATA_KEY];
54
+ return typeof gap === "number" ? gap : 0;
55
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The WebSocket client transport for Node (finding of roadmap R36).
3
+ *
4
+ * This class is the `WebSocketTransport` of the browser entry with one other default: its sockets
5
+ * are of the `WebSocket` class of the `ws` package. A drop by the heartbeat or by the time limit
6
+ * of the handshake then ends the socket at once with `terminate()`. The global `WebSocket` of Node
7
+ * has only `close()`, which waits for the answer of the peer. With a dead peer, that socket stays
8
+ * open until the kernel stops it: 924.6 seconds in the harness cells of R36.
9
+ *
10
+ * Only the Node entry `@procedures/client/node` exports this class. The browser entry does not
11
+ * import `ws`.
12
+ */
13
+
14
+ import { WebSocket as NodeWebSocket } from "ws";
15
+ import { WebSocketTransport as PortableWebSocketTransport } from "./transport.js";
16
+ import type { WebSocketTransportOptions } from "./types.js";
17
+
18
+ /**
19
+ * The WebSocket client transport with the `WebSocket` class of the `ws` package as the default
20
+ * class of its sockets. The option `webSocket` gives another class.
21
+ *
22
+ * @example
23
+ * ```typescript
24
+ * import { Client } from "@procedures/client";
25
+ * import { WebSocketTransport } from "@procedures/client/node";
26
+ *
27
+ * const client = new Client({ transport: new WebSocketTransport({ url: "ws://localhost:3000/ws" }) });
28
+ * ```
29
+ */
30
+ export class WebSocketTransport extends PortableWebSocketTransport {
31
+ constructor(options: WebSocketTransportOptions) {
32
+ super({ ...options, webSocket: options.webSocket ?? NodeWebSocket });
33
+ }
34
+ }
@@ -10,6 +10,7 @@ import type { WebSocketTransportOptions, WebSocketMessage, ServerRequestHandler,
10
10
  import { WebSocketState } from "./types.js";
11
11
  import { withoutInternalKeys } from "../../metadata.js";
12
12
  import { IncomingCalls } from "../shared/rpc.js";
13
+ import { StreamSequence, gapOf, itemMetadata } from "../../sequence.js";
13
14
  import type { RequestHandler, ServerResponse } from "../../../server/types.js";
14
15
  import { methodToPath } from "../../../server/method.js";
15
16
  import { optionalMs } from "../../../duration.js";
@@ -20,6 +21,17 @@ import { heartbeat, type Heartbeat } from "@mark1russell7/service/heartbeat";
20
21
  import { backoffDelay } from "@mark1russell7/service/backoff";
21
22
  import type { MachineTransition } from "../../../machines.js";
22
23
  import { WEBSOCKET_CONNECTION_MACHINE } from "../../../machines.js";
24
+ import {
25
+ SubscriptionError,
26
+ remoteEvents,
27
+ subscribeRemote,
28
+ type RemoteEvent,
29
+ type RemoteEventHandler,
30
+ type RemoteEventsOptions,
31
+ type SubscribeOptions,
32
+ type Subscription,
33
+ type SubscriptionHooks,
34
+ } from "../../../events/remote.js";
23
35
 
24
36
  /**
25
37
  * Pending request waiting for response.
@@ -74,6 +86,8 @@ interface PendingRequest {
74
86
  messageId: string;
75
87
  /** The response items of the request, in order */
76
88
  queue: ItemQueue<ResponseItem<unknown>>;
89
+ /** The `seq` of the stream frames: a duplicate or a late frame gives no item */
90
+ order: StreamSequence;
77
91
  /** The request timeout: it runs until the first frame arrives */
78
92
  timeout?: Cancel | undefined;
79
93
  }
@@ -96,6 +110,23 @@ interface PendingRequest {
96
110
  * const client = new Client({ transport });
97
111
  * ```
98
112
  */
113
+ /** The `readyState` of an open socket. The global `WebSocket` and the `ws` package use the same number. */
114
+ const OPEN = 1;
115
+
116
+ /**
117
+ * The error handler of a socket that the transport left. A `WebSocket` of the `ws` package that
118
+ * closes during its handshake emits an error. Without a handler, that error ends the process.
119
+ */
120
+ const ignoreError = (): void => undefined;
121
+
122
+ /** This function removes the handlers of the transport from a socket, and keeps `ignoreError`. */
123
+ function detach(ws: WebSocket): void {
124
+ ws.onopen = null;
125
+ ws.onmessage = null;
126
+ ws.onclose = null;
127
+ ws.onerror = ignoreError;
128
+ }
129
+
99
130
  function errorItem<TRes>(id: string, code: string, message: string, retryable: boolean): ResponseItem<TRes> {
100
131
  return { id, status: { type: "error", code, message, retryable }, payload: null as TRes, metadata: {} };
101
132
  }
@@ -217,6 +248,8 @@ export class WebSocketTransport implements Transport {
217
248
  private incoming: IncomingCalls | undefined;
218
249
  /** The time limit of the handshake of the socket that opens now */
219
250
  private handshakeTimer: Cancel | undefined = undefined;
251
+ /** The signal of the subscriptions of the transport: `close()` aborts it, so they end (EVENTS.md) */
252
+ private readonly subscriptions = new AbortController();
220
253
 
221
254
  constructor(options: WebSocketTransportOptions) {
222
255
  this.options = {
@@ -368,10 +401,7 @@ export class WebSocketTransport implements Transport {
368
401
  const ws = this.ws;
369
402
  this.ws = null;
370
403
  if (ws) {
371
- ws.onopen = null;
372
- ws.onmessage = null;
373
- ws.onclose = null;
374
- ws.onerror = null;
404
+ detach(ws);
375
405
  try {
376
406
  // A socket of the ws package ends at once. close() waits for the answer of a peer that can
377
407
  // be dead (finding of roadmap R36).
@@ -500,7 +530,7 @@ export class WebSocketTransport implements Transport {
500
530
  return new IncomingCalls({
501
531
  handler,
502
532
  send: (frame) => ws.send(JSON.stringify(frame)),
503
- isOpen: () => this.ws === ws && ws.readyState === WebSocket.OPEN,
533
+ isOpen: () => this.ws === ws && ws.readyState === OPEN,
504
534
  // A call of the server that this client opened: the client trusts it, so it has no
505
535
  // credentials (a ProcedureServer applies no token to it)
506
536
  requestOf: (frame, signal) => ({
@@ -548,7 +578,8 @@ export class WebSocketTransport implements Transport {
548
578
  return;
549
579
  }
550
580
 
551
- // Handle event (subscription event from server)
581
+ // The frame type `event` is reserved: no side of protocol v1 sends it. A server outside this
582
+ // repository can send it, so the frame still goes to onEvent (EVENTS.md).
552
583
  if (message.type === "event") {
553
584
  if (this.options.onEvent && message.topic && message.subscriptionId) {
554
585
  try {
@@ -563,7 +594,8 @@ export class WebSocketTransport implements Transport {
563
594
  // Handle response
564
595
  const pending = this.pendingRequests.get(message.id);
565
596
  if (!pending) {
566
- console.warn(`[${this.name}] Received response for unknown request: ${message.id}`);
597
+ // A stream frame that left before the cancel of its call is normal: an unsubscribe, a break
598
+ if (message.type !== "stream") console.warn(`[${this.name}] Received response for unknown request: ${message.id}`);
567
599
  return;
568
600
  }
569
601
 
@@ -576,6 +608,9 @@ export class WebSocketTransport implements Transport {
576
608
  // A stream frame: one item, or the end of the stream (BUGS-2026-07 H5: before, the
577
609
  // transport resolved on the first frame and dropped the others)
578
610
  if (message.type === "stream") {
611
+ // A duplicate or a late frame: its item came before, or the delivery is at most once
612
+ const gap = pending.order.next(message.seq, message.id);
613
+ if (gap < 0) return;
579
614
  if (message.stream?.done) {
580
615
  this.pendingRequests.delete(message.id);
581
616
  pending.queue.end();
@@ -585,7 +620,7 @@ export class WebSocketTransport implements Transport {
585
620
  id: pending.messageId,
586
621
  status: { type: "success", code: 200 },
587
622
  payload: message.payload,
588
- metadata: message.metadata || {},
623
+ metadata: itemMetadata(message.metadata, gap),
589
624
  });
590
625
  return;
591
626
  }
@@ -686,7 +721,7 @@ export class WebSocketTransport implements Transport {
686
721
  };
687
722
 
688
723
  const queue = new ItemQueue<ResponseItem<unknown>>();
689
- const pending: PendingRequest = { messageId: message.id, queue };
724
+ const pending: PendingRequest = { messageId: message.id, queue, order: new StreamSequence() };
690
725
  pending.timeout = this.clock.after(this.options.requestTimeout, () => {
691
726
  if (this.pendingRequests.get(wireId) !== pending) return;
692
727
  this.pendingRequests.delete(wireId);
@@ -719,8 +754,10 @@ export class WebSocketTransport implements Transport {
719
754
  let taken = 0;
720
755
  for await (const item of queue) {
721
756
  yield item as ResponseItem<TRes>;
722
- // The reader took the item: give the server credit, in batches of half the window
723
- if (++taken >= window / 2 && this.pendingRequests.get(wireId) === pending) {
757
+ // The reader took the item: give the server credit, in batches of half the window.
758
+ // The frames of a gap took credit too, and no reader takes them.
759
+ taken += 1 + gapOf(item.metadata);
760
+ if (taken >= window / 2 && this.pendingRequests.get(wireId) === pending) {
724
761
  this.sendCredit(wireId, taken);
725
762
  taken = 0;
726
763
  }
@@ -835,9 +872,87 @@ export class WebSocketTransport implements Transport {
835
872
  }
836
873
 
837
874
  /**
838
- * Close WebSocket connection. The requests in progress fail.
875
+ * This function subscribes to topics of the bus of the server (`documentation/EVENTS.md`). The
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.
878
+ *
879
+ * @param topics - One topic, or a list of 1 to 64 topics
880
+ * @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
+ * @returns The unsubscribe function, with the `ready` promise of the first confirmation
883
+ *
884
+ * @example
885
+ * ```typescript
886
+ * const unsubscribe = transport.subscribe("orders.created", (order) => console.log(order));
887
+ * await unsubscribe.ready;
888
+ * unsubscribe();
889
+ * ```
890
+ */
891
+ subscribe<T = unknown>(topics: string | readonly string[], handler: RemoteEventHandler<T>, options: SubscribeOptions = {}): Subscription {
892
+ return subscribeRemote<T>(this, topics, handler, options, this.subscriptionHooks());
893
+ }
894
+
895
+ /**
896
+ * This function gives the events of topics of the bus of the server as an async iterator. A
897
+ * `break` or the signal ends the subscription. The transport resubscribes after a drop, as for
898
+ * `subscribe()`. An error that ends the subscription ends the iteration.
899
+ *
900
+ * @param topics - One topic, or a list of 1 to 64 topics
901
+ * @param options - A signal, `onSubscribed`, the metadata of the call
902
+ */
903
+ events<T = unknown>(topics: string | readonly string[], options: RemoteEventsOptions = {}): AsyncIterable<RemoteEvent<T>> {
904
+ return remoteEvents<T>(this, topics, options, this.subscriptionHooks());
905
+ }
906
+
907
+ /** The parts of the subscriptions of this transport: its connection, its backoff, its close and `onEvent`. */
908
+ private subscriptionHooks(): SubscriptionHooks {
909
+ return {
910
+ lifetime: this.subscriptions.signal,
911
+ ready: (signal) => this.whenConnected(signal),
912
+ backoff: (attempt) => reconnectDelay(attempt, this.options.reconnect),
913
+ onEvent: (event) => {
914
+ if (!this.options.onEvent) return;
915
+ try {
916
+ this.options.onEvent(event.topic, event.data, event.subscription);
917
+ } catch (error) {
918
+ console.error(`[${this.name}] Event handler error:`, error);
919
+ }
920
+ },
921
+ };
922
+ }
923
+
924
+ /**
925
+ * This function resolves when the transport is connected, or when the signal aborts. It has no
926
+ * time limit: a subscription waits for each reconnect. It rejects when the transport closed or
927
+ * stopped its reconnects.
928
+ */
929
+ private whenConnected(signal: AbortSignal): Promise<void> {
930
+ const closed = (): SubscriptionError => new SubscriptionError("CONNECTION_CLOSED", "The WebSocket transport is closed", false);
931
+ if (this.state === WebSocketState.CONNECTED || signal.aborted) return Promise.resolve();
932
+ if (this.state === WebSocketState.DISCONNECTED || this.closing) return Promise.reject(closed());
933
+ return new Promise((resolve, reject) => {
934
+ const finish = (error?: SubscriptionError): void => {
935
+ this.stateListeners.delete(check);
936
+ signal.removeEventListener("abort", onAbort);
937
+ if (error) reject(error);
938
+ else resolve();
939
+ };
940
+ const check = (): void => {
941
+ if (this.state === WebSocketState.CONNECTED) finish();
942
+ else if (this.state === WebSocketState.DISCONNECTED) finish(closed());
943
+ };
944
+ const onAbort = (): void => finish();
945
+ this.stateListeners.add(check);
946
+ signal.addEventListener("abort", onAbort, { once: true });
947
+ });
948
+ }
949
+
950
+ /**
951
+ * Close WebSocket connection. The requests in progress fail, and the subscriptions end.
839
952
  */
840
953
  async close(): Promise<void> {
954
+ // The subscriptions end first: each one sends its cancel while the socket is open
955
+ this.subscriptions.abort();
841
956
  this.closing = true;
842
957
  this.setState(WebSocketState.DISCONNECTING, "close");
843
958
  this.stopHeartbeat();
@@ -854,10 +969,7 @@ export class WebSocketTransport implements Transport {
854
969
  if (this.ws) {
855
970
  const ws = this.ws;
856
971
  this.ws = null;
857
- ws.onopen = null;
858
- ws.onmessage = null;
859
- ws.onclose = null;
860
- ws.onerror = null;
972
+ detach(ws);
861
973
  ws.close();
862
974
  }
863
975
 
@@ -152,8 +152,10 @@ export interface WebSocketTransportOptions {
152
152
  serve?: RequestHandler;
153
153
 
154
154
  /**
155
- * Handler for subscription events.
156
- * Called when the server pushes an event to a subscribed topic.
155
+ * This handler gets each event of each subscription of this transport (`subscribe()` and
156
+ * `events()`), with the id of its subscription (`documentation/EVENTS.md`). It is a wrapper: the
157
+ * handler of a subscription gets the same events. It also gets an `event` frame of a server
158
+ * outside this repository. No side of protocol v1 sends that frame.
157
159
  */
158
160
  onEvent?: EventHandler;
159
161
 
@@ -170,8 +172,9 @@ export interface WebSocketTransportOptions {
170
172
  onTransition?: (transition: MachineTransition) => void;
171
173
 
172
174
  /**
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
175
+ * The class of the sockets. The default is the global `WebSocket`. The `WebSocketTransport` of
176
+ * the Node entry `@procedures/client/node` has the `WebSocket` of the `ws` package as its
177
+ * default. With that class, a drop ends its socket at once with `terminate()`. The global
175
178
  * `WebSocket` has only `close()`, which waits for the answer of the peer. With a dead peer, the
176
179
  * socket stays open until the kernel stops it, at most 15 minutes (the harness cells of R36).
177
180
  */
@@ -249,6 +252,13 @@ export interface WebSocketMessage<T = unknown> {
249
252
  */
250
253
  credit?: number;
251
254
 
255
+ /**
256
+ * The number of a `stream` frame in its call: 1 in the first frame, and one more in each next
257
+ * frame. The receiver drops a frame whose `seq` is not above the last one. A frame of an older
258
+ * peer has no `seq`.
259
+ */
260
+ seq?: number;
261
+
252
262
  /** Stream indicator (for streaming responses) */
253
263
  stream?: {
254
264
  /** Is this the last message in the stream? */
@@ -285,7 +295,8 @@ export type ServerRequestHandler = (
285
295
  ) => Promise<unknown>;
286
296
 
287
297
  /**
288
- * Handler for subscription events.
298
+ * The handler of the `onEvent` option: it gets the topic, the data and the id of the subscription
299
+ * of each event.
289
300
  */
290
301
  export type EventHandler = (
291
302
  topic: string,
@@ -24,6 +24,15 @@ import { optionalMs } from "../../../duration.js";
24
24
  import { credentialsOf, withoutSecretHeaders } from "../../../server/request-auth.js";
25
25
  import { PROTOCOL_VERSION, protocolVersionOf } from "../../../protocol.js";
26
26
  import { TIMER_DEFAULTS } from "../../../timers.js";
27
+ import {
28
+ remoteEvents,
29
+ subscribeRemote,
30
+ type RemoteEvent,
31
+ type RemoteEventHandler,
32
+ type RemoteEventsOptions,
33
+ type SubscribeOptions,
34
+ type Subscription,
35
+ } from "../../../events/remote.js";
27
36
 
28
37
  /**
29
38
  * WebSocket server transport adapter.
@@ -389,6 +398,8 @@ export class WebSocketServerTransport implements ServerTransport {
389
398
  if (channel.incoming.receive(message)) return;
390
399
  // A server-response frame of an older client, for a call that no longer exists
391
400
  if (message.type === "server-response") return;
401
+ // A late answer of a call that the server stopped: a cancel (an unsubscribe), a time limit
402
+ if (message.type === "stream" || message.type === "response" || message.type === "error") return;
392
403
  this.sendError(ws, message.id || "unknown", "Invalid message format");
393
404
  }
394
405
 
@@ -524,6 +535,38 @@ export class WebSocketServerTransport implements ServerTransport {
524
535
  };
525
536
  }
526
537
 
538
+ /**
539
+ * This function subscribes to topics of the bus of a client (`documentation/EVENTS.md`). The
540
+ * client serves them with its `serve` option: a `ProcedureServer` with the option `events`. The
541
+ * subscription ends with its connection: a reconnect is a new connection, so the server
542
+ * subscribes again in its `onConnect` handler. The `onError` handler gets the end.
543
+ *
544
+ * @param connectionId - The id of the connection of the client
545
+ * @param topics - One topic, or a list of 1 to 64 topics
546
+ * @param handler - The handler of each event, in order
547
+ * @param options - `onSubscribed`, `onError`, a signal, the metadata of the call
548
+ */
549
+ subscribe<T = unknown>(
550
+ connectionId: string,
551
+ topics: string | readonly string[],
552
+ handler: RemoteEventHandler<T>,
553
+ options: SubscribeOptions = {},
554
+ ): Subscription {
555
+ return subscribeRemote<T>(this.transportFor(connectionId), topics, handler, options, { resubscribe: false });
556
+ }
557
+
558
+ /**
559
+ * This function gives the events of topics of the bus of a client as an async iterator. The
560
+ * iteration ends with the connection, with an error.
561
+ *
562
+ * @param connectionId - The id of the connection of the client
563
+ * @param topics - One topic, or a list of 1 to 64 topics
564
+ * @param options - A signal, `onSubscribed`, the metadata of the call
565
+ */
566
+ 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 });
568
+ }
569
+
527
570
  /**
528
571
  * Call a procedure on a specific client (server-to-client RPC). It gives the value of the
529
572
  * call, and fails with its error. It is a wrapper of `callConnection` (roadmap R24).
@@ -550,7 +593,6 @@ export class WebSocketServerTransport implements ServerTransport {
550
593
  }
551
594
 
552
595
  /**
553
- * Register a handler for new connections. /**
554
596
  * Register a handler for new connections.
555
597
  * Returns a function to unregister the handler.
556
598
  */
@@ -150,6 +150,13 @@ export interface WebSocketMessage<TReq = unknown> {
150
150
  */
151
151
  credit?: number;
152
152
 
153
+ /**
154
+ * The number of a `stream` frame in its call: 1 in the first frame, and one more in each next
155
+ * frame. The receiver drops a frame whose `seq` is not above the last one. A frame of an older
156
+ * peer has no `seq`.
157
+ */
158
+ seq?: number;
159
+
153
160
  /** Stream indicator (for streaming responses) */
154
161
  stream?: {
155
162
  /** Is this the last message in the stream? */
@@ -15,6 +15,7 @@ import type { Message, ResponseItem } from "../../../client/types.js";
15
15
  import type { RequestHandler, ServerRequest } from "../../../server/types.js";
16
16
  import type { WebSocketMessage } from "../server/types.js";
17
17
  import { withoutInternalKeys } from "../../metadata.js";
18
+ import { StreamSequence, gapOf, itemMetadata } from "../../sequence.js";
18
19
  import { DELIVERY_METADATA_KEY } from "../../../client/middleware/items.js";
19
20
 
20
21
  /**
@@ -143,6 +144,8 @@ interface PendingCall {
143
144
  messageId: string;
144
145
  /** The response items of the call, in order */
145
146
  queue: ItemQueue<ResponseItem<unknown>>;
147
+ /** The `seq` of the stream frames: a duplicate or a late frame gives no item */
148
+ order: StreamSequence;
146
149
  /** The time limit until the first frame */
147
150
  timeout?: ReturnType<typeof setTimeout> | undefined;
148
151
  }
@@ -192,7 +195,7 @@ export class OutgoingCalls {
192
195
  const wireId = `${this.options.idPrefix}${message.id}~${++this.seq}`;
193
196
  const window = this.options.streamWindow;
194
197
  const queue = new ItemQueue<ResponseItem<unknown>>();
195
- const pending: PendingCall = { messageId: message.id, queue };
198
+ const pending: PendingCall = { messageId: message.id, queue, order: new StreamSequence() };
196
199
  const limited = Number.isFinite(this.options.requestTimeoutMs) && this.options.requestTimeoutMs > 0;
197
200
  if (limited) pending.timeout = setTimeout(() => {
198
201
  if (this.pending.get(wireId) !== pending) return;
@@ -231,8 +234,10 @@ export class OutgoingCalls {
231
234
  let taken = 0;
232
235
  for await (const item of queue) {
233
236
  yield item as ResponseItem<TRes>;
234
- // The reader took the item: give the other side credit, in batches of half the window
235
- if (++taken >= window / 2 && this.pending.get(wireId) === pending) {
237
+ // The reader took the item: give the other side credit, in batches of half the window.
238
+ // The frames of a gap took credit too, and no reader takes them.
239
+ taken += 1 + gapOf(item.metadata);
240
+ if (taken >= window / 2 && this.pending.get(wireId) === pending) {
236
241
  this.sendCredit(wireId, taken);
237
242
  taken = 0;
238
243
  }
@@ -262,12 +267,15 @@ export class OutgoingCalls {
262
267
  pending.timeout = undefined;
263
268
  }
264
269
  if (frame.type === "stream" && !frame.status) {
270
+ // A duplicate or a late frame: its item came before, or the delivery is at most once
271
+ const gap = pending.order.next(frame.seq, frame.id);
272
+ if (gap < 0) return true;
265
273
  if (frame.stream?.done) {
266
274
  this.pending.delete(frame.id);
267
275
  pending.queue.end();
268
276
  return true;
269
277
  }
270
- pending.queue.push({ id: pending.messageId, status: { type: "success", code: 200 }, payload: frame.payload, metadata: frame.metadata || {} });
278
+ pending.queue.push({ id: pending.messageId, status: { type: "success", code: 200 }, payload: frame.payload, metadata: itemMetadata(frame.metadata, gap) });
271
279
  return true;
272
280
  }
273
281
  this.pending.delete(frame.id);
@@ -394,13 +402,15 @@ export class IncomingCalls {
394
402
 
395
403
  /**
396
404
  * Send a stream (BUGS-2026-07 H5): one `stream` frame for each item, then a frame with
397
- * `stream.done`. An error of the stream ends it with an `error` frame. When the signal aborts
398
- * (a cancel frame, a closed socket), the loop stops, and the handler of the stream gets
399
- * return(). The stream takes its next item only when the reader has credit for it.
405
+ * `stream.done`. Each `stream` frame has its `seq`, from 1. An error of the stream ends it with
406
+ * an `error` frame. When the signal aborts (a cancel frame, a closed socket), the loop stops,
407
+ * and the handler of the stream gets return(). The stream takes its next item only when the
408
+ * reader has credit for it.
400
409
  */
401
410
  private async sendStream(id: string, stream: AsyncIterable<unknown>, signal: AbortSignal, credit: StreamCredit | undefined): Promise<void> {
402
411
  const iterator = stream[Symbol.asyncIterator]();
403
412
  let done = false;
413
+ let seq = 0;
404
414
  try {
405
415
  for (;;) {
406
416
  if (credit && !(await credit.take(signal))) return;
@@ -411,11 +421,11 @@ export class IncomingCalls {
411
421
  break;
412
422
  }
413
423
  if (signal.aborted || !this.options.isOpen()) return;
414
- const item: WebSocketMessage = { id, type: "stream", payload: result.value, stream: { done: false } };
424
+ const item: WebSocketMessage = { id, type: "stream", seq: ++seq, payload: result.value, stream: { done: false } };
415
425
  if (this.options.sendWithBackpressure) await this.options.sendWithBackpressure(item, signal);
416
426
  else this.send(item);
417
427
  }
418
- this.send({ id, type: "stream", stream: { done: true } });
428
+ this.send({ id, type: "stream", seq: ++seq, stream: { done: true } });
419
429
  } catch (error) {
420
430
  done = true;
421
431
  if (signal.aborted) return;
package/src/browser.ts CHANGED
@@ -323,6 +323,8 @@ export type { TraceContext } from "./tracing/traceparent.js";
323
323
  // Wire protocol v1: the version and the one list of error codes (roadmap R25)
324
324
  export { PROTOCOL_VERSION, PROTOCOL_HEADER, ERROR_CODES, isProtocolErrorCode, httpStatusOf, protocolVersionOf } from "./protocol.js";
325
325
  export type { ProtocolErrorCode, ErrorCodeInfo } from "./protocol.js";
326
+ // The seq of the stream frames: the metadata key of an item after a gap (WIRE-PROTOCOL.md)
327
+ export { STREAM_GAP_METADATA_KEY } from "./adapters/sequence.js";
326
328
  // The deadline of a call: the remaining time goes as procedure-timeout (roadmap R10)
327
329
  export { TIMEOUT_METADATA_KEY, DEADLINE_METADATA_KEY } from "./client/deadline.js";
328
330
  // Typed durations: each public option that is a time takes a Duration (roadmap R11)
@@ -411,6 +413,19 @@ export type {
411
413
  EventWaitOptions,
412
414
  ChannelMap,
413
415
  } from "./events/index.js";
416
+ // The remote events: a peer subscribes to the topics of the bus of another peer (EVENTS.md)
417
+ export { EventPublisher, topicMatcher, EVENTS_SUBSCRIBE_PATH, SubscriptionError, remoteEvents, subscribeRemote } from "./events/index.js";
418
+ export type {
419
+ EventPublisherOptions,
420
+ TopicRule,
421
+ TopicCall,
422
+ EventItem,
423
+ RemoteEvent,
424
+ RemoteEventHandler,
425
+ RemoteEventsOptions,
426
+ SubscribeOptions,
427
+ Subscription,
428
+ } from "./events/index.js";
414
429
 
415
430
  // ============================================================================
416
431
  // Components (serializable UI descriptors) moved to @procedures/splay, the UI package
@@ -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));