actor-kit
Version:
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
690 lines (600 loc) • 20.3 kB
text/typescript
import { DurableObject } from "cloudflare:workers";
import { compare } from "fast-json-patch";
import {
Actor,
AnyEventObject,
createActor,
EventFromLogic,
InputFrom,
matchesState,
SnapshotFrom,
StateValueFrom,
Subscription,
} from "xstate";
import { xstateMigrate } from "xstate-migrate";
import { z } from "zod";
import { PERSISTED_SNAPSHOT_KEY } from "./constants";
import { CallerSchema } from "./schemas";
import type {
ActorKitInputProps,
ActorServer,
AnyActorKitStateMachine,
Caller,
CallerSnapshotFrom,
ClientEventFrom,
EnvFromMachine,
MachineServerOptions,
ServiceEventFrom,
} from "./types";
import { assert, getCallerFromRequest } from "./utils";
const StorageSchema = z.object({
actorType: z.string(),
actorId: z.string(),
initialCaller: CallerSchema,
input: z.record(z.unknown()),
});
const WebSocketAttachmentSchema = z.object({
caller: CallerSchema,
lastSentChecksum: z.string().optional(),
});
type WebSocketAttachment = z.infer<typeof WebSocketAttachmentSchema>;
type ActorKitWebSocket = WebSocket & {
serializeAttachment(value: WebSocketAttachment): void;
deserializeAttachment(): unknown;
};
type WebSocketResponseInit = ResponseInit & {
webSocket: WebSocket;
};
type ActorKitSnapshotView<TMachine extends AnyActorKitStateMachine> = {
value: CallerSnapshotFrom<TMachine>["value"];
context: {
public: CallerSnapshotFrom<TMachine>["public"];
private: Record<string, CallerSnapshotFrom<TMachine>["private"]>;
};
};
const InputSearchSchema = z.object({
input: z.string().optional(),
});
const ParsedMessageSchema = z.string().transform((value, context) => {
try {
return JSON.parse(value) as unknown;
} catch {
context.addIssue({
code: z.ZodIssueCode.custom,
message: "Expected valid JSON payload",
});
return z.NEVER;
}
});
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : "Unknown error";
}
function parseStoredJson<T>(value: unknown, fallbackSchema: z.ZodType<T>): T {
const parsedString = z.string().parse(value);
return fallbackSchema.parse(JSON.parse(parsedString));
}
export const createMachineServer = <
TClientEvent extends AnyEventObject,
TServiceEvent extends AnyEventObject,
TInputSchema extends z.ZodObject<z.ZodRawShape>,
TMachine extends AnyActorKitStateMachine
>({
machine,
schemas,
options,
}: {
machine: TMachine;
schemas: {
clientEvent: z.ZodSchema<TClientEvent>;
serviceEvent: z.ZodSchema<TServiceEvent>;
inputProps: TInputSchema;
};
options?: MachineServerOptions;
}): new (
state: DurableObjectState,
env: EnvFromMachine<TMachine>
) => ActorServer<TMachine> =>
class MachineServerImpl extends DurableObject implements ActorServer<TMachine> {
actor: Actor<TMachine> | undefined;
actorType: string | undefined;
actorId: string | undefined;
input: Record<string, unknown> | undefined;
initialCaller: Caller | undefined;
lastPersistedSnapshot: SnapshotFrom<TMachine> | null = null;
snapshotCache = new Map<
string,
{ snapshot: SnapshotFrom<TMachine>; timestamp: number }
>();
state: DurableObjectState;
storage: DurableObjectStorage;
attachments = new Map<WebSocket, WebSocketAttachment>();
subscriptions = new Map<WebSocket, Subscription>();
#sendQueues = new Map<WebSocket, Promise<void>>();
env: EnvFromMachine<TMachine>;
currentChecksum: string | null = null;
constructor(state: DurableObjectState, env: EnvFromMachine<TMachine>) {
super(state, env);
this.state = state;
this.storage = state.storage;
this.env = env;
this.state.blockConcurrencyWhile(async () => {
const [actorType, actorId, initialCallerString, inputString] =
await Promise.all([
this.storage.get("actorType"),
this.storage.get("actorId"),
this.storage.get("initialCaller"),
this.storage.get("input"),
]);
if (actorType && actorId && initialCallerString && inputString) {
try {
const parsedData = StorageSchema.parse({
actorType,
actorId,
initialCaller: parseStoredJson(initialCallerString, CallerSchema),
input: parseStoredJson(inputString, z.record(z.unknown())),
});
this.actorType = parsedData.actorType;
this.actorId = parsedData.actorId;
this.initialCaller = parsedData.initialCaller;
this.input = parsedData.input;
if (options?.persisted) {
const persistedSnapshot = await this.loadPersistedSnapshot();
if (persistedSnapshot) {
this.restorePersistedActor(persistedSnapshot);
} else {
this.#ensureActorRunning();
}
} else {
this.#ensureActorRunning();
}
} catch {
// Ignore corrupt startup state and wait for a fresh spawn.
}
}
for (const socket of this.state.getWebSockets()) {
this.#subscribeSocketToActor(socket);
}
});
this.#startPeriodicCacheCleanup();
}
#ensureActorRunning() {
assert(this.actorId, "actorId is not set");
assert(this.actorType, "actorType is not set");
assert(this.input, "input is not set");
assert(this.initialCaller, "initialCaller is not set");
if (!this.actor) {
const input = {
id: this.actorId,
caller: this.initialCaller,
env: this.env,
storage: this.storage,
...this.input,
} satisfies ActorKitInputProps;
this.actor = createActor(machine, {
input: input as InputFrom<TMachine>,
});
if (options?.persisted) {
this.#setupStatePersistence(this.actor);
}
this.actor.start();
}
return this.actor;
}
#subscribeSocketToActor(ws: WebSocket) {
try {
const socket = ws as ActorKitWebSocket;
const attachment = WebSocketAttachmentSchema.parse(
socket.deserializeAttachment()
);
this.attachments.set(socket, attachment);
this.#enqueueSendStateUpdate(socket);
const subscription = this.actor?.subscribe(() => {
this.#enqueueSendStateUpdate(socket);
});
if (subscription) {
this.subscriptions.set(socket, subscription);
}
} catch {
// Ignore malformed socket state.
}
}
#enqueueSendStateUpdate(ws: ActorKitWebSocket) {
const prev = this.#sendQueues.get(ws);
const sendTask = prev
? prev.then(() => this.#sendStateUpdate(ws))
: this.#sendStateUpdate(ws);
const next = sendTask.catch(() => {
// Errors in send are non-fatal; the next update will retry.
});
this.#sendQueues.set(ws, next);
}
async #sendStateUpdate(ws: ActorKitWebSocket) {
assert(this.actor, "actor is not running");
const attachment = this.attachments.get(ws);
assert(attachment, "Attachment missing for WebSocket");
const fullSnapshot = this.actor.getSnapshot();
const currentChecksum = await this.#calculateChecksum(fullSnapshot);
this.snapshotCache.set(currentChecksum, {
snapshot: fullSnapshot,
timestamp: Date.now(),
});
this.#scheduleSnapshotCacheCleanup(currentChecksum);
this.currentChecksum = currentChecksum;
if (attachment.lastSentChecksum === currentChecksum) {
return;
}
const nextSnapshot = this.#createCallerSnapshot(
fullSnapshot,
attachment.caller.id
);
let lastSnapshot: Partial<typeof nextSnapshot> = {};
if (attachment.lastSentChecksum) {
const cachedSnapshot = this.snapshotCache.get(attachment.lastSentChecksum);
if (cachedSnapshot) {
lastSnapshot = this.#createCallerSnapshot(
cachedSnapshot.snapshot,
attachment.caller.id
);
}
}
const operations = compare(lastSnapshot, nextSnapshot);
if (operations.length === 0) {
return;
}
ws.send(JSON.stringify({ operations, checksum: currentChecksum }));
attachment.lastSentChecksum = currentChecksum;
ws.serializeAttachment(attachment);
}
#setupStatePersistence(actor: Actor<TMachine>) {
actor.subscribe(() => {
const fullSnapshot = actor.getSnapshot();
this.#persistSnapshot(fullSnapshot).catch(() => {
// Ignore persistence errors.
});
});
}
async #persistSnapshot(snapshot: SnapshotFrom<TMachine>) {
if (
this.lastPersistedSnapshot &&
compare(this.lastPersistedSnapshot, snapshot).length === 0
) {
return;
}
await this.storage.put(PERSISTED_SNAPSHOT_KEY, JSON.stringify(snapshot));
this.lastPersistedSnapshot = snapshot;
}
async #setupActorFromRequest(request: Request): Promise<Response | null> {
const url = new URL(request.url);
const searchParams = InputSearchSchema.parse(
Object.fromEntries(url.searchParams)
);
const pathParts = url.pathname.split("/").filter(Boolean);
const [, actorType, actorId] = pathParts;
if (!actorType || !actorId) {
return new Response("Invalid actor path", { status: 400 });
}
const hasRequiredFields = Object.values(schemas.inputProps.shape).some(
(field) => !field.isOptional()
);
if (hasRequiredFields && !searchParams.input) {
return new Response("Input parameters required for initial actor setup", {
status: 400,
});
}
let input: Record<string, unknown> = {};
if (searchParams.input) {
try {
input = schemas.inputProps.parse(JSON.parse(searchParams.input));
} catch (error: unknown) {
return new Response(`Invalid input: ${getErrorMessage(error)}`, {
status: 400,
});
}
}
const caller = await this.#getValidatedCaller(request, actorType, actorId);
if (!caller) {
return new Response("Unauthorized", { status: 401 });
}
await this.#storeActorData(actorType, actorId, caller, input);
this.actorType = actorType;
this.actorId = actorId;
this.initialCaller = caller;
this.input = input;
return null;
}
async #getValidatedCaller(
request: Request,
actorType: string,
actorId: string
): Promise<Caller | null> {
try {
return await getCallerFromRequest(
request,
actorType,
actorId,
this.env.ACTOR_KIT_SECRET
);
} catch {
return null;
}
}
async #storeActorData(
actorType: string,
actorId: string,
caller: Caller,
input: Record<string, unknown>
) {
await Promise.all([
this.storage.put("actorType", actorType),
this.storage.put("actorId", actorId),
this.storage.put("initialCaller", JSON.stringify(caller)),
this.storage.put("input", JSON.stringify(input)),
]);
}
#isActorRunning() {
return Boolean(this.actorType);
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
const clientChecksum = url.searchParams.get("checksum");
if (!this.#isActorRunning()) {
const setupError = await this.#setupActorFromRequest(request);
if (setupError) {
return setupError;
}
}
this.#ensureActorRunning();
assert(this.actorType, "actorType is not set");
assert(this.actorId, "actorId is not set");
const webSocketPair = new WebSocketPair();
const client = webSocketPair[0];
const server = webSocketPair[1] as ActorKitWebSocket;
const caller = await this.#getValidatedCaller(
request,
this.actorType,
this.actorId
);
if (!caller) {
return new Response("Unauthorized", { status: 401 });
}
this.state.acceptWebSocket(server);
server.serializeAttachment({
caller,
lastSentChecksum: clientChecksum ?? undefined,
});
this.#subscribeSocketToActor(server);
return new Response(null, {
status: 101,
webSocket: client,
} as WebSocketResponseInit);
}
async webSocketMessage(ws: WebSocket, message: ArrayBuffer | string) {
const attachment = this.attachments.get(ws);
assert(attachment, "Attachment missing for WebSocket");
const messageString =
typeof message === "string"
? message
: new TextDecoder().decode(message);
const parsedMessage = ParsedMessageSchema.parse(messageString);
if (attachment.caller.type === "client") {
const clientEvent = schemas.clientEvent.parse(parsedMessage);
this.send({
...clientEvent,
caller: attachment.caller,
} as ClientEventFrom<TMachine>);
return;
}
if (attachment.caller.type === "service") {
const serviceEvent = schemas.serviceEvent.parse(parsedMessage);
this.send({
...serviceEvent,
caller: attachment.caller,
} as ServiceEventFrom<TMachine>);
return;
}
throw new Error(`Unknown caller type: ${attachment.caller.type}`);
}
async webSocketError(_ws: WebSocket, _error: Error) {
// No-op; the runtime closes the socket for us.
}
async webSocketClose(
ws: WebSocket,
code: number,
_reason: string,
_wasClean: boolean
) {
ws.close(code, "Durable Object is closing WebSocket");
const subscription = this.subscriptions.get(ws);
if (subscription) {
subscription.unsubscribe();
this.subscriptions.delete(ws);
}
this.attachments.delete(ws);
this.#sendQueues.delete(ws);
}
send(event: ClientEventFrom<TMachine> | ServiceEventFrom<TMachine>) {
assert(this.actor, "Actor is not running");
this.actor.send({
...event,
env: this.env,
storage: this.storage,
});
}
async getSnapshot(
caller: Caller,
options?: {
waitForEvent?: ClientEventFrom<TMachine>;
waitForState?: StateValueFrom<TMachine>;
timeout?: number;
errorOnWaitTimeout?: boolean;
}
): Promise<{
checksum: string;
snapshot: CallerSnapshotFrom<TMachine>;
}> {
this.#ensureActorRunning();
if (!options?.waitForEvent && !options?.waitForState) {
return await this.#getCurrentSnapshot(caller);
}
const timeoutPromise = new Promise<{
checksum: string;
snapshot: CallerSnapshotFrom<TMachine>;
}>((resolve, reject) => {
setTimeout(async () => {
if (options.errorOnWaitTimeout !== false) {
reject(new Error("Timeout waiting for event or state"));
} else {
resolve(await this.#getCurrentSnapshot(caller));
}
}, options.timeout ?? 5000);
});
const waitPromise = new Promise<{
checksum: string;
snapshot: CallerSnapshotFrom<TMachine>;
}>((resolve) => {
const subscription = this.actor?.subscribe(async (snapshot) => {
if (
(options.waitForEvent &&
this.#matchesEvent(snapshot, options.waitForEvent)) ||
(options.waitForState &&
this.#matchesState(snapshot, options.waitForState))
) {
subscription?.unsubscribe();
resolve(await this.#getCurrentSnapshot(caller));
}
});
});
return Promise.race([waitPromise, timeoutPromise]);
}
async #getCurrentSnapshot(caller: Caller) {
const fullSnapshot = this.actor?.getSnapshot();
assert(fullSnapshot, "Actor snapshot is not available");
return {
snapshot: this.#createCallerSnapshot(fullSnapshot, caller.id),
checksum: await this.#calculateChecksum(fullSnapshot),
};
}
#matchesEvent(
_snapshot: SnapshotFrom<TMachine>,
_event: ClientEventFrom<TMachine>
) {
return true;
}
#matchesState(
snapshot: SnapshotFrom<TMachine>,
stateValue: StateValueFrom<TMachine>
) {
return matchesState(stateValue, snapshot);
}
async #calculateChecksum(snapshot: SnapshotFrom<TMachine>) {
const str = JSON.stringify(snapshot);
const buffer = new TextEncoder().encode(str);
const hash = await crypto.subtle.digest("SHA-256", buffer);
const array = new Uint8Array(hash);
return Array.from(array, (b) => b.toString(16).padStart(2, "0")).join("");
}
#createCallerSnapshot(
fullSnapshot: SnapshotFrom<TMachine>,
callerId: string
): CallerSnapshotFrom<TMachine> {
const snapshot = fullSnapshot as unknown as ActorKitSnapshotView<TMachine>;
assert(snapshot.value, "expected value");
assert(snapshot.context.public, "expected public key in context");
assert(snapshot.context.private, "expected private key in context");
return {
public: snapshot.context.public,
private:
snapshot.context.private[callerId] ??
({} as CallerSnapshotFrom<TMachine>["private"]),
value: snapshot.value,
};
}
async spawn(props: {
actorType: string;
actorId: string;
caller: Caller;
input: Record<string, unknown>;
}) {
if (this.actorType || this.actorId || this.initialCaller) {
return;
}
await this.#storeActorData(
props.actorType,
props.actorId,
props.caller,
props.input
);
this.actorType = props.actorType;
this.actorId = props.actorId;
this.initialCaller = props.caller;
this.input = props.input;
this.#ensureActorRunning();
}
#scheduleSnapshotCacheCleanup(checksum: string) {
setTimeout(() => {
this.#cleanupSnapshotCache(checksum);
}, 300000);
}
#startPeriodicCacheCleanup() {
setInterval(() => {
const now = Date.now();
for (const [checksum, { timestamp }] of this.snapshotCache.entries()) {
if (now - timestamp > 300000) {
this.snapshotCache.delete(checksum);
}
}
}, 300000);
}
#cleanupSnapshotCache(checksum: string) {
if (checksum === this.currentChecksum) {
return;
}
const cachedData = this.snapshotCache.get(checksum);
if (cachedData && Date.now() - cachedData.timestamp > 300000) {
this.snapshotCache.delete(checksum);
}
}
async loadPersistedSnapshot(): Promise<SnapshotFrom<TMachine> | null> {
const snapshotString = await this.storage.get(PERSISTED_SNAPSHOT_KEY);
return snapshotString
? (JSON.parse(z.string().parse(snapshotString)) as SnapshotFrom<TMachine>)
: null;
}
restorePersistedActor(persistedSnapshot: SnapshotFrom<TMachine>) {
assert(this.actorId, "actorId is not set");
assert(this.actorType, "actorType is not set");
assert(this.initialCaller, "initialCaller is not set");
assert(this.input, "input is not set");
const input = {
id: this.actorId,
caller: this.initialCaller,
storage: this.storage,
env: this.env,
...this.input,
} as InputFrom<TMachine>;
const migrations = xstateMigrate.generateMigrations(
machine,
persistedSnapshot,
input
);
const restoredSnapshot = xstateMigrate.applyMigrations(
persistedSnapshot,
migrations
);
this.actor = createActor(machine, {
snapshot: restoredSnapshot,
input,
});
if (options?.persisted) {
this.#setupStatePersistence(this.actor);
}
this.actor.start();
this.actor.send({
type: "RESUME",
caller: { id: this.actorId, type: "system" },
env: this.env,
storage: this.storage,
} as EventFromLogic<TMachine>);
this.lastPersistedSnapshot = restoredSnapshot as SnapshotFrom<TMachine>;
}
};