@collective-intelligence/external-apps-client-sdk 0.1.18 → 0.1.21
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/README.md +53 -0
- package/dist/index.d.ts +111 -1
- package/dist/index.js +638 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -271,6 +271,59 @@ not presign, with `invalid_request` (`details.reason` `not_a_file_field` or
|
|
|
271
271
|
`invalid_request` with `details.field` and `details.reason`; a File field a
|
|
272
272
|
connector writes answers `conflict` with `details.field`.
|
|
273
273
|
|
|
274
|
+
## Realtime topics
|
|
275
|
+
|
|
276
|
+
`createRealtimeClient` keeps one realtime connection per tab for every
|
|
277
|
+
subscribed Realtime Topic. A topic is the records of one entity schema,
|
|
278
|
+
optionally filtered by one data path: `{ schema, filter?: { field, eq } }`.
|
|
279
|
+
Events carry ids only; refetch the records through your own data routes.
|
|
280
|
+
|
|
281
|
+
```ts
|
|
282
|
+
import { createRealtimeClient } from "@collective-intelligence/external-apps-client-sdk"
|
|
283
|
+
|
|
284
|
+
const realtime = createRealtimeClient({
|
|
285
|
+
ticketUrl: "/api/realtime/ticket",
|
|
286
|
+
document,
|
|
287
|
+
window,
|
|
288
|
+
onReset: () => queryClient.invalidateQueries(),
|
|
289
|
+
onAccessChanged: () => session.refresh()
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
const unsubscribe = realtime.subscribe(
|
|
293
|
+
{ schema: "crm.lead", filter: { field: "ownerId", eq: userId } },
|
|
294
|
+
(changes) => invalidateLeads(changes.map((change) => change.id))
|
|
295
|
+
)
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
- Subscriptions to equal topics share one registration; the connection carries
|
|
299
|
+
the union of the registered topics and routes each `change` by its topic.
|
|
300
|
+
- Listeners receive changes in batches. The first change after a delivery
|
|
301
|
+
schedules the next delivery 250 ms plus a random 0 to 750 ms later; later
|
|
302
|
+
changes join it without delaying it. A batch holds the latest change of each
|
|
303
|
+
record, in order of last arrival, and goes to the topic's listeners at
|
|
304
|
+
delivery time. A topic that loses its last listener drops its pending
|
|
305
|
+
changes; `close()` drops every pending change.
|
|
306
|
+
- Pass the page `document` and `window` in a browser. Without `document` the
|
|
307
|
+
client treats the page as always visible; without `window` it ignores
|
|
308
|
+
`pagehide`.
|
|
309
|
+
- The connection opens only while the page is visible and a topic has a
|
|
310
|
+
listener. It closes on `visibilitychange: hidden` and `pagehide`, and when the
|
|
311
|
+
last listener unsubscribes. A topic set change reconnects after 300 ms.
|
|
312
|
+
- Each connection uses a new ticket from `ticketUrl` (the server SDK BFF route)
|
|
313
|
+
and resumes after the last received event id, so missed changes replay.
|
|
314
|
+
`reset` means the gap was too old to replay: refetch every subscribed topic.
|
|
315
|
+
`onReset` runs at the next delivery instead of its pending changes, so the
|
|
316
|
+
tabs that reset together refetch apart. `onAccessChanged` runs at once.
|
|
317
|
+
- A connection without a ping for 45 seconds is reopened. Failures, 429 and 503
|
|
318
|
+
back off with full jitter between 1 and 30 seconds; `Retry-After` and the
|
|
319
|
+
server's `retry:` set the lower bound. The server ends each connection after
|
|
320
|
+
its lifetime; the client reconnects with a new ticket.
|
|
321
|
+
- The default stream reader uses `fetch`, because `EventSource` does not expose
|
|
322
|
+
the server's ping comments or a refused stream's status. Inject
|
|
323
|
+
`eventSourceFactory`, `document`, `window`, `timers` and `random` to test.
|
|
324
|
+
- `getState()` reports `status`, `connections` (0 or 1), `topics` and
|
|
325
|
+
`lastEventId`; `close()` ends the connection and the page listeners.
|
|
326
|
+
|
|
274
327
|
## Workspace development
|
|
275
328
|
|
|
276
329
|
The repository's [Effect demo](../../../demo/external-app-effect/README.md)
|
package/dist/index.d.ts
CHANGED
|
@@ -937,4 +937,114 @@ type DirectTransportOptions = Readonly<{
|
|
|
937
937
|
tenantUrl: string;
|
|
938
938
|
}>;
|
|
939
939
|
declare const createDirectTransport: <Operations extends ClientOperationMap>(options: DirectTransportOptions) => TypedOperationExecutor<Operations>;
|
|
940
|
-
|
|
940
|
+
/** One dispatched Server-Sent Event. */
|
|
941
|
+
type RealtimeStreamEvent = Readonly<{
|
|
942
|
+
type: string;
|
|
943
|
+
data: string;
|
|
944
|
+
/** The `id:` of this event, when it carried one. */
|
|
945
|
+
id: string | undefined;
|
|
946
|
+
}>;
|
|
947
|
+
/**
|
|
948
|
+
* How a stream ended: `status` when the server refused it, and the
|
|
949
|
+
* reconnect delay the server asked for with `retry:` or `Retry-After`.
|
|
950
|
+
*/
|
|
951
|
+
type RealtimeStreamEnd = Readonly<{
|
|
952
|
+
status: number | undefined;
|
|
953
|
+
retryMilliseconds: number | undefined;
|
|
954
|
+
}>;
|
|
955
|
+
type RealtimeStreamHandlers = Readonly<{
|
|
956
|
+
onOpen: () => void;
|
|
957
|
+
onEvent: (event: RealtimeStreamEvent) => void;
|
|
958
|
+
/** A comment or an `id:`-only frame: the server is alive. */
|
|
959
|
+
onPing: (id?: string) => void;
|
|
960
|
+
/** Called once, unless the stream was closed by the client. */
|
|
961
|
+
onEnd: (end: RealtimeStreamEnd) => void;
|
|
962
|
+
}>;
|
|
963
|
+
type RealtimeEventSource = Readonly<{
|
|
964
|
+
close: () => void;
|
|
965
|
+
}>;
|
|
966
|
+
/** Opens one event stream; `close` stops it without calling `onEnd`. */
|
|
967
|
+
type RealtimeEventSourceFactory = (url: string, handlers: RealtimeStreamHandlers) => RealtimeEventSource;
|
|
968
|
+
/** Reads `text/event-stream` with `fetch`, so pings and refusals are visible. */
|
|
969
|
+
declare const createFetchEventSourceFactory: (fetch: ClientFetch) => RealtimeEventSourceFactory;
|
|
970
|
+
/** One equality filter on a dotted data path of a topic's records. */
|
|
971
|
+
type RealtimeTopicFilter = Readonly<{
|
|
972
|
+
field: string;
|
|
973
|
+
eq: string | number | boolean | null;
|
|
974
|
+
}>;
|
|
975
|
+
/** The records of one entity schema, named by FQN or id, optionally filtered. */
|
|
976
|
+
type RealtimeTopic = Readonly<{
|
|
977
|
+
schema: string;
|
|
978
|
+
filter?: RealtimeTopicFilter;
|
|
979
|
+
}>;
|
|
980
|
+
/** A record of a subscribed topic changed; refetch it, the event carries ids only. */
|
|
981
|
+
type RealtimeChange = Readonly<{
|
|
982
|
+
id: string;
|
|
983
|
+
op: "create" | "update" | "delete";
|
|
984
|
+
revision: string;
|
|
985
|
+
topic: RealtimeTopic;
|
|
986
|
+
}>;
|
|
987
|
+
/** One stable key per topic, whatever the key order of the object. */
|
|
988
|
+
declare const realtimeTopicKey: ({ filter, schema }: RealtimeTopic) => string;
|
|
989
|
+
/** Schedules `callback` after `milliseconds`; the result cancels it. */
|
|
990
|
+
type RealtimeTimers = Readonly<{
|
|
991
|
+
schedule: (callback: () => void, milliseconds: number) => () => void;
|
|
992
|
+
}>;
|
|
993
|
+
type PageEvents = Readonly<{
|
|
994
|
+
addEventListener: (type: string, listener: () => void) => void;
|
|
995
|
+
removeEventListener: (type: string, listener: () => void) => void;
|
|
996
|
+
}>;
|
|
997
|
+
type RealtimeDocument = PageEvents & Readonly<{
|
|
998
|
+
visibilityState: DocumentVisibilityState;
|
|
999
|
+
}>;
|
|
1000
|
+
type RealtimeWindow = PageEvents;
|
|
1001
|
+
type RealtimeClientOptions = Readonly<{
|
|
1002
|
+
/** The application's ticket route, e.g. `/api/realtime/ticket`. */
|
|
1003
|
+
ticketUrl: string;
|
|
1004
|
+
fetch?: ClientFetch;
|
|
1005
|
+
eventSourceFactory?: RealtimeEventSourceFactory;
|
|
1006
|
+
/** The page document; without it the page counts as always visible. */
|
|
1007
|
+
document?: RealtimeDocument;
|
|
1008
|
+
/** The page window; without it `pagehide`/`pageshow` are ignored. */
|
|
1009
|
+
window?: RealtimeWindow;
|
|
1010
|
+
timers?: RealtimeTimers;
|
|
1011
|
+
/** A number in `[0, 1)` for the backoff jitter. */
|
|
1012
|
+
random?: () => number;
|
|
1013
|
+
/**
|
|
1014
|
+
* The server could not replay the missed changes: refetch every subscribed
|
|
1015
|
+
* topic. It arrives at the next delivery and replaces its pending changes.
|
|
1016
|
+
*/
|
|
1017
|
+
onReset?: () => void;
|
|
1018
|
+
/** The user's access changed: re-check the session and its capabilities. */
|
|
1019
|
+
onAccessChanged?: () => void;
|
|
1020
|
+
}>;
|
|
1021
|
+
type RealtimeClientStatus = "idle" | "connecting" | "open" | "backoff";
|
|
1022
|
+
type RealtimeClientState = Readonly<{
|
|
1023
|
+
status: RealtimeClientStatus;
|
|
1024
|
+
/** Open or opening connections of this client: 0 or 1. */
|
|
1025
|
+
connections: number;
|
|
1026
|
+
topics: readonly RealtimeTopic[];
|
|
1027
|
+
lastEventId: string | undefined;
|
|
1028
|
+
}>;
|
|
1029
|
+
type RealtimeClient = Readonly<{
|
|
1030
|
+
/**
|
|
1031
|
+
* Listens to one topic; the result stops listening. Changes arrive in
|
|
1032
|
+
* batches, one change per record: the latest, in order of arrival.
|
|
1033
|
+
*/
|
|
1034
|
+
subscribe: (topic: RealtimeTopic, onChanges: (changes: readonly RealtimeChange[]) => void) => () => void;
|
|
1035
|
+
getState: () => RealtimeClientState;
|
|
1036
|
+
/** Closes the connection and stops reacting to the page. */
|
|
1037
|
+
close: () => void;
|
|
1038
|
+
}>;
|
|
1039
|
+
/**
|
|
1040
|
+
* One realtime connection per tab for every subscribed topic. The connection
|
|
1041
|
+
* opens while the page is visible and a topic has a listener; a topic set
|
|
1042
|
+
* change reconnects after a short debounce, and every reconnect resumes after
|
|
1043
|
+
* the last received event. Changes and resets reach the listeners in one
|
|
1044
|
+
* delivery per tab, 250 to 1000 ms after the first of them: a burst of
|
|
1045
|
+
* changes refetches once, and tabs that reset together refetch apart.
|
|
1046
|
+
* Connection decisions live in the pure state machine (`./machine`); this
|
|
1047
|
+
* client only runs its commands.
|
|
1048
|
+
*/
|
|
1049
|
+
declare const createRealtimeClient: (options: RealtimeClientOptions) => RealtimeClient;
|
|
1050
|
+
export { type CiError, type CiErrorCode, type CiResult, type CiRunCancellation, type CiStreamEvent, type CiStreamPage, type ClientDomains, type ClientFetch, type ClientOperationDefinition, type ClientOperationId, type ClientOperationInput, type ClientOperationMap, type ClientOperationOutput, type ClientSdkClient, type ClientSdkEntrypoint, DEFAULT_FOLLOW_RUN_DEADLINE_MS, type DelegatedApplicationRuntime, type DelegatedApplicationRuntimeOptions, type DelegatedRuntimeState, type DirectTransportOptions, type EntityClient, type EntityCreateInput, type EntityDeleteInput, type EntityFileFieldWrite, type EntityFileValue, type EntityFileWrite, type EntityHttpDestination, type EntityHttpTransportOptions, type EntityListQuery, type EntityMemberValue, type EntityOperation, type EntityOperationResult, type EntityPage, type EntityProtocol, type EntityReadLimitDetails, type EntityRequest, type EntityTransport, type EntityUpdateInput, type EntityUploadInput, type EntityUploadSlot, type FederatedAuth, FederatedAuthError, type FederatedAuthErrorCode, type FederatedAuthOptions, type FederatedAuthState, type FederatedSessionIdentity, type FixedWorkflowClient, type FixedWorkflowClientOptions, type FollowRunOptions, type FollowRunSource, type IdempotencyKey, type JsonObject, type JsonValue, type KnowledgeQuery, type OperationExecutionOptions, type OperationRequest, type OperationSuccess, type OrganizationClient, type PortableResourceSchema, type ProxyAuthentication, type ProxyTransportOptions, type QueryFilter, type RealtimeChange, type RealtimeClient, type RealtimeClientOptions, type RealtimeClientState, type RealtimeClientStatus, type RealtimeDocument, type RealtimeEventSource, type RealtimeEventSourceFactory, type RealtimeStreamEnd, type RealtimeStreamEvent, type RealtimeStreamHandlers, type RealtimeTimers, type RealtimeTopic, type RealtimeTopicFilter, type RealtimeWindow, type ResourceCapabilities, type ResourceClient, type ResourceCreateInput, type ResourceCreateRequest, type ResourceCursor, type ResourceDefinition, type ResourceError, type ResourceErrorCode, type ResourceFilter, type ResourceGetRequest, type ResourceId, type ResourceIdentityField, type ResourceJsonValue, type ResourceLifecycleInput, type ResourceLifecycleRequest, type ResourceListQuery, type ResourceListRequest, type ResourceListResult, type ResourceMutationInput, type ResourcePagination, type ResourcePaginationMode, type ResourceRecord, type ResourceRecordId, type ResourceRequestOptions, type ResourceResult, type ResourceRevision, type ResourceSort, type ResourceTransport, type ResourceTransportRequest, type ResourceUpdateRequest, type SearchQuery, type ServerSessionAuthOptions, type TypedOperationExecutor, type WorkflowInvokeTarget, clientOperationIds, clientSdkProtocolVersion, createClient, createDelegatedApplicationRuntime, createDirectTransport, createEntityHttpTransport, createEntityProtocol, createFederatedAuth, createFetchEventSourceFactory, createFixedWorkflowClient, createProxyTransport, createRealtimeClient, createResourceClient, createServerSessionAuth, followRun, isClientOperationId, isEntityReadLimitError, nextStreamCursor, realtimeTopicKey };
|
package/dist/index.js
CHANGED
|
@@ -44,10 +44,10 @@ const normalizedReturnTarget = (value, applicationOrigin) => {
|
|
|
44
44
|
if (target.origin !== applicationOrigin) throw new FederatedAuthError("invalid_return_target", "The return target must share the callback origin");
|
|
45
45
|
return target.href;
|
|
46
46
|
};
|
|
47
|
-
const isRecord = (value) => value !== null && Object(value) === value && !Array.isArray(value);
|
|
47
|
+
const isRecord$1 = (value) => value !== null && Object(value) === value && !Array.isArray(value);
|
|
48
48
|
const parseTransaction = (serialized) => {
|
|
49
49
|
const value = JSON.parse(serialized);
|
|
50
|
-
if (!isRecord(value) || typeof value.expiresAt !== "number" || typeof value.returnTo !== "string" || typeof value.state !== "string" || typeof value.verifier !== "string") throw new FederatedAuthError("invalid_state", "Invalid login transaction");
|
|
50
|
+
if (!isRecord$1(value) || typeof value.expiresAt !== "number" || typeof value.returnTo !== "string" || typeof value.state !== "string" || typeof value.verifier !== "string") throw new FederatedAuthError("invalid_state", "Invalid login transaction");
|
|
51
51
|
return {
|
|
52
52
|
expiresAt: value.expiresAt,
|
|
53
53
|
returnTo: value.returnTo,
|
|
@@ -432,7 +432,7 @@ const eventKind = (event) => {
|
|
|
432
432
|
const record = asRecord$1(event);
|
|
433
433
|
return record === null ? null : readString(record, "kind");
|
|
434
434
|
};
|
|
435
|
-
const idle = async (ms, sleep, signal) => {
|
|
435
|
+
const idle$1 = async (ms, sleep, signal) => {
|
|
436
436
|
if (ms <= 0 || signal?.aborted === true) return;
|
|
437
437
|
if (sleep !== void 0) {
|
|
438
438
|
await sleep(ms);
|
|
@@ -502,7 +502,7 @@ const followRun = async (runs, options) => {
|
|
|
502
502
|
if (status !== null && terminalSnapshotStatuses.has(status)) return await readRemainingPages(runs, options, page.value);
|
|
503
503
|
if (page.value.nextCursor !== null) afterCursor = page.value.nextCursor;
|
|
504
504
|
emptyPolls = page.value.events.length === 0 ? emptyPolls + 1 : 0;
|
|
505
|
-
await idle(Math.min(idleMs * 2 ** emptyPolls, maxIdleMs), options.sleep, options.signal);
|
|
505
|
+
await idle$1(Math.min(idleMs * 2 ** emptyPolls, maxIdleMs), options.sleep, options.signal);
|
|
506
506
|
}
|
|
507
507
|
throw new Error("Run did not finish");
|
|
508
508
|
};
|
|
@@ -739,4 +739,637 @@ const createDirectTransport = (options) => {
|
|
|
739
739
|
origin: tenantUrl
|
|
740
740
|
});
|
|
741
741
|
};
|
|
742
|
-
|
|
742
|
+
const emptyBlock = () => ({
|
|
743
|
+
comment: false,
|
|
744
|
+
data: [],
|
|
745
|
+
id: void 0,
|
|
746
|
+
type: ""
|
|
747
|
+
});
|
|
748
|
+
/**
|
|
749
|
+
* A Server-Sent Events parser that, unlike `EventSource`, reports comments:
|
|
750
|
+
* the server's pings are comments, and the client's watchdog needs them.
|
|
751
|
+
*/
|
|
752
|
+
const createEventStreamParser = (handlers) => {
|
|
753
|
+
let buffer = "";
|
|
754
|
+
let block = emptyBlock();
|
|
755
|
+
let retry;
|
|
756
|
+
const dispatch = () => {
|
|
757
|
+
const ended = block;
|
|
758
|
+
block = emptyBlock();
|
|
759
|
+
if (ended.data.length > 0) handlers.onEvent({
|
|
760
|
+
data: ended.data.join("\n"),
|
|
761
|
+
id: ended.id,
|
|
762
|
+
type: ended.type === "" ? "message" : ended.type
|
|
763
|
+
});
|
|
764
|
+
else if (ended.comment || ended.id !== void 0) handlers.onPing(ended.id);
|
|
765
|
+
};
|
|
766
|
+
const line = (text) => {
|
|
767
|
+
if (text === "") {
|
|
768
|
+
dispatch();
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
if (text.startsWith(":")) {
|
|
772
|
+
block.comment = true;
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
const colon = text.indexOf(":");
|
|
776
|
+
const field = colon === -1 ? text : text.slice(0, colon);
|
|
777
|
+
const raw = colon === -1 ? "" : text.slice(colon + 1);
|
|
778
|
+
const value = raw.startsWith(" ") ? raw.slice(1) : raw;
|
|
779
|
+
if (field === "data") block.data.push(value);
|
|
780
|
+
else if (field === "event") block.type = value;
|
|
781
|
+
else if (field === "id" && !value.includes("\0")) block.id = value;
|
|
782
|
+
else if (field === "retry" && /^\d+$/u.test(value)) retry = Number(value);
|
|
783
|
+
};
|
|
784
|
+
return {
|
|
785
|
+
push: (chunk) => {
|
|
786
|
+
buffer += chunk;
|
|
787
|
+
const lines = buffer.split("\n");
|
|
788
|
+
buffer = lines.pop() ?? "";
|
|
789
|
+
for (const text of lines) line(text.endsWith("\r") ? text.slice(0, -1) : text);
|
|
790
|
+
},
|
|
791
|
+
/** The last `retry:` the stream sent, in milliseconds. */
|
|
792
|
+
retry: () => retry
|
|
793
|
+
};
|
|
794
|
+
};
|
|
795
|
+
/** `Retry-After` in milliseconds, seconds form only. */
|
|
796
|
+
const retryAfterMilliseconds = (response) => {
|
|
797
|
+
const value = response.headers.get("retry-after");
|
|
798
|
+
return value !== null && /^\d+$/u.test(value) ? Number(value) * 1e3 : void 0;
|
|
799
|
+
};
|
|
800
|
+
/** Reads `text/event-stream` with `fetch`, so pings and refusals are visible. */
|
|
801
|
+
const createFetchEventSourceFactory = (fetch) => (url, handlers) => {
|
|
802
|
+
const abort = new AbortController();
|
|
803
|
+
const live = () => !abort.signal.aborted;
|
|
804
|
+
const parser = createEventStreamParser({
|
|
805
|
+
onEvent: (event) => {
|
|
806
|
+
if (live()) handlers.onEvent(event);
|
|
807
|
+
},
|
|
808
|
+
onPing: (id) => {
|
|
809
|
+
if (live()) handlers.onPing(id);
|
|
810
|
+
}
|
|
811
|
+
});
|
|
812
|
+
const read = async () => {
|
|
813
|
+
try {
|
|
814
|
+
const response = await fetch(url, {
|
|
815
|
+
cache: "no-store",
|
|
816
|
+
headers: { accept: "text/event-stream" },
|
|
817
|
+
signal: abort.signal
|
|
818
|
+
});
|
|
819
|
+
if (!response.ok || response.body === null) {
|
|
820
|
+
parser.push(`${await response.text()}\n\n`);
|
|
821
|
+
if (live()) handlers.onEnd({
|
|
822
|
+
retryMilliseconds: retryAfterMilliseconds(response) ?? parser.retry(),
|
|
823
|
+
status: response.status
|
|
824
|
+
});
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
if (live()) handlers.onOpen();
|
|
828
|
+
const decoder = new TextDecoder();
|
|
829
|
+
const reader = response.body.getReader();
|
|
830
|
+
for (;;) {
|
|
831
|
+
const { done, value } = await reader.read();
|
|
832
|
+
if (done) break;
|
|
833
|
+
parser.push(decoder.decode(value, { stream: true }));
|
|
834
|
+
}
|
|
835
|
+
} catch {}
|
|
836
|
+
if (live()) handlers.onEnd({
|
|
837
|
+
retryMilliseconds: parser.retry(),
|
|
838
|
+
status: void 0
|
|
839
|
+
});
|
|
840
|
+
};
|
|
841
|
+
read();
|
|
842
|
+
return { close: () => abort.abort() };
|
|
843
|
+
};
|
|
844
|
+
const topicSetDebounceMilliseconds = 300;
|
|
845
|
+
const pingTimeoutMilliseconds = 45e3;
|
|
846
|
+
const backoffBaseMilliseconds = 1e3;
|
|
847
|
+
const backoffCapMilliseconds = 3e4;
|
|
848
|
+
const initialMachineState = (documentVisible) => ({
|
|
849
|
+
attempt: 0,
|
|
850
|
+
documentVisible,
|
|
851
|
+
lastEventId: void 0,
|
|
852
|
+
nextLinkId: 1,
|
|
853
|
+
pageShown: true,
|
|
854
|
+
phase: "idle",
|
|
855
|
+
topics: []
|
|
856
|
+
});
|
|
857
|
+
/** Full jitter over an exponential cap; a server hint is a lower bound. */
|
|
858
|
+
const backoffDelay = (attempt, retryMilliseconds, jitter) => {
|
|
859
|
+
const cap = Math.min(backoffCapMilliseconds, backoffBaseMilliseconds * 2 ** attempt);
|
|
860
|
+
return Math.max(retryMilliseconds ?? 0, Math.floor(jitter * cap));
|
|
861
|
+
};
|
|
862
|
+
const currentLink = (state) => "link" in state ? state.link : void 0;
|
|
863
|
+
const shared = (state) => ({
|
|
864
|
+
attempt: state.attempt,
|
|
865
|
+
documentVisible: state.documentVisible,
|
|
866
|
+
lastEventId: state.lastEventId,
|
|
867
|
+
nextLinkId: state.nextLinkId,
|
|
868
|
+
pageShown: state.pageShown,
|
|
869
|
+
topics: state.topics
|
|
870
|
+
});
|
|
871
|
+
const visible = (state) => state.documentVisible && state.pageShown;
|
|
872
|
+
const wanted = (state) => state.phase !== "closed" && visible(state) && state.topics.length > 0;
|
|
873
|
+
const sameTopics = (left, right) => left.length === right.length && left.every((key) => right.includes(key));
|
|
874
|
+
const unchanged = (state) => ({
|
|
875
|
+
commands: [],
|
|
876
|
+
state
|
|
877
|
+
});
|
|
878
|
+
/** Commands that end the current link, if any. */
|
|
879
|
+
const unlink = (state) => {
|
|
880
|
+
const link = currentLink(state);
|
|
881
|
+
return link === void 0 ? [] : [{
|
|
882
|
+
linkId: link.id,
|
|
883
|
+
type: "disconnect"
|
|
884
|
+
}, {
|
|
885
|
+
timer: "watchdog",
|
|
886
|
+
type: "cancel"
|
|
887
|
+
}];
|
|
888
|
+
};
|
|
889
|
+
const watchdog = (linkId) => ({
|
|
890
|
+
event: {
|
|
891
|
+
linkId,
|
|
892
|
+
type: "watchdogElapsed"
|
|
893
|
+
},
|
|
894
|
+
milliseconds: pingTimeoutMilliseconds,
|
|
895
|
+
timer: "watchdog",
|
|
896
|
+
type: "schedule"
|
|
897
|
+
});
|
|
898
|
+
const idle = (state) => ({
|
|
899
|
+
...shared(state),
|
|
900
|
+
phase: state.phase === "closed" ? "closed" : "idle"
|
|
901
|
+
});
|
|
902
|
+
const stop = (state) => ({
|
|
903
|
+
commands: [
|
|
904
|
+
{
|
|
905
|
+
timer: "debounce",
|
|
906
|
+
type: "cancel"
|
|
907
|
+
},
|
|
908
|
+
{
|
|
909
|
+
timer: "backoff",
|
|
910
|
+
type: "cancel"
|
|
911
|
+
},
|
|
912
|
+
...unlink(state)
|
|
913
|
+
],
|
|
914
|
+
state: idle(state)
|
|
915
|
+
});
|
|
916
|
+
const connect = (state) => {
|
|
917
|
+
if (!wanted(state)) return {
|
|
918
|
+
commands: unlink(state),
|
|
919
|
+
state: idle(state)
|
|
920
|
+
};
|
|
921
|
+
const link = {
|
|
922
|
+
id: state.nextLinkId,
|
|
923
|
+
topics: state.topics
|
|
924
|
+
};
|
|
925
|
+
return {
|
|
926
|
+
commands: [
|
|
927
|
+
...unlink(state),
|
|
928
|
+
watchdog(link.id),
|
|
929
|
+
{
|
|
930
|
+
link,
|
|
931
|
+
type: "mint"
|
|
932
|
+
}
|
|
933
|
+
],
|
|
934
|
+
state: {
|
|
935
|
+
...shared(state),
|
|
936
|
+
link,
|
|
937
|
+
nextLinkId: state.nextLinkId + 1,
|
|
938
|
+
phase: "connecting"
|
|
939
|
+
}
|
|
940
|
+
};
|
|
941
|
+
};
|
|
942
|
+
const retry = (state, retryMilliseconds) => {
|
|
943
|
+
if (!wanted(state)) return {
|
|
944
|
+
commands: unlink(state),
|
|
945
|
+
state: idle(state)
|
|
946
|
+
};
|
|
947
|
+
return {
|
|
948
|
+
commands: [...unlink(state), {
|
|
949
|
+
attempt: state.attempt,
|
|
950
|
+
retryMilliseconds,
|
|
951
|
+
type: "backoff"
|
|
952
|
+
}],
|
|
953
|
+
state: {
|
|
954
|
+
...shared(state),
|
|
955
|
+
attempt: state.attempt + 1,
|
|
956
|
+
phase: "backoff"
|
|
957
|
+
}
|
|
958
|
+
};
|
|
959
|
+
};
|
|
960
|
+
/** Brings the connection in line with the page and the topic set. */
|
|
961
|
+
const sync = (state) => {
|
|
962
|
+
if (!wanted(state)) return stop(state);
|
|
963
|
+
const link = currentLink(state);
|
|
964
|
+
if (state.phase === "backoff" || link !== void 0 && sameTopics(link.topics, state.topics)) return {
|
|
965
|
+
commands: [{
|
|
966
|
+
timer: "debounce",
|
|
967
|
+
type: "cancel"
|
|
968
|
+
}],
|
|
969
|
+
state
|
|
970
|
+
};
|
|
971
|
+
return connect(state);
|
|
972
|
+
};
|
|
973
|
+
const reactToPage = (state) => {
|
|
974
|
+
if (!visible(state)) return stop(state);
|
|
975
|
+
return currentLink(state) === void 0 && state.phase !== "backoff" ? sync(state) : unchanged(state);
|
|
976
|
+
};
|
|
977
|
+
const streamUrlAfter = (streamUrl, lastEventId) => {
|
|
978
|
+
const url = new URL(streamUrl);
|
|
979
|
+
if (lastEventId !== void 0) url.searchParams.set("lastEventId", lastEventId);
|
|
980
|
+
return url.href;
|
|
981
|
+
};
|
|
982
|
+
/** Events of the client, the page and its timers. */
|
|
983
|
+
const clientTransition = (state, event) => {
|
|
984
|
+
switch (event.type) {
|
|
985
|
+
case "topicsChanged": {
|
|
986
|
+
const next = {
|
|
987
|
+
...state,
|
|
988
|
+
topics: event.topics
|
|
989
|
+
};
|
|
990
|
+
return next.phase === "closed" ? unchanged(next) : {
|
|
991
|
+
commands: [{
|
|
992
|
+
event: { type: "debounceElapsed" },
|
|
993
|
+
milliseconds: topicSetDebounceMilliseconds,
|
|
994
|
+
timer: "debounce",
|
|
995
|
+
type: "schedule"
|
|
996
|
+
}],
|
|
997
|
+
state: next
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
case "debounceElapsed": return sync(state);
|
|
1001
|
+
case "visibilityChanged": return reactToPage({
|
|
1002
|
+
...state,
|
|
1003
|
+
documentVisible: event.visible
|
|
1004
|
+
});
|
|
1005
|
+
case "pageHidden": return stop({
|
|
1006
|
+
...state,
|
|
1007
|
+
pageShown: false
|
|
1008
|
+
});
|
|
1009
|
+
case "pageShown": return reactToPage({
|
|
1010
|
+
...state,
|
|
1011
|
+
documentVisible: event.visible,
|
|
1012
|
+
pageShown: true
|
|
1013
|
+
});
|
|
1014
|
+
case "backoffElapsed": return state.phase === "backoff" ? connect(state) : unchanged(state);
|
|
1015
|
+
case "close": return {
|
|
1016
|
+
...stop(state),
|
|
1017
|
+
state: {
|
|
1018
|
+
...shared(state),
|
|
1019
|
+
phase: "closed"
|
|
1020
|
+
}
|
|
1021
|
+
};
|
|
1022
|
+
default: return unchanged(state);
|
|
1023
|
+
}
|
|
1024
|
+
};
|
|
1025
|
+
/** Events of one link; an event of an earlier link changes nothing. */
|
|
1026
|
+
const linkTransition = (state, event) => {
|
|
1027
|
+
const link = currentLink(state);
|
|
1028
|
+
if (link?.id !== event.linkId) return unchanged(state);
|
|
1029
|
+
switch (event.type) {
|
|
1030
|
+
case "ticketMinted": return {
|
|
1031
|
+
commands: [{
|
|
1032
|
+
linkId: link.id,
|
|
1033
|
+
type: "openStream",
|
|
1034
|
+
url: streamUrlAfter(event.streamUrl, state.lastEventId)
|
|
1035
|
+
}],
|
|
1036
|
+
state
|
|
1037
|
+
};
|
|
1038
|
+
case "ticketFailed":
|
|
1039
|
+
case "streamEnded": return retry(state, event.retryMilliseconds);
|
|
1040
|
+
case "streamOpened": return {
|
|
1041
|
+
commands: [watchdog(link.id)],
|
|
1042
|
+
state: {
|
|
1043
|
+
...shared(state),
|
|
1044
|
+
link,
|
|
1045
|
+
phase: "open"
|
|
1046
|
+
}
|
|
1047
|
+
};
|
|
1048
|
+
case "received": {
|
|
1049
|
+
const { eventId } = event;
|
|
1050
|
+
return {
|
|
1051
|
+
commands: [watchdog(link.id)],
|
|
1052
|
+
state: {
|
|
1053
|
+
...state,
|
|
1054
|
+
attempt: 0,
|
|
1055
|
+
lastEventId: eventId === void 0 || eventId === "" ? state.lastEventId : eventId
|
|
1056
|
+
}
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
case "watchdogElapsed": return retry(state);
|
|
1060
|
+
default: return unchanged(state);
|
|
1061
|
+
}
|
|
1062
|
+
};
|
|
1063
|
+
const transition = (state, event) => "linkId" in event ? linkTransition(state, event) : clientTransition(state, event);
|
|
1064
|
+
/** One stable key per topic, whatever the key order of the object. */
|
|
1065
|
+
const realtimeTopicKey = ({ filter, schema }) => JSON.stringify(filter === void 0 ? [schema] : [
|
|
1066
|
+
schema,
|
|
1067
|
+
filter.field,
|
|
1068
|
+
filter.eq
|
|
1069
|
+
]);
|
|
1070
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1071
|
+
const isFilterValue = (value) => value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
|
1072
|
+
const isTopic = (value) => isRecord(value) && typeof value.schema === "string" && (value.filter === void 0 || isRecord(value.filter) && typeof value.filter.field === "string" && isFilterValue(value.filter.eq));
|
|
1073
|
+
const isOp = (value) => value === "create" || value === "update" || value === "delete";
|
|
1074
|
+
/** The `data` of a `change` event, or undefined when it is malformed. */
|
|
1075
|
+
const parseRealtimeChange = (data) => {
|
|
1076
|
+
let value;
|
|
1077
|
+
try {
|
|
1078
|
+
value = JSON.parse(data);
|
|
1079
|
+
} catch {
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
return isRecord(value) && typeof value.id === "string" && isOp(value.op) && typeof value.revision === "string" && isTopic(value.topic) ? {
|
|
1083
|
+
id: value.id,
|
|
1084
|
+
op: value.op,
|
|
1085
|
+
revision: value.revision,
|
|
1086
|
+
topic: value.topic
|
|
1087
|
+
} : void 0;
|
|
1088
|
+
};
|
|
1089
|
+
/** The stream URL of a ticket route answer, or undefined when it is malformed. */
|
|
1090
|
+
const readStreamUrl = (value) => isRecord(value) && typeof value.streamUrl === "string" ? value.streamUrl : void 0;
|
|
1091
|
+
/** Surfaces a listener's error without stopping the other listeners. */
|
|
1092
|
+
const report = (error) => {
|
|
1093
|
+
if ("reportError" in globalThis) {
|
|
1094
|
+
globalThis.reportError(error);
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
queueMicrotask(() => {
|
|
1098
|
+
throw error;
|
|
1099
|
+
});
|
|
1100
|
+
};
|
|
1101
|
+
const notify = (listener) => {
|
|
1102
|
+
try {
|
|
1103
|
+
listener();
|
|
1104
|
+
} catch (error) {
|
|
1105
|
+
report(error);
|
|
1106
|
+
}
|
|
1107
|
+
};
|
|
1108
|
+
/** The least wait before a delivery: a burst of changes joins one delivery. */
|
|
1109
|
+
const changeWindowMilliseconds = 250;
|
|
1110
|
+
/** The random extra wait, so that the tabs of many users refetch apart. */
|
|
1111
|
+
const changeJitterMilliseconds = 750;
|
|
1112
|
+
const defaultTimers = { schedule: (callback, milliseconds) => {
|
|
1113
|
+
const handle = globalThis.setTimeout(callback, milliseconds);
|
|
1114
|
+
return () => globalThis.clearTimeout(handle);
|
|
1115
|
+
} };
|
|
1116
|
+
/**
|
|
1117
|
+
* One realtime connection per tab for every subscribed topic. The connection
|
|
1118
|
+
* opens while the page is visible and a topic has a listener; a topic set
|
|
1119
|
+
* change reconnects after a short debounce, and every reconnect resumes after
|
|
1120
|
+
* the last received event. Changes and resets reach the listeners in one
|
|
1121
|
+
* delivery per tab, 250 to 1000 ms after the first of them: a burst of
|
|
1122
|
+
* changes refetches once, and tabs that reset together refetch apart.
|
|
1123
|
+
* Connection decisions live in the pure state machine (`./machine`); this
|
|
1124
|
+
* client only runs its commands.
|
|
1125
|
+
*/
|
|
1126
|
+
const createRealtimeClient = (options) => {
|
|
1127
|
+
const fetch = options.fetch ?? globalThis.fetch;
|
|
1128
|
+
const openStream = options.eventSourceFactory ?? createFetchEventSourceFactory(fetch);
|
|
1129
|
+
const timers = options.timers ?? defaultTimers;
|
|
1130
|
+
const random = options.random ?? Math.random;
|
|
1131
|
+
const page = options.document;
|
|
1132
|
+
const view = options.window;
|
|
1133
|
+
const documentVisible = () => page === void 0 || page.visibilityState !== "hidden";
|
|
1134
|
+
const registrations = /* @__PURE__ */ new Map();
|
|
1135
|
+
const pendingTimers = /* @__PURE__ */ new Map();
|
|
1136
|
+
const links = /* @__PURE__ */ new Map();
|
|
1137
|
+
const queue = [];
|
|
1138
|
+
/** Per topic key, the latest change of each record, by last arrival. */
|
|
1139
|
+
const pending = /* @__PURE__ */ new Map();
|
|
1140
|
+
let resetPending = false;
|
|
1141
|
+
let cancelDelivery;
|
|
1142
|
+
let state = initialMachineState(documentVisible());
|
|
1143
|
+
/** Runs events one at a time; events sent by a command wait in the queue. */
|
|
1144
|
+
const dispatch = (event) => {
|
|
1145
|
+
queue.push(event);
|
|
1146
|
+
if (queue.length > 1) return;
|
|
1147
|
+
for (let next = queue.at(0); next !== void 0; next = queue.at(0)) try {
|
|
1148
|
+
const { commands, state: nextState } = transition(state, next);
|
|
1149
|
+
state = nextState;
|
|
1150
|
+
for (const command of commands) run(command);
|
|
1151
|
+
} finally {
|
|
1152
|
+
queue.shift();
|
|
1153
|
+
}
|
|
1154
|
+
};
|
|
1155
|
+
/** Delivers the pending changes, or the pending reset, to the current listeners. */
|
|
1156
|
+
const deliver = () => {
|
|
1157
|
+
cancelDelivery = void 0;
|
|
1158
|
+
const batches = [...pending];
|
|
1159
|
+
pending.clear();
|
|
1160
|
+
if (resetPending) {
|
|
1161
|
+
resetPending = false;
|
|
1162
|
+
notify(() => options.onReset?.());
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
for (const [key, changes] of batches) {
|
|
1166
|
+
const batch = [...changes.values()];
|
|
1167
|
+
for (const listener of registrations.get(key)?.listeners ?? []) notify(() => listener(batch));
|
|
1168
|
+
}
|
|
1169
|
+
};
|
|
1170
|
+
/**
|
|
1171
|
+
* The first change or reset after a delivery schedules the next one; later
|
|
1172
|
+
* ones join it without delaying it.
|
|
1173
|
+
*/
|
|
1174
|
+
const scheduleDelivery = () => {
|
|
1175
|
+
cancelDelivery ??= timers.schedule(deliver, changeWindowMilliseconds + Math.floor(random() * changeJitterMilliseconds));
|
|
1176
|
+
};
|
|
1177
|
+
const route = (change) => {
|
|
1178
|
+
const key = realtimeTopicKey(change.topic);
|
|
1179
|
+
if (!registrations.has(key)) return;
|
|
1180
|
+
const changes = pending.get(key) ?? /* @__PURE__ */ new Map();
|
|
1181
|
+
changes.delete(change.id);
|
|
1182
|
+
changes.set(change.id, change);
|
|
1183
|
+
pending.set(key, changes);
|
|
1184
|
+
scheduleDelivery();
|
|
1185
|
+
};
|
|
1186
|
+
const ticketEvent = async (link, signal) => {
|
|
1187
|
+
const topics = link.topics.flatMap((key) => {
|
|
1188
|
+
const registration = registrations.get(key);
|
|
1189
|
+
return registration === void 0 ? [] : [registration.topic];
|
|
1190
|
+
});
|
|
1191
|
+
try {
|
|
1192
|
+
const response = await fetch(options.ticketUrl, {
|
|
1193
|
+
body: JSON.stringify({ topics }),
|
|
1194
|
+
credentials: "same-origin",
|
|
1195
|
+
headers: { "content-type": "application/json" },
|
|
1196
|
+
method: "POST",
|
|
1197
|
+
signal
|
|
1198
|
+
});
|
|
1199
|
+
if (!response.ok) return {
|
|
1200
|
+
linkId: link.id,
|
|
1201
|
+
retryMilliseconds: retryAfterMilliseconds(response),
|
|
1202
|
+
type: "ticketFailed"
|
|
1203
|
+
};
|
|
1204
|
+
const streamUrl = readStreamUrl(await response.json());
|
|
1205
|
+
return streamUrl === void 0 ? {
|
|
1206
|
+
linkId: link.id,
|
|
1207
|
+
retryMilliseconds: void 0,
|
|
1208
|
+
type: "ticketFailed"
|
|
1209
|
+
} : {
|
|
1210
|
+
linkId: link.id,
|
|
1211
|
+
streamUrl,
|
|
1212
|
+
type: "ticketMinted"
|
|
1213
|
+
};
|
|
1214
|
+
} catch {
|
|
1215
|
+
return {
|
|
1216
|
+
linkId: link.id,
|
|
1217
|
+
retryMilliseconds: void 0,
|
|
1218
|
+
type: "ticketFailed"
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
};
|
|
1222
|
+
const mint = async (link, signal) => {
|
|
1223
|
+
dispatch(await ticketEvent(link, signal));
|
|
1224
|
+
};
|
|
1225
|
+
const openEvents = (linkId, url) => openStream(url, {
|
|
1226
|
+
onEnd: (end) => {
|
|
1227
|
+
dispatch({
|
|
1228
|
+
linkId,
|
|
1229
|
+
retryMilliseconds: end.retryMilliseconds,
|
|
1230
|
+
type: "streamEnded"
|
|
1231
|
+
});
|
|
1232
|
+
},
|
|
1233
|
+
onEvent: (event) => {
|
|
1234
|
+
if (currentLink(state)?.id !== linkId) return;
|
|
1235
|
+
dispatch({
|
|
1236
|
+
eventId: event.id,
|
|
1237
|
+
linkId,
|
|
1238
|
+
type: "received"
|
|
1239
|
+
});
|
|
1240
|
+
if (event.type === "change") {
|
|
1241
|
+
const change = parseRealtimeChange(event.data);
|
|
1242
|
+
if (change !== void 0) route(change);
|
|
1243
|
+
} else if (event.type === "reset") {
|
|
1244
|
+
resetPending = true;
|
|
1245
|
+
scheduleDelivery();
|
|
1246
|
+
} else if (event.type === "access-changed") options.onAccessChanged?.();
|
|
1247
|
+
},
|
|
1248
|
+
onOpen: () => {
|
|
1249
|
+
dispatch({
|
|
1250
|
+
linkId,
|
|
1251
|
+
type: "streamOpened"
|
|
1252
|
+
});
|
|
1253
|
+
},
|
|
1254
|
+
onPing: (id) => {
|
|
1255
|
+
dispatch({
|
|
1256
|
+
eventId: id,
|
|
1257
|
+
linkId,
|
|
1258
|
+
type: "received"
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
}).close;
|
|
1262
|
+
const cancelTimer = (timer) => {
|
|
1263
|
+
pendingTimers.get(timer)?.();
|
|
1264
|
+
pendingTimers.delete(timer);
|
|
1265
|
+
};
|
|
1266
|
+
const scheduleTimer = (timer, milliseconds, event) => {
|
|
1267
|
+
cancelTimer(timer);
|
|
1268
|
+
pendingTimers.set(timer, timers.schedule(() => {
|
|
1269
|
+
pendingTimers.delete(timer);
|
|
1270
|
+
dispatch(event);
|
|
1271
|
+
}, milliseconds));
|
|
1272
|
+
};
|
|
1273
|
+
const run = (command) => {
|
|
1274
|
+
switch (command.type) {
|
|
1275
|
+
case "schedule":
|
|
1276
|
+
scheduleTimer(command.timer, command.milliseconds, command.event);
|
|
1277
|
+
return;
|
|
1278
|
+
case "cancel":
|
|
1279
|
+
cancelTimer(command.timer);
|
|
1280
|
+
return;
|
|
1281
|
+
case "backoff":
|
|
1282
|
+
scheduleTimer("backoff", backoffDelay(command.attempt, command.retryMilliseconds, random()), { type: "backoffElapsed" });
|
|
1283
|
+
return;
|
|
1284
|
+
case "mint": {
|
|
1285
|
+
const abort = new AbortController();
|
|
1286
|
+
links.set(command.link.id, {
|
|
1287
|
+
abort,
|
|
1288
|
+
close: void 0
|
|
1289
|
+
});
|
|
1290
|
+
mint(command.link, abort.signal);
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
case "openStream": {
|
|
1294
|
+
const resources = links.get(command.linkId);
|
|
1295
|
+
if (resources !== void 0) links.set(command.linkId, {
|
|
1296
|
+
...resources,
|
|
1297
|
+
close: openEvents(command.linkId, command.url)
|
|
1298
|
+
});
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
case "disconnect": {
|
|
1302
|
+
const resources = links.get(command.linkId);
|
|
1303
|
+
links.delete(command.linkId);
|
|
1304
|
+
resources?.abort.abort();
|
|
1305
|
+
resources?.close?.();
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
};
|
|
1310
|
+
const topicsChanged = () => {
|
|
1311
|
+
dispatch({
|
|
1312
|
+
topics: [...registrations.keys()],
|
|
1313
|
+
type: "topicsChanged"
|
|
1314
|
+
});
|
|
1315
|
+
};
|
|
1316
|
+
const onVisibility = () => {
|
|
1317
|
+
dispatch({
|
|
1318
|
+
type: "visibilityChanged",
|
|
1319
|
+
visible: documentVisible()
|
|
1320
|
+
});
|
|
1321
|
+
};
|
|
1322
|
+
const onPageHide = () => {
|
|
1323
|
+
dispatch({ type: "pageHidden" });
|
|
1324
|
+
};
|
|
1325
|
+
const onPageShow = () => {
|
|
1326
|
+
dispatch({
|
|
1327
|
+
type: "pageShown",
|
|
1328
|
+
visible: documentVisible()
|
|
1329
|
+
});
|
|
1330
|
+
};
|
|
1331
|
+
page?.addEventListener("visibilitychange", onVisibility);
|
|
1332
|
+
view?.addEventListener("pagehide", onPageHide);
|
|
1333
|
+
view?.addEventListener("pageshow", onPageShow);
|
|
1334
|
+
return {
|
|
1335
|
+
close: () => {
|
|
1336
|
+
dispatch({ type: "close" });
|
|
1337
|
+
cancelDelivery?.();
|
|
1338
|
+
cancelDelivery = void 0;
|
|
1339
|
+
pending.clear();
|
|
1340
|
+
resetPending = false;
|
|
1341
|
+
page?.removeEventListener("visibilitychange", onVisibility);
|
|
1342
|
+
view?.removeEventListener("pagehide", onPageHide);
|
|
1343
|
+
view?.removeEventListener("pageshow", onPageShow);
|
|
1344
|
+
},
|
|
1345
|
+
getState: () => ({
|
|
1346
|
+
connections: currentLink(state) === void 0 ? 0 : 1,
|
|
1347
|
+
lastEventId: state.lastEventId,
|
|
1348
|
+
status: state.phase === "closed" ? "idle" : state.phase,
|
|
1349
|
+
topics: [...registrations.values()].map(({ topic }) => topic)
|
|
1350
|
+
}),
|
|
1351
|
+
subscribe: (topic, onChanges) => {
|
|
1352
|
+
const key = realtimeTopicKey(topic);
|
|
1353
|
+
const registration = registrations.get(key) ?? {
|
|
1354
|
+
listeners: /* @__PURE__ */ new Set(),
|
|
1355
|
+
topic
|
|
1356
|
+
};
|
|
1357
|
+
const listener = (changes) => onChanges(changes);
|
|
1358
|
+
registration.listeners.add(listener);
|
|
1359
|
+
if (!registrations.has(key)) {
|
|
1360
|
+
registrations.set(key, registration);
|
|
1361
|
+
topicsChanged();
|
|
1362
|
+
}
|
|
1363
|
+
const unsubscribe = () => {
|
|
1364
|
+
if (!registration.listeners.delete(listener)) return;
|
|
1365
|
+
if (registration.listeners.size === 0) {
|
|
1366
|
+
registrations.delete(key);
|
|
1367
|
+
pending.delete(key);
|
|
1368
|
+
topicsChanged();
|
|
1369
|
+
}
|
|
1370
|
+
};
|
|
1371
|
+
return unsubscribe;
|
|
1372
|
+
}
|
|
1373
|
+
};
|
|
1374
|
+
};
|
|
1375
|
+
export { DEFAULT_FOLLOW_RUN_DEADLINE_MS, FederatedAuthError, clientOperationIds, clientSdkProtocolVersion, createClient, createDelegatedApplicationRuntime, createDirectTransport, createEntityHttpTransport, createEntityProtocol, createFederatedAuth, createFetchEventSourceFactory, createFixedWorkflowClient, createProxyTransport, createRealtimeClient, createResourceClient, createServerSessionAuth, followRun, isClientOperationId, isEntityReadLimitError, nextStreamCursor, realtimeTopicKey };
|
package/package.json
CHANGED