@cloudflare/actors
Version:
An easier way to build with Cloudflare Durable Objects
431 lines • 18.1 kB
JavaScript
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