@procedures/client 0.4.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.
- package/CHANGELOG.md +39 -0
- package/README.md +46 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/adapters/http/client/transport.d.ts +2 -1
- package/dist/adapters/http/client/transport.d.ts.map +1 -1
- package/dist/adapters/http/client/transport.js +9 -2
- package/dist/adapters/http/client/transport.js.map +1 -1
- package/dist/adapters/http/server/transport.d.ts +3 -1
- package/dist/adapters/http/server/transport.d.ts.map +1 -1
- package/dist/adapters/http/server/transport.js +6 -3
- package/dist/adapters/http/server/transport.js.map +1 -1
- package/dist/adapters/local/client/transport.d.ts +2 -1
- package/dist/adapters/local/client/transport.d.ts.map +1 -1
- package/dist/adapters/local/client/transport.js +8 -2
- package/dist/adapters/local/client/transport.js.map +1 -1
- package/dist/adapters/local/client/types.d.ts +6 -0
- package/dist/adapters/local/client/types.d.ts.map +1 -1
- package/dist/adapters/local/client/types.js.map +1 -1
- package/dist/adapters/sequence.d.ts +36 -0
- package/dist/adapters/sequence.d.ts.map +1 -0
- package/dist/adapters/sequence.js +55 -0
- package/dist/adapters/sequence.js.map +1 -0
- package/dist/adapters/websocket/client/transport.d.ts +39 -1
- package/dist/adapters/websocket/client/transport.d.ts.map +1 -1
- package/dist/adapters/websocket/client/transport.js +102 -7
- package/dist/adapters/websocket/client/transport.js.map +1 -1
- package/dist/adapters/websocket/client/types.d.ts +12 -3
- package/dist/adapters/websocket/client/types.d.ts.map +1 -1
- package/dist/adapters/websocket/client/types.js.map +1 -1
- package/dist/adapters/websocket/server/transport.d.ts +22 -1
- package/dist/adapters/websocket/server/transport.d.ts.map +1 -1
- package/dist/adapters/websocket/server/transport.js +29 -1
- package/dist/adapters/websocket/server/transport.js.map +1 -1
- package/dist/adapters/websocket/server/types.d.ts +6 -0
- package/dist/adapters/websocket/server/types.d.ts.map +1 -1
- package/dist/adapters/websocket/shared/rpc.d.ts +4 -3
- package/dist/adapters/websocket/shared/rpc.d.ts.map +1 -1
- package/dist/adapters/websocket/shared/rpc.js +18 -9
- package/dist/adapters/websocket/shared/rpc.js.map +1 -1
- package/dist/browser.d.ts +3 -0
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +4 -0
- package/dist/browser.js.map +1 -1
- package/dist/client/errors/registry.d.ts.map +1 -1
- package/dist/client/errors/registry.js +1 -0
- package/dist/client/errors/registry.js.map +1 -1
- package/dist/events/index.d.ts +4 -0
- package/dist/events/index.d.ts.map +1 -1
- package/dist/events/index.js +3 -0
- package/dist/events/index.js.map +1 -1
- package/dist/events/publisher.d.ts +95 -0
- package/dist/events/publisher.d.ts.map +1 -0
- package/dist/events/publisher.js +268 -0
- package/dist/events/publisher.js.map +1 -0
- package/dist/events/remote.d.ts +164 -0
- package/dist/events/remote.d.ts.map +1 -0
- package/dist/events/remote.js +250 -0
- package/dist/events/remote.js.map +1 -0
- package/dist/procedures/collection/index.d.ts +1 -1
- package/dist/procedures/collection/index.d.ts.map +1 -1
- package/dist/procedures/collection/index.js.map +1 -1
- package/dist/procedures/collection/procedures.d.ts.map +1 -1
- package/dist/procedures/collection/procedures.js +6 -1
- package/dist/procedures/collection/procedures.js.map +1 -1
- package/dist/procedures/collection/schemas.d.ts +16 -1
- package/dist/procedures/collection/schemas.d.ts.map +1 -1
- package/dist/procedures/collection/schemas.js +13 -2
- package/dist/procedures/collection/schemas.js.map +1 -1
- package/dist/procedures/collection/storage.d.ts +14 -2
- package/dist/procedures/collection/storage.d.ts.map +1 -1
- package/dist/procedures/invoke.d.ts +4 -2
- package/dist/procedures/invoke.d.ts.map +1 -1
- package/dist/procedures/invoke.js +1 -1
- package/dist/procedures/invoke.js.map +1 -1
- package/dist/protocol.d.ts +1 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +1 -0
- package/dist/protocol.js.map +1 -1
- package/dist/server/host-policy.d.ts +11 -0
- package/dist/server/host-policy.d.ts.map +1 -1
- package/dist/server/host-policy.js +5 -0
- package/dist/server/host-policy.js.map +1 -1
- package/dist/server/procedure-server.d.ts +24 -2
- package/dist/server/procedure-server.d.ts.map +1 -1
- package/dist/server/procedure-server.js +34 -4
- package/dist/server/procedure-server.js.map +1 -1
- package/package.json +3 -3
- package/src/adapters/http/client/transport.ts +9 -2
- package/src/adapters/http/server/transport.ts +6 -3
- package/src/adapters/local/client/transport.ts +8 -2
- package/src/adapters/local/client/types.ts +7 -0
- package/src/adapters/sequence.ts +55 -0
- package/src/adapters/websocket/client/transport.ts +108 -7
- package/src/adapters/websocket/client/types.ts +13 -3
- package/src/adapters/websocket/server/transport.ts +43 -1
- package/src/adapters/websocket/server/types.ts +7 -0
- package/src/adapters/websocket/shared/rpc.ts +19 -9
- package/src/browser.ts +15 -0
- package/src/client/errors/registry.ts +1 -0
- package/src/events/index.ts +16 -0
- package/src/events/publisher.ts +314 -0
- package/src/events/remote.ts +369 -0
- package/src/procedures/collection/index.ts +1 -0
- package/src/procedures/collection/procedures.ts +6 -1
- package/src/procedures/collection/schemas.ts +28 -2
- package/src/procedures/collection/storage.ts +14 -2
- package/src/procedures/invoke.ts +4 -2
- package/src/protocol.ts +3 -1
- package/src/server/host-policy.ts +14 -0
- 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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
156
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
398
|
-
* (a cancel frame, a closed socket), the loop stops,
|
|
399
|
-
* return(). The stream takes its next item only when the
|
|
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
|
/**
|
package/src/events/index.ts
CHANGED
|
@@ -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,
|