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
632 lines (631 loc) • 23.8 kB
JavaScript
import { a as CallerStringSchema, i as CallerSchema, o as EmittedEventSchema, t as AnyEventSchema } from "./schemas-DTUwr6Qg.mjs";
import { t as createAccessToken } from "./createAccessToken-zuXaUS00.mjs";
import { z } from "zod";
import { applyPatch, compare } from "fast-json-patch";
import { produce } from "immer";
import { createActor, fromCallback, matchesState } from "xstate";
import { jwtVerify } from "jose";
import { DurableObject } from "cloudflare:workers";
import { xstateMigrate } from "xstate-migrate";
//#region src/utils.ts
function assert(expression, errorMessage) {
if (!expression) {
const stack = new Error(errorMessage).stack?.split("\n");
const assertLine = stack && stack.length >= 3 ? stack[2] : "unknown location";
throw new Error(`${errorMessage} (Assert failed at ${assertLine?.trim()})`);
}
}
async function getCallerFromRequest(request, actorType, actorId, secret) {
let accessToken;
if (request.headers.get("Upgrade") !== "websocket") {
const stringPart = request.headers.get("Authorization")?.split(" ")[1];
assert(stringPart, "Expected authorization header to be set");
accessToken = stringPart;
} else {
const paramString = new URLSearchParams(request.url.split("?")[1]).get("accessToken");
assert(paramString, "expected accessToken when connecting to socket");
accessToken = paramString;
}
return parseAccessTokenForCaller({
accessToken,
type: actorType,
id: actorId,
secret
});
}
async function parseAccessTokenForCaller({ accessToken, type, id, secret }) {
const verified = await jwtVerify(accessToken, new TextEncoder().encode(secret));
if (!verified.payload.jti) throw new Error("Expected JTI on accessToken");
if (verified.payload.jti !== id) throw new Error(`Expected JTI on accessToken to match actor id: ${id}`);
if (!verified.payload.aud) throw new Error(`Expected accessToken audience to match actor type: ${type}`);
if (!(Array.isArray(verified.payload.aud) ? verified.payload.aud : [verified.payload.aud]).includes(type)) throw new Error(`Expected accessToken audience to match actor type: ${type}`);
if (!verified.payload.sub) throw new Error("Expected accessToken to have subject");
return CallerStringSchema.parse(verified.payload.sub);
}
//#endregion
//#region src/createActorKitRouter.ts
const SnapshotRequestSearchSchema = z.object({
waitForEvent: z.string().optional(),
waitForState: z.string().optional(),
timeout: z.string().transform((value) => Number.parseInt(value, 10)).pipe(z.number().int().positive()).optional(),
errorOnWaitTimeout: z.enum(["true", "false"]).transform((value) => value === "true").optional()
});
const createActorKitRouter = (routes) => {
const spawnedActors = /* @__PURE__ */ new Set();
function getDurableObjectNamespace(env, key) {
const namespace = env[key.toUpperCase()];
if (namespace && typeof namespace === "object" && "get" in namespace && "idFromName" in namespace) return namespace;
}
return async (request, env, _ctx) => {
const pathParts = new URL(request.url).pathname.split("/").filter(Boolean);
if (pathParts.length !== 3 || pathParts[0] !== "api") return new Response("Not Found", { status: 404 });
const [, actorType, actorId] = pathParts;
if (!routes.includes(actorType)) return new Response(`Unknown actor type: ${actorType}`, { status: 400 });
const durableObjectNamespace = getDurableObjectNamespace(env, actorType);
if (!durableObjectNamespace) return new Response(`Durable Object namespace not found for actor type: ${actorType}`, { status: 500 });
const durableObjectId = durableObjectNamespace.idFromName(actorId);
const durableObjectStub = durableObjectNamespace.get(durableObjectId);
let caller;
try {
caller = await getCallerFromRequest(request, actorType, actorId, env.ACTOR_KIT_SECRET);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return new Response(`Error: ${errorMessage}. API requests must specify a valid caller in Bearer token in the Authorization header using fetch method created from 'createActorFetch' or use 'createAccessToken' directly.`, { status: 401 });
}
const actorKey = `${actorType}:${actorId}`;
if (!spawnedActors.has(actorKey)) {
await durableObjectStub.spawn({
actorType,
actorId,
caller,
input: {}
});
spawnedActors.add(actorKey);
}
if (request.headers.get("Upgrade") === "websocket") return durableObjectStub.fetch(request);
if (request.method === "GET") {
let searchParams;
try {
searchParams = SnapshotRequestSearchSchema.parse(Object.fromEntries(new URL(request.url).searchParams));
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Invalid search params";
return new Response(JSON.stringify({ error: errorMessage }), { status: 400 });
}
const result = await durableObjectStub.getSnapshot(caller, {
waitForEvent: searchParams.waitForEvent ? AnyEventSchema.parse(JSON.parse(searchParams.waitForEvent)) : void 0,
waitForState: searchParams.waitForState ? JSON.parse(searchParams.waitForState) : void 0,
timeout: searchParams.timeout,
errorOnWaitTimeout: searchParams.errorOnWaitTimeout
});
return new Response(JSON.stringify(result), { headers: { "Content-Type": "application/json" } });
} else if (request.method === "POST") {
let event;
try {
const json = await request.json();
event = AnyEventSchema.parse(json);
} catch (ex) {
const errorMessage = ex instanceof Error ? ex.message : "Invalid JSON";
return new Response(JSON.stringify({ error: errorMessage }), { status: 400 });
}
durableObjectStub.send({
...event,
caller
});
return new Response(JSON.stringify({ success: true }));
} else return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405 });
};
};
//#endregion
//#region src/constants.ts
const PERSISTED_SNAPSHOT_KEY = "persistedSnapshot";
//#endregion
//#region src/createMachineServer.ts
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()
});
const InputSearchSchema = z.object({ input: z.string().optional() });
const ParsedMessageSchema = z.string().transform((value, context) => {
try {
return JSON.parse(value);
} catch {
context.addIssue({
code: z.ZodIssueCode.custom,
message: "Expected valid JSON payload"
});
return z.NEVER;
}
});
function getErrorMessage(error) {
return error instanceof Error ? error.message : "Unknown error";
}
function parseStoredJson(value, fallbackSchema) {
const parsedString = z.string().parse(value);
return fallbackSchema.parse(JSON.parse(parsedString));
}
const createMachineServer = ({ machine, schemas, options }) => class MachineServerImpl extends DurableObject {
actor;
actorType;
actorId;
input;
initialCaller;
lastPersistedSnapshot = null;
snapshotCache = /* @__PURE__ */ new Map();
state;
storage;
attachments = /* @__PURE__ */ new Map();
subscriptions = /* @__PURE__ */ new Map();
#sendQueues = /* @__PURE__ */ new Map();
env;
currentChecksum = null;
constructor(state, env) {
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 {}
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) {
this.actor = createActor(machine, { input: {
id: this.actorId,
caller: this.initialCaller,
env: this.env,
storage: this.storage,
...this.input
} });
if (options?.persisted) this.#setupStatePersistence(this.actor);
this.actor.start();
}
return this.actor;
}
#subscribeSocketToActor(ws) {
try {
const socket = ws;
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 {}
}
#enqueueSendStateUpdate(ws) {
const prev = this.#sendQueues.get(ws);
const next = (prev ? prev.then(() => this.#sendStateUpdate(ws)) : this.#sendStateUpdate(ws)).catch(() => {});
this.#sendQueues.set(ws, next);
}
async #sendStateUpdate(ws) {
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 = {};
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.subscribe(() => {
const fullSnapshot = actor.getSnapshot();
this.#persistSnapshot(fullSnapshot).catch(() => {});
});
}
async #persistSnapshot(snapshot) {
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) {
const url = new URL(request.url);
const searchParams = InputSearchSchema.parse(Object.fromEntries(url.searchParams));
const [, actorType, actorId] = url.pathname.split("/").filter(Boolean);
if (!actorType || !actorId) return new Response("Invalid actor path", { status: 400 });
if (Object.values(schemas.inputProps.shape).some((field) => !field.isOptional()) && !searchParams.input) return new Response("Input parameters required for initial actor setup", { status: 400 });
let input = {};
if (searchParams.input) try {
input = schemas.inputProps.parse(JSON.parse(searchParams.input));
} catch (error) {
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, actorType, actorId) {
try {
return await getCallerFromRequest(request, actorType, actorId, this.env.ACTOR_KIT_SECRET);
} catch {
return null;
}
}
async #storeActorData(actorType, actorId, caller, input) {
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) {
const clientChecksum = new URL(request.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];
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 ?? void 0
});
this.#subscribeSocketToActor(server);
return new Response(null, {
status: 101,
webSocket: client
});
}
async webSocketMessage(ws, message) {
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
});
return;
}
if (attachment.caller.type === "service") {
const serviceEvent = schemas.serviceEvent.parse(parsedMessage);
this.send({
...serviceEvent,
caller: attachment.caller
});
return;
}
throw new Error(`Unknown caller type: ${attachment.caller.type}`);
}
async webSocketError(_ws, _error) {}
async webSocketClose(ws, code, _reason, _wasClean) {
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) {
assert(this.actor, "Actor is not running");
this.actor.send({
...event,
env: this.env,
storage: this.storage
});
}
async getSnapshot(caller, options) {
this.#ensureActorRunning();
if (!options?.waitForEvent && !options?.waitForState) return await this.#getCurrentSnapshot(caller);
const timeoutPromise = new Promise((resolve, reject) => {
setTimeout(async () => {
if (options.errorOnWaitTimeout !== false) reject(/* @__PURE__ */ new Error("Timeout waiting for event or state"));
else resolve(await this.#getCurrentSnapshot(caller));
}, options.timeout ?? 5e3);
});
const waitPromise = new Promise((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) {
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, _event) {
return true;
}
#matchesState(snapshot, stateValue) {
return matchesState(stateValue, snapshot);
}
async #calculateChecksum(snapshot) {
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, callerId) {
const snapshot = fullSnapshot;
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] ?? {},
value: snapshot.value
};
}
async spawn(props) {
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) {
setTimeout(() => {
this.#cleanupSnapshotCache(checksum);
}, 3e5);
}
#startPeriodicCacheCleanup() {
setInterval(() => {
const now = Date.now();
for (const [checksum, { timestamp }] of this.snapshotCache.entries()) if (now - timestamp > 3e5) this.snapshotCache.delete(checksum);
}, 3e5);
}
#cleanupSnapshotCache(checksum) {
if (checksum === this.currentChecksum) return;
const cachedData = this.snapshotCache.get(checksum);
if (cachedData && Date.now() - cachedData.timestamp > 3e5) this.snapshotCache.delete(checksum);
}
async loadPersistedSnapshot() {
const snapshotString = await this.storage.get(PERSISTED_SNAPSHOT_KEY);
return snapshotString ? JSON.parse(z.string().parse(snapshotString)) : null;
}
restorePersistedActor(persistedSnapshot) {
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
};
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
});
this.lastPersistedSnapshot = restoredSnapshot;
}
};
//#endregion
//#region src/fromActorKit.ts
/**
* fromActorKit — XState callback actor for DO-to-DO communication.
*
* Creates an XState `fromCallback` actor that connects a parent actor-kit
* Durable Object to a remote child actor-kit DO via WebSocket. The callback:
*
* 1. Spawns the remote DO (idempotent)
* 2. Creates a JWT for the remote actor
* 3. Opens a WebSocket via stub.fetch() with Upgrade header
* 4. Receives JSON Patch snapshot updates → emits {TYPE}_UPDATED to parent
* 5. Forwards events from parent → remote DO (validated via Zod schema)
* 6. Queues events sent before WebSocket is ready
* 7. Cleans up WebSocket on actor stop
*
* @example
* ```ts
* const sessionMachine = setup({
* actors: {
* zone: fromActorKit<ZoneMachine>("zone"),
* },
* }).createMachine({
* invoke: {
* id: "zone",
* src: "zone",
* input: ({ context }) => ({
* server: context.env.ZONE,
* actorId: context.public.zoneId,
* actorInput: {},
* caller: { id: context.public.playerId, type: "service" as const },
* signingKey: context.env.ACTOR_KIT_SECRET,
* eventSchema: ZoneServiceEventSchema,
* }),
* },
* on: { ZONE_UPDATED: { actions: "syncZoneSnapshot" } },
* });
* ```
*
* @module
*/
function toScreamingSnake(s) {
return s.replace(/-/g, "_").toUpperCase();
}
/**
* Creates an XState callback actor that bridges a parent actor-kit DO
* to a remote child actor-kit DO via WebSocket.
*
* @param actorType — kebab-case actor type name (e.g. `"zone"`)
*/
function fromActorKit(actorType) {
const TYPE = toScreamingSnake(actorType);
function emitUpdated(emitActorType, actorId, snapshot, operations) {
return {
type: `${TYPE}_UPDATED`,
actorType: emitActorType,
actorId,
snapshot,
operations
};
}
function emitError(emitActorType, actorId, error) {
return {
type: `${TYPE}_ERROR`,
actorType: emitActorType,
actorId,
error
};
}
const callback = ({ sendBack, receive, input }) => {
let ws = null;
const pendingEvents = [];
(async () => {
try {
const id = input.server.idFromName(input.actorId);
const stub = input.server.get(id);
if (stub.spawn) await Promise.resolve(stub.spawn({
actorType,
actorId: input.actorId,
caller: input.caller,
input: input.actorInput
}));
const accessToken = await createAccessToken({
signingKey: input.signingKey,
actorId: input.actorId,
actorType,
callerId: input.caller.id,
callerType: input.caller.type
});
const webSocket = (await stub.fetch(new Request(`https://internal/api/${actorType}/${input.actorId}/?accessToken=${accessToken}`, { headers: { Upgrade: "websocket" } }))).webSocket;
if (!webSocket) throw new Error(`WebSocket upgrade failed for ${actorType}/${input.actorId}`);
webSocket.accept();
ws = webSocket;
let currentSnapshot;
ws.addEventListener("message", (event) => {
try {
const raw = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
const data = EmittedEventSchema.parse(JSON.parse(raw));
currentSnapshot = produce(currentSnapshot ?? {}, (draft) => {
applyPatch(draft, data.operations);
});
sendBack(emitUpdated(actorType, input.actorId, currentSnapshot, data.operations));
} catch (err) {
console.error(`[fromActorKit:${actorType}] message error:`, err);
}
});
ws.addEventListener("error", () => {
sendBack(emitError(actorType, input.actorId, /* @__PURE__ */ new Error(`WebSocket error for ${actorType}/${input.actorId}`)));
});
ws.addEventListener("close", (event) => {
ws = null;
sendBack(emitError(actorType, input.actorId, /* @__PURE__ */ new Error(`WebSocket closed for ${actorType}/${input.actorId}: code=${event.code} reason=${event.reason}`)));
});
while (pendingEvents.length > 0) ws.send(pendingEvents.shift());
} catch (err) {
console.error(`[fromActorKit:${actorType}] setup error:`, err);
sendBack(emitError(actorType, input.actorId, err instanceof Error ? err : /* @__PURE__ */ new Error(`Setup failed for ${actorType}/${input.actorId}`)));
}
})();
receive((event) => {
const result = input.eventSchema.safeParse(event);
if (!result.success) return;
const serialized = JSON.stringify(result.data);
if (ws) ws.send(serialized);
else pendingEvents.push(serialized);
});
return () => {
if (ws) {
try {
ws.close(1e3, "Actor stopped");
} catch {}
ws = null;
}
};
};
return fromCallback(callback);
}
//#endregion
export { createActorKitRouter, createMachineServer, fromActorKit };
//# sourceMappingURL=worker.mjs.map