UNPKG

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
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