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