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

312 lines (281 loc) 9.36 kB
/** * 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 */ import { applyPatch } from "fast-json-patch"; import type { Operation } from "fast-json-patch"; import { produce } from "immer"; import type { CallbackLogicFunction } from "xstate"; import { fromCallback } from "xstate"; import type { z } from "zod"; import { createAccessToken } from "./createAccessToken"; import { EmittedEventSchema } from "./schemas"; import type { AnyActorKitStateMachine, Caller, CallerSnapshotFrom, } from "./types"; // --------------------------------------------------------------------------- // Public types // --------------------------------------------------------------------------- /** Convert kebab-case to SCREAMING_SNAKE: `"my-actor"` → `"MY_ACTOR"` */ type KebabToScreamingSnake<S extends string> = string extends S ? string : S extends `${infer Head}-${infer Tail}` ? `${Uppercase<Head>}_${KebabToScreamingSnake<Tail>}` : Uppercase<S>; /** Events emitted back to the parent machine. */ export type FromActorKitEmitted< TActorType extends string, TMachine extends AnyActorKitStateMachine, > = | { type: `${KebabToScreamingSnake<TActorType>}_UPDATED`; actorType: TActorType; actorId: string; snapshot: CallerSnapshotFrom<TMachine>; operations: Operation[]; } | { type: `${KebabToScreamingSnake<TActorType>}_ERROR`; actorType: TActorType; actorId: string; error: Error; }; /** * Minimal interface for a DO namespace — compatible with both * `DurableObjectNamespace` (untyped) and `DurableObjectNamespace<T>` (RPC-typed). */ export interface ActorKitNamespaceLike { idFromName(name: string): unknown; get(id: unknown): { fetch(request: Request): Promise<Response>; spawn?(props: { actorType: string; actorId: string; caller: Caller; input: Record<string, unknown>; }): Promise<void> | void; [key: string]: unknown; }; } /** Input the parent must provide when invoking the callback actor. */ export interface FromActorKitInput< TEventSchema extends z.ZodTypeAny = z.ZodTypeAny, > { /** DO namespace binding for the remote actor (e.g. `env.ZONE`). */ server: ActorKitNamespaceLike; /** ID of the remote actor instance. */ actorId: string; /** Input props forwarded to the remote actor on spawn. */ actorInput: Record<string, unknown>; /** Caller identity used for JWT signing. */ caller: Caller; /** Secret key used to sign the JWT. */ signingKey: string; /** Zod schema that validates events before forwarding to the remote DO. */ eventSchema: TEventSchema; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function toScreamingSnake(s: string): string { return s.replace(/-/g, "_").toUpperCase(); } // --------------------------------------------------------------------------- // Main export // --------------------------------------------------------------------------- /** * 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"`) */ export function fromActorKit< TMachine extends AnyActorKitStateMachine, TActorType extends string = string, >(actorType: TActorType) { type TEmitted = FromActorKitEmitted<TActorType, TMachine>; const TYPE = toScreamingSnake(actorType); function emitUpdated( emitActorType: TActorType, actorId: string, snapshot: CallerSnapshotFrom<TMachine>, operations: Operation[], ): TEmitted { return { type: `${TYPE}_UPDATED`, actorType: emitActorType, actorId, snapshot, operations, } as TEmitted; } function emitError( emitActorType: TActorType, actorId: string, error: Error, ): TEmitted { return { type: `${TYPE}_ERROR`, actorType: emitActorType, actorId, error, } as TEmitted; } const callback: CallbackLogicFunction< Record<string, unknown> & { type: string }, // events parent can send TEmitted, // events sent back to parent FromActorKitInput // input > = ({ sendBack, receive, input }) => { let ws: WebSocket | null = null; const pendingEvents: string[] = []; // Async setup — spawn, auth, connect (async () => { try { const id = input.server.idFromName(input.actorId); const stub = input.server.get(id); // 1. Spawn remote actor (idempotent) via RPC if (stub.spawn) { await Promise.resolve( stub.spawn({ actorType, actorId: input.actorId, caller: input.caller, input: input.actorInput, }) ); } // 2. Create JWT const accessToken = await createAccessToken({ signingKey: input.signingKey, actorId: input.actorId, actorType, callerId: input.caller.id, callerType: input.caller.type, }); // 3. Open WebSocket via fetch upgrade const response = await stub.fetch( new Request( `https://internal/api/${actorType}/${input.actorId}/?accessToken=${accessToken}`, { headers: { Upgrade: "websocket" } } ) ); const webSocket = response.webSocket; if (!webSocket) { throw new Error( `WebSocket upgrade failed for ${actorType}/${input.actorId}` ); } webSocket.accept(); ws = webSocket; // 4. Track snapshot via patches let currentSnapshot: CallerSnapshotFrom<TMachine> | undefined; ws.addEventListener("message", (event: MessageEvent) => { try { const raw = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data); const data = EmittedEventSchema.parse(JSON.parse(raw)); currentSnapshot = produce( currentSnapshot ?? ({} as CallerSnapshotFrom<TMachine>), (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, new Error(`WebSocket error for ${actorType}/${input.actorId}`))); }); // Handle normal close (e.g., child DO eviction/restart) ws.addEventListener("close", (event: CloseEvent) => { ws = null; sendBack(emitError( actorType, input.actorId, new Error(`WebSocket closed for ${actorType}/${input.actorId}: code=${event.code} reason=${event.reason}`) )); }); // 5. Flush queued events 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 : new Error(`Setup failed for ${actorType}/${input.actorId}`) )); } })(); // 6. Forward validated events from parent → remote DO receive((event) => { const result = input.eventSchema.safeParse(event); if (!result.success) return; // not meant for this actor const serialized = JSON.stringify(result.data); if (ws) { ws.send(serialized); } else { pendingEvents.push(serialized); } }); // 7. Cleanup return () => { if (ws) { try { ws.close(1000, "Actor stopped"); } catch { // ignore } ws = null; } }; }; return fromCallback(callback); }