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

200 lines (181 loc) 5.87 kB
import { z } from "zod"; import { AnyEventSchema } from "./schemas"; import { AnyEvent, Caller, ActorKitEnv, KebabToScreamingSnake, ScreamingSnakeToKebab, } from "./types"; import { getCallerFromRequest } from "./utils"; 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(), }); type ActorKitDurableObjectStub = { spawn(props: { actorType: string; actorId: string; caller: Caller; input: Record<string, unknown>; }): Promise<void> | void; getSnapshot( caller: Caller, options?: { waitForEvent?: unknown; waitForState?: unknown; timeout?: number; errorOnWaitTimeout?: boolean; } ): Promise<{ checksum: string; snapshot: unknown; }>; send(event: { caller: Caller; type: string }): void; fetch(request: Request): Promise<Response>; }; type ActorKitNamespace = { idFromName(name: string): unknown; get(id: unknown): ActorKitDurableObjectStub; }; export const createActorKitRouter = <Env extends ActorKitEnv>( routes: Array<ScreamingSnakeToKebab<Extract<keyof Env, string>>> ) => { type ActorType = ScreamingSnakeToKebab<Extract<keyof Env, string>>; // Add a Set to keep track of spawned actors const spawnedActors = new Set<string>(); function getDurableObjectNamespace< T extends ScreamingSnakeToKebab<Extract<keyof Env, string>> >( env: Env, key: T ): ActorKitNamespace | undefined { const envKey = key.toUpperCase() as KebabToScreamingSnake<T> & keyof Env; const namespace = env[envKey]; if ( namespace && typeof namespace === "object" && "get" in namespace && "idFromName" in namespace ) { return namespace as unknown as ActorKitNamespace; } return undefined; } return async ( request: Request, env: Env, _ctx?: unknown ): Promise<Response> => { const url = new URL(request.url); const pathParts = 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 as ActorType)) { return new Response(`Unknown actor type: ${actorType}`, { status: 400 }); } const durableObjectNamespace = getDurableObjectNamespace<ActorType>( env, actorType as 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); // Parse the auth header to get the caller token let caller: Caller; try { caller = await getCallerFromRequest( request, actorType, actorId, env.ACTOR_KIT_SECRET ); } catch (error: unknown) { 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 } ); } // Create a unique key for the actor const actorKey = `${actorType}:${actorId}`; // Check if the actor has already been spawned if (!spawnedActors.has(actorKey)) { // If not, spawn it and mark it as spawned 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: z.infer<typeof SnapshotRequestSearchSchema>; try { searchParams = SnapshotRequestSearchSchema.parse( Object.fromEntries(new URL(request.url).searchParams) ); } catch (error: unknown) { 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)) : undefined, waitForState: searchParams.waitForState ? JSON.parse(searchParams.waitForState) : undefined, timeout: searchParams.timeout, errorOnWaitTimeout: searchParams.errorOnWaitTimeout, }); return new Response(JSON.stringify(result), { headers: { "Content-Type": "application/json" }, }); } else if (request.method === "POST") { let event: AnyEvent; try { const json = await request.json(); event = AnyEventSchema.parse(json); } catch (ex: unknown) { 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, }); } }; };