@procedures/client 0.4.0 → 0.6.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 (110) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +46 -1
  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 +6 -3
  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 +8 -2
  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/transport.d.ts +39 -1
  24. package/dist/adapters/websocket/client/transport.d.ts.map +1 -1
  25. package/dist/adapters/websocket/client/transport.js +102 -7
  26. package/dist/adapters/websocket/client/transport.js.map +1 -1
  27. package/dist/adapters/websocket/client/types.d.ts +12 -3
  28. package/dist/adapters/websocket/client/types.d.ts.map +1 -1
  29. package/dist/adapters/websocket/client/types.js.map +1 -1
  30. package/dist/adapters/websocket/server/transport.d.ts +22 -1
  31. package/dist/adapters/websocket/server/transport.d.ts.map +1 -1
  32. package/dist/adapters/websocket/server/transport.js +29 -1
  33. package/dist/adapters/websocket/server/transport.js.map +1 -1
  34. package/dist/adapters/websocket/server/types.d.ts +6 -0
  35. package/dist/adapters/websocket/server/types.d.ts.map +1 -1
  36. package/dist/adapters/websocket/shared/rpc.d.ts +4 -3
  37. package/dist/adapters/websocket/shared/rpc.d.ts.map +1 -1
  38. package/dist/adapters/websocket/shared/rpc.js +18 -9
  39. package/dist/adapters/websocket/shared/rpc.js.map +1 -1
  40. package/dist/browser.d.ts +3 -0
  41. package/dist/browser.d.ts.map +1 -1
  42. package/dist/browser.js +4 -0
  43. package/dist/browser.js.map +1 -1
  44. package/dist/client/errors/registry.d.ts.map +1 -1
  45. package/dist/client/errors/registry.js +1 -0
  46. package/dist/client/errors/registry.js.map +1 -1
  47. package/dist/events/index.d.ts +4 -0
  48. package/dist/events/index.d.ts.map +1 -1
  49. package/dist/events/index.js +3 -0
  50. package/dist/events/index.js.map +1 -1
  51. package/dist/events/publisher.d.ts +95 -0
  52. package/dist/events/publisher.d.ts.map +1 -0
  53. package/dist/events/publisher.js +268 -0
  54. package/dist/events/publisher.js.map +1 -0
  55. package/dist/events/remote.d.ts +164 -0
  56. package/dist/events/remote.d.ts.map +1 -0
  57. package/dist/events/remote.js +250 -0
  58. package/dist/events/remote.js.map +1 -0
  59. package/dist/procedures/collection/index.d.ts +1 -1
  60. package/dist/procedures/collection/index.d.ts.map +1 -1
  61. package/dist/procedures/collection/index.js.map +1 -1
  62. package/dist/procedures/collection/procedures.d.ts.map +1 -1
  63. package/dist/procedures/collection/procedures.js +6 -1
  64. package/dist/procedures/collection/procedures.js.map +1 -1
  65. package/dist/procedures/collection/schemas.d.ts +16 -1
  66. package/dist/procedures/collection/schemas.d.ts.map +1 -1
  67. package/dist/procedures/collection/schemas.js +13 -2
  68. package/dist/procedures/collection/schemas.js.map +1 -1
  69. package/dist/procedures/collection/storage.d.ts +14 -2
  70. package/dist/procedures/collection/storage.d.ts.map +1 -1
  71. package/dist/procedures/invoke.d.ts +4 -2
  72. package/dist/procedures/invoke.d.ts.map +1 -1
  73. package/dist/procedures/invoke.js +1 -1
  74. package/dist/procedures/invoke.js.map +1 -1
  75. package/dist/protocol.d.ts +1 -1
  76. package/dist/protocol.d.ts.map +1 -1
  77. package/dist/protocol.js +1 -0
  78. package/dist/protocol.js.map +1 -1
  79. package/dist/server/host-policy.d.ts +11 -0
  80. package/dist/server/host-policy.d.ts.map +1 -1
  81. package/dist/server/host-policy.js +5 -0
  82. package/dist/server/host-policy.js.map +1 -1
  83. package/dist/server/procedure-server.d.ts +24 -2
  84. package/dist/server/procedure-server.d.ts.map +1 -1
  85. package/dist/server/procedure-server.js +34 -4
  86. package/dist/server/procedure-server.js.map +1 -1
  87. package/package.json +3 -3
  88. package/src/adapters/http/client/transport.ts +9 -2
  89. package/src/adapters/http/server/transport.ts +6 -3
  90. package/src/adapters/local/client/transport.ts +8 -2
  91. package/src/adapters/local/client/types.ts +7 -0
  92. package/src/adapters/sequence.ts +55 -0
  93. package/src/adapters/websocket/client/transport.ts +108 -7
  94. package/src/adapters/websocket/client/types.ts +13 -3
  95. package/src/adapters/websocket/server/transport.ts +43 -1
  96. package/src/adapters/websocket/server/types.ts +7 -0
  97. package/src/adapters/websocket/shared/rpc.ts +19 -9
  98. package/src/browser.ts +15 -0
  99. package/src/client/errors/registry.ts +1 -0
  100. package/src/events/index.ts +16 -0
  101. package/src/events/publisher.ts +314 -0
  102. package/src/events/remote.ts +369 -0
  103. package/src/procedures/collection/index.ts +1 -0
  104. package/src/procedures/collection/procedures.ts +6 -1
  105. package/src/procedures/collection/schemas.ts +28 -2
  106. package/src/procedures/collection/storage.ts +14 -2
  107. package/src/procedures/invoke.ts +4 -2
  108. package/src/protocol.ts +3 -1
  109. package/src/server/host-policy.ts +14 -0
  110. package/src/server/procedure-server.ts +41 -6
