@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
package/README.md ADDED
@@ -0,0 +1,2042 @@
1
+ # 🎭 Actor Kit
2
+
3
+ Actor Kit is a library for running state machines in Cloudflare Workers, leveraging XState for robust state management. It provides a framework for managing the logic, lifecycle, persistence, synchronization, and access control of actors in a distributed environment.
4
+
5
+ ## 📚 Table of Contents
6
+
7
+ - [💾 Installation](#-installation)
8
+ - [🏗️ Architecture](#️-architecture)
9
+ - [🌟 Key Concepts](#-key-concepts)
10
+ - [🛠️ Usage](#️-usage)
11
+ - [1️⃣ Define your event schemas and types](#1️⃣-define-your-event-schemas-and-types)
12
+ - [2️⃣ Define your state machine](#2️⃣-define-your-state-machine)
13
+ - [3️⃣ Set up the Actor Server](#3️⃣-set-up-the-actor-server)
14
+ - [4️⃣ Configure Wrangler](#4️⃣-configure-wrangler)
15
+ - [5️⃣ Create a Cloudflare Worker with Actor Kit Router](#5️⃣-create-a-cloudflare-worker-with-actor-kit-router)
16
+ - [6️⃣ Create the Actor Kit Context](#6️⃣-create-the-actor-kit-context)
17
+ - [7️⃣ Fetch data server-side](#7️⃣-fetch-data-server-side)
18
+ - [8️⃣ Create a client-side component](#8️⃣-create-a-client-side-component)
19
+ - [🚀 Getting Started](#-getting-started)
20
+ - [🗂️ Framework Examples](#️-framework-examples)
21
+ - [⚛️ Next.js](/examples/nextjs-actorkit-todo/README.md)
22
+ - [🎸 Remix](/examples/remix-actorkit-todo/README.md)
23
+ - [📖 API Reference](#-api-reference)
24
+ - [🔧 actor-kit/worker](#-actor-kitworker)
25
+ - [🖥️ actor-kit/server](#%EF%B8%8F-actor-kitserver)
26
+ - [🌐 actor-kit/browser](#-actor-kitbrowser)
27
+ - [⚛️ actor-kit/react](#%EF%B8%8F-actor-kitreact)
28
+ - [🧪 actor-kit/test](#-actor-kittest)
29
+ - [📚 actor-kit/storybook](#-actor-kitstorybook)
30
+ - [🔑 TypeScript Types](#-typescript-types)
31
+ - [👥 Caller Types](#-caller-types)
32
+ - [🔐 Public and Private Data](#-public-and-private-data)
33
+ - [📚 Storybook Integration](#-storybook-integration)
34
+ - [📜 License](#-license)
35
+ - [🔗 Related Technologies and Inspiration](#-related-technologies-and-inspiration)
36
+ - [🚧 Development Status](#-development-status)
37
+
38
+ ## 💾 Installation
39
+
40
+ To install Actor Kit, use your preferred package manager:
41
+
42
+ ```bash
43
+ npm install actor-kit xstate zod jose react
44
+ # or
45
+ yarn add actor-kit xstate zod jose react
46
+ # or
47
+ pnpm add actor-kit xstate zod jose react
48
+ ```
49
+
50
+ ## 🌟 Key Concepts
51
+
52
+ - 🖥️ **Server-Side Rendering**: Fetch initial state server-side for optimal performance and SEO.
53
+ - ⚡ **Real-time Updates**: Changes are immediately reflected across all connected clients, ensuring a responsive user experience.
54
+ - 🛡️ **Type Safety**: Leverage TypeScript and Zod for robust type checking and runtime validation.
55
+ - 🎭 **Event-Driven Architecture**: All state changes are driven by events, providing a clear and predictable data flow.
56
+ - 🧠 **State Machine Logic**: Powered by XState, making complex state management more manageable and visualizable.
57
+ - 🔄 **Seamless Synchronization**: Actor Kit handles state synchronization between server and clients automatically.
58
+ - 🔐 **Public and Private Data**: Manage shared data across all clients and caller-specific information securely.
59
+ - 🌐 **Distributed Systems**: Built for scalable, distributed applications running on edge computing platforms.
60
+
61
+ ## 🏗️ Architecture
62
+
63
+ ```mermaid
64
+ graph TD
65
+ subgraph "User Browser"
66
+ A[Client Components<br>APIs: useSelector, useSend]
67
+ B[Actor Kit Client<br>API: createActorKitClient]
68
+ end
69
+
70
+ subgraph "Cloudflare Worker"
71
+ C[Actor Kit Router<br>API: createActorKitRouter]
72
+ subgraph "Actor Server"
73
+ D[Machine Server DO<br>API: createMachineServer]
74
+ X[XState Machine]
75
+ F[(Durable Object Storage)]
76
+ end
77
+ end
78
+
79
+ subgraph "Server-Side Rendering"
80
+ E[Next.js/Remix/etc<br>APIs: createActorFetch, createAccessToken]
81
+ end
82
+
83
+ G[External API]
84
+ H[(Database)]
85
+ I[Third-party Service<br>e.g., Authentication, Payment, Analytics]
86
+
87
+ A -->|Events| B
88
+ B <-->|Send Events / Recv Patches| C
89
+ C -->|Route| D
90
+ D <-->|Manage State| X
91
+ X <-->|Read/Write| F
92
+ X --> G
93
+ X --> H
94
+ X --> I
95
+ E <-->|Fetch/Send Events| C
96
+ E -->|Deliver HTML/JS| A
97
+ E <--> H
98
+
99
+ classDef browser fill:#f0f0f0,stroke:#333,stroke-width:2px;
100
+ classDef worker fill:#ffe6e6,stroke:#333,stroke-width:2px;
101
+ classDef actorserver fill:#ffcccc,stroke:#333,stroke-width:2px;
102
+ classDef ssr fill:#ffe6ff,stroke:#333,stroke-width:2px;
103
+ classDef storage fill:#e6e6ff,stroke:#333,stroke-width:2px;
104
+ classDef external fill:#e6ffff,stroke:#333,stroke-width:2px;
105
+
106
+ class A,B browser;
107
+ class C worker;
108
+ class D,X,F actorserver;
109
+ class E ssr;
110
+ class G,H,I external;
111
+ ```
112
+
113
+ ## 🛠️ Usage
114
+
115
+ Here's a comprehensive example of how to use Actor Kit to create a todo list application with Next.js and Cloudflare Workers:
116
+
117
+ ### 1️⃣ Define your event schemas and types
118
+
119
+ First, define the schemas and types for your events:
120
+
121
+ ```typescript
122
+ // src/todo.schemas.ts
123
+ import { z } from "zod";
124
+
125
+ export const TodoClientEventSchema = z.discriminatedUnion("type", [
126
+ z.object({
127
+ type: z.literal("ADD_TODO"),
128
+ text: z.string(),
129
+ }),
130
+ z.object({
131
+ type: z.literal("TOGGLE_TODO"),
132
+ id: z.string(),
133
+ }),
134
+ z.object({
135
+ type: z.literal("DELETE_TODO"),
136
+ id: z.string(),
137
+ }),
138
+ ]);
139
+
140
+ export const TodoServiceEventSchema = z.discriminatedUnion("type", [
141
+ z.object({
142
+ type: z.literal("SYNC_TODOS"),
143
+ todos: z.array(
144
+ z.object({ id: z.string(), text: z.string(), completed: z.boolean() })
145
+ ),
146
+ }),
147
+ ]);
148
+
149
+ export const TodoInputPropsSchema = z.object({
150
+ accessCount: z.number(),
151
+ });
152
+
153
+ // src/todo.types.ts
154
+ import type {
155
+ ActorKitSystemEvent,
156
+ WithActorKitEvent,
157
+ WithActorKitInput,
158
+ } from "actor-kit";
159
+ import { z } from "zod";
160
+ import {
161
+ TodoClientEventSchema,
162
+ TodoInputPropsSchema,
163
+ TodoServiceEventSchema,
164
+ } from "./todo.schemas";
165
+
166
+ export type TodoClientEvent = z.infer<typeof TodoClientEventSchema>;
167
+ export type TodoServiceEvent = z.infer<typeof TodoServiceEventSchema>;
168
+ export type TodoInputProps = z.infer<typeof TodoInputPropsSchema>;
169
+ export type TodoInput = WithActorKitInput<TodoInputProps>;
170
+
171
+ export type TodoEvent =
172
+ | WithActorKitEvent<TodoClientEvent, "client">
173
+ | WithActorKitEvent<TodoServiceEvent, "service">
174
+ | ActorKitSystemEvent;
175
+
176
+ export type TodoPublicContext = {
177
+ ownerId: string;
178
+ todos: Array<{ id: string; text: string; completed: boolean }>;
179
+ lastSync: number | null;
180
+ };
181
+
182
+ export type TodoPrivateContext = {
183
+ accessCount: number;
184
+ };
185
+
186
+ export type TodoServerContext = {
187
+ public: TodoPublicContext;
188
+ private: Record<string, TodoPrivateContext>;
189
+ };
190
+ ```
191
+
192
+ ### 2️⃣ Define your state machine
193
+
194
+ Now that we have our event types defined, we can create our state machine:
195
+
196
+ ```typescript
197
+ // src/todo.machine.ts
198
+ import { ActorKitStateMachine } from "actor-kit";
199
+ import { assign, setup } from "xstate";
200
+ import type {
201
+ TodoEvent,
202
+ TodoInput,
203
+ TodoPrivateContext,
204
+ TodoPublicContext,
205
+ TodoServerContext,
206
+ } from "./todo.types";
207
+
208
+ export const todoMachine = setup({
209
+ types: {
210
+ context: {} as TodoServerContext,
211
+ events: {} as TodoEvent,
212
+ input: {} as TodoInput,
213
+ },
214
+ actions: {
215
+ addTodo: assign({
216
+ public: ({ context, event }) => {
217
+ if (event.type !== "ADD_TODO") return context.public;
218
+ return {
219
+ ...context.public,
220
+ todos: [
221
+ ...context.public.todos,
222
+ { id: crypto.randomUUID(), text: event.text, completed: false },
223
+ ],
224
+ lastSync: Date.now(),
225
+ };
226
+ },
227
+ }),
228
+ // ... other actions ...
229
+ },
230
+ guards: {
231
+ // ... guards ...
232
+ },
233
+ }).createMachine({
234
+ id: "todoList",
235
+ initial: "idle",
236
+ context: {
237
+ public: {
238
+ ownerId: caller.id,
239
+ todos: [],
240
+ lastSync: null,
241
+ },
242
+ private: {},
243
+ },
244
+ states: {
245
+ idle: {
246
+ on: {
247
+ ADD_TODO: {
248
+ actions: "addTodo",
249
+ },
250
+ // ... other transitions ...
251
+ },
252
+ },
253
+ // ... other states ...
254
+ },
255
+ }) satisfies ActorKitStateMachine<
256
+ TodoEvent,
257
+ TodoInput,
258
+ TodoPrivateContext,
259
+ TodoPublicContext
260
+ >;
261
+ ```
262
+
263
+ ### 3️⃣ Set up the Actor Server
264
+
265
+ Create the Actor Server using the `createMachineServer` function:
266
+
267
+ ```typescript
268
+ // src/todo.server.ts
269
+ import { createMachineServer } from "actor-kit/worker";
270
+ import { todoMachine } from "./todo.machine";
271
+ import {
272
+ TodoClientEventSchema,
273
+ TodoServiceEventSchema,
274
+ TodoInputPropsSchema,
275
+ } from "./todo.schemas";
276
+
277
+ export const Todo = createMachineServer({
278
+ machine: todoMachine,
279
+ schemas: {
280
+ clientEvent: TodoClientEventSchema,
281
+ serviceEvent: TodoServiceEventSchema,
282
+ inputProps: TodoInputPropsSchema,
283
+ },
284
+ options: {
285
+ persisted: true,
286
+ },
287
+ });
288
+
289
+ export type TodoServer = InstanceType<typeof Todo>;
290
+ export default Todo;
291
+ ```
292
+
293
+ ### 4️⃣ Configure Wrangler
294
+
295
+ Create a `wrangler.toml` file in your project root:
296
+
297
+ ```toml
298
+ name = "nextjs-actorkit-todo"
299
+ main = "src/server.ts"
300
+ compatibility_date = "2024-09-25"
301
+
302
+ [vars]
303
+ ACTOR_KIT_SECRET = "foobarbaz"
304
+
305
+ [[durable_objects.bindings]]
306
+ name = "TODO"
307
+ class_name = "Todo"
308
+
309
+ [[migrations]]
310
+ tag = "v1"
311
+ new_classes = ["Todo"]
312
+ ```
313
+
314
+ ### 5️⃣ Create a Cloudflare Worker with Actor Kit Router
315
+
316
+ Create a new file, e.g., `src/server.ts`, to set up your Cloudflare Worker:
317
+
318
+ ```typescript
319
+ // src/server.ts
320
+ import { DurableObjectNamespace } from "@cloudflare/workers-types";
321
+ import { AnyActorServer } from "actor-kit";
322
+ import { createActorKitRouter } from "actor-kit/worker";
323
+ import { WorkerEntrypoint } from "cloudflare:workers";
324
+ import { Todo, TodoServer } from "./todo.server";
325
+
326
+ interface Env {
327
+ TODO: DurableObjectNamespace<TodoServer>;
328
+ ACTOR_KIT_SECRET: string;
329
+ [key: string]: DurableObjectNamespace<AnyActorServer> | unknown;
330
+ }
331
+
332
+ const router = createActorKitRouter<Env>(["todo"]);
333
+
334
+ export { Todo };
335
+
336
+ export default class Worker extends WorkerEntrypoint<Env> {
337
+ fetch(request: Request): Promise<Response> | Response {
338
+ if (request.url.includes("/api/")) {
339
+ return router(request, this.env, this.ctx);
340
+ }
341
+
342
+ return new Response("API powered by ActorKit");
343
+ }
344
+ }
345
+ ```
346
+
347
+ ### 6️⃣ Create the Actor Kit Context
348
+
349
+ ```typescript
350
+ // src/todo.context.tsx
351
+ "use client";
352
+
353
+ import type { TodoMachine } from "./todo.machine";
354
+ import { createActorKitContext } from "actor-kit/react";
355
+
356
+ export const TodoActorKitContext = createActorKitContext<TodoMachine>("todo");
357
+ export const TodoActorKitProvider = TodoActorKitContext.Provider;
358
+ ```
359
+
360
+ ### 7️⃣ Fetch data server-side
361
+
362
+ ```typescript
363
+ // src/app/lists/[id]/page.tsx
364
+ import { getUserId } from "@/session";
365
+ import { createAccessToken, createActorFetch } from "actor-kit/server";
366
+ import { TodoActorKitProvider } from "./todo.context";
367
+ import type { TodoMachine } from "./todo.machine";
368
+ import { TodoList } from "./components";
369
+
370
+ const host = process.env.ACTOR_KIT_HOST!;
371
+ const signingKey = process.env.ACTOR_KIT_SECRET!;
372
+
373
+ const fetchTodoActor = createActorFetch<TodoMachine>({
374
+ actorType: "todo",
375
+ host,
376
+ });
377
+
378
+ export default async function TodoPage(props: { params: { id: string } }) {
379
+ const listId = props.params.id;
380
+ const userId = await getUserId();
381
+
382
+ const accessToken = await createAccessToken({
383
+ signingKey,
384
+ actorId: listId,
385
+ actorType: "todo",
386
+ callerId: userId,
387
+ callerType: "client",
388
+ });
389
+
390
+ const payload = await fetchTodoActor({
391
+ actorId: listId,
392
+ accessToken,
393
+ });
394
+
395
+ return (
396
+ <TodoActorKitProvider
397
+ host={host}
398
+ actorId={listId}
399
+ accessToken={accessToken}
400
+ checksum={payload.checksum}
401
+ initialSnapshot={payload.snapshot}
402
+ >
403
+ <TodoList />
404
+ </TodoActorKitProvider>
405
+ );
406
+ }
407
+ ```
408
+
409
+ ### 8️⃣ Create a client-side component
410
+
411
+ ```typescript
412
+ // src/app/lists/[id]/components.tsx
413
+ "use client";
414
+
415
+ import React, { useState } from "react";
416
+ import { TodoActorKitContext } from "./todo.context";
417
+
418
+ export function TodoList() {
419
+ const todos = TodoActorKitContext.useSelector((state) => state.public.todos);
420
+ const send = TodoActorKitContext.useSend();
421
+ const [newTodoText, setNewTodoText] = useState("");
422
+
423
+ const handleAddTodo = (e: React.FormEvent) => {
424
+ e.preventDefault();
425
+ if (newTodoText.trim()) {
426
+ send({ type: "ADD_TODO", text: newTodoText.trim() });
427
+ setNewTodoText("");
428
+ }
429
+ };
430
+
431
+ return (
432
+ <div>
433
+ <h1>Todo List</h1>
434
+ <form onSubmit={handleAddTodo}>
435
+ <input
436
+ type="text"
437
+ value={newTodoText}
438
+ onChange={(e) => setNewTodoText(e.target.value)}
439
+ placeholder="Add a new todo"
440
+ />
441
+ <button type="submit">Add</button>
442
+ </form>
443
+ <ul>
444
+ {todos.map((todo) => (
445
+ <li key={todo.id}>
446
+ <span
447
+ style={{
448
+ textDecoration: todo.completed ? "line-through" : "none",
449
+ }}
450
+ >
451
+ {todo.text}
452
+ </span>
453
+ <button onClick={() => send({ type: "TOGGLE_TODO", id: todo.id })}>
454
+ {todo.completed ? "Undo" : "Complete"}
455
+ </button>
456
+ <button onClick={() => send({ type: "DELETE_TODO", id: todo.id })}>
457
+ Delete
458
+ </button>
459
+ </li>
460
+ ))}
461
+ </ul>
462
+ </div>
463
+ );
464
+ }
465
+ ```
466
+
467
+ This example demonstrates how to set up and use Actor Kit in a Next.js application with Cloudflare Workers, including:
468
+
469
+ 1. Defining event schemas and types
470
+ 2. Creating the state machine with proper typing
471
+ 3. Setting up the Actor Server
472
+ 4. Configuring Wrangler for Cloudflare Workers
473
+ 5. Creating a Cloudflare Worker with Actor Kit Router
474
+ 6. Setting up the Actor Kit context
475
+ 7. Fetching data server-side with access token creation
476
+ 8. Creating a client-side component that interacts with the actor
477
+
478
+ ## 🚀 Getting Started
479
+
480
+ 1. Install dependencies:
481
+
482
+ ```bash
483
+ npm install actor-kit xstate zod
484
+ npm install -D wrangler
485
+ ```
486
+
487
+ 2. Set up environment variables:
488
+
489
+ For development:
490
+ Create a `.dev.vars` file in your project root:
491
+
492
+ ```bash
493
+ touch .dev.vars
494
+ ```
495
+
496
+ Add the following to `.dev.vars`:
497
+
498
+ ```
499
+ ACTOR_KIT_SECRET=your-secret-key
500
+ ```
501
+
502
+ Replace `your-secret-key` with a secure, randomly generated secret.
503
+
504
+ For production:
505
+ Set up the secret using Wrangler:
506
+
507
+ ```bash
508
+ npx wrangler secret put ACTOR_KIT_SECRET
509
+ ```
510
+
511
+ Enter the same secret key you used in your `.dev.vars` file.
512
+
513
+ 3. Create a `wrangler.toml` file in your project root:
514
+
515
+ ```toml
516
+ name = "your-project-name"
517
+ main = "src/server.ts"
518
+ compatibility_date = "2024-09-25"
519
+
520
+ [[durable_objects.bindings]]
521
+ name = "YOUR_ACTOR"
522
+ class_name = "YourActor"
523
+
524
+ [[migrations]]
525
+ tag = "v1"
526
+ new_classes = ["YourActor"]
527
+ ```
528
+
529
+ Replace `your-project-name` with your project's name, and `YOUR_ACTOR` and `YourActor` with your specific actor names.
530
+
531
+ 4. Create your Worker script (e.g., `src/server.ts`):
532
+
533
+ ```typescript
534
+ import { createActorKitRouter } from "actor-kit/worker";
535
+ import { YourActor } from "./your-actor.server";
536
+
537
+ const actorKitRouter = createActorKitRouter({
538
+ yourActor: YourActor,
539
+ });
540
+
541
+ export default {
542
+ async fetch(
543
+ request: Request,
544
+ env: Env,
545
+ ctx: ExecutionContext
546
+ ): Promise<Response> {
547
+ const url = new URL(request.url);
548
+
549
+ if (url.pathname.startsWith("/api/")) {
550
+ return actorKitRouter(request, env, ctx);
551
+ }
552
+
553
+ return new Response("Server powered by ActorKit");
554
+ },
555
+ };
556
+ ```
557
+
558
+ 5. Start the Cloudflare Worker development server:
559
+
560
+ ```bash
561
+ npx wrangler dev
562
+ ```
563
+
564
+ 6. Deploy your Worker to Cloudflare:
565
+
566
+ ```bash
567
+ npx wrangler deploy
568
+ ```
569
+
570
+ 7. If you're using Next.js or another external server, set up your `ACTOR_KIT_HOST` environment variable to point to your deployed Worker's URL.
571
+
572
+ By following these steps, you'll have a basic Actor Kit setup running on Cloudflare Workers. For more detailed, framework-specific instructions, please refer to our example projects for [Next.js](/examples/nextjs-actorkit-todo/README.md) and [Remix](/examples/remix-actorkit-todo/README.md).
573
+
574
+ ## 🗂️ Framework Examples
575
+
576
+ Actor Kit includes example todo list applications demonstrating integration with popular web frameworks.
577
+
578
+ - [Next.js example](/examples/nextjs-actorkit-todo/README.md) in `/examples/nextjs-actorkit-todo`
579
+
580
+ - Live demo: [https://nextjs-actorkit-todo.vercel.app/](https://nextjs-actorkit-todo.vercel.app/)
581
+
582
+ - [Remix example](/examples/remix-actorkit-todo/README.md) in `/examples/remix-actorkit-todo`
583
+ - Live demo: [https://remix-actorkit-todo.jonathanrmumm.workers.dev/](https://remix-actorkit-todo.jonathanrmumm.workers.dev/)
584
+
585
+ These examples showcase how to integrate Actor Kit with different frameworks, demonstrating real-time, event-driven todo lists with owner-based access control. Visit the live demos to see Actor Kit in action!
586
+
587
+ ## 📖 API Reference
588
+
589
+ ### 🔧 actor-kit/worker
590
+
591
+ The `actor-kit/worker` package provides the core functionality for running state machines in Cloudflare Workers. It includes utilities for creating machine servers and routing requests.
592
+
593
+ #### `createMachineServer<TClientEvent, TServiceEvent, TInputSchema, TMachine, Env>(props)`
594
+
595
+ Creates a server instance of a state machine that runs in a Cloudflare Worker Durable Object.
596
+
597
+ Parameters:
598
+ - `machine`: The XState machine to run on the server
599
+ - `schemas`: Zod schemas for validating events and input
600
+ - `clientEvent`: Schema for events from clients
601
+ - `serviceEvent`: Schema for events from trusted services
602
+ - `inputProps`: Schema for initialization props
603
+ - `options`: Configuration options
604
+ - `persisted`: Whether to persist state to storage (default: false)
605
+
606
+ Example usage:
607
+
608
+ ```typescript
609
+ // src/todo.server.ts
610
+ import { createMachineServer } from "actor-kit/worker";
611
+ import { todoMachine } from "./todo.machine";
612
+ import {
613
+ TodoClientEventSchema,
614
+ TodoInputPropsSchema,
615
+ TodoServiceEventSchema,
616
+ } from "./todo.schemas";
617
+
618
+ export const Todo = createMachineServer({
619
+ machine: todoMachine,
620
+ schemas: {
621
+ clientEvent: TodoClientEventSchema,
622
+ serviceEvent: TodoServiceEventSchema,
623
+ inputProps: TodoInputPropsSchema,
624
+ },
625
+ options: {
626
+ persisted: true,
627
+ },
628
+ });
629
+
630
+ export type TodoServer = InstanceType<typeof Todo>;
631
+ export default Todo;
632
+ ```
633
+
634
+ Then set up your Cloudflare Worker to use the server:
635
+
636
+ ```typescript
637
+ // src/server.ts
638
+ import { DurableObjectNamespace } from "@cloudflare/workers-types";
639
+ import { AnyActorServer } from "actor-kit";
640
+ import { createActorKitRouter } from "actor-kit/worker";
641
+ import { WorkerEntrypoint } from "cloudflare:workers";
642
+ import { Todo, TodoServer } from "./todo.server";
643
+
644
+ // Define environment interface with your Durable Object bindings
645
+ interface Env {
646
+ TODO: DurableObjectNamespace<TodoServer>;
647
+ ACTOR_KIT_SECRET: string;
648
+ [key: string]: DurableObjectNamespace<AnyActorServer> | unknown;
649
+ }
650
+
651
+ // Create router with your actor types
652
+ const router = createActorKitRouter<Env>(["todo"]);
653
+
654
+ // Export your Durable Object class
655
+ export { Todo };
656
+
657
+ // Create your Worker
658
+ export default class Worker extends WorkerEntrypoint<Env> {
659
+ fetch(request: Request): Promise<Response> | Response {
660
+ if (request.url.includes("/api/")) {
661
+ return router(request, this.env, this.ctx);
662
+ }
663
+
664
+ return new Response("API powered by ActorKit");
665
+ }
666
+ }
667
+ ```
668
+
669
+ Configure your `wrangler.toml`:
670
+
671
+ ```toml
672
+ name = "your-project"
673
+ main = "src/server.ts"
674
+ compatibility_date = "2024-09-25"
675
+
676
+ [vars]
677
+ ACTOR_KIT_SECRET = "your-secret-key"
678
+
679
+ [[durable_objects.bindings]]
680
+ name = "TODO"
681
+ class_name = "Todo"
682
+
683
+ [[migrations]]
684
+ tag = "v1"
685
+ new_classes = ["Todo"]
686
+ ```
687
+
688
+ The key components are:
689
+ 1. Create your machine server with `createMachineServer`
690
+ 2. Set up your Worker environment interface with Durable Object bindings
691
+ 3. Create a router with your actor types
692
+ 4. Export your Durable Object class
693
+ 5. Create your Worker class that uses the router
694
+ 6. Configure wrangler.toml with your Durable Object bindings
695
+
696
+ #### `createActorKitRouter<Env>(routes)`
697
+
698
+ Creates a router for handling Actor Kit requests in a Cloudflare Worker.
699
+
700
+ Parameters:
701
+ - `routes`: Array of actor type strings (e.g., `["todo", "game"]`)
702
+ - `Env`: Type parameter for your Worker's environment bindings
703
+
704
+ Returns a function that handles HTTP requests and routes them to the appropriate actor.
705
+
706
+ Example usage:
707
+
708
+ ```typescript
709
+ const router = createActorKitRouter<Env>(["todo"]);
710
+
711
+ export default {
712
+ fetch(request: Request, env: Env, ctx: ExecutionContext) {
713
+ if (request.url.includes("/api/")) {
714
+ return router(request, env, ctx);
715
+ }
716
+ return new Response("API powered by ActorKit");
717
+ }
718
+ };
719
+ ```
720
+
721
+ The router handles:
722
+ - Actor creation and initialization
723
+ - Event routing to the correct actor
724
+ - Access token validation
725
+ - WebSocket connections for real-time updates
726
+
727
+ ### 🖥️ `actor-kit/server`
728
+
729
+ #### `createActorFetch<TMachine>({ actorType, host })`
730
+
731
+ Creates a function for fetching actor data. Used in a trusted server environment, typically for server-side rendering or initial data fetching.
732
+
733
+ - `TMachine`: Type parameter extending `ActorKitStateMachine`
734
+ - `actorType`: String identifier for the actor type
735
+ - `host`: The host URL for the Actor Kit server
736
+
737
+ Returns a function with the following signature:
738
+
739
+ ```typescript
740
+ (
741
+ props: {
742
+ actorId: string;
743
+ accessToken: string;
744
+ input?: Record<string, unknown>;
745
+ waitForEvent?: ClientEventFrom<TMachine>;
746
+ waitForState?: StateValueFrom<TMachine>;
747
+ timeout?: number;
748
+ errorOnWaitTimeout?: boolean;
749
+ },
750
+ options?: RequestInit
751
+ ) =>
752
+ Promise<{
753
+ snapshot: CallerSnapshotFrom<TMachine>;
754
+ checksum: string;
755
+ }>;
756
+ ```
757
+
758
+ The `waitForEvent`, `waitForState`, and `errorOnWaitTimeout` parameters allow you to specify conditions for when and how the snapshot should be returned:
759
+
760
+ - `waitForEvent`: Waits for a specific event to be received by the actor before returning the snapshot. The event object must match exactly (deep equality check) with the received event.
761
+ - `waitForState`: Waits for the actor to reach a specific state before returning the snapshot. State matching is performed as described in the XState documentation.
762
+ - `errorOnWaitTimeout`: Determines the behavior when a timeout occurs while waiting for an event or state. Default is `true`.
763
+ - If `true` (default), throws a 408 (Request Timeout) error on timeout.
764
+ - If `false`, returns a 200 status with the current snapshot, even if the wait condition wasn't met.
765
+
766
+ State matching works as follows:
767
+ - For simple states, an exact string match is performed.
768
+ - For compound states, a partial object match is performed. The provided state value must be a subset of the current state value.
769
+ - For parallel states, all specified regions must match.
770
+
771
+ Example usage:
772
+
773
+ ```typescript
774
+ import { createActorFetch } from "actor-kit/server";
775
+ import type { TodoMachine } from "./todo.machine";
776
+
777
+ const fetchTodoActor = createActorFetch<TodoMachine>({
778
+ actorType: "todo",
779
+ host: "your-worker.workers.dev",
780
+ });
781
+
782
+ // Wait for a specific event, throw error on timeout
783
+ const { snapshot, checksum } = await fetchTodoActor({
784
+ actorId: "todo-123",
785
+ accessToken: "your-access-token",
786
+ waitForEvent: { type: "TODOS_LOADED" },
787
+ timeout: 5000, // 5 seconds timeout
788
+ errorOnWaitTimeout: true, // default behavior
789
+ });
790
+
791
+ // Wait for a specific state, return current snapshot on timeout
792
+ const { snapshot, checksum } = await fetchTodoActor({
793
+ actorId: "todo-123",
794
+ accessToken: "your-access-token",
795
+ waitForState: { loaded: "success" },
796
+ timeout: 5000, // 5 seconds timeout
797
+ errorOnWaitTimeout: false,
798
+ });
799
+ ```
800
+
801
+ Here's how to handle different timeout scenarios:
802
+
803
+ ```typescript
804
+ try {
805
+ // Scenario 1: Error on timeout (default behavior)
806
+ const { snapshot, checksum } = await fetchTodoActor({
807
+ actorId: "todo-123",
808
+ accessToken: "your-access-token",
809
+ waitForEvent: { type: "TODOS_LOADED" },
810
+ timeout: 5000,
811
+ // errorOnWaitTimeout: true, // This is the default
812
+ });
813
+ // Use the snapshot data
814
+ } catch (error) {
815
+ if (error.status === 408) {
816
+ console.error("Timeout waiting for actor response");
817
+ // Handle timeout error (e.g., show an error message to the user)
818
+ } else {
819
+ console.error("Error fetching actor data:", error);
820
+ // Handle other errors
821
+ }
822
+ }
823
+
824
+ // Scenario 2: Return current snapshot on timeout
825
+ const { snapshot, checksum } = await fetchTodoActor({
826
+ actorId: "todo-123",
827
+ accessToken: "your-access-token",
828
+ waitForState: { loaded: "success" },
829
+ timeout: 5000,
830
+ errorOnWaitTimeout: false,
831
+ });
832
+ // Always returns a snapshot, even if the wait condition wasn't met
833
+ // You may want to check if the desired state or event was reached
834
+ ```
835
+
836
+ By using `waitForEvent` or `waitForState` along with `errorOnWaitTimeout`, you can control how your server-side rendering or initial data fetch behaves when waiting for specific actor states or events. This allows for flexible error handling and timeout management in your application.
837
+
838
+ #### `createAccessToken({ signingKey, actorId, actorType, callerId, callerType })`
839
+
840
+ Creates an access token for authenticating with an actor.
841
+
842
+ Parameters:
843
+
844
+ - `signingKey`: String used to sign the token
845
+ - `actorId`: Unique identifier for the actor
846
+ - `actorType`: Type of the actor
847
+ - `callerId`: Identifier for the caller
848
+ - `callerType`: Type of the caller (e.g., 'client', 'service')
849
+
850
+ Returns a Promise that resolves to a JWT token string.
851
+
852
+ Example usage:
853
+
854
+ ```typescript
855
+ import { createAccessToken } from "actor-kit/server";
856
+
857
+ const accessToken = await createAccessToken({
858
+ signingKey: process.env.ACTOR_KIT_SECRET!,
859
+ actorId: "todo-123",
860
+ actorType: "todo",
861
+ callerId: "user-456",
862
+ callerType: "client",
863
+ });
864
+ ```
865
+
866
+ ### 🌐 `actor-kit/browser`
867
+
868
+ #### `createActorKitClient<TMachine>(props: ActorKitClientProps<TMachine>)`
869
+
870
+ Creates an Actor Kit client for managing state and communication with the server.
871
+
872
+ - `TMachine`: Type parameter extending `ActorKitStateMachine`
873
+
874
+ `ActorKitClientProps<TMachine>` includes:
875
+
876
+ - `host`: String
877
+ - `actorType`: String
878
+ - `actorId`: String
879
+ - `checksum`: String
880
+ - `accessToken`: String
881
+ - `initialSnapshot`: `CallerSnapshotFrom<TMachine>`
882
+
883
+ Returns an `ActorKitClient<TMachine>` object with methods to interact with the actor.
884
+
885
+ Example usage:
886
+
887
+ ```typescript
888
+ import { createActorKitClient } from "actor-kit/browser";
889
+ import type { TodoMachine } from "./todo.machine";
890
+
891
+ const client = createActorKitClient<TodoMachine>({
892
+ host: "your-worker.workers.dev",
893
+ actorType: "todo",
894
+ actorId: "todo-123",
895
+ checksum: "initial-checksum",
896
+ accessToken: "your-access-token",
897
+ initialSnapshot: {
898
+ public: { todos: [] },
899
+ private: {},
900
+ value: "idle",
901
+ },
902
+ });
903
+
904
+ await client.connect();
905
+ client.send({ type: "ADD_TODO", text: "Buy milk" });
906
+ ```
907
+
908
+ #### `ActorKitClient` Methods
909
+
910
+ - **`connect()`**: Establishes connection to the actor server
911
+ - **`disconnect()`**: Closes the connection to the actor server
912
+ - **`send(event)`**: Sends an event to the actor
913
+ - **`getState()`**: Returns the current state snapshot
914
+ - **`subscribe(listener)`**: Registers a listener for state changes
915
+ - **`waitFor(predicateFn, timeoutMs?)`**: Waits for a state condition to be met
916
+
917
+ ##### Using `waitFor`
918
+
919
+ The `waitFor` method allows you to wait for specific state conditions:
920
+
921
+ ```typescript
922
+ import { createActorKitClient } from 'actor-kit/browser';
923
+
924
+ const client = createActorKitClient<TodoMachine>({
925
+ // ... client config
926
+ });
927
+
928
+ // Wait for a specific state value
929
+ await client.waitFor(state => state.value === 'ready');
930
+
931
+ // Wait for a condition with custom timeout
932
+ await client.waitFor(
933
+ state => state.public.todos.length > 0,
934
+ 10000 // 10 seconds
935
+ );
936
+
937
+ // Wait for complex conditions
938
+ await client.waitFor(state =>
939
+ state.public.todos.some(todo => todo.text === 'Buy milk' && todo.completed)
940
+ );
941
+ ```
942
+
943
+ ### ⚛️ `actor-kit/react`
944
+
945
+ #### `createActorKitContext<TMachine>(actorType: string)`
946
+
947
+ Creates a React context and associated hooks and components for integrating Actor Kit into a React application.
948
+
949
+ - `TMachine`: Type parameter extending `ActorKitStateMachine`
950
+ - `actorType`: String identifier for the actor type
951
+
952
+ Returns an object with:
953
+
954
+ - `Provider`: React component to provide the Actor Kit client to its children
955
+ - `useClient()`: Hook to access the Actor Kit client directly
956
+ - `useSelector<T>(selector: (snapshot: CallerSnapshotFrom<TMachine>) => T)`: Hook to select and subscribe to specific parts of the state
957
+ - `useSend()`: Hook to get a function for sending events to the Actor Kit client
958
+ - `useMatches(stateValue: StateValueFrom<TMachine>)`: Hook to check if the current state matches a given state value
959
+ - `Matches`: Component for conditionally rendering based on state matches
960
+
961
+ Example usage:
962
+
963
+ ```tsx
964
+ import { createActorKitContext } from "actor-kit/react";
965
+ import type { TodoMachine } from "./todo.machine";
966
+
967
+ const TodoActorKitContext = createActorKitContext<TodoMachine>("todo");
968
+
969
+ function App() {
970
+ return (
971
+ <TodoActorKitContext.Provider
972
+ host="your-worker.workers.dev"
973
+ actorId="todo-123"
974
+ accessToken="your-access-token"
975
+ checksum="initial-checksum"
976
+ initialSnapshot={{
977
+ public: { todos: [] },
978
+ private: {},
979
+ value: "idle",
980
+ }}
981
+ >
982
+ <TodoList />
983
+ </TodoActorKitContext.Provider>
984
+ );
985
+ }
986
+
987
+ function TodoList() {
988
+ const todos = TodoActorKitContext.useSelector((state) => state.public.todos);
989
+ const send = TodoActorKitContext.useSend();
990
+ const isIdle = TodoActorKitContext.useMatches("idle");
991
+
992
+ return (
993
+ <div>
994
+ {isIdle && <p>The todo list is idle</p>}
995
+ <ul>
996
+ {todos.map((todo) => (
997
+ <li key={todo.id}>
998
+ {todo.text}
999
+ <button onClick={() => send({ type: "TOGGLE_TODO", id: todo.id })}>
1000
+ Toggle
1001
+ </button>
1002
+ </li>
1003
+ ))}
1004
+ </ul>
1005
+ </div>
1006
+ );
1007
+ }
1008
+ ```
1009
+
1010
+ #### `useClient()`
1011
+
1012
+ Hook to access the Actor Kit client directly.
1013
+
1014
+ Example usage:
1015
+
1016
+ ```tsx
1017
+ function TodoActions() {
1018
+ const client = TodoActorKitContext.useClient();
1019
+
1020
+ const handleClearCompleted = () => {
1021
+ client.send({ type: "CLEAR_COMPLETED" });
1022
+ };
1023
+
1024
+ return <button onClick={handleClearCompleted}>Clear Completed</button>;
1025
+ }
1026
+ ```
1027
+
1028
+ #### `useSelector<T>(selector: (snapshot: CallerSnapshotFrom<TMachine>) => T)`
1029
+
1030
+ Hook to select and subscribe to specific parts of the state.
1031
+
1032
+ Example usage:
1033
+
1034
+ ```tsx
1035
+ function CompletedTodosCount() {
1036
+ const completedCount = TodoActorKitContext.useSelector(
1037
+ (state) => state.public.todos.filter((todo) => todo.completed).length
1038
+ );
1039
+
1040
+ return <span>Completed todos: {completedCount}</span>;
1041
+ }
1042
+ ```
1043
+
1044
+ #### `useSend()`
1045
+
1046
+ Hook to get a function for sending events to the Actor Kit client.
1047
+
1048
+ Example usage:
1049
+
1050
+ ```tsx
1051
+ function AddTodoForm() {
1052
+ const [text, setText] = useState("");
1053
+ const send = TodoActorKitContext.useSend();
1054
+
1055
+ const handleSubmit = (e: React.FormEvent) => {
1056
+ e.preventDefault();
1057
+ if (text.trim()) {
1058
+ send({ type: "ADD_TODO", text: text.trim() });
1059
+ setText("");
1060
+ }
1061
+ };
1062
+
1063
+ return (
1064
+ <form onSubmit={handleSubmit}>
1065
+ <input
1066
+ type="text"
1067
+ value={text}
1068
+ onChange={(e) => setText(e.target.value)}
1069
+ placeholder="Add a new todo"
1070
+ />
1071
+ <button type="submit">Add</button>
1072
+ </form>
1073
+ );
1074
+ }
1075
+ ```
1076
+
1077
+ #### `useMatches(stateValue: StateValueFrom<TMachine>)`
1078
+
1079
+ Hook to check if the current state matches a given state value.
1080
+
1081
+ Example usage:
1082
+
1083
+ ```tsx
1084
+ function LoadingIndicator() {
1085
+ const isLoading = TodoActorKitContext.useMatches("loading");
1086
+
1087
+ return isLoading ? <div>Loading...</div> : null;
1088
+ }
1089
+ ```
1090
+
1091
+ #### `Matches` Component
1092
+
1093
+ The `Matches` component allows for conditional rendering based on the current state of the actor machine.
1094
+
1095
+ Props:
1096
+
1097
+ - `state: StateValueFrom<TMachine>`: The state value to match against
1098
+ - `and?: StateValueFrom<TMachine>`: Optional additional state to match (AND condition)
1099
+ - `or?: StateValueFrom<TMachine>`: Optional alternative state to match (OR condition)
1100
+ - `not?: boolean`: Invert the match result if true
1101
+ - `children: ReactNode`: Content to render when the condition is met
1102
+ - `initialValueOverride?: boolean`: Optional override for the initial render value
1103
+
1104
+ Example usage:
1105
+
1106
+ ````tsx
1107
+ function TodoList() {
1108
+ const todos = TodoActorKitContext.useSelector((state) => state.public.todos);
1109
+ const send = TodoActorKitContext.useSend();
1110
+
1111
+ return (
1112
+ <div>
1113
+ <TodoActorKitContext.Matches state="idle">
1114
+ <p>The todo list is idle</p>
1115
+ </TodoActorKitContext.Matches>
1116
+ <TodoActorKitContext.Matches state="loading">
1117
+ <p>Loading todos...</p>
1118
+ </TodoActorKitContext.Matches>
1119
+ <TodoActorKitContext.Matches state="error" not>
1120
+ <ul>
1121
+ {todos.map((todo) => (
1122
+ <li key={todo.id}>
1123
+ {todo.text}
1124
+ <button
1125
+ onClick={() => send({ type: "TOGGLE_TODO", id: todo.id })}
1126
+ >
1127
+ Toggle
1128
+ </button>
1129
+ </li>
1130
+ ))}
1131
+ </ul>
1132
+ </TodoActorKitContext.Matches>
1133
+ </div>
1134
+ );
1135
+ }
1136
+ ````
1137
+
1138
+ You can also use the `Matches` component with more complex conditions:
1139
+
1140
+ ````tsx
1141
+ function TodoList() {
1142
+ const todos = TodoActorKitContext.useSelector((state) => state.public.todos);
1143
+ const send = TodoActorKitContext.useSend();
1144
+
1145
+ return (
1146
+ <div>
1147
+ <TodoActorKitContext.Matches state="idle" or="ready">
1148
+ <p>The todo list is ready for action</p>
1149
+ </TodoActorKitContext.Matches>
1150
+ <TodoActorKitContext.Matches state="loading" and={{ data: "fetching" }}>
1151
+ <p>Fetching todos from the server...</p>
1152
+ </TodoActorKitContext.Matches>
1153
+ <TodoActorKitContext.Matches state="error" not>
1154
+ <ul>
1155
+ {todos.map((todo) => (
1156
+ <li key={todo.id}>
1157
+ {todo.text}
1158
+ <button
1159
+ onClick={() => send({ type: "TOGGLE_TODO", id: todo.id })}
1160
+ >
1161
+ Toggle
1162
+ </button>
1163
+ </li>
1164
+ ))}
1165
+ </ul>
1166
+ </TodoActorKitContext.Matches>
1167
+ </div>
1168
+ );
1169
+ }
1170
+ ````
1171
+
1172
+ ### 🧪 actor-kit/test
1173
+
1174
+ #### `createActorKitMockClient<TMachine>(props: ActorKitMockClientProps<TMachine>)`
1175
+
1176
+ Creates a mock client for testing Actor Kit state machines without needing a live server.
1177
+
1178
+ Parameters:
1179
+ - `initialSnapshot`: Initial state snapshot for the mock client
1180
+ - `onSend?`: Optional callback function invoked whenever an event is sent
1181
+
1182
+ Returns a mock client that implements the standard `ActorKitClient` interface plus additional testing utilities:
1183
+ - All standard client methods (send, subscribe, etc.)
1184
+ - `produce(recipe: (draft: Draft<CallerSnapshotFrom<TMachine>>) => void)`: Method for directly manipulating state using Immer
1185
+
1186
+ Example usage:
1187
+
1188
+ ````typescript
1189
+ import { createActorKitMockClient } from 'actor-kit/test';
1190
+ import type { TodoMachine } from './todo.machine';
1191
+
1192
+ describe('Todo State Management', () => {
1193
+ it('should handle state transitions', () => {
1194
+ const mockClient = createActorKitMockClient<TodoMachine>({
1195
+ initialSnapshot: {
1196
+ public: {
1197
+ todos: [],
1198
+ status: 'idle'
1199
+ },
1200
+ private: {},
1201
+ value: 'idle'
1202
+ }
1203
+ });
1204
+
1205
+ // Use Immer's produce to update state
1206
+ mockClient.produce((draft) => {
1207
+ draft.public.todos.push({
1208
+ id: '1',
1209
+ text: 'Test todo',
1210
+ completed: false
1211
+ });
1212
+ });
1213
+
1214
+ expect(mockClient.getState().public.todos).toHaveLength(1);
1215
+ });
1216
+
1217
+ it('should track sent events', () => {
1218
+ const sendSpy = vi.fn();
1219
+ const mockClient = createActorKitMockClient<TodoMachine>({
1220
+ initialSnapshot: { /* ... */ },
1221
+ onSend: sendSpy
1222
+ });
1223
+
1224
+ mockClient.send({ type: 'ADD_TODO', text: 'Test todo' });
1225
+ expect(sendSpy).toHaveBeenCalledWith({
1226
+ type: 'ADD_TODO',
1227
+ text: 'Test todo'
1228
+ });
1229
+ });
1230
+ });
1231
+ ````
1232
+
1233
+ ### 📚 actor-kit/storybook
1234
+
1235
+ #### `withActorKit<TMachine>({ actorType, context })`
1236
+
1237
+ Creates a Storybook decorator for testing components that depend on Actor Kit state.
1238
+
1239
+ Parameters:
1240
+ - `actorType`: String identifier for the actor type
1241
+ - `context`: The Actor Kit context created by `createActorKitContext`
1242
+
1243
+ Returns a Storybook decorator function.
1244
+
1245
+ Example usage:
1246
+
1247
+ ````typescript
1248
+ import { withActorKit } from 'actor-kit/storybook';
1249
+ import { GameContext } from './game.context';
1250
+ import type { GameMachine } from './game.machine';
1251
+
1252
+ const meta = {
1253
+ title: 'Components/GameView',
1254
+ component: GameView,
1255
+ decorators: [
1256
+ withActorKit<GameMachine>({
1257
+ actorType: "game",
1258
+ context: GameContext,
1259
+ }),
1260
+ ],
1261
+ };
1262
+ ````
1263
+
1264
+ #### `StoryWithActorKit<TMachine>`
1265
+
1266
+ A utility type for stories that use Actor Kit state machines. It combines the standard Storybook story type with Actor Kit parameters.
1267
+
1268
+ Example usage:
1269
+
1270
+ ````typescript
1271
+ import type { StoryWithActorKit } from 'actor-kit/storybook';
1272
+ import type { GameMachine } from './game.machine';
1273
+
1274
+ export const GameStory: StoryWithActorKit<GameMachine> = {
1275
+ parameters: {
1276
+ actorKit: {
1277
+ game: {
1278
+ "game-123": {
1279
+ public: { /* initial state */ },
1280
+ private: {},
1281
+ value: "idle"
1282
+ }
1283
+ }
1284
+ }
1285
+ }
1286
+ }
1287
+ ````
1288
+
1289
+
1290
+ ### System Events
1291
+
1292
+ Actor Kit includes several system events that are automatically handled by the state machine. These events are of type `ActorKitSystemEvent` and include:
1293
+
1294
+ - `INITIALIZE`: Fired when an actor is first created.
1295
+ - `CONNECT`: Fired when a client connects to the actor.
1296
+ - `DISCONNECT`: Fired when a client disconnects from the actor.
1297
+ - `RESUME`: Fired when an actor is resumed.
1298
+ - `MIGRATE`: Fired when an actor needs to migrate its state, including an operations array.
1299
+
1300
+ The `ActorKitSystemEvent` type is defined as follows:
1301
+
1302
+ ```typescript
1303
+ export type ActorKitSystemEvent =
1304
+ | { type: "INITIALIZE"; caller: { type: "system" id: string } }
1305
+ | { type: "CONNECT"; caller: { type: "system"; id: string }; clientId: string }
1306
+ | { type: "DISCONNECT"; caller: { type: "system"; id: string }; clientId: string }
1307
+ | { type: "RESUME"; caller: { type: "system"; id: string } }
1308
+ | { type: "MIGRATE"; caller: { type: "system"; id: string }; operations: any[] };
1309
+ ```
1310
+
1311
+ These events can be handled in your state machine definition:
1312
+
1313
+ ```typescript
1314
+ createMachine({
1315
+ // ... other configuration ...
1316
+ states: {
1317
+ idle: {
1318
+ on: {
1319
+ INITIALIZE: {
1320
+ actions: "initializeActor",
1321
+ },
1322
+ CONNECT: {
1323
+ actions: "handleClientConnection",
1324
+ },
1325
+ DISCONNECT: {
1326
+ actions: "handleClientDisconnection",
1327
+ },
1328
+ RESUME: {
1329
+ actions: "handleActorResume",
1330
+ },
1331
+ MIGRATE: {
1332
+ actions: "handleActorMigration",
1333
+ },
1334
+ // ... other transitions ...
1335
+ },
1336
+ },
1337
+ // ... other states ...
1338
+ },
1339
+ });
1340
+ ```
1341
+
1342
+ ### 🧪 Testing with Mock Client
1343
+
1344
+ #### `createActorKitMockClient<TMachine>`
1345
+
1346
+ Creates a mock client for testing Actor Kit state machines without needing a live server. It implements the standard `ActorKitClient` interface plus additional testing utilities.
1347
+
1348
+ **Type Parameters:**
1349
+ - `TMachine`: The type of the state machine, extending `AnyActorKitStateMachine`
1350
+
1351
+ **Parameters:**
1352
+ - `props: ActorKitMockClientProps<TMachine>`: Configuration options including:
1353
+ - `initialSnapshot`: The initial state snapshot
1354
+ - `onSend?`: Optional callback function invoked whenever an event is sent
1355
+
1356
+ **Returns:**
1357
+ An `ActorKitMockClient<TMachine>` with all standard client methods plus:
1358
+ - `produce(recipe: (draft: Draft<CallerSnapshotFrom<TMachine>>) => void)`: Method for directly manipulating state using Immer
1359
+
1360
+ **Basic Example:**
1361
+
1362
+ ```typescript
1363
+ import { createActorKitMockClient } from 'actor-kit/test';
1364
+ import type { TodoMachine } from './todo.machine';
1365
+
1366
+ describe('Todo State Management', () => {
1367
+ it('should handle state transitions', () => {
1368
+ const mockClient = createActorKitMockClient<TodoMachine>({
1369
+ initialSnapshot: {
1370
+ public: {
1371
+ todos: [],
1372
+ status: 'idle'
1373
+ },
1374
+ private: {},
1375
+ value: 'idle'
1376
+ }
1377
+ });
1378
+
1379
+ // Use Immer's produce to update state
1380
+ mockClient.produce((draft) => {
1381
+ draft.public.todos.push({
1382
+ id: '1',
1383
+ text: 'Test todo',
1384
+ completed: false
1385
+ });
1386
+ draft.value = 'ready';
1387
+ });
1388
+
1389
+ // Verify state changes
1390
+ expect(mockClient.getState().public.todos).toHaveLength(1);
1391
+ expect(mockClient.getState().value).toBe('ready');
1392
+ });
1393
+ });
1394
+ ```
1395
+
1396
+ **Spying on Events:**
1397
+
1398
+ ```typescript
1399
+ import { vi } from 'vitest'; // or jest
1400
+
1401
+ describe('Todo Event Handling', () => {
1402
+ it('should track sent events', () => {
1403
+ const sendSpy = vi.fn();
1404
+ const mockClient = createActorKitMockClient<TodoMachine>({
1405
+ initialSnapshot: {
1406
+ public: {
1407
+ todos: [],
1408
+ status: 'idle'
1409
+ },
1410
+ private: {},
1411
+ value: 'idle'
1412
+ },
1413
+ onSend: sendSpy
1414
+ });
1415
+
1416
+ // Send an event
1417
+ mockClient.send({ type: 'ADD_TODO', text: 'Test todo' });
1418
+
1419
+ // Verify the event was sent
1420
+ expect(sendSpy).toHaveBeenCalledWith({
1421
+ type: 'ADD_TODO',
1422
+ text: 'Test todo'
1423
+ });
1424
+
1425
+ // Check call count
1426
+ expect(sendSpy).toHaveBeenCalledTimes(1);
1427
+
1428
+ // Verify specific event properties
1429
+ const [sentEvent] = sendSpy.mock.calls[0];
1430
+ expect(sentEvent.type).toBe('ADD_TODO');
1431
+ expect(sentEvent.text).toBe('Test todo');
1432
+ });
1433
+
1434
+ it('should track multiple events in order', () => {
1435
+ const sendSpy = vi.fn();
1436
+ const mockClient = createActorKitMockClient<TodoMachine>({
1437
+ initialSnapshot: {
1438
+ public: {
1439
+ todos: [{ id: '1', text: 'Test todo', completed: false }],
1440
+ status: 'idle'
1441
+ },
1442
+ private: {},
1443
+ value: 'idle'
1444
+ },
1445
+ onSend: sendSpy
1446
+ });
1447
+
1448
+ // Send multiple events
1449
+ mockClient.send({ type: 'TOGGLE_TODO', id: '1' });
1450
+ mockClient.send({ type: 'DELETE_TODO', id: '1' });
1451
+
1452
+ // Verify events were sent in order
1453
+ expect(sendSpy.mock.calls).toEqual([
1454
+ [{ type: 'TOGGLE_TODO', id: '1' }],
1455
+ [{ type: 'DELETE_TODO', id: '1' }]
1456
+ ]);
1457
+ });
1458
+ });
1459
+ ```
1460
+
1461
+ **Testing React Components:**
1462
+
1463
+ ```typescript
1464
+ import { render, screen } from '@testing-library/react';
1465
+ import { TodoActorKitContext } from './todo.context';
1466
+ import { createActorKitMockClient } from 'actor-kit/test';
1467
+
1468
+ describe('TodoList', () => {
1469
+ it('renders todos correctly', () => {
1470
+ const mockClient = createActorKitMockClient<TodoMachine>({
1471
+ initialSnapshot: {
1472
+ public: {
1473
+ todos: [],
1474
+ status: 'idle'
1475
+ },
1476
+ private: {},
1477
+ value: 'idle'
1478
+ }
1479
+ });
1480
+
1481
+ render(
1482
+ <TodoActorKitContext.ProviderFromClient client={mockClient}>
1483
+ <TodoList />
1484
+ </TodoActorKitContext.ProviderFromClient>
1485
+ );
1486
+
1487
+ // Update state using Immer
1488
+ mockClient.produce((draft) => {
1489
+ draft.public.todos.push({
1490
+ id: '1',
1491
+ text: 'Test todo',
1492
+ completed: false
1493
+ });
1494
+ });
1495
+
1496
+ // Verify UI updates
1497
+ expect(screen.getByText('Test todo')).toBeInTheDocument();
1498
+ });
1499
+ });
1500
+ ```
1501
+
1502
+ The mock client provides two powerful testing capabilities:
1503
+
1504
+ 1. **State Manipulation**: The `produce` method uses Immer to allow intuitive, mutable-style updates to the immutable state. This makes it easy to set up different test scenarios.
1505
+
1506
+ 2. **Event Tracking**: The `onSend` callback can be used with testing framework spies to verify that components are sending the correct events at the right times.
1507
+
1508
+ These features make it simple to test both state transitions and component behavior without needing a real server connection.
1509
+
1510
+ ## 👥 Caller Types
1511
+
1512
+ Actor Kit supports different types of callers, each with its own level of trust and permissions:
1513
+
1514
+ - 👤 `client`: Events from end-users or client applications
1515
+ - 🤖 `system`: Internal events generated by the actor system (handled internally)
1516
+ - 🔧 `service`: Events from trusted external services or internal microservices
1517
+
1518
+ ## 🔑 TypeScript Types
1519
+
1520
+ The following key types are exported from the main `actor-kit` package:
1521
+
1522
+ ### `WithActorKitEvent<TEvent, TCallerType>`
1523
+
1524
+ Utility type that wraps an event type with Actor Kit-specific properties. This is crucial for adding caller information and other metadata to your events.
1525
+
1526
+ Example usage:
1527
+
1528
+ ```typescript
1529
+ import { WithActorKitEvent } from "actor-kit";
1530
+
1531
+ type MyClientEvent =
1532
+ | { type: "ADD_TODO"; text: string }
1533
+ | { type: "TOGGLE_TODO"; id: string };
1534
+
1535
+ type MyServiceEvent = {
1536
+ type: "SYNC_TODOS";
1537
+ todos: Array<{ id: string; text: string; completed: boolean }>;
1538
+ };
1539
+
1540
+ type MyEvent =
1541
+ | WithActorKitEvent<MyClientEvent, "client">
1542
+ | WithActorKitEvent<MyServiceEvent, "service">
1543
+ | ActorKitSystemEvent;
1544
+ ```
1545
+
1546
+ ### `ActorKitSystemEvent`
1547
+
1548
+ Type representing system events that Actor Kit generates internally. These events are automatically included in your machine's event type and are used to handle lifecycle operations.
1549
+
1550
+ Key system events:
1551
+
1552
+ - `INITIALIZE`: Fired when an actor is first created or resumed from storage.
1553
+ - `CONNECT`: Fired when a client connects to the actor.
1554
+ - `DISCONNECT`: Fired when a client disconnects from the actor.
1555
+
1556
+ Example usage in a state machine:
1557
+
1558
+ ```typescript
1559
+ createMachine({
1560
+ // ... other configuration ...
1561
+ states: {
1562
+ idle: {
1563
+ on: {
1564
+ INITIALIZE: {
1565
+ actions: "initializeActor",
1566
+ },
1567
+ CONNECT: {
1568
+ actions: "handleClientConnection",
1569
+ },
1570
+ DISCONNECT: {
1571
+ actions: "handleClientDisconnection",
1572
+ },
1573
+ // ... other transitions ...
1574
+ },
1575
+ },
1576
+ // ... other states ...
1577
+ },
1578
+ });
1579
+ ```
1580
+
1581
+ ### `CallerSnapshotFrom<TMachine>`
1582
+
1583
+ Utility type to extract the caller-specific snapshot from a machine type. This is useful when working with the state in your components or actions.
1584
+
1585
+ Example usage:
1586
+
1587
+ ```typescript
1588
+ import { CallerSnapshotFrom } from "actor-kit";
1589
+ import { TodoMachine } from "./todo.machine";
1590
+
1591
+ type TodoSnapshot = CallerSnapshotFrom<TodoMachine>;
1592
+
1593
+ function TodoList({ snapshot }: { snapshot: TodoSnapshot }) {
1594
+ return (
1595
+ <ul>
1596
+ {snapshot.public.todos.map((todo) => (
1597
+ <li key={todo.id}>{todo.text}</li>
1598
+ ))}
1599
+ </ul>
1600
+ );
1601
+ }
1602
+ ```
1603
+
1604
+ #### `ActorKitStateMachine`
1605
+
1606
+ Represents the structure of an Actor Kit state machine.
1607
+
1608
+ ```typescript
1609
+ type ActorKitStateMachine<
1610
+ TEvent extends BaseActorKitEvent & EventObject,
1611
+ TInput extends { id: string; caller: Caller },
1612
+ TPrivateProps extends { [key: string]: unknown },
1613
+ TPublicProps extends { [key: string]: unknown }
1614
+ > = StateMachine<...>
1615
+ ```
1616
+
1617
+ Example usage:
1618
+
1619
+ ```typescript
1620
+ import { ActorKitStateMachine } from "actor-kit";
1621
+ import { setup } from "xstate";
1622
+ import type {
1623
+ TodoEvent,
1624
+ TodoInput,
1625
+ TodoPrivateContext,
1626
+ TodoPublicContext,
1627
+ } from "./todo.types";
1628
+
1629
+ export const todoMachine = setup({
1630
+ // ... machine setup
1631
+ }).createMachine({
1632
+ // ... machine definition
1633
+ }) satisfies ActorKitStateMachine<
1634
+ TodoEvent,
1635
+ TodoInput,
1636
+ TodoPrivateContext,
1637
+ TodoPublicContext
1638
+ >;
1639
+ ```
1640
+
1641
+ ### Other Types
1642
+
1643
+ - `ClientEventFrom<TMachine extends AnyActorKitStateMachine>`: Utility type to extract client events from an Actor Kit state machine.
1644
+ - `ServiceEventFrom<TMachine extends AnyActorKitStateMachine>`: Utility type to extract service events from an Actor Kit state machine.
1645
+
1646
+ By including these types in your Actor Kit implementation, you ensure type safety and proper handling of events and state across your application.
1647
+
1648
+ ## 🔐 Public and Private Data
1649
+
1650
+ Actor Kit supports the concepts of public and private data in the context. This allows you to manage shared data across all clients and caller-specific information securely.
1651
+
1652
+ ## 📚 Storybook Integration
1653
+
1654
+ Actor Kit provides seamless integration with Storybook through the `withActorKit` decorator, allowing you to easily test and develop components that depend on actor state.
1655
+
1656
+ ### Basic Usage
1657
+
1658
+ ```typescript
1659
+ import { withActorKit } from 'actor-kit/storybook';
1660
+ import { GameContext } from './game.context';
1661
+ import type { GameMachine } from './game.machine';
1662
+
1663
+ const meta = {
1664
+ title: 'Components/GameView',
1665
+ component: GameView,
1666
+ decorators: [
1667
+ withActorKit<GameMachine>({
1668
+ actorType: "game",
1669
+ context: GameContext,
1670
+ }),
1671
+ ],
1672
+ };
1673
+
1674
+ export default meta;
1675
+ type Story = StoryObj<typeof GameView>;
1676
+
1677
+ export const Default: Story = {
1678
+ parameters: {
1679
+ actorKit: {
1680
+ game: {
1681
+ "game-123": {
1682
+ public: {
1683
+ players: [],
1684
+ gameStatus: "idle"
1685
+ },
1686
+ private: {},
1687
+ value: "idle"
1688
+ }
1689
+ }
1690
+ }
1691
+ }
1692
+ };
1693
+ ```
1694
+
1695
+ ### Testing Patterns
1696
+
1697
+ There are two main patterns for testing with Actor Kit in Storybook:
1698
+
1699
+ #### 1. Static Stories (Using parameters.actorKit)
1700
+
1701
+ Best for simple stories that don't need state manipulation:
1702
+
1703
+ ```typescript
1704
+ export const Static: Story = {
1705
+ parameters: {
1706
+ actorKit: {
1707
+ game: {
1708
+ "game-123": {
1709
+ public: { /* initial state */ },
1710
+ private: {},
1711
+ value: "idle"
1712
+ }
1713
+ }
1714
+ }
1715
+ },
1716
+ play: async ({ canvasElement }) => {
1717
+ const canvas = within(canvasElement);
1718
+ // Test UI state...
1719
+ }
1720
+ };
1721
+ ```
1722
+
1723
+ #### 2. Interactive Stories (Using mount + direct client)
1724
+
1725
+ Better for stories that need to manipulate state:
1726
+
1727
+ ```typescript
1728
+ export const Interactive: Story = {
1729
+ play: async ({ canvasElement, mount }) => {
1730
+ const client = createActorKitMockClient<GameMachine>({
1731
+ initialSnapshot: {
1732
+ public: { /* initial state */ },
1733
+ private: {},
1734
+ value: "idle"
1735
+ }
1736
+ });
1737
+
1738
+ await mount(
1739
+ <GameContext.ProviderFromClient client={client}>
1740
+ <GameView />
1741
+ </GameContext.ProviderFromClient>
1742
+ );
1743
+
1744
+ // Now you can manipulate state
1745
+ client.produce((draft) => {
1746
+ draft.public.players.push({
1747
+ id: "player-1",
1748
+ name: "Player 1"
1749
+ });
1750
+ });
1751
+ }
1752
+ };
1753
+ ```
1754
+
1755
+ ### Multiple Actors
1756
+
1757
+ You can use multiple actors in a single story:
1758
+
1759
+ ```typescript
1760
+ const meta = {
1761
+ decorators: [
1762
+ withActorKit<SessionMachine>({
1763
+ actorType: "session",
1764
+ context: SessionContext,
1765
+ }),
1766
+ withActorKit<GameMachine>({
1767
+ actorType: "game",
1768
+ context: GameContext,
1769
+ }),
1770
+ ],
1771
+ };
1772
+
1773
+ export const MultipleActors: Story = {
1774
+ parameters: {
1775
+ actorKit: {
1776
+ session: {
1777
+ "session-123": {
1778
+ public: { /* session state */ },
1779
+ private: {},
1780
+ value: "ready"
1781
+ }
1782
+ },
1783
+ game: {
1784
+ "game-123": {
1785
+ public: { /* game state */ },
1786
+ private: {},
1787
+ value: "active"
1788
+ }
1789
+ }
1790
+ }
1791
+ }
1792
+ };
1793
+ ```
1794
+
1795
+ ### Basic Event Spy Example
1796
+
1797
+ ```typescript
1798
+ import type { Meta, StoryObj } from "@storybook/react";
1799
+ import { expect, fn } from "@storybook/test";
1800
+ import { createActorKitMockClient } from "actor-kit/test";
1801
+ import { GameContext } from "./game.context";
1802
+ import type { GameMachine } from "./game.machine";
1803
+
1804
+ export const JoinGame: Story = {
1805
+ play: async ({ canvasElement, mount }) => {
1806
+ const sendSpy = fn();
1807
+ const client = createActorKitMockClient<GameMachine>({
1808
+ initialSnapshot: {
1809
+ public: {
1810
+ players: [],
1811
+ gameStatus: "lobby"
1812
+ },
1813
+ private: {},
1814
+ value: "lobby"
1815
+ },
1816
+ onSend: sendSpy
1817
+ });
1818
+
1819
+ await mount(
1820
+ <GameContext.ProviderFromClient client={client}>
1821
+ <JoinGameForm />
1822
+ </GameContext.ProviderFromClient>
1823
+ );
1824
+
1825
+ // Find and fill the name input
1826
+ const nameInput = await canvas.findByLabelText("Player Name");
1827
+ await userEvent.type(nameInput, "Test Player");
1828
+
1829
+ // Click the join button
1830
+ const joinButton = await canvas.findByText("Join Game");
1831
+ await userEvent.click(joinButton);
1832
+
1833
+ // Verify the JOIN_GAME event was sent with correct payload
1834
+ expect(sendSpy).toHaveBeenCalledWith({
1835
+ type: "JOIN_GAME",
1836
+ playerName: "Test Player"
1837
+ });
1838
+ }
1839
+ };
1840
+ ```
1841
+
1842
+ ### Testing Multiple Events in Sequence
1843
+
1844
+ ```typescript
1845
+ export const GameRound: Story = {
1846
+ play: async ({ canvasElement, mount }) => {
1847
+ const sendSpy = fn();
1848
+ const client = createActorKitMockClient<GameMachine>({
1849
+ initialSnapshot: {
1850
+ public: {
1851
+ players: [
1852
+ { id: "player-1", name: "Player 1", score: 0 },
1853
+ { id: "player-2", name: "Player 2", score: 0 }
1854
+ ],
1855
+ currentQuestion: {
1856
+ text: "What is 2 + 2?",
1857
+ answer: "4"
1858
+ },
1859
+ gameStatus: "active"
1860
+ },
1861
+ private: {},
1862
+ value: { active: "questionActive" }
1863
+ },
1864
+ onSend: sendSpy
1865
+ });
1866
+
1867
+ await mount(
1868
+ <GameContext.ProviderFromClient client={client}>
1869
+ <GameView />
1870
+ </GameContext.ProviderFromClient>
1871
+ );
1872
+
1873
+ // Test buzzing in
1874
+ const buzzerButton = await canvas.findByText("Buzz In");
1875
+ await userEvent.click(buzzerButton);
1876
+
1877
+ expect(sendSpy).toHaveBeenCalledWith({
1878
+ type: "BUZZ_IN",
1879
+ playerId: "player-1"
1880
+ });
1881
+
1882
+ // Test submitting an answer
1883
+ const answerInput = await canvas.findByLabelText("Your Answer");
1884
+ await userEvent.type(answerInput, "4");
1885
+
1886
+ const submitButton = await canvas.findByText("Submit Answer");
1887
+ await userEvent.click(submitButton);
1888
+
1889
+ // Verify events were sent in order with correct payloads
1890
+ expect(sendSpy.mock.calls).toEqual([
1891
+ [{ type: "BUZZ_IN", playerId: "player-1" }],
1892
+ [{ type: "SUBMIT_ANSWER", answer: "4" }]
1893
+ ]);
1894
+ }
1895
+ };
1896
+ ```
1897
+
1898
+ ### Testing Complex Event Payloads
1899
+
1900
+ ```typescript
1901
+ export const GameConfiguration: Story = {
1902
+ play: async ({ canvasElement, mount }) => {
1903
+ const sendSpy = fn();
1904
+ const client = createActorKitMockClient<GameMachine>({
1905
+ initialSnapshot: {
1906
+ public: {
1907
+ gameStatus: "setup",
1908
+ config: {
1909
+ maxPlayers: 4,
1910
+ timeLimit: 30,
1911
+ categories: []
1912
+ }
1913
+ },
1914
+ private: {},
1915
+ value: "setup"
1916
+ },
1917
+ onSend: sendSpy
1918
+ });
1919
+
1920
+ await mount(
1921
+ <GameContext.ProviderFromClient client={client}>
1922
+ <GameConfigForm />
1923
+ </GameContext.ProviderFromClient>
1924
+ );
1925
+
1926
+ // Fill out configuration form
1927
+ await userEvent.selectOptions(
1928
+ await canvas.findByLabelText("Max Players"),
1929
+ "6"
1930
+ );
1931
+
1932
+ await userEvent.selectOptions(
1933
+ await canvas.findByLabelText("Time Limit"),
1934
+ "60"
1935
+ );
1936
+
1937
+ const categoryCheckboxes = await canvas.findAllByRole("checkbox");
1938
+ await userEvent.click(categoryCheckboxes[0]); // Select "History"
1939
+ await userEvent.click(categoryCheckboxes[2]); // Select "Science"
1940
+
1941
+ const saveButton = await canvas.findByText("Save Configuration");
1942
+ await userEvent.click(saveButton);
1943
+
1944
+ // Verify the UPDATE_CONFIG event was sent with the exact payload structure
1945
+ expect(sendSpy).toHaveBeenCalledWith({
1946
+ type: "UPDATE_CONFIG",
1947
+ config: {
1948
+ maxPlayers: 6,
1949
+ timeLimit: 60,
1950
+ categories: ["history", "science"]
1951
+ }
1952
+ });
1953
+
1954
+ // You can also use partial matching for complex objects
1955
+ expect(sendSpy).toHaveBeenCalledWith(
1956
+ expect.objectContaining({
1957
+ type: "UPDATE_CONFIG",
1958
+ config: expect.objectContaining({
1959
+ maxPlayers: 6,
1960
+ categories: expect.arrayContaining(["history", "science"])
1961
+ })
1962
+ })
1963
+ );
1964
+ }
1965
+ };
1966
+ ```
1967
+
1968
+ ### Testing Event Properties with Custom Matchers
1969
+
1970
+ ```typescript
1971
+ export const ChatMessage: Story = {
1972
+ play: async ({ canvasElement, mount }) => {
1973
+ const sendSpy = fn();
1974
+ const client = createActorKitMockClient<GameMachine>({
1975
+ initialSnapshot: {
1976
+ public: {
1977
+ messages: [],
1978
+ gameStatus: "active"
1979
+ },
1980
+ private: {},
1981
+ value: "active"
1982
+ },
1983
+ onSend: sendSpy
1984
+ });
1985
+
1986
+ await mount(
1987
+ <GameContext.ProviderFromClient client={client}>
1988
+ <ChatBox />
1989
+ </GameContext.ProviderFromClient>
1990
+ );
1991
+
1992
+ // Send a chat message
1993
+ const messageInput = await canvas.findByLabelText("Message");
1994
+ await userEvent.type(messageInput, "Hello, world!");
1995
+
1996
+ const sendButton = await canvas.findByText("Send");
1997
+ await userEvent.click(sendButton);
1998
+
1999
+ // Verify the SEND_MESSAGE event with timestamp
2000
+ expect(sendSpy).toHaveBeenCalledWith({
2001
+ type: "SEND_MESSAGE",
2002
+ text: "Hello, world!",
2003
+ timestamp: expect.any(Number),
2004
+ sender: expect.objectContaining({
2005
+ id: expect.any(String),
2006
+ name: expect.any(String)
2007
+ })
2008
+ });
2009
+
2010
+ // You can also create custom matchers for common patterns
2011
+ const isValidMessage = (event: any) => {
2012
+ return (
2013
+ event.type === "SEND_MESSAGE" &&
2014
+ typeof event.text === "string" &&
2015
+ typeof event.timestamp === "number" &&
2016
+ Date.now() - event.timestamp < 1000 // Message was sent within last second
2017
+ );
2018
+ };
2019
+
2020
+ expect(sendSpy).toHaveBeenCalledWith(expect.custom(isValidMessage));
2021
+ }
2022
+ };
2023
+ ```
2024
+
2025
+ ## 📜 License
2026
+
2027
+ Actor Kit is [MIT licensed](LICENSE.md).
2028
+
2029
+ ## 🔗 Related Technologies and Inspiration
2030
+
2031
+ Actor Kit builds upon and draws inspiration from several excellent technologies:
2032
+
2033
+ - [XState](https://xstate.js.org/): A powerful state management library for JavaScript and TypeScript applications.
2034
+ - [Cloudflare Workers](https://workers.cloudflare.com/): A serverless platform for building and deploying applications at the edge.
2035
+ - [Zod](https://zod.dev/): A TypeScript-first schema declaration and validation library.
2036
+ - [PartyKit](https://www.partykit.io/): An inspiration for Actor Kit, providing real-time multiplayer infrastructure.
2037
+ - [PartyServer](https://github.com/threepointone/partyserver/tree/main): PartyKit, for workers
2038
+ - [xstate-migrate](https://github.com/jonmumm/xstate-migrate): A migration library for persisted XState machines, designed to facilitate state machine migrations when updating your XState configurations.
2039
+
2040
+ ## 🚧 Development Status
2041
+
2042
+ Actor Kit is currently in active development and is considered alpha software. It is not yet stable or recommended for production use. Use at your own risk and expect frequent changes.