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
text/typescript
/**
* 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);
}