@methodacting/actor-kit 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/LICENSE.md +7 -0
  2. package/README.md +2042 -0
  3. package/dist/browser.d.ts +384 -0
  4. package/dist/browser.js +2 -0
  5. package/dist/browser.js.map +1 -0
  6. package/dist/index.d.ts +644 -0
  7. package/dist/index.js +2 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/react.d.ts +416 -0
  10. package/dist/react.js +2 -0
  11. package/dist/react.js.map +1 -0
  12. package/dist/src/alarms.d.ts +47 -0
  13. package/dist/src/alarms.d.ts.map +1 -0
  14. package/dist/src/browser.d.ts +2 -0
  15. package/dist/src/browser.d.ts.map +1 -0
  16. package/dist/src/constants.d.ts +12 -0
  17. package/dist/src/constants.d.ts.map +1 -0
  18. package/dist/src/createAccessToken.d.ts +9 -0
  19. package/dist/src/createAccessToken.d.ts.map +1 -0
  20. package/dist/src/createActorFetch.d.ts +18 -0
  21. package/dist/src/createActorFetch.d.ts.map +1 -0
  22. package/dist/src/createActorKitClient.d.ts +13 -0
  23. package/dist/src/createActorKitClient.d.ts.map +1 -0
  24. package/dist/src/createActorKitContext.d.ts +29 -0
  25. package/dist/src/createActorKitContext.d.ts.map +1 -0
  26. package/dist/src/createActorKitMockClient.d.ts +11 -0
  27. package/dist/src/createActorKitMockClient.d.ts.map +1 -0
  28. package/dist/src/createActorKitRouter.d.ts +4 -0
  29. package/dist/src/createActorKitRouter.d.ts.map +1 -0
  30. package/dist/src/createMachineServer.d.ts +20 -0
  31. package/dist/src/createMachineServer.d.ts.map +1 -0
  32. package/dist/src/durable-object-system.d.ts +36 -0
  33. package/dist/src/durable-object-system.d.ts.map +1 -0
  34. package/dist/src/index.d.ts +7 -0
  35. package/dist/src/index.d.ts.map +1 -0
  36. package/dist/src/react.d.ts +2 -0
  37. package/dist/src/react.d.ts.map +1 -0
  38. package/dist/src/schemas.d.ts +312 -0
  39. package/dist/src/schemas.d.ts.map +1 -0
  40. package/dist/src/server.d.ts +3 -0
  41. package/dist/src/server.d.ts.map +1 -0
  42. package/dist/src/storage.d.ts +64 -0
  43. package/dist/src/storage.d.ts.map +1 -0
  44. package/dist/src/storybook.d.ts +13 -0
  45. package/dist/src/storybook.d.ts.map +1 -0
  46. package/dist/src/test.d.ts +2 -0
  47. package/dist/src/test.d.ts.map +1 -0
  48. package/dist/src/types.d.ts +181 -0
  49. package/dist/src/types.d.ts.map +1 -0
  50. package/dist/src/utils.d.ts +30 -0
  51. package/dist/src/utils.d.ts.map +1 -0
  52. package/dist/src/withActorKit.d.ts +9 -0
  53. package/dist/src/withActorKit.d.ts.map +1 -0
  54. package/dist/src/worker.d.ts +3 -0
  55. package/dist/src/worker.d.ts.map +1 -0
  56. package/package.json +87 -0
  57. package/src/alarms.ts +237 -0
  58. package/src/browser.ts +1 -0
  59. package/src/constants.ts +31 -0
  60. package/src/createAccessToken.ts +29 -0
  61. package/src/createActorFetch.ts +111 -0
  62. package/src/createActorKitClient.ts +224 -0
  63. package/src/createActorKitContext.tsx +228 -0
  64. package/src/createActorKitMockClient.ts +138 -0
  65. package/src/createActorKitRouter.ts +149 -0
  66. package/src/createMachineServer.ts +844 -0
  67. package/src/durable-object-system.ts +212 -0
  68. package/src/global.d.ts +7 -0
  69. package/src/index.ts +6 -0
  70. package/src/react.ts +1 -0
  71. package/src/schemas.ts +95 -0
  72. package/src/server.ts +3 -0
  73. package/src/storage.ts +404 -0
  74. package/src/storybook.ts +42 -0
  75. package/src/test.ts +1 -0
  76. package/src/types.ts +334 -0
  77. package/src/utils.ts +171 -0
  78. package/src/withActorKit.tsx +103 -0
  79. package/src/worker.ts +2 -0
