actor-kit
v0.47.0
Published
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
Downloads
1,662
Maintainers
Readme
🎭 Actor Kit
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.
📚 Table of Contents
- 💾 Installation
- 🏗️ Architecture
- 🌟 Key Concepts
- 🛠️ Usage
- 🚀 Getting Started
- 🗂️ Framework Examples
- 📖 API Reference
- 🔑 TypeScript Types
- 👥 Caller Types
- 🔐 Public and Private Data
- 📚 Storybook Integration
- 📜 License
- 🔗 Related Technologies and Inspiration
- 🚧 Development Status
💾 Installation
To install Actor Kit, use your preferred package manager:
npm install actor-kit xstate zod jose react
# or
yarn add actor-kit xstate zod jose react
# or
pnpm add actor-kit xstate zod jose react
🌟 Key Concepts
- 🖥️ Server-Side Rendering: Fetch initial state server-side for optimal performance and SEO.
- ⚡ Real-time Updates: Changes are immediately reflected across all connected clients, ensuring a responsive user experience.
- 🛡️ Type Safety: Leverage TypeScript and Zod for robust type checking and runtime validation.
- 🎭 Event-Driven Architecture: All state changes are driven by events, providing a clear and predictable data flow.
- 🧠 State Machine Logic: Powered by XState, making complex state management more manageable and visualizable.
- 🔄 Seamless Synchronization: Actor Kit handles state synchronization between server and clients automatically.
- 🔐 Public and Private Data: Manage shared data across all clients and caller-specific information securely.
- 🌐 Distributed Systems: Built for scalable, distributed applications running on edge computing platforms.
🏗️ Architecture
graph TD
subgraph "User Browser"
A[Client Components<br>APIs: useSelector, useSend]
B[Actor Kit Client<br>API: createActorKitClient]
end
subgraph "Cloudflare Worker"
C[Actor Kit Router<br>API: createActorKitRouter]
subgraph "Actor Server"
D[Machine Server DO<br>API: createMachineServer]
X[XState Machine]
F[(Durable Object Storage)]
end
end
subgraph "Server-Side Rendering"
E[Next.js/Remix/etc<br>APIs: createActorFetch, createAccessToken]
end
G[External API]
H[(Database)]
I[Third-party Service<br>e.g., Authentication, Payment, Analytics]
A -->|Events| B
B <-->|Send Events / Recv Patches| C
C -->|Route| D
D <-->|Manage State| X
X <-->|Read/Write| F
X --> G
X --> H
X --> I
E <-->|Fetch/Send Events| C
E -->|Deliver HTML/JS| A
E <--> H
classDef browser fill:#f0f0f0,stroke:#333,stroke-width:2px;
classDef worker fill:#ffe6e6,stroke:#333,stroke-width:2px;
classDef actorserver fill:#ffcccc,stroke:#333,stroke-width:2px;
classDef ssr fill:#ffe6ff,stroke:#333,stroke-width:2px;
classDef storage fill:#e6e6ff,stroke:#333,stroke-width:2px;
classDef external fill:#e6ffff,stroke:#333,stroke-width:2px;
class A,B browser;
class C worker;
class D,X,F actorserver;
class E ssr;
class G,H,I external;
🛠️ Usage
Here's a comprehensive example of how to use Actor Kit to create a todo list application with Next.js and Cloudflare Workers:
1️⃣ Define your event schemas and types
First, define the schemas and types for your events:
// src/todo.schemas.ts
import { z } from "zod";
export const TodoClientEventSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("ADD_TODO"),
text: z.string(),
}),
z.object({
type: z.literal("TOGGLE_TODO"),
id: z.string(),
}),
z.object({
type: z.literal("DELETE_TODO"),
id: z.string(),
}),
]);
export const TodoServiceEventSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("SYNC_TODOS"),
todos: z.array(
z.object({ id: z.string(), text: z.string(), completed: z.boolean() })
),
}),
]);
export const TodoInputPropsSchema = z.object({
accessCount: z.number(),
});
// src/todo.types.ts
import type {
ActorKitSystemEvent,
WithActorKitEvent,
WithActorKitInput,
} from "actor-kit";
import { z } from "zod";
import {
TodoClientEventSchema,
TodoInputPropsSchema,
TodoServiceEventSchema,
} from "./todo.schemas";
export type TodoClientEvent = z.infer<typeof TodoClientEventSchema>;
export type TodoServiceEvent = z.infer<typeof TodoServiceEventSchema>;
export type TodoInputProps = z.infer<typeof TodoInputPropsSchema>;
export type TodoInput = WithActorKitInput<TodoInputProps>;
export type TodoEvent =
| WithActorKitEvent<TodoClientEvent, "client">
| WithActorKitEvent<TodoServiceEvent, "service">
| ActorKitSystemEvent;
export type TodoPublicContext = {
ownerId: string;
todos: Array<{ id: string; text: string; completed: boolean }>;
lastSync: number | null;
};
export type TodoPrivateContext = {
accessCount: number;
};
export type TodoServerContext = {
public: TodoPublicContext;
private: Record<string, TodoPrivateContext>;
};
2️⃣ Define your state machine
Now that we have our event types defined, we can create our state machine:
// src/todo.machine.ts
import { ActorKitStateMachine } from "actor-kit";
import { assign, setup } from "xstate";
import type {
TodoEvent,
TodoInput,
TodoPrivateContext,
TodoPublicContext,
TodoServerContext,
} from "./todo.types";
export const todoMachine = setup({
types: {
context: {} as TodoServerContext,
events: {} as TodoEvent,
input: {} as TodoInput,
},
actions: {
addTodo: assign({
public: ({ context, event }) => {
if (event.type !== "ADD_TODO") return context.public;
return {
...context.public,
todos: [
...context.public.todos,
{ id: crypto.randomUUID(), text: event.text, completed: false },
],
lastSync: Date.now(),
};
},
}),
// ... other actions ...
},
guards: {
// ... guards ...
},
}).createMachine({
id: "todoList",
initial: "idle",
context: {
public: {
ownerId: caller.id,
todos: [],
lastSync: null,
},
private: {},
},
states: {
idle: {
on: {
ADD_TODO: {
actions: "addTodo",
},
// ... other transitions ...
},
},
// ... other states ...
},
}) satisfies ActorKitStateMachine<
TodoEvent,
TodoInput,
TodoPrivateContext,
TodoPublicContext
>;
3️⃣ Set up the Actor Server
Create the Actor Server using the createMachineServer
function:
// src/todo.server.ts
import { createMachineServer } from "actor-kit/worker";
import { todoMachine } from "./todo.machine";
import {
TodoClientEventSchema,
TodoServiceEventSchema,
TodoInputPropsSchema,
} from "./todo.schemas";
export const Todo = createMachineServer({
machine: todoMachine,
schemas: {
clientEvent: TodoClientEventSchema,
serviceEvent: TodoServiceEventSchema,
inputProps: TodoInputPropsSchema,
},
options: {
persisted: true,
},
});
export type TodoServer = InstanceType<typeof Todo>;
export default Todo;
4️⃣ Configure Wrangler
Create a wrangler.toml
file in your project root:
name = "nextjs-actorkit-todo"
main = "src/server.ts"
compatibility_date = "2024-09-25"
[vars]
ACTOR_KIT_SECRET = "foobarbaz"
[[durable_objects.bindings]]
name = "TODO"
class_name = "Todo"
[[migrations]]
tag = "v1"
new_classes = ["Todo"]
5️⃣ Create a Cloudflare Worker with Actor Kit Router
Create a new file, e.g., src/server.ts
, to set up your Cloudflare Worker:
// src/server.ts
import { DurableObjectNamespace } from "@cloudflare/workers-types";
import { AnyActorServer } from "actor-kit";
import { createActorKitRouter } from "actor-kit/worker";
import { WorkerEntrypoint } from "cloudflare:workers";
import { Todo, TodoServer } from "./todo.server";
interface Env {
TODO: DurableObjectNamespace<TodoServer>;
ACTOR_KIT_SECRET: string;
[key: string]: DurableObjectNamespace<AnyActorServer> | unknown;
}
const router = createActorKitRouter<Env>(["todo"]);
export { Todo };
export default class Worker extends WorkerEntrypoint<Env> {
fetch(request: Request): Promise<Response> | Response {
if (request.url.includes("/api/")) {
return router(request, this.env, this.ctx);
}
return new Response("API powered by ActorKit");
}
}
6️⃣ Create the Actor Kit Context
// src/todo.context.tsx
"use client";
import type { TodoMachine } from "./todo.machine";
import { createActorKitContext } from "actor-kit/react";
export const TodoActorKitContext = createActorKitContext<TodoMachine>("todo");
export const TodoActorKitProvider = TodoActorKitContext.Provider;
7️⃣ Fetch data server-side
// src/app/lists/[id]/page.tsx
import { getUserId } from "@/session";
import { createAccessToken, createActorFetch } from "actor-kit/server";
import { TodoActorKitProvider } from "./todo.context";
import type { TodoMachine } from "./todo.machine";
import { TodoList } from "./components";
const host = process.env.ACTOR_KIT_HOST!;
const signingKey = process.env.ACTOR_KIT_SECRET!;
const fetchTodoActor = createActorFetch<TodoMachine>({
actorType: "todo",
host,
});
export default async function TodoPage(props: { params: { id: string } }) {
const listId = props.params.id;
const userId = await getUserId();
const accessToken = await createAccessToken({
signingKey,
actorId: listId,
actorType: "todo",
callerId: userId,
callerType: "client",
});
const payload = await fetchTodoActor({
actorId: listId,
accessToken,
});
return (
<TodoActorKitProvider
host={host}
actorId={listId}
accessToken={accessToken}
checksum={payload.checksum}
initialSnapshot={payload.snapshot}
>
<TodoList />
</TodoActorKitProvider>
);
}
8️⃣ Create a client-side component
// src/app/lists/[id]/components.tsx
"use client";
import React, { useState } from "react";
import { TodoActorKitContext } from "./todo.context";
export function TodoList() {
const todos = TodoActorKitContext.useSelector((state) => state.public.todos);
const send = TodoActorKitContext.useSend();
const [newTodoText, setNewTodoText] = useState("");
const handleAddTodo = (e: React.FormEvent) => {
e.preventDefault();
if (newTodoText.trim()) {
send({ type: "ADD_TODO", text: newTodoText.trim() });
setNewTodoText("");
}
};
return (
<div>
<h1>Todo List</h1>
<form onSubmit={handleAddTodo}>
<input
type="text"
value={newTodoText}
onChange={(e) => setNewTodoText(e.target.value)}
placeholder="Add a new todo"
/>
<button type="submit">Add</button>
</form>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<span
style={{
textDecoration: todo.completed ? "line-through" : "none",
}}
>
{todo.text}
</span>
<button onClick={() => send({ type: "TOGGLE_TODO", id: todo.id })}>
{todo.completed ? "Undo" : "Complete"}
</button>
<button onClick={() => send({ type: "DELETE_TODO", id: todo.id })}>
Delete
</button>
</li>
))}
</ul>
</div>
);
}
This example demonstrates how to set up and use Actor Kit in a Next.js application with Cloudflare Workers, including:
- Defining event schemas and types
- Creating the state machine with proper typing
- Setting up the Actor Server
- Configuring Wrangler for Cloudflare Workers
- Creating a Cloudflare Worker with Actor Kit Router
- Setting up the Actor Kit context
- Fetching data server-side with access token creation
- Creating a client-side component that interacts with the actor
🚀 Getting Started
Install dependencies:
npm install actor-kit xstate zod npm install -D wrangler
Set up environment variables:
For development: Create a
.dev.vars
file in your project root:touch .dev.vars
Add the following to
.dev.vars
:ACTOR_KIT_SECRET=your-secret-key
Replace
your-secret-key
with a secure, randomly generated secret.For production: Set up the secret using Wrangler:
npx wrangler secret put ACTOR_KIT_SECRET
Enter the same secret key you used in your
.dev.vars
file.Create a
wrangler.toml
file in your project root:name = "your-project-name" main = "src/server.ts" compatibility_date = "2024-09-25" [[durable_objects.bindings]] name = "YOUR_ACTOR" class_name = "YourActor" [[migrations]] tag = "v1" new_classes = ["YourActor"]
Replace
your-project-name
with your project's name, andYOUR_ACTOR
andYourActor
with your specific actor names.Create your Worker script (e.g.,
src/server.ts
):import { createActorKitRouter } from "actor-kit/worker"; import { YourActor } from "./your-actor.server"; const actorKitRouter = createActorKitRouter({ yourActor: YourActor, }); export default { async fetch( request: Request, env: Env, ctx: ExecutionContext ): Promise<Response> { const url = new URL(request.url); if (url.pathname.startsWith("/api/")) { return actorKitRouter(request, env, ctx); } return new Response("Server powered by ActorKit"); }, };
Start the Cloudflare Worker development server:
npx wrangler dev
Deploy your Worker to Cloudflare:
npx wrangler deploy
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.
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 and Remix.
🗂️ Framework Examples
Actor Kit includes example todo list applications demonstrating integration with popular web frameworks.
Next.js example in
/examples/nextjs-actorkit-todo
- Live demo: https://nextjs-actorkit-todo.vercel.app/
Remix example in
/examples/remix-actorkit-todo
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!
📖 API Reference
🔧 actor-kit/worker
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.
createMachineServer<TClientEvent, TServiceEvent, TInputSchema, TMachine, Env>(props)
Creates a server instance of a state machine that runs in a Cloudflare Worker Durable Object.
Parameters:
machine
: The XState machine to run on the serverschemas
: Zod schemas for validating events and inputclientEvent
: Schema for events from clientsserviceEvent
: Schema for events from trusted servicesinputProps
: Schema for initialization props
options
: Configuration optionspersisted
: Whether to persist state to storage (default: false)
Example usage:
// src/todo.server.ts
import { createMachineServer } from "actor-kit/worker";
import { todoMachine } from "./todo.machine";
import {
TodoClientEventSchema,
TodoInputPropsSchema,
TodoServiceEventSchema,
} from "./todo.schemas";
export const Todo = createMachineServer({
machine: todoMachine,
schemas: {
clientEvent: TodoClientEventSchema,
serviceEvent: TodoServiceEventSchema,
inputProps: TodoInputPropsSchema,
},
options: {
persisted: true,
},
});
export type TodoServer = InstanceType<typeof Todo>;
export default Todo;
Then set up your Cloudflare Worker to use the server:
// src/server.ts
import { DurableObjectNamespace } from "@cloudflare/workers-types";
import { AnyActorServer } from "actor-kit";
import { createActorKitRouter } from "actor-kit/worker";
import { WorkerEntrypoint } from "cloudflare:workers";
import { Todo, TodoServer } from "./todo.server";
// Define environment interface with your Durable Object bindings
interface Env {
TODO: DurableObjectNamespace<TodoServer>;
ACTOR_KIT_SECRET: string;
[key: string]: DurableObjectNamespace<AnyActorServer> | unknown;
}
// Create router with your actor types
const router = createActorKitRouter<Env>(["todo"]);
// Export your Durable Object class
export { Todo };
// Create your Worker
export default class Worker extends WorkerEntrypoint<Env> {
fetch(request: Request): Promise<Response> | Response {
if (request.url.includes("/api/")) {
return router(request, this.env, this.ctx);
}
return new Response("API powered by ActorKit");
}
}
Configure your wrangler.toml
:
name = "your-project"
main = "src/server.ts"
compatibility_date = "2024-09-25"
[vars]
ACTOR_KIT_SECRET = "your-secret-key"
[[durable_objects.bindings]]
name = "TODO"
class_name = "Todo"
[[migrations]]
tag = "v1"
new_classes = ["Todo"]
The key components are:
- Create your machine server with
createMachineServer
- Set up your Worker environment interface with Durable Object bindings
- Create a router with your actor types
- Export your Durable Object class
- Create your Worker class that uses the router
- Configure wrangler.toml with your Durable Object bindings
createActorKitRouter<Env>(routes)
Creates a router for handling Actor Kit requests in a Cloudflare Worker.
Parameters:
routes
: Array of actor type strings (e.g.,["todo", "game"]
)Env
: Type parameter for your Worker's environment bindings
Returns a function that handles HTTP requests and routes them to the appropriate actor.
Example usage:
const router = createActorKitRouter<Env>(["todo"]);
export default {
fetch(request: Request, env: Env, ctx: ExecutionContext) {
if (request.url.includes("/api/")) {
return router(request, env, ctx);
}
return new Response("API powered by ActorKit");
}
};
The router handles:
- Actor creation and initialization
- Event routing to the correct actor
- Access token validation
- WebSocket connections for real-time updates
🖥️ actor-kit/server
createActorFetch<TMachine>({ actorType, host })
Creates a function for fetching actor data. Used in a trusted server environment, typically for server-side rendering or initial data fetching.
TMachine
: Type parameter extendingActorKitStateMachine
actorType
: String identifier for the actor typehost
: The host URL for the Actor Kit server
Returns a function with the following signature:
(
props: {
actorId: string;
accessToken: string;
input?: Record<string, unknown>;
waitForEvent?: ClientEventFrom<TMachine>;
waitForState?: StateValueFrom<TMachine>;
timeout?: number;
errorOnWaitTimeout?: boolean;
},
options?: RequestInit
) =>
Promise<{
snapshot: CallerSnapshotFrom<TMachine>;
checksum: string;
}>;
The waitForEvent
, waitForState
, and errorOnWaitTimeout
parameters allow you to specify conditions for when and how the snapshot should be returned:
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.waitForState
: Waits for the actor to reach a specific state before returning the snapshot. State matching is performed as described in the XState documentation.errorOnWaitTimeout
: Determines the behavior when a timeout occurs while waiting for an event or state. Default istrue
.- If
true
(default), throws a 408 (Request Timeout) error on timeout. - If
false
, returns a 200 status with the current snapshot, even if the wait condition wasn't met.
- If
State matching works as follows:
- For simple states, an exact string match is performed.
- For compound states, a partial object match is performed. The provided state value must be a subset of the current state value.
- For parallel states, all specified regions must match.
Example usage:
import { createActorFetch } from "actor-kit/server";
import type { TodoMachine } from "./todo.machine";
const fetchTodoActor = createActorFetch<TodoMachine>({
actorType: "todo",
host: "your-worker.workers.dev",
});
// Wait for a specific event, throw error on timeout
const { snapshot, checksum } = await fetchTodoActor({
actorId: "todo-123",
accessToken: "your-access-token",
waitForEvent: { type: "TODOS_LOADED" },
timeout: 5000, // 5 seconds timeout
errorOnWaitTimeout: true, // default behavior
});
// Wait for a specific state, return current snapshot on timeout
const { snapshot, checksum } = await fetchTodoActor({
actorId: "todo-123",
accessToken: "your-access-token",
waitForState: { loaded: "success" },
timeout: 5000, // 5 seconds timeout
errorOnWaitTimeout: false,
});
Here's how to handle different timeout scenarios:
try {
// Scenario 1: Error on timeout (default behavior)
const { snapshot, checksum } = await fetchTodoActor({
actorId: "todo-123",
accessToken: "your-access-token",
waitForEvent: { type: "TODOS_LOADED" },
timeout: 5000,
// errorOnWaitTimeout: true, // This is the default
});
// Use the snapshot data
} catch (error) {
if (error.status === 408) {
console.error("Timeout waiting for actor response");
// Handle timeout error (e.g., show an error message to the user)
} else {
console.error("Error fetching actor data:", error);
// Handle other errors
}
}
// Scenario 2: Return current snapshot on timeout
const { snapshot, checksum } = await fetchTodoActor({
actorId: "todo-123",
accessToken: "your-access-token",
waitForState: { loaded: "success" },
timeout: 5000,
errorOnWaitTimeout: false,
});
// Always returns a snapshot, even if the wait condition wasn't met
// You may want to check if the desired state or event was reached
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.
createAccessToken({ signingKey, actorId, actorType, callerId, callerType })
Creates an access token for authenticating with an actor.
Parameters:
signingKey
: String used to sign the tokenactorId
: Unique identifier for the actoractorType
: Type of the actorcallerId
: Identifier for the callercallerType
: Type of the caller (e.g., 'client', 'service')
Returns a Promise that resolves to a JWT token string.
Example usage:
import { createAccessToken } from "actor-kit/server";
const accessToken = await createAccessToken({
signingKey: process.env.ACTOR_KIT_SECRET!,
actorId: "todo-123",
actorType: "todo",
callerId: "user-456",
callerType: "client",
});
🌐 actor-kit/browser
createActorKitClient<TMachine>(props: ActorKitClientProps<TMachine>)
Creates an Actor Kit client for managing state and communication with the server.
TMachine
: Type parameter extendingActorKitStateMachine
ActorKitClientProps<TMachine>
includes:
host
: StringactorType
: StringactorId
: Stringchecksum
: StringaccessToken
: StringinitialSnapshot
:CallerSnapshotFrom<TMachine>
Returns an ActorKitClient<TMachine>
object with methods to interact with the actor.
Example usage:
import { createActorKitClient } from "actor-kit/browser";
import type { TodoMachine } from "./todo.machine";
const client = createActorKitClient<TodoMachine>({
host: "your-worker.workers.dev",
actorType: "todo",
actorId: "todo-123",
checksum: "initial-checksum",
accessToken: "your-access-token",
initialSnapshot: {
public: { todos: [] },
private: {},
value: "idle",
},
});
await client.connect();
client.send({ type: "ADD_TODO", text: "Buy milk" });
ActorKitClient
Methods
connect()
: Establishes connection to the actor serverdisconnect()
: Closes the connection to the actor serversend(event)
: Sends an event to the actorgetState()
: Returns the current state snapshotsubscribe(listener)
: Registers a listener for state changeswaitFor(predicateFn, timeoutMs?)
: Waits for a state condition to be met
Using waitFor
The waitFor
method allows you to wait for specific state conditions:
import { createActorKitClient } from 'actor-kit/browser';
const client = createActorKitClient<TodoMachine>({
// ... client config
});
// Wait for a specific state value
await client.waitFor(state => state.value === 'ready');
// Wait for a condition with custom timeout
await client.waitFor(
state => state.public.todos.length > 0,
10000 // 10 seconds
);
// Wait for complex conditions
await client.waitFor(state =>
state.public.todos.some(todo => todo.text === 'Buy milk' && todo.completed)
);
⚛️ actor-kit/react
createActorKitContext<TMachine>(actorType: string)
Creates a React context and associated hooks and components for integrating Actor Kit into a React application.
TMachine
: Type parameter extendingActorKitStateMachine
actorType
: String identifier for the actor type
Returns an object with:
Provider
: React component to provide the Actor Kit client to its childrenuseClient()
: Hook to access the Actor Kit client directlyuseSelector<T>(selector: (snapshot: CallerSnapshotFrom<TMachine>) => T)
: Hook to select and subscribe to specific parts of the stateuseSend()
: Hook to get a function for sending events to the Actor Kit clientuseMatches(stateValue: StateValueFrom<TMachine>)
: Hook to check if the current state matches a given state valueMatches
: Component for conditionally rendering based on state matches
Example usage:
import { createActorKitContext } from "actor-kit/react";
import type { TodoMachine } from "./todo.machine";
const TodoActorKitContext = createActorKitContext<TodoMachine>("todo");
function App() {
return (
<TodoActorKitContext.Provider
host="your-worker.workers.dev"
actorId="todo-123"
accessToken="your-access-token"
checksum="initial-checksum"
initialSnapshot={{
public: { todos: [] },
private: {},
value: "idle",
}}
>
<TodoList />
</TodoActorKitContext.Provider>
);
}
function TodoList() {
const todos = TodoActorKitContext.useSelector((state) => state.public.todos);
const send = TodoActorKitContext.useSend();
const isIdle = TodoActorKitContext.useMatches("idle");
return (
<div>
{isIdle && <p>The todo list is idle</p>}
<ul>
{todos.map((todo) => (
<li key={todo.id}>
{todo.text}
<button onClick={() => send({ type: "TOGGLE_TODO", id: todo.id })}>
Toggle
</button>
</li>
))}
</ul>
</div>
);
}
useClient()
Hook to access the Actor Kit client directly.
Example usage:
function TodoActions() {
const client = TodoActorKitContext.useClient();
const handleClearCompleted = () => {
client.send({ type: "CLEAR_COMPLETED" });
};
return <button onClick={handleClearCompleted}>Clear Completed</button>;
}
useSelector<T>(selector: (snapshot: CallerSnapshotFrom<TMachine>) => T)
Hook to select and subscribe to specific parts of the state.
Example usage:
function CompletedTodosCount() {
const completedCount = TodoActorKitContext.useSelector(
(state) => state.public.todos.filter((todo) => todo.completed).length
);
return <span>Completed todos: {completedCount}</span>;
}
useSend()
Hook to get a function for sending events to the Actor Kit client.
Example usage:
function AddTodoForm() {
const [text, setText] = useState("");
const send = TodoActorKitContext.useSend();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (text.trim()) {
send({ type: "ADD_TODO", text: text.trim() });
setText("");
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Add a new todo"
/>
<button type="submit">Add</button>
</form>
);
}
useMatches(stateValue: StateValueFrom<TMachine>)
Hook to check if the current state matches a given state value.
Example usage:
function LoadingIndicator() {
const isLoading = TodoActorKitContext.useMatches("loading");
return isLoading ? <div>Loading...</div> : null;
}
Matches
Component
The Matches
component allows for conditional rendering based on the current state of the actor machine.
Props:
state: StateValueFrom<TMachine>
: The state value to match againstand?: StateValueFrom<TMachine>
: Optional additional state to match (AND condition)or?: StateValueFrom<TMachine>
: Optional alternative state to match (OR condition)not?: boolean
: Invert the match result if truechildren: ReactNode
: Content to render when the condition is metinitialValueOverride?: boolean
: Optional override for the initial render value
Example usage:
function TodoList() {
const todos = TodoActorKitContext.useSelector((state) => state.public.todos);
const send = TodoActorKitContext.useSend();
return (
<div>
<TodoActorKitContext.Matches state="idle">
<p>The todo list is idle</p>
</TodoActorKitContext.Matches>
<TodoActorKitContext.Matches state="loading">
<p>Loading todos...</p>
</TodoActorKitContext.Matches>
<TodoActorKitContext.Matches state="error" not>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
{todo.text}
<button
onClick={() => send({ type: "TOGGLE_TODO", id: todo.id })}
>
Toggle
</button>
</li>
))}
</ul>
</TodoActorKitContext.Matches>
</div>
);
}
You can also use the Matches
component with more complex conditions:
function TodoList() {
const todos = TodoActorKitContext.useSelector((state) => state.public.todos);
const send = TodoActorKitContext.useSend();
return (
<div>
<TodoActorKitContext.Matches state="idle" or="ready">
<p>The todo list is ready for action</p>
</TodoActorKitContext.Matches>
<TodoActorKitContext.Matches state="loading" and={{ data: "fetching" }}>
<p>Fetching todos from the server...</p>
</TodoActorKitContext.Matches>
<TodoActorKitContext.Matches state="error" not>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
{todo.text}
<button
onClick={() => send({ type: "TOGGLE_TODO", id: todo.id })}
>
Toggle
</button>
</li>
))}
</ul>
</TodoActorKitContext.Matches>
</div>
);
}
🧪 actor-kit/test
createActorKitMockClient<TMachine>(props: ActorKitMockClientProps<TMachine>)
Creates a mock client for testing Actor Kit state machines without needing a live server.
Parameters:
initialSnapshot
: Initial state snapshot for the mock clientonSend?
: Optional callback function invoked whenever an event is sent
Returns a mock client that implements the standard ActorKitClient
interface plus additional testing utilities:
- All standard client methods (send, subscribe, etc.)
produce(recipe: (draft: Draft<CallerSnapshotFrom<TMachine>>) => void)
: Method for directly manipulating state using Immer
Example usage:
import { createActorKitMockClient } from 'actor-kit/test';
import type { TodoMachine } from './todo.machine';
describe('Todo State Management', () => {
it('should handle state transitions', () => {
const mockClient = createActorKitMockClient<TodoMachine>({
initialSnapshot: {
public: {
todos: [],
status: 'idle'
},
private: {},
value: 'idle'
}
});
// Use Immer's produce to update state
mockClient.produce((draft) => {
draft.public.todos.push({
id: '1',
text: 'Test todo',
completed: false
});
});
expect(mockClient.getState().public.todos).toHaveLength(1);
});
it('should track sent events', () => {
const sendSpy = vi.fn();
const mockClient = createActorKitMockClient<TodoMachine>({
initialSnapshot: { /* ... */ },
onSend: sendSpy
});
mockClient.send({ type: 'ADD_TODO', text: 'Test todo' });
expect(sendSpy).toHaveBeenCalledWith({
type: 'ADD_TODO',
text: 'Test todo'
});
});
});
📚 actor-kit/storybook
withActorKit<TMachine>({ actorType, context })
Creates a Storybook decorator for testing components that depend on Actor Kit state.
Parameters:
actorType
: String identifier for the actor typecontext
: The Actor Kit context created bycreateActorKitContext
Returns a Storybook decorator function.
Example usage:
import { withActorKit } from 'actor-kit/storybook';
import { GameContext } from './game.context';
import type { GameMachine } from './game.machine';
const meta = {
title: 'Components/GameView',
component: GameView,
decorators: [
withActorKit<GameMachine>({
actorType: "game",
context: GameContext,
}),
],
};
StoryWithActorKit<TMachine>
A utility type for stories that use Actor Kit state machines. It combines the standard Storybook story type with Actor Kit parameters.
Example usage:
import type { StoryWithActorKit } from 'actor-kit/storybook';
import type { GameMachine } from './game.machine';
export const GameStory: StoryWithActorKit<GameMachine> = {
parameters: {
actorKit: {
game: {
"game-123": {
public: { /* initial state */ },
private: {},
value: "idle"
}
}
}
}
}
System Events
Actor Kit includes several system events that are automatically handled by the state machine. These events are of type ActorKitSystemEvent
and include:
INITIALIZE
: Fired when an actor is first created.CONNECT
: Fired when a client connects to the actor.DISCONNECT
: Fired when a client disconnects from the actor.RESUME
: Fired when an actor is resumed.MIGRATE
: Fired when an actor needs to migrate its state, including an operations array.
The ActorKitSystemEvent
type is defined as follows:
export type ActorKitSystemEvent =
| { type: "INITIALIZE"; caller: { type: "system" id: string } }
| { type: "CONNECT"; caller: { type: "system"; id: string }; clientId: string }
| { type: "DISCONNECT"; caller: { type: "system"; id: string }; clientId: string }
| { type: "RESUME"; caller: { type: "system"; id: string } }
| { type: "MIGRATE"; caller: { type: "system"; id: string }; operations: any[] };
These events can be handled in your state machine definition:
createMachine({
// ... other configuration ...
states: {
idle: {
on: {
INITIALIZE: {
actions: "initializeActor",
},
CONNECT: {
actions: "handleClientConnection",
},
DISCONNECT: {
actions: "handleClientDisconnection",
},
RESUME: {
actions: "handleActorResume",
},
MIGRATE: {
actions: "handleActorMigration",
},
// ... other transitions ...
},
},
// ... other states ...
},
});
🧪 Testing with Mock Client
createActorKitMockClient<TMachine>
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.
Type Parameters:
TMachine
: The type of the state machine, extendingAnyActorKitStateMachine
Parameters:
props: ActorKitMockClientProps<TMachine>
: Configuration options including:initialSnapshot
: The initial state snapshotonSend?
: Optional callback function invoked whenever an event is sent
Returns:
An ActorKitMockClient<TMachine>
with all standard client methods plus:
produce(recipe: (draft: Draft<CallerSnapshotFrom<TMachine>>) => void)
: Method for directly manipulating state using Immer
Basic Example:
import { createActorKitMockClient } from 'actor-kit/test';
import type { TodoMachine } from './todo.machine';
describe('Todo State Management', () => {
it('should handle state transitions', () => {
const mockClient = createActorKitMockClient<TodoMachine>({
initialSnapshot: {
public: {
todos: [],
status: 'idle'
},
private: {},
value: 'idle'
}
});
// Use Immer's produce to update state
mockClient.produce((draft) => {
draft.public.todos.push({
id: '1',
text: 'Test todo',
completed: false
});
draft.value = 'ready';
});
// Verify state changes
expect(mockClient.getState().public.todos).toHaveLength(1);
expect(mockClient.getState().value).toBe('ready');
});
});
Spying on Events:
import { vi } from 'vitest'; // or jest
describe('Todo Event Handling', () => {
it('should track sent events', () => {
const sendSpy = vi.fn();
const mockClient = createActorKitMockClient<TodoMachine>({
initialSnapshot: {
public: {
todos: [],
status: 'idle'
},
private: {},
value: 'idle'
},
onSend: sendSpy
});
// Send an event
mockClient.send({ type: 'ADD_TODO', text: 'Test todo' });
// Verify the event was sent
expect(sendSpy).toHaveBeenCalledWith({
type: 'ADD_TODO',
text: 'Test todo'
});
// Check call count
expect(sendSpy).toHaveBeenCalledTimes(1);
// Verify specific event properties
const [sentEvent] = sendSpy.mock.calls[0];
expect(sentEvent.type).toBe('ADD_TODO');
expect(sentEvent.text).toBe('Test todo');
});
it('should track multiple events in order', () => {
const sendSpy = vi.fn();
const mockClient = createActorKitMockClient<TodoMachine>({
initialSnapshot: {
public: {
todos: [{ id: '1', text: 'Test todo', completed: false }],
status: 'idle'
},
private: {},
value: 'idle'
},
onSend: sendSpy
});
// Send multiple events
mockClient.send({ type: 'TOGGLE_TODO', id: '1' });
mockClient.send({ type: 'DELETE_TODO', id: '1' });
// Verify events were sent in order
expect(sendSpy.mock.calls).toEqual([
[{ type: 'TOGGLE_TODO', id: '1' }],
[{ type: 'DELETE_TODO', id: '1' }]
]);
});
});
Testing React Components:
import { render, screen } from '@testing-library/react';
import { TodoActorKitContext } from './todo.context';
import { createActorKitMockClient } from 'actor-kit/test';
describe('TodoList', () => {
it('renders todos correctly', () => {
const mockClient = createActorKitMockClient<TodoMachine>({
initialSnapshot: {
public: {
todos: [],
status: 'idle'
},
private: {},
value: 'idle'
}
});
render(
<TodoActorKitContext.ProviderFromClient client={mockClient}>
<TodoList />
</TodoActorKitContext.ProviderFromClient>
);
// Update state using Immer
mockClient.produce((draft) => {
draft.public.todos.push({
id: '1',
text: 'Test todo',
completed: false
});
});
// Verify UI updates
expect(screen.getByText('Test todo')).toBeInTheDocument();
});
});
The mock client provides two powerful testing capabilities:
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.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.
These features make it simple to test both state transitions and component behavior without needing a real server connection.
👥 Caller Types
Actor Kit supports different types of callers, each with its own level of trust and permissions:
- 👤
client
: Events from end-users or client applications - 🤖
system
: Internal events generated by the actor system (handled internally) - 🔧
service
: Events from trusted external services or internal microservices
🔑 TypeScript Types
The following key types are exported from the main actor-kit
package:
WithActorKitEvent<TEvent, TCallerType>
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.
Example usage:
import { WithActorKitEvent } from "actor-kit";
type MyClientEvent =
| { type: "ADD_TODO"; text: string }
| { type: "TOGGLE_TODO"; id: string };
type MyServiceEvent = {
type: "SYNC_TODOS";
todos: Array<{ id: string; text: string; completed: boolean }>;
};
type MyEvent =
| WithActorKitEvent<MyClientEvent, "client">
| WithActorKitEvent<MyServiceEvent, "service">
| ActorKitSystemEvent;
ActorKitSystemEvent
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.
Key system events:
INITIALIZE
: Fired when an actor is first created or resumed from storage.CONNECT
: Fired when a client connects to the actor.DISCONNECT
: Fired when a client disconnects from the actor.
Example usage in a state machine:
createMachine({
// ... other configuration ...
states: {
idle: {
on: {
INITIALIZE: {
actions: "initializeActor",
},
CONNECT: {
actions: "handleClientConnection",
},
DISCONNECT: {
actions: "handleClientDisconnection",
},
// ... other transitions ...
},
},
// ... other states ...
},
});
CallerSnapshotFrom<TMachine>
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.
Example usage:
import { CallerSnapshotFrom } from "actor-kit";
import { TodoMachine } from "./todo.machine";
type TodoSnapshot = CallerSnapshotFrom<TodoMachine>;
function TodoList({ snapshot }: { snapshot: TodoSnapshot }) {
return (
<ul>
{snapshot.public.todos.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
);
}
ActorKitStateMachine
Represents the structure of an Actor Kit state machine.
type ActorKitStateMachine<
TEvent extends BaseActorKitEvent & EventObject,
TInput extends { id: string; caller: Caller },
TPrivateProps extends { [key: string]: unknown },
TPublicProps extends { [key: string]: unknown }
> = StateMachine<...>
Example usage:
import { ActorKitStateMachine } from "actor-kit";
import { setup } from "xstate";
import type {
TodoEvent,
TodoInput,
TodoPrivateContext,
TodoPublicContext,
} from "./todo.types";
export const todoMachine = setup({
// ... machine setup
}).createMachine({
// ... machine definition
}) satisfies ActorKitStateMachine<
TodoEvent,
TodoInput,
TodoPrivateContext,
TodoPublicContext
>;
Other Types
ClientEventFrom<TMachine extends AnyActorKitStateMachine>
: Utility type to extract client events from an Actor Kit state machine.ServiceEventFrom<TMachine extends AnyActorKitStateMachine>
: Utility type to extract service events from an Actor Kit state machine.
By including these types in your Actor Kit implementation, you ensure type safety and proper handling of events and state across your application.
🔐 Public and Private Data
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.
📚 Storybook Integration
Actor Kit provides seamless integration with Storybook through the withActorKit
decorator, allowing you to easily test and develop components that depend on actor state.
Basic Usage
import { withActorKit } from 'actor-kit/storybook';
import { GameContext } from './game.context';
import type { GameMachine } from './game.machine';
const meta = {
title: 'Components/GameView',
component: GameView,
decorators: [
withActorKit<GameMachine>({
actorType: "game",
context: GameContext,
}),
],
};
export default meta;
type Story = StoryObj<typeof GameView>;
export const Default: Story = {
parameters: {
actorKit: {
game: {
"game-123": {
public: {
players: [],
gameStatus: "idle"
},
private: {},
value: "idle"
}
}
}
}
};
Testing Patterns
There are two main patterns for testing with Actor Kit in Storybook:
1. Static Stories (Using parameters.actorKit)
Best for simple stories that don't need state manipulation:
export const Static: Story = {
parameters: {
actorKit: {
game: {
"game-123": {
public: { /* initial state */ },
private: {},
value: "idle"
}
}
}
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Test UI state...
}
};
2. Interactive Stories (Using mount + direct client)
Better for stories that need to manipulate state:
export const Interactive: Story = {
play: async ({ canvasElement, mount }) => {
const client = createActorKitMockClient<GameMachine>({
initialSnapshot: {
public: { /* initial state */ },
private: {},
value: "idle"
}
});
await mount(
<GameContext.ProviderFromClient client={client}>
<GameView />
</GameContext.ProviderFromClient>
);
// Now you can manipulate state
client.produce((draft) => {
draft.public.players.push({
id: "player-1",
name: "Player 1"
});
});
}
};
Multiple Actors
You can use multiple actors in a single story:
const meta = {
decorators: [
withActorKit<SessionMachine>({
actorType: "session",
context: SessionContext,
}),
withActorKit<GameMachine>({
actorType: "game",
context: GameContext,
}),
],
};
export const MultipleActors: Story = {
parameters: {
actorKit: {
session: {
"session-123": {
public: { /* session state */ },
private: {},
value: "ready"
}
},
game: {
"game-123": {
public: { /* game state */ },
private: {},
value: "active"
}
}
}
}
};
Basic Event Spy Example
import type { Meta, StoryObj } from "@storybook/react";
import { expect, fn } from "@storybook/test";
import { createActorKitMockClient } from "actor-kit/test";
import { GameContext } from "./game.context";
import type { GameMachine } from "./game.machine";
export const JoinGame: Story = {
play: async ({ canvasElement, mount }) => {
const sendSpy = fn();
const client = createActorKitMockClient<GameMachine>({
initialSnapshot: {
public: {
players: [],
gameStatus: "lobby"
},
private: {},
value: "lobby"
},
onSend: sendSpy
});
await mount(
<GameContext.ProviderFromClient client={client}>
<JoinGameForm />
</GameContext.ProviderFromClient>
);
// Find and fill the name input
const nameInput = await canvas.findByLabelText("Player Name");
await userEvent.type(nameInput, "Test Player");
// Click the join button
const joinButton = await canvas.findByText("Join Game");
await userEvent.click(joinButton);
// Verify the JOIN_GAME event was sent with correct payload
expect(sendSpy).toHaveBeenCalledWith({
type: "JOIN_GAME",
playerName: "Test Player"
});
}
};
Testing Multiple Events in Sequence
export const GameRound: Story = {
play: async ({ canvasElement, mount }) => {
const sendSpy = fn();
const client = createActorKitMockClient<GameMachine>({
initialSnapshot: {
public: {
players: [
{ id: "player-1", name: "Player 1", score: 0 },
{ id: "player-2", name: "Player 2", score: 0 }
],
currentQuestion: {
text: "What is 2 + 2?",
answer: "4"
},
gameStatus: "active"
},
private: {},
value: { active: "questionActive" }
},
onSend: sendSpy
});
await mount(
<GameContext.ProviderFromClient client={client}>
<GameView />
</GameContext.ProviderFromClient>
);
// Test buzzing in
const buzzerButton = await canvas.findByText("Buzz In");
await userEvent.click(buzzerButton);
expect(sendSpy).toHaveBeenCalledWith({
type: "BUZZ_IN",
playerId: "player-1"
});
// Test submitting an answer
const answerInput = await canvas.findByLabelText("Your Answer");
await userEvent.type(answerInput, "4");
const submitButton = await canvas.findByText("Submit Answer");
await userEvent.click(submitButton);
// Verify events were sent in order with correct payloads
expect(sendSpy.mock.calls).toEqual([
[{ type: "BUZZ_IN", playerId: "player-1" }],
[{ type: "SUBMIT_ANSWER", answer: "4" }]
]);
}
};
Testing Complex Event Payloads
export const GameConfiguration: Story = {
play: async ({ canvasElement, mount }) => {
const sendSpy = fn();
const client = createActorKitMockClient<GameMachine>({
initialSnapshot: {
public: {
gameStatus: "setup",
config: {
maxPlayers: 4,
timeLimit: 30,
categories: []
}
},
private: {},
value: "setup"
},
onSend: sendSpy
});
await mount(
<GameContext.ProviderFromClient client={client}>
<GameConfigForm />
</GameContext.ProviderFromClient>
);
// Fill out configuration form
await userEvent.selectOptions(
await canvas.findByLabelText("Max Players"),
"6"
);
await userEvent.selectOptions(
await canvas.findByLabelText("Time Limit"),
"60"
);
const categoryCheckboxes = await canvas.findAllByRole("checkbox");
await userEvent.click(categoryCheckboxes[0]); // Select "History"
await userEvent.click(categoryCheckboxes[2]); // Select "Science"
const saveButton = await canvas.findByText("Save Configuration");
await userEvent.click(saveButton);
// Verify the UPDATE_CONFIG event was sent with the exact payload structure
expect(sendSpy).toHaveBeenCalledWith({
type: "UPDATE_CONFIG",
config: {
maxPlayers: 6,
timeLimit: 60,
categories: ["history", "science"]
}
});
// You can also use partial matching for complex objects
expect(sendSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: "UPDATE_CONFIG",
config: expect.objectContaining({
maxPlayers: 6,
categories: expect.arrayContaining(["history", "science"])
})
})
);
}
};
Testing Event Properties with Custom Matchers
export const ChatMessage: Story = {
play: async ({ canvasElement, mount }) => {
const sendSpy = fn();
const client = createActorKitMockClient<GameMachine>({
initialSnapshot: {
public: {
messages: [],
gameStatus: "active"
},
private: {},
value: "active"
},
onSend: sendSpy
});
await mount(
<GameContext.ProviderFromClient client={client}>
<ChatBox />
</GameContext.ProviderFromClient>
);
// Send a chat message
const messageInput = await canvas.findByLabelText("Message");
await userEvent.type(messageInput, "Hello, world!");
const sendButton = await canvas.findByText("Send");
await userEvent.click(sendButton);
// Verify the SEND_MESSAGE event with timestamp
expect(sendSpy).toHaveBeenCalledWith({
type: "SEND_MESSAGE",
text: "Hello, world!",
timestamp: expect.any(Number),
sender: expect.objectContaining({
id: expect.any(String),
name: expect.any(String)
})
});
// You can also create custom matchers for common patterns
const isValidMessage = (event: any) => {
return (
event.type === "SEND_MESSAGE" &&
typeof event.text === "string" &&
typeof event.timestamp === "number" &&
Date.now() - event.timestamp < 1000 // Message was sent within last second
);
};
expect(sendSpy).toHaveBeenCalledWith(expect.custom(isValidMessage));
}
};
📜 License
Actor Kit is MIT licensed.
🔗 Related Technologies and Inspiration
Actor Kit builds upon and draws inspiration from several excellent technologies:
- XState: A powerful state management library for JavaScript and TypeScript applications.
- Cloudflare Workers: A serverless platform for building and deploying applications at the edge.
- Zod: A TypeScript-first schema declaration and validation library.
- PartyKit: An inspiration for Actor Kit, providing real-time multiplayer infrastructure.
- PartyServer: PartyKit, for workers
- xstate-migrate: A migration library for persisted XState machines, designed to facilitate state machine migrations when updating your XState configurations.
🚧 Development Status
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.