UNPKG

@cloudflare/actors

Version:

An easier way to build with Cloudflare Durable Objects

431 lines 18.1 kB
import { env, DurableObject, WorkerEntrypoint } from "cloudflare:workers"; import { Storage } from "../../storage/src/index"; import { Alarms } from "../../alarms/src/index"; import { Sockets } from "../../sockets/src/index"; import { Persist, PERSISTED_VALUES, initializePersistedProperties, persistProperty } from "./persist"; export { Persist }; export * from "./retries"; /** * Provide a default name value for an actor. */ const DEFAULT_ACTOR_NAME = "default"; /** * Provide a default name value for the tracking actor. */ const TRACKING_ACTOR_NAME = "_cf_actors"; /** * Base abstract class for Workers that provides common functionality and structure. * @template T - The type of the environment object that will be available to the worker */ export class Entrypoint extends WorkerEntrypoint { } /** * Extended Actor class that provides additional functionality for Durable Objects. * This class adds SQL storage capabilities and browsing functionality to the base DurableObject. * @template E - The type of the environment object that will be available to the actor */ export class Actor extends DurableObject { get name() { return this._name; } __studio(_) { return this.storage.__studio(_); } /** * Set the identifier for the actor as named by the client * @param id The identifier to set */ async setName(id) { this.identifier = id; this._name = id; this._setNameCalled = true; // Set the actor name on our alarm so it can store a reference to the actor // when an alarm is set (so actors awoken by alarms can be referenced by name). this.alarms.actorName = this.identifier; // Call onInit if it hasn't been called yet if (!this._onInitCalled) { this._onInitCalled = true; await this.onInit(); } } /** * Static method to extract an ID from a request URL. Default response "default". * @param request - The incoming request * @returns The name string value defined by the client application to reference an instance */ static async nameFromRequest(request) { return DEFAULT_ACTOR_NAME; } ; /** * Static method to get an actor instance by ID * @param id - The ID of the actor to get * @returns The actor instance */ static get(id) { const stub = getActor(this, id); // This may seem repetitive from when we do this in `getActor` prior to returning the stub // but this allows classes to do `this.ctx.blockConcurrencyWhile` and log out the identifier // there. Without doing this again, that seems to fail for one reason or another. stub.setName(id); return stub; } /** * Creates a new instance of Actor. * @param ctx - The DurableObjectState for this actor * @param env - The environment object containing bindings and configuration */ constructor(ctx, env) { if (ctx && env) { super(ctx, env); } else { // @ts-ignore - This is handled internally by the framework super(); } // Initialize flags this._onInitCalled = false; this._setNameCalled = false; if (ctx && env) { this.storage = new Storage(ctx.storage); this.alarms = new Alarms(ctx, this); this.sockets = new Sockets(ctx, this); // Initialize the persisted values map this[PERSISTED_VALUES] = new Map(); // Move all initialization into blockConcurrencyWhile to ensure // persisted properties are loaded before any code runs ctx.blockConcurrencyWhile(async () => { // Load persisted properties await this._initializePersistedProperties(); }); } else { this.storage = new Storage(undefined); this.alarms = new Alarms(undefined, this); this.sockets = new Sockets(undefined, this); // Initialize the persisted values map this[PERSISTED_VALUES] = new Map(); } // Set a default identifier or name if none exists if (!this.identifier) { this.identifier = DEFAULT_ACTOR_NAME; } if (!this.name) { this._name = DEFAULT_ACTOR_NAME; } } /** * Initializes the persisted properties table and loads any stored values. * This is called during construction to ensure properties are loaded before any code uses them. * @private */ async _initializePersistedProperties() { await initializePersistedProperties(this); } /** * Waits for setName to be called before proceeding with request handling. * This ensures that onRequest has access to the correct identifier. * @private */ async _waitForSetName() { const timeout = 5000; // 5 second timeout const startTime = Date.now(); // Poll until setName has been called or timeout is reached, we do this because // the alternative is making `getActor` async which might also be a valid option // but since users might be use to a more synchronous nature with DO's I opted for // this method... for the time being. Making `getActor` async would require users // to write `const actor = await MyActor.get('default')` whereas right now the // `await` keyword is not required. while (!this._setNameCalled) { const elapsed = Date.now() - startTime; if (elapsed > timeout) { throw new Error(`setName() was not called within ${timeout}ms. Actor may not be properly initialized.`); } await scheduler.wait(0); } } /** * Persists a property value to the Durable Object storage. * @param propertyKey The name of the property to persist * @param value The value to persist * @private */ async _persistProperty(propertyKey, value) { await persistProperty(this, propertyKey, value); } /** * Abstract method that must be implemented by derived classes to handle incoming requests. * @param request - The incoming request to handle * @returns A Promise that resolves to a Response */ async fetch(request) { // If the request route is `/ws` then we should upgrade the connection to a WebSocket // Get configuration from the static property const config = this.constructor.configuration(request); // Parse the URL to check if the path component matches the upgradePath const url = new URL(request.url); const upgradePath = config?.sockets?.upgradePath ?? "/ws"; if (url.pathname === upgradePath || url.pathname.startsWith(`${upgradePath}/`)) { const shouldUpgrade = this.shouldUpgradeSocket(request); // Only continue to upgrade path if shouldUpgrade returns true if (shouldUpgrade) { return Promise.resolve(this.onWebSocketUpgrade(request)); } } // Autoresponse in sockets allows clients to send a ping message and receive a pong response // without waking the durable object up from hibernation. if (config?.sockets?.autoResponse) { this.ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair(config.sockets.autoResponse.ping, config.sockets.autoResponse.pong)); } // Wait for setName to be called before running onRequest if (!this._setNameCalled) { // If setName hasn't been called yet, wait for it // This ensures onRequest has access to the correct identifier try { await this._waitForSetName(); } catch (error) { console.error('Failed to wait for setName:', error); return new Response("Actor initialization timeout", { status: 503 }); } } return this.onRequest(request); } /** * Lifecycle method that is called when the actor is initialized. * @protected */ async onInit() { // Default implementation is a no-op } /** * Lifecycle method that is called when the actor is notified of an alarm. * @protected * @param alarmInfo - Information about the alarm that was triggered */ async onAlarm(alarmInfo) { // Default implementation is a no-op } /** * Hook that is called whenever a @Persist decorated property is stored in the database. * Override this method to listen to persistence events. * @param key The property key that was persisted * @param value The value that was persisted */ onPersist(key, value) { // Default implementation is a no-op } onRequest(request) { return Promise.resolve(new Response("Not Found", { status: 404 })); } shouldUpgradeSocket(request) { // By default we do not want to assume every application needs to use sockets // and we do not want to upgrade every request to a socket. return false; } // Only need to override if you want to handle the socket upgrade yourself. // Otherwise this is all handled for you automatically. onWebSocketUpgrade(request) { const { client, server } = this.sockets.acceptWebSocket(request); const response = new Response(null, { status: 101, webSocket: client, }); // Schedule onWebSocketConnect to run after the response is sent Promise.resolve().then(() => { this.onWebSocketConnect(server, request); }); return response; } onWebSocketConnect(ws, request) { // Default implementation is a no-op } onWebSocketDisconnect(ws) { // Default implementation is a no-op } onWebSocketMessage(ws, message) { // Default implementation is a no-op } async webSocketMessage(ws, message) { this.sockets.webSocketMessage(ws, message); // Call user defined onWebSocketMessage method before proceeding this.onWebSocketMessage(ws, message); } async webSocketClose(ws, code) { // Close the WebSocket connection this.sockets.webSocketClose(ws, code); // Call user defined onWebSocketDisconnect method before proceeding this.onWebSocketDisconnect(ws); } async alarm(alarmInfo) { // Call user defined onAlarm method before proceeding await this.onAlarm(alarmInfo); if (this.alarms) { return this.alarms.alarm(alarmInfo); } return; } /** * Execute SQL queries against the Agent's database * @template T Type of the returned rows * @param strings SQL query template strings * @param values Values to be inserted into the query * @returns Array of query results */ sql(strings, ...values) { let query = ""; try { // Construct the SQL query with placeholders query = strings.reduce((acc, str, i) => acc + str + (i < values.length ? "?" : ""), ""); // Execute the SQL query with the provided values return [...this.ctx.storage.sql.exec(query, ...values)]; } catch (e) { console.error(`failed to execute sql query: ${query}`, e); throw e; } } /** * Tracks the last access time of an actor instance. * @param idString The identifier of the actor instance to track. */ async track(idString) { if (TRACKING_ACTOR_NAME === idString) { throw new Error(`Cannot track instance with same name as tracking instance, change value to differ from "${TRACKING_ACTOR_NAME}"`); } const trackingStub = getActor(this.constructor, TRACKING_ACTOR_NAME); const currentDateTime = new Date().toISOString(); await trackingStub.__studio({ type: 'query', statement: 'CREATE TABLE IF NOT EXISTS actors (identifier TEXT PRIMARY KEY, last_accessed TEXT)' }); await trackingStub.__studio({ type: 'query', statement: `INSERT INTO actors (identifier, last_accessed) VALUES (?, ?) ON CONFLICT(identifier) DO UPDATE SET last_accessed = ?`, params: [idString, currentDateTime, currentDateTime] }); } /** * Destroy the Actor by removing all actor library specific tables and state * that is associated with the actor. * @param _ - Optional configuration object * @param _.trackingInstance - Optional tracking instance name * @param _.forceEviction - When true, forces eviction of the actor from the cache * @throws Will throw an exception when forceEviction is true */ async destroy(_) { // If tracking instance is defined, delete the instance name from the tracking instance map. if (this.name) { try { const trackerActor = getActor(this.constructor, TRACKING_ACTOR_NAME); if (trackerActor) { await trackerActor.sql `DELETE FROM actors WHERE identifier = ${this.name};`; } } catch (e) { console.error(`Failed to delete actor from tracking instance: ${e instanceof Error ? e.message : 'Unknown error'}`); } } // Remove all alarms & delete all the storage await this.ctx.storage.deleteAlarm(); await this.ctx.storage.deleteAll(); if (_?.forceEviction) { // Enforce eviction of the actor. When forceEviction is true, the actor will be destroyed // and the worker will be evicted from the cache. This will throw an exception. this.ctx.abort("destroyed"); } } } /** * Static method to configure the actor. * @param options * @returns */ Actor.configuration = (request) => { return { locationHint: undefined, sockets: { upgradePath: "/ws" } }; }; /** * Creates a handler for a Worker or Actor. * This function can handle both class-based and function-based handlers. * @template E - The type of the environment object * @param input - The handler input (class or function) * @param opts - Optional options for integration features * @returns An ExportedHandler that can be used as a Worker */ export function handler(input, opts) { // If input is a plain function (not a class), wrap it in a simple handler if (typeof input === 'function' && !input.prototype) { return { async fetch(request, env, ctx) { const handler = input; const result = await handler(request, env, ctx); return result; } }; } // Handle existing Worker and DurableObject cases const ObjectClass = input; // Check if it's a Worker (has a no-arg constructor) if (ObjectClass && ObjectClass.prototype instanceof Entrypoint) { return { async fetch(request, env, ctx) { const instance = new ObjectClass(ctx, env); return instance.fetch(request); } }; } // For Actor classes, automatically create the worker if Actor is being used as an entrypoint if (ObjectClass.prototype instanceof Actor) { const worker = { async fetch(request, env, ctx) { try { const idString = await ObjectClass.nameFromRequest(request); // If no identifier is found or returned in `nameFromRequest` method, throw an error // to prevent attempting to access an instance that is invalid. if (idString === undefined) { return new Response(JSON.stringify({ error: "Internal Server Error", message: "Invalid actor identifier" }), { status: 500, headers: { "Content-Type": "application/json" } }); } const stub = getActor(ObjectClass, idString); // If tracking is enabled, track the current actor identifier in a separate durable object. if (opts?.track?.enabled) { try { await stub.track(idString); } catch (error) { console.error(`Failed to track actor instance: ${error instanceof Error ? error.message : 'Unknown error'}`); } } return stub.fetch(request); } catch (error) { return new Response(`Error handling request: ${error instanceof Error ? error.message : 'Unknown error'}`, { status: 500 }); } } }; return worker; } // If no class provided or it's not an Actor, return a more informative error return { async fetch(request) { return new Response("Invalid handler configuration. Please provide a valid Worker, Actor, or request handler function.", { status: 400 }); } }; } export function getActor(ActorClass, id) { const className = ActorClass.name; const envObj = env; const locationHint = ActorClass.configuration().locationHint; const bindingName = Object.keys(envObj).find(key => { const binding = env.__DURABLE_OBJECT_BINDINGS?.[key]; return key === className || binding?.class_name === className; }); if (!bindingName) { throw new Error(`No Durable Object binding found for actor class ${className}. Check update your wrangler.jsonc to match the binding "name" and "class_name" to be the same as the class name.`); } const namespace = envObj[bindingName]; const stub = namespace.getByName(id, { locationHint }); stub.setName(id); return stub; } //# sourceMappingURL=index.js.map