@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 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
- 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 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, createFixedWorkflowClient, createProxyTransport, createResourceClient, createServerSessionAuth, followRun, isClientOperationId, isEntityReadLimitError, nextStreamCursor };
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
- export { DEFAULT_FOLLOW_RUN_DEADLINE_MS, FederatedAuthError, clientOperationIds, clientSdkProtocolVersion, createClient, createDelegatedApplicationRuntime, createDirectTransport, createEntityHttpTransport, createEntityProtocol, createFederatedAuth, createFixedWorkflowClient, createProxyTransport, createResourceClient, createServerSessionAuth, followRun, isClientOperationId, isEntityReadLimitError, nextStreamCursor };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@collective-intelligence/external-apps-client-sdk",
3
- "version": "0.1.18",
3
+ "version": "0.1.21",
4
4
  "description": "Framework-neutral client for Collective Intelligence external applications.",
5
5
  "type": "module",
6
6
  "sideEffects": false,