@@ -0,0 +1,111 @@
1
+ import { StateValueFrom } from "xstate";
2
+ import { z } from "zod";
3
+ import { AnyActorKitStateMachine, CallerSnapshotFrom, ClientEventFrom } from "./types";
4
+
5
+ const ResponseSchema = z.object({
6
+ snapshot: z.record(z.any()),
7
+ checksum: z.string(),
8
+ });
9
+
10
+ export function createActorFetch<TMachine extends AnyActorKitStateMachine>({
11
+ actorType,
12
+ host,
13
+ }: {
14
+ actorType: string;
15
+ host: string;
16
+ }) {
17
+ return async function fetchActor(
18
+ props: {
19
+ actorId: string;
20
+ accessToken: string;
21
+ input?: Record<string, unknown>;
22
+ waitForEvent?: ClientEventFrom<TMachine>;
23
+ waitForState?: StateValueFrom<TMachine>;
24
+ timeout?: number;
25
+ errorOnWaitTimeout?: boolean;
26
+ },
27
+ options?: RequestInit
28
+ ): Promise<{
29
+ snapshot: CallerSnapshotFrom<TMachine>;
30
+ checksum: string;
31
+ }> {
32
+ const input = props.input ?? {};
33
+
34
+ if (!host) throw new Error("Actor Kit host is not defined");
35
+
36
+ const route = getActorRoute(actorType, props.actorId);
37
+ const protocol = getHttpProtocol(host);
38
+ const url = new URL(`${protocol}://${host}${route}`);
39
+
40
+ // Add input to URL parameters
41
+ url.searchParams.append("input", JSON.stringify(input));
42
+
43
+ // Add waitForEvent or waitForState to URL parameters
44
+ if (props.waitForEvent) {
45
+ url.searchParams.append(
46
+ "waitForEvent",
47
+ JSON.stringify(props.waitForEvent)
48
+ );
49
+ }
50
+ if (props.waitForState) {
51
+ url.searchParams.append(
52
+ "waitForState",
53
+ JSON.stringify(props.waitForState)
54
+ );
55
+ }
56
+
57
+ // Add timeout to URL parameters if specified
58
+ if (props.timeout) {
59
+ url.searchParams.append("timeout", props.timeout.toString());
60
+ }
61
+
62
+ // Add errorOnWaitTimeout to URL parameters if specified
63
+ if (props.errorOnWaitTimeout !== undefined) {
64
+ url.searchParams.append(
65
+ "errorOnWaitTimeout",
66
+ props.errorOnWaitTimeout.toString()
67
+ );
68
+ }
69
+
70
+ const response = await fetch(url.toString(), {
71
+ ...options,
72
+ headers: {
73
+ ...options?.headers,
74
+ Authorization: `Bearer ${props.accessToken}`,
75
+ },
76
+ });
77
+
78
+ if (!response.ok) {
79
+ if (response.status === 408 && props.errorOnWaitTimeout !== false) {
80
+ throw new Error(
81
+ `Timeout waiting for actor response: ${response.statusText}`
82
+ );
83
+ }
84
+ throw new Error(`Failed to fetch actor: ${response.statusText}`);
85
+ }
86
+
87
+ const data = await response.json();
88
+ const { checksum, snapshot } = ResponseSchema.parse(data);
89
+
90
+ return {
91
+ snapshot: snapshot as CallerSnapshotFrom<TMachine>,
92
+ checksum,
93
+ };
94
+ };
95
+ }
96
+
97
+ function getActorRoute(actorType: string, actorId: string) {
98
+ return `/api/${actorType}/${actorId}`;
99
+ }
100
+
101
+ function getHttpProtocol(host: string): "http" | "https" {
102
+ return isLocal(host) ? "http" : "https";
103
+ }
104
+
105
+ function isLocal(host: string): boolean {
106
+ return (
107
+ host.startsWith("localhost") ||
108
+ host.startsWith("127.0.0.1") ||
109
+ host.startsWith("0.0.0.0")
110
+ );
111
+ }
@@ -0,0 +1,224 @@
1
+ import { applyPatch } from "fast-json-patch";
2
+ import { produce } from "immer";
3
+
4
+ import {
5
+ ActorKitClient,
6
+ ActorKitEmittedEvent,
7
+ AnyActorKitStateMachine,
8
+ CallerSnapshotFrom,
9
+ ClientEventFrom,
10
+ } from "./types";
11
+
12
+ export type ActorKitClientProps<TMachine extends AnyActorKitStateMachine> = {
13
+ host: string;
14
+ actorType: string;
15
+ actorId: string;
16
+ checksum: string;
17
+ accessToken: string;
18
+ initialSnapshot: CallerSnapshotFrom<TMachine>;
19
+ onStateChange?: (newState: CallerSnapshotFrom<TMachine>) => void;
20
+ onError?: (error: Error) => void;
21
+ };
22
+
23
+ type Listener<T> = (state: T) => void;
24
+
25
+ /**
26
+ * Creates an Actor Kit client for managing state and communication with the server.
27
+ *
28
+ * @template TMachine - The type of the state machine.
29
+ * @param {ActorKitClientProps<TMachine>} props - Configuration options for the client.
30
+ * @returns {ActorKitClient<TMachine>} An object with methods to interact with the actor.
31
+ */
32
+ export function createActorKitClient<TMachine extends AnyActorKitStateMachine>(
33
+ props: ActorKitClientProps<TMachine>
34
+ ): ActorKitClient<TMachine> {
35
+ let currentSnapshot = props.initialSnapshot;
36
+ let socket: WebSocket | null = null;
37
+ const listeners: Set<Listener<CallerSnapshotFrom<TMachine>>> = new Set();
38
+ let reconnectAttempts = 0;
39
+ const maxReconnectAttempts = 5;
40
+
41
+ /**
42
+ * Notifies all registered listeners with the current state.
43
+ */
44
+ const notifyListeners = () => {
45
+ listeners.forEach((listener) => listener(currentSnapshot));
46
+ };
47
+
48
+ /**
49
+ * Establishes a WebSocket connection to the Actor Kit server.
50
+ * @returns {Promise<void>} A promise that resolves when the connection is established.
51
+ */
52
+ const connect = async () => {
53
+ const url = getWebSocketUrl(props);
54
+
55
+ socket = new WebSocket(url);
56
+
57
+ socket.addEventListener("open", () => {
58
+ reconnectAttempts = 0;
59
+ });
60
+
61
+ socket.addEventListener("message", (event: MessageEvent) => {
62
+ try {
63
+ const data = JSON.parse(
64
+ typeof event.data === "string"
65
+ ? event.data
66
+ : new TextDecoder().decode(event.data)
67
+ ) as ActorKitEmittedEvent;
68
+
69
+ currentSnapshot = produce(currentSnapshot, (draft) => {
70
+ applyPatch(draft, data.operations);
71
+ });
72
+
73
+ props.onStateChange?.(currentSnapshot);
74
+ notifyListeners();
75
+ } catch (error) {
76
+ console.error(`[ActorKitClient] Error processing message:`, error);
77
+ props.onError?.(error as Error);
78
+ }
79
+ });
80
+
81
+ socket.addEventListener("error", (error: any) => {
82
+ console.error(`[ActorKitClient] WebSocket error:`, error);
83
+ console.error(`[ActorKitClient] Error details:`, {
84
+ message: error.message,
85
+ type: error.type,
86
+ target: error.target,
87
+ eventPhase: error.eventPhase,
88
+ });
89
+ props.onError?.(new Error(`WebSocket error: ${JSON.stringify(error)}`));
90
+ });
91
+
92
+ // todo, how do we reconnect when a user returns to the tab
93
+ // later after it's disconnected
94
+
95
+ socket.addEventListener("close", (event) => {
96
+ // Implement reconnection logic
97
+ if (reconnectAttempts < maxReconnectAttempts) {
98
+ reconnectAttempts++;
99
+ const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
100
+ setTimeout(connect, delay);
101
+ } else {
102
+ console.error(`[ActorKitClient] Max reconnection attempts reached`);
103
+ }
104
+ });
105
+
106
+ return new Promise<void>((resolve) => {
107
+ socket!.addEventListener("open", () => resolve());
108
+ });
109
+ };
110
+
111
+ /**
112
+ * Closes the WebSocket connection to the Actor Kit server.
113
+ */
114
+ const disconnect = () => {
115
+ if (socket) {
116
+ socket.close();
117
+ socket = null;
118
+ }
119
+ };
120
+
121
+ /**
122
+ * Sends an event to the Actor Kit server.
123
+ * @param {ClientEventFrom<TMachine>} event - The event to send.
124
+ */
125
+ const send = (event: ClientEventFrom<TMachine>) => {
126
+ if (socket && socket.readyState === WebSocket.OPEN) {
127
+ socket.send(JSON.stringify(event));
128
+ } else {
129
+ props.onError?.(
130
+ new Error("Cannot send event: WebSocket is not connected")
131
+ );
132
+ }
133
+ };
134
+
135
+ /**
136
+ * Retrieves the current state of the actor.
137
+ * @returns {CallerSnapshotFrom<TMachine>} The current state.
138
+ */
139
+ const getState = () => currentSnapshot;
140
+
141
+ /**
142
+ * Subscribes a listener to state changes.
143
+ * @param {Listener<CallerSnapshotFrom<TMachine>>} listener - The listener function to be called on state changes.
144
+ * @returns {() => void} A function to unsubscribe the listener.
145
+ */
146
+ const subscribe = (listener: Listener<CallerSnapshotFrom<TMachine>>) => {
147
+ listeners.add(listener);
148
+ return () => {
149
+ listeners.delete(listener);
150
+ };
151
+ };
152
+
153
+ /**
154
+ * Waits for a state condition to be met.
155
+ * @param {(state: CallerSnapshotFrom<TMachine>) => boolean} predicateFn - Function that returns true when condition is met
156
+ * @param {number} [timeoutMs=5000] - Maximum time to wait in milliseconds
157
+ * @returns {Promise<void>} Resolves when condition is met, rejects on timeout
158
+ */
159
+ const waitFor = async (
160
+ predicateFn: (state: CallerSnapshotFrom<TMachine>) => boolean,
161
+ timeoutMs: number = 5000
162
+ ): Promise<void> => {
163
+ // Check if condition is already met
164
+ if (predicateFn(currentSnapshot)) {
165
+ return Promise.resolve();
166
+ }
167
+ return new Promise((resolve, reject) => {
168
+ let timeoutId: number | null = null;
169
+
170
+ // Set up timeout to reject if condition isn't met in time
171
+ if (timeoutMs > 0) {
172
+ timeoutId = setTimeout(() => {
173
+ unsubscribe();
174
+ reject(
175
+ new Error(`Timeout waiting for condition after ${timeoutMs}ms`)
176
+ );
177
+ }, timeoutMs);
178
+ }
179
+
180
+ // Subscribe to state changes
181
+ const unsubscribe = subscribe((state) => {
182
+ if (predicateFn(state)) {
183
+ if (timeoutId) {
184
+ clearTimeout(timeoutId);
185
+ }
186
+ unsubscribe();
187
+ resolve();
188
+ }
189
+ });
190
+ });
191
+ };
192
+
193
+ return {
194
+ connect,
195
+ disconnect,
196
+ send,
197
+ getState,
198
+ subscribe,
199
+ waitFor,
200
+ };
201
+ }
202
+
203
+ function getWebSocketUrl(props: ActorKitClientProps<any>): string {
204
+ const { host, actorId, actorType, accessToken, checksum } = props;
205
+
206
+ // Determine protocol (ws or wss)
207
+ const protocol =
208
+ /^(localhost|127\.0\.0\.1|192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.)/.test(
209
+ host
210
+ )
211
+ ? "ws"
212
+ : "wss";
213
+
214
+ // Construct base URL
215
+ const baseUrl = `${protocol}://${host}/api/${actorType}/${actorId}`;
216
+
217
+ // Add query parameters
218
+ const params = new URLSearchParams({ accessToken });
219
+ if (checksum) params.append("checksum", checksum);
220
+
221
+ const finalUrl = `${baseUrl}?${params.toString()}`;
222
+
223
+ return finalUrl;
224
+ }
@@ -0,0 +1,228 @@
1
+ "use client";
2
+
3
+ import React, {
4
+ createContext,
5
+ memo,
6
+ ReactNode,
7
+ useCallback,
8
+ useContext,
9
+ useEffect,
10
+ useMemo,
11
+ useRef,
12
+ useSyncExternalStore,
13
+ } from "react";
14
+ import { matchesState, StateValueFrom } from "xstate";
15
+ import type { ActorKitClientProps } from "./createActorKitClient";
16
+ import { createActorKitClient } from "./createActorKitClient";
17
+ import type {
18
+ ActorKitClient,
19
+ AnyActorKitStateMachine,
20
+ CallerSnapshotFrom,
21
+ ClientEventFrom,
22
+ MatchesProps,
23
+ } from "./types";
24
+
25
+ export function createActorKitContext<TMachine extends AnyActorKitStateMachine>(
26
+ actorType: string
27
+ ) {
28
+ const ActorKitContext = createContext<ActorKitClient<TMachine> | null>(null);
29
+
30
+ const ProviderFromClient: React.FC<{
31
+ children: ReactNode;
32
+ client: ActorKitClient<TMachine>;
33
+ }> = ({ children, client }) => {
34
+ return (
35
+ <ActorKitContext.Provider value={client}>
36
+ {children}
37
+ </ActorKitContext.Provider>
38
+ );
39
+ };
40
+
41
+ const Provider: React.FC<
42
+ {
43
+ children: ReactNode;
44
+ } & Omit<ActorKitClientProps<TMachine>, "actorType">
45
+ > = memo((props) => {
46
+ const clientRef = useRef(
47
+ createActorKitClient<TMachine>({
48
+ host: props.host,
49
+ actorId: props.actorId,
50
+ accessToken: props.accessToken,
51
+ checksum: props.checksum,
52
+ initialSnapshot: props.initialSnapshot,
53
+ actorType,
54
+ })
55
+ );
56
+ const initializedRef = useRef(false);
57
+
58
+ useEffect(() => {
59
+ if (!initializedRef.current) {
60
+ initializedRef.current = true;
61
+ clientRef.current.connect().then(() => {});
62
+ }
63
+ }, [initializedRef]);
64
+
65
+ return (
66
+ <ActorKitContext.Provider value={clientRef.current}>
67
+ {props.children}
68
+ </ActorKitContext.Provider>
69
+ );
70
+ });
71
+
72
+ function useClient(): ActorKitClient<TMachine> {
73
+ const client = useContext(ActorKitContext);
74
+ if (!client) {
75
+ throw new Error(
76
+ "useClient must be used within an ActorKitContext.Provider"
77
+ );
78
+ }
79
+ return client;
80
+ }
81
+
82
+ const useSelector = <T,>(
83
+ selector: (snapshot: CallerSnapshotFrom<TMachine>) => T
84
+ ) => {
85
+ const client = useClient();
86
+
87
+ return useSyncExternalStoreWithSelector(
88
+ client.subscribe,
89
+ client.getState,
90
+ client.getState,
91
+ selector,
92
+ defaultCompare
93
+ );
94
+ };
95
+
96
+ function useSend(): (event: ClientEventFrom<TMachine>) => void {
97
+ const client = useClient();
98
+ return client.send;
99
+ }
100
+
101
+ function useMatches(stateValue: StateValueFrom<TMachine>): boolean {
102
+ return useSelector((state) => matchesState(stateValue, state.value as any));
103
+ }
104
+
105
+ const Matches: React.FC<MatchesProps<TMachine> & { children: ReactNode }> & {
106
+ create: (
107
+ state: StateValueFrom<TMachine>,
108
+ options?: {
109
+ and?: StateValueFrom<TMachine>;
110
+ or?: StateValueFrom<TMachine>;
111
+ not?: boolean;
112
+ }
113
+ ) => React.FC<
114
+ Omit<MatchesProps<TMachine>, "state" | "and" | "or" | "not"> & {
115
+ children: ReactNode;
116
+ }
117
+ >;
118
+ } = (props) => {
119
+ const active = useMatches(props.state);
120
+ const matchesAnd = props.and ? useMatches(props.and) : true;
121
+ const matchesOr = props.or ? useMatches(props.or) : false;
122
+ const value =
123
+ typeof props.initialValueOverride === "boolean"
124
+ ? props.initialValueOverride
125
+ : (active && matchesAnd) || matchesOr;
126
+ const finalValue = props.not ? !value : value;
127
+ return finalValue ? <>{props.children}</> : null;
128
+ };
129
+
130
+ Matches.create = (state, options = {}) => {
131
+ const Component: React.FC<
132
+ Omit<MatchesProps<TMachine>, "state" | "and" | "or" | "not"> & {
133
+ children: ReactNode;
134
+ }
135
+ > = ({ children, initialValueOverride }) => (
136
+ <Matches
137
+ state={state}
138
+ and={options.and}
139
+ or={options.or}
140
+ not={options.not}
141
+ initialValueOverride={initialValueOverride}
142
+ >
143
+ {children}
144
+ </Matches>
145
+ );
146
+ Component.displayName = `MatchesComponent(${state.toString()})`;
147
+ return Component;
148
+ };
149
+
150
+ return {
151
+ Provider,
152
+ ProviderFromClient,
153
+ useClient,
154
+ useSelector,
155
+ useSend,
156
+ useMatches,
157
+ Matches,
158
+ };
159
+ }
160
+
161
+ function useSyncExternalStoreWithSelector<Snapshot, Selection>(
162
+ subscribe: (onStoreChange: () => void) => () => void,
163
+ getSnapshot: () => Snapshot,
164
+ getServerSnapshot: undefined | null | (() => Snapshot),
165
+ selector: (snapshot: Snapshot) => Selection,
166
+ isEqual?: (a: Selection, b: Selection) => boolean
167
+ ): Selection {
168
+ const [getSelection, getServerSelection] = useMemo(() => {
169
+ let hasMemo = false;
170
+ let memoizedSnapshot: Snapshot;
171
+ let memoizedSelection: Selection;
172
+
173
+ const memoizedSelector = (nextSnapshot: Snapshot) => {
174
+ if (!hasMemo) {
175
+ hasMemo = true;
176
+ memoizedSnapshot = nextSnapshot;
177
+ memoizedSelection = selector(nextSnapshot);
178
+ return memoizedSelection;
179
+ }
180
+
181
+ if (Object.is(memoizedSnapshot, nextSnapshot)) {
182
+ return memoizedSelection;
183
+ }
184
+
185
+ const nextSelection = selector(nextSnapshot);
186
+
187
+ if (isEqual && isEqual(memoizedSelection, nextSelection)) {
188
+ memoizedSnapshot = nextSnapshot;
189
+ return memoizedSelection;
190
+ }
191
+
192
+ memoizedSnapshot = nextSnapshot;
193
+ memoizedSelection = nextSelection;
194
+ return nextSelection;
195
+ };
196
+
197
+ const getSnapshotWithSelector = () => memoizedSelector(getSnapshot());
198
+ const getServerSnapshotWithSelector = getServerSnapshot
199
+ ? () => memoizedSelector(getServerSnapshot())
200
+ : undefined;
201
+
202
+ return [getSnapshotWithSelector, getServerSnapshotWithSelector];
203
+ }, [getSnapshot, getServerSnapshot, selector, isEqual]);
204
+
205
+ const subscribeWithSelector = useCallback(
206
+ (onStoreChange: () => void) => {
207
+ let previousSelection = getSelection();
208
+ return subscribe(() => {
209
+ const nextSelection = getSelection();
210
+ if (!isEqual || !isEqual(previousSelection, nextSelection)) {
211
+ previousSelection = nextSelection;
212
+ onStoreChange();
213
+ }
214
+ });
215
+ },
216
+ [subscribe, getSelection, isEqual]
217
+ );
218
+
219
+ return useSyncExternalStore(
220
+ subscribeWithSelector,
221
+ getSelection,
222
+ getServerSelection
223
+ );
224
+ }
225
+
226
+ function defaultCompare<T>(a: T, b: T) {
227
+ return a === b;
228
+ }
@@ -0,0 +1,138 @@
1
+ import { Draft, produce } from "immer";
2
+ import {
3
+ ActorKitClient,
4
+ AnyActorKitStateMachine,
5
+ CallerSnapshotFrom,
6
+ ClientEventFrom,
7
+ } from "./types";
8
+
9
+ export type ActorKitMockClientProps<TMachine extends AnyActorKitStateMachine> = {
10
+ initialSnapshot: CallerSnapshotFrom<TMachine>;
11
+ onSend?: (event: ClientEventFrom<TMachine>) => void;
12
+ };
13
+
14
+ export type ActorKitMockClient<TMachine extends AnyActorKitStateMachine> = ActorKitClient<TMachine> & {
15
+ produce: (recipe: (draft: Draft<CallerSnapshotFrom<TMachine>>) => void) => void;
16
+ };
17
+
18
+ /**
19
+ * Creates a mock Actor Kit client for testing purposes.
20
+ *
21
+ * @template TMachine - The type of the state machine.
22
+ * @param {ActorKitMockClientProps<TMachine>} props - Configuration options for the mock client.
23
+ * @returns {ActorKitMockClient<TMachine>} An object with methods to interact with the mock actor.
24
+ */
25
+ export function createActorKitMockClient<TMachine extends AnyActorKitStateMachine>(
26
+ props: ActorKitMockClientProps<TMachine>
27
+ ): ActorKitMockClient<TMachine> {
28
+ let currentSnapshot = props.initialSnapshot;
29
+ const listeners: Set<(state: CallerSnapshotFrom<TMachine>) => void> = new Set();
30
+
31
+ /**
32
+ * Notifies all registered listeners with the current state.
33
+ */
34
+ const notifyListeners = () => {
35
+ listeners.forEach((listener) => listener(currentSnapshot));
36
+ };
37
+
38
+ /**
39
+ * Updates the state using an Immer producer function.
40
+ * @param {(draft: Draft<CallerSnapshotFrom<TMachine>>) => void} recipe - The state update recipe.
41
+ */
42
+ const produceFn = (recipe: (draft: Draft<CallerSnapshotFrom<TMachine>>) => void) => {
43
+ currentSnapshot = produce(currentSnapshot, recipe);
44
+ notifyListeners();
45
+ };
46
+
47
+ /**
48
+ * Sends an event to the mock client.
49
+ * @param {ClientEventFrom<TMachine>} event - The event to send.
50
+ */
51
+ const send = (event: ClientEventFrom<TMachine>) => {
52
+ props.onSend?.(event);
53
+ notifyListeners();
54
+ };
55
+
56
+ /**
57
+ * Retrieves the current state of the mock actor.
58
+ * @returns {CallerSnapshotFrom<TMachine>} The current state.
59
+ */
60
+ const getState = () => currentSnapshot;
61
+
62
+ /**
63
+ * Subscribes a listener to state changes.
64
+ * @param {(state: CallerSnapshotFrom<TMachine>) => void} listener - The listener function to be called on state changes.
65
+ * @returns {() => void} A function to unsubscribe the listener.
66
+ */
67
+ const subscribe = (listener: (state: CallerSnapshotFrom<TMachine>) => void) => {
68
+ listeners.add(listener);
69
+ return () => {
70
+ listeners.delete(listener);
71
+ };
72
+ };
73
+
74
+ /**
75
+ * Mock connect method.
76
+ * @returns {Promise<void>} A promise that resolves immediately.
77
+ */
78
+ const connect = async () => {
79
+ // Mock implementation, resolves immediately
80
+ return Promise.resolve();
81
+ };
82
+
83
+ /**
84
+ * Mock disconnect method.
85
+ */
86
+ const disconnect = () => {
87
+ // Mock implementation, does nothing
88
+ };
89
+
90
+ /**
91
+ * Waits for a state condition to be met.
92
+ * @param {(state: CallerSnapshotFrom<TMachine>) => boolean} predicateFn - Function that returns true when condition is met
93
+ * @param {number} [timeoutMs=5000] - Maximum time to wait in milliseconds
94
+ * @returns {Promise<void>} Resolves when condition is met, rejects on timeout
95
+ */
96
+ const waitFor = async (
97
+ predicateFn: (state: CallerSnapshotFrom<TMachine>) => boolean,
98
+ timeoutMs: number = 5000
99
+ ): Promise<void> => {
100
+ // Check if condition is already met
101
+ if (predicateFn(currentSnapshot)) {
102
+ return Promise.resolve();
103
+ }
104
+
105
+ return new Promise((resolve, reject) => {
106
+ let timeoutId: number | null = null;
107
+
108
+ // Set up timeout to reject if condition isn't met in time
109
+ if (timeoutMs > 0) {
110
+ timeoutId = setTimeout(() => {
111
+ unsubscribe();
112
+ reject(new Error(`Timeout waiting for condition after ${timeoutMs}ms`));
113
+ }, timeoutMs);
114
+ }
115
+
116
+ // Subscribe to state changes
117
+ const unsubscribe = subscribe((state) => {
118
+ if (predicateFn(state)) {
119
+ if (timeoutId) {
120
+ clearTimeout(timeoutId);
121
+ }
122
+ unsubscribe();
123
+ resolve();
124
+ }
125
+ });
126
+ });
127
+ };
128
+
129
+ return {
130
+ connect,
131
+ disconnect,
132
+ send,
133
+ getState,
134
+ subscribe,
135
+ produce: produceFn,
136
+ waitFor,
137
+ };
138
+ }