@@ -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
+ }
@@ -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
  }
@@ -234,6 +248,8 @@ export class WebSocketTransport implements Transport {
234
248
  private incoming: IncomingCalls | undefined;
235
249
  /** The time limit of the handshake of the socket that opens now */
236
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();
237
253
 
238
254
  constructor(options: WebSocketTransportOptions) {
239
255
  this.options = {
@@ -562,7 +578,8 @@ export class WebSocketTransport implements Transport {
562
578
  return;
563
579
  }
564
580
 
565
- // 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).
566
583
  if (message.type === "event") {
567
584
  if (this.options.onEvent && message.topic && message.subscriptionId) {
568
585
  try {
@@ -577,7 +594,8 @@ export class WebSocketTransport implements Transport {
577
594
  // Handle response
578
595
  const pending = this.pendingRequests.get(message.id);
579
596
  if (!pending) {
580
- 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}`);
581
599
  return;
582
600
  }
583
601
 
@@ -590,6 +608,9 @@ export class WebSocketTransport implements Transport {
590
608
  // A stream frame: one item, or the end of the stream (BUGS-2026-07 H5: before, the
591
609
  // transport resolved on the first frame and dropped the others)
592
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;
593
614
  if (message.stream?.done) {
594
615
  this.pendingRequests.delete(message.id);
595
616
  pending.queue.end();
@@ -599,7 +620,7 @@ export class WebSocketTransport implements Transport {
599
620
  id: pending.messageId,
600
621
  status: { type: "success", code: 200 },
601
622
  payload: message.payload,
602
- metadata: message.metadata || {},
623
+ metadata: itemMetadata(message.metadata, gap),
603
624
  });
604
625
  return;
605
626
  }
@@ -700,7 +721,7 @@ export class WebSocketTransport implements Transport {
700
721
  };
701
722
 
702
723
  const queue = new ItemQueue<ResponseItem<unknown>>();
703
- const pending: PendingRequest = { messageId: message.id, queue };
724
+ const pending: PendingRequest = { messageId: message.id, queue, order: new StreamSequence() };
704
725
  pending.timeout = this.clock.after(this.options.requestTimeout, () => {
705
726
  if (this.pendingRequests.get(wireId) !== pending) return;
706
727
  this.pendingRequests.delete(wireId);
@@ -733,8 +754,10 @@ export class WebSocketTransport implements Transport {
733
754
  let taken = 0;
734
755
  for await (const item of queue) {
735
756
  yield item as ResponseItem<TRes>;
736
- // The reader took the item: give the server credit, in batches of half the window
737
- 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) {
738
761
  this.sendCredit(wireId, taken);
739
762
  taken = 0;
740
763
  }
@@ -849,9 +872,87 @@ export class WebSocketTransport implements Transport {
849
872
  }
850
873
 
851
874
  /**
852
- * 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.
853
952
  */
854
953
  async close(): Promise<void> {
954
+ // The subscriptions end first: each one sends its cancel while the socket is open
955
+ this.subscriptions.abort();
855
956
  this.closing = true;
856
957
  this.setState(WebSocketState.DISCONNECTING, "close");
857
958
  this.stopHeartbeat();
@@ -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
 
@@ -250,6 +252,13 @@ export interface WebSocketMessage<T = unknown> {
250
252
  */
251
253
  credit?: number;
252
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
+
253
262
  /** Stream indicator (for streaming responses) */
254
263
  stream?: {
255
264
  /** Is this the last message in the stream? */
@@ -286,7 +295,8 @@ export type ServerRequestHandler = (
286
295
  ) => Promise<unknown>;
287
296
 
288
297
  /**
289
- * 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.
290
300
  */
291
301
  export type EventHandler = (
292
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
@@ -83,6 +83,7 @@ const PROTOCOL_CATEGORIES: Readonly<Record<ProtocolErrorCode, ErrorCategory>> =
83
83
  UNAVAILABLE: ErrorCategory.SERVER,
84
84
  CONNECTION_CLOSED: ErrorCategory.NETWORK,
85
85
  CONNECTION_NOT_FOUND: ErrorCategory.CLIENT,
86
+ TOPIC_NOT_FOUND: ErrorCategory.CLIENT,
86
87
  };
87
88
 
88
89
  /**
@@ -55,6 +55,22 @@ export {
55
55
  withEventBusSignal,
56
56
  } from "./bus.js";
57
57
 
58
+ // The remote events: a peer subscribes to the topics of the bus of another peer (EVENTS.md)
59
+ export { EventPublisher, topicMatcher, MAX_SUBSCRIPTION_TOPICS, MAX_TOPIC_LENGTH } from "./publisher.js";
60
+ export type { EventPublisherOptions, TopicRule, TopicCall } from "./publisher.js";
61
+ export { EVENTS_SUBSCRIBE_PATH, SubscriptionError, remoteEvents, subscribeRemote } from "./remote.js";
62
+ export type {
63
+ EventItem,
64
+ EventTransport,
65
+ RemoteEvent,
66
+ RemoteEventHandler,
67
+ RemoteEventsOptions,
68
+ SubscribeInput,
69
+ SubscribeOptions,
70
+ Subscription,
71
+ SubscriptionHooks,
72
+ } from "./remote.js";
73
+
58
74
  // Utilities
59
75
  export {
60
76
  createChannelPrefix,