UNPKG

@rivetkit/core

Version:

1,369 lines (1,216 loc) 35.8 kB
import * as cbor from "cbor-x"; import invariant from "invariant"; import onChange from "on-change"; import type { ActorKey } from "@/actor/mod"; import type * as wsToClient from "@/actor/protocol/message/to-client"; import type * as wsToServer from "@/actor/protocol/message/to-server"; import type { Client } from "@/client/client"; import type { Logger } from "@/common/log"; import { isCborSerializable, stringifyError } from "@/common/utils"; import type { UniversalWebSocket } from "@/common/websocket-interface"; import { ActorInspector } from "@/inspector/actor"; import type { Registry, RegistryConfig } from "@/mod"; import type { ActionContext } from "./action"; import type { ActorConfig, OnConnectOptions } from "./config"; import { Conn, type ConnId } from "./connection"; import { ActorContext } from "./context"; import type { AnyDatabaseProvider, InferDatabaseClient } from "./database"; import type { ActorDriver, ConnDriver, ConnDrivers } from "./driver"; import * as errors from "./errors"; import { instanceLogger, logger } from "./log"; import type { PersistedActor, PersistedConn, PersistedScheduleEvents, } from "./persisted"; import { processMessage } from "./protocol/message/mod"; import { CachedSerializer } from "./protocol/serde"; import { Schedule } from "./schedule"; import { DeadlineError, deadline, Lock } from "./utils"; /** * Options for the `_saveState` method. */ export interface SaveStateOptions { /** * Forces the state to be saved immediately. This function will return when the state has saved successfully. */ immediate?: boolean; } /** Actor type alias with all `any` types. Used for `extends` in classes referencing this actor. */ export type AnyActorInstance = ActorInstance< // biome-ignore lint/suspicious/noExplicitAny: Needs to be used in `extends` any, // biome-ignore lint/suspicious/noExplicitAny: Needs to be used in `extends` any, // biome-ignore lint/suspicious/noExplicitAny: Needs to be used in `extends` any, // biome-ignore lint/suspicious/noExplicitAny: Needs to be used in `extends` any, // biome-ignore lint/suspicious/noExplicitAny: Needs to be used in `extends` any, // biome-ignore lint/suspicious/noExplicitAny: Needs to be used in `extends` any, // biome-ignore lint/suspicious/noExplicitAny: Needs to be used in `extends` any >; export type ExtractActorState<A extends AnyActorInstance> = A extends ActorInstance< infer State, // biome-ignore lint/suspicious/noExplicitAny: Must be used for `extends` any, // biome-ignore lint/suspicious/noExplicitAny: Must be used for `extends` any, // biome-ignore lint/suspicious/noExplicitAny: Must be used for `extends` any, // biome-ignore lint/suspicious/noExplicitAny: Must be used for `extends` any, // biome-ignore lint/suspicious/noExplicitAny: Must be used for `extends` any, // biome-ignore lint/suspicious/noExplicitAny: Must be used for `extends` any > ? State : never; export type ExtractActorConnParams<A extends AnyActorInstance> = A extends ActorInstance< // biome-ignore lint/suspicious/noExplicitAny: Must be used for `extends` any, infer ConnParams, // biome-ignore lint/suspicious/noExplicitAny: Must be used for `extends` any, // biome-ignore lint/suspicious/noExplicitAny: Must be used for `extends` any, // biome-ignore lint/suspicious/noExplicitAny: Must be used for `extends` any, // biome-ignore lint/suspicious/noExplicitAny: Must be used for `extends` any, // biome-ignore lint/suspicious/noExplicitAny: Must be used for `extends` any > ? ConnParams : never; export type ExtractActorConnState<A extends AnyActorInstance> = A extends ActorInstance< // biome-ignore lint/suspicious/noExplicitAny: Must be used for `extends` any, // biome-ignore lint/suspicious/noExplicitAny: Must be used for `extends` any, infer ConnState, // biome-ignore lint/suspicious/noExplicitAny: Must be used for `extends` any, // biome-ignore lint/suspicious/noExplicitAny: Must be used for `extends` any, // biome-ignore lint/suspicious/noExplicitAny: Must be used for `extends` any, // biome-ignore lint/suspicious/noExplicitAny: Must be used for `extends` any > ? ConnState : never; export class ActorInstance< S, CP, CS, V, I, AD, DB extends AnyDatabaseProvider, > { // Shared actor context for this instance actorContext: ActorContext<S, CP, CS, V, I, AD, DB>; isStopping = false; #persistChanged = false; /** * The proxied state that notifies of changes automatically. * * Any data that should be stored indefinitely should be held within this object. */ #persist!: PersistedActor<S, CP, CS, I>; /** Raw state without the proxy wrapper */ #persistRaw!: PersistedActor<S, CP, CS, I>; #writePersistLock = new Lock<void>(void 0); #lastSaveTime = 0; #pendingSaveTimeout?: NodeJS.Timeout; #vars?: V; #backgroundPromises: Promise<void>[] = []; #config: ActorConfig<S, CP, CS, V, I, AD, DB>; #connectionDrivers!: ConnDrivers; #actorDriver!: ActorDriver; #inlineClient!: Client<Registry<any>>; #actorId!: string; #name!: string; #key!: ActorKey; #region!: string; #ready = false; #connections = new Map<ConnId, Conn<S, CP, CS, V, I, AD, DB>>(); #subscriptionIndex = new Map<string, Set<Conn<S, CP, CS, V, I, AD, DB>>>(); #schedule!: Schedule; #db!: InferDatabaseClient<DB>; #inspector = new ActorInspector(() => { return { isDbEnabled: async () => { return this.#db !== undefined; }, getDb: async () => { return this.db; }, isStateEnabled: async () => { return this.stateEnabled; }, getState: async () => { this.#validateStateEnabled(); // Must return from `#persistRaw` in order to not return the `onchange` proxy return this.#persistRaw.s as Record<string, any> as unknown; }, getRpcs: async () => { return Object.keys(this.#config.actions); }, getConnections: async () => { return Array.from(this.#connections.entries()).map(([id, conn]) => ({ id, stateEnabled: conn._stateEnabled, params: conn.params as {}, state: conn._stateEnabled ? conn.state : undefined, auth: conn.auth as {}, })); }, setState: async (state: unknown) => { this.#validateStateEnabled(); // Must set on `#persist` instead of `#persistRaw` in order to ensure that the `Proxy` is correctly configured // // We have to use `...` so `on-change` recognizes the changes to `state` (i.e. set #persistChanged` to true). This is because: // 1. In `getState`, we returned the value from `persistRaw`, which does not have the Proxy to monitor state changes // 2. If we were to assign `state` to `#persist.s`, `on-change` would assume nothing changed since `state` is still === `#persist.s` since we returned a reference in `getState` this.#persist.s = { ...(state as S) }; await this.saveState({ immediate: true }); }, }; }); get id() { return this.#actorId; } get inlineClient(): Client<Registry<any>> { return this.#inlineClient; } get inspector() { return this.#inspector; } /** * This constructor should never be used directly. * * Constructed in {@link ActorInstance.start}. * * @private */ constructor(config: ActorConfig<S, CP, CS, V, I, AD, DB>) { this.#config = config; this.actorContext = new ActorContext(this); } async start( connectionDrivers: ConnDrivers, actorDriver: ActorDriver, inlineClient: Client<Registry<any>>, actorId: string, name: string, key: ActorKey, region: string, ) { this.#connectionDrivers = connectionDrivers; this.#actorDriver = actorDriver; this.#inlineClient = inlineClient; this.#actorId = actorId; this.#name = name; this.#key = key; this.#region = region; this.#schedule = new Schedule(this); // Initialize server // // Store the promise so network requests can await initialization await this.#initialize(); // TODO: Exit process if this errors if (this.#varsEnabled) { let vars: V | undefined; if ("createVars" in this.#config) { const dataOrPromise = this.#config.createVars( this.actorContext as unknown as ActorContext< undefined, undefined, undefined, undefined, undefined, undefined, any >, this.#actorDriver.getContext(this.#actorId), ); if (dataOrPromise instanceof Promise) { vars = await deadline( dataOrPromise, this.#config.options.lifecycle.createVarsTimeout, ); } else { vars = dataOrPromise; } } else if ("vars" in this.#config) { vars = structuredClone(this.#config.vars); } else { throw new Error("Could not variables from 'createVars' or 'vars'"); } this.#vars = vars; } // TODO: Exit process if this errors logger().info("actor starting"); if (this.#config.onStart) { const result = this.#config.onStart(this.actorContext); if (result instanceof Promise) { await result; } } // Setup Database if ("db" in this.#config && this.#config.db) { const client = await this.#config.db.createClient({ getDatabase: () => actorDriver.getDatabase(this.#actorId), }); logger().info("database migration starting"); await this.#config.db.onMigrate?.(client); logger().info("database migration complete"); this.#db = client; } // Set alarm for next scheduled event if any exist after finishing initiation sequence if (this.#persist.e.length > 0) { await this.#actorDriver.setAlarm(this, this.#persist.e[0].t); } logger().info("actor ready"); this.#ready = true; } async scheduleEvent( timestamp: number, fn: string, args: unknown[], ): Promise<void> { // Build event const eventId = crypto.randomUUID(); const newEvent: PersistedScheduleEvents = { e: eventId, t: timestamp, a: fn, ar: args, }; this.actorContext.log.info("scheduling event", { event: eventId, timestamp, action: fn, }); // Insert event in to index const insertIndex = this.#persist.e.findIndex((x) => x.t > newEvent.t); if (insertIndex === -1) { this.#persist.e.push(newEvent); } else { this.#persist.e.splice(insertIndex, 0, newEvent); } // Update alarm if: // - this is the newest event (i.e. at beginning of array) or // - this is the only event (i.e. the only event in the array) if (insertIndex === 0 || this.#persist.e.length === 1) { this.actorContext.log.info("setting alarm", { timestamp }); await this.#actorDriver.setAlarm(this, newEvent.t); } } async onAlarm() { const now = Date.now(); this.actorContext.log.debug("alarm triggered", { now, events: this.#persist.e.length, }); // Remove events from schedule that we're about to run const runIndex = this.#persist.e.findIndex((x) => x.t <= now); if (runIndex === -1) { this.actorContext.log.debug("no events to run", { now }); return; } const scheduleEvents = this.#persist.e.splice(0, runIndex + 1); this.actorContext.log.debug("running events", { count: scheduleEvents.length, }); // Set alarm for next event if (this.#persist.e.length > 0) { await this.#actorDriver.setAlarm(this, this.#persist.e[0].t); } // Iterate by event key in order to ensure we call the events in order for (const event of scheduleEvents) { try { this.actorContext.log.info("running action for event", { event: event.e, timestamp: event.t, action: event.a, args: event.ar, }); // Look up function const fn: unknown = this.#config.actions[event.a]; if (!fn) throw new Error(`Missing action for alarm ${event.a}`); if (typeof fn !== "function") throw new Error( `Alarm function lookup for ${event.a} returned ${typeof fn}`, ); // Call function try { await fn.call(undefined, this.actorContext, ...event.ar); } catch (error) { this.actorContext.log.error("error while running event", { error: stringifyError(error), event: event.e, timestamp: event.t, action: event.a, args: event.ar, }); } } catch (error) { this.actorContext.log.error("internal error while running event", { error: stringifyError(error), event: event.e, timestamp: event.t, action: event.a, args: event.ar, }); } } } get stateEnabled() { return "createState" in this.#config || "state" in this.#config; } #validateStateEnabled() { if (!this.stateEnabled) { throw new errors.StateNotEnabled(); } } get #connStateEnabled() { return "createConnState" in this.#config || "connState" in this.#config; } get #varsEnabled() { return "createVars" in this.#config || "vars" in this.#config; } #validateVarsEnabled() { if (!this.#varsEnabled) { throw new errors.VarsNotEnabled(); } } /** Promise used to wait for a save to complete. This is required since you cannot await `#saveStateThrottled`. */ #onPersistSavedPromise?: PromiseWithResolvers<void>; /** Throttled save state method. Used to write to KV at a reasonable cadence. */ #savePersistThrottled() { const now = Date.now(); const timeSinceLastSave = now - this.#lastSaveTime; const saveInterval = this.#config.options.state.saveInterval; // If we're within the throttle window and not already scheduled, schedule the next save. if (timeSinceLastSave < saveInterval) { if (this.#pendingSaveTimeout === undefined) { this.#pendingSaveTimeout = setTimeout(() => { this.#pendingSaveTimeout = undefined; this.#savePersistInner(); }, saveInterval - timeSinceLastSave); } } else { // If we're outside the throttle window, save immediately this.#savePersistInner(); } } /** Saves the state to KV. You probably want to use #saveStateThrottled instead except for a few edge cases. */ async #savePersistInner() { try { this.#lastSaveTime = Date.now(); if (this.#persistChanged) { // Use a lock in order to avoid race conditions with multiple // parallel promises writing to KV. This should almost never happen // unless there are abnormally high latency in KV writes. await this.#writePersistLock.lock(async () => { logger().debug("saving persist"); // There might be more changes while we're writing, so we set this // before writing to KV in order to avoid a race condition. this.#persistChanged = false; // Write to KV await this.#actorDriver.writePersistedData( this.#actorId, cbor.encode(this.#persistRaw), ); logger().debug("persist saved"); }); } this.#onPersistSavedPromise?.resolve(); } catch (error) { this.#onPersistSavedPromise?.reject(error); throw error; } } /** * Creates proxy for `#persist` that handles automatically flagging when state needs to be updated. */ #setPersist(target: PersistedActor<S, CP, CS, I>) { // Set raw persist object this.#persistRaw = target; // TODO: Only validate this for conn state // TODO: Allow disabling in production // If this can't be proxied, return raw value if (target === null || typeof target !== "object") { let invalidPath = ""; if ( !isCborSerializable( target, (path) => { invalidPath = path; }, "", ) ) { throw new errors.InvalidStateType({ path: invalidPath }); } return target; } // Unsubscribe from old state if (this.#persist) { onChange.unsubscribe(this.#persist); } // Listen for changes to the object in order to automatically write state this.#persist = onChange( target, // biome-ignore lint/suspicious/noExplicitAny: Don't know types in proxy (path: string, value: any, _previousValue: any, _applyData: any) => { let invalidPath = ""; if ( !isCborSerializable( value, (invalidPathPart) => { invalidPath = invalidPathPart; }, "", ) ) { throw new errors.InvalidStateType({ path: path + (invalidPath ? `.${invalidPath}` : ""), }); } this.#persistChanged = true; // Inform the inspector about state changes this.inspector.emitter.emit("stateUpdated", this.#persist.s); // Call onStateChange if it exists if (this.#config.onStateChange && this.#ready) { try { this.#config.onStateChange(this.actorContext, this.#persistRaw.s); } catch (error) { logger().error("error in `_onStateChange`", { error: stringifyError(error), }); } } // State will be flushed at the end of the action }, { ignoreDetached: true }, ); } async #initialize() { // Read initial state const persistDataBuffer = await this.#actorDriver.readPersistedData( this.#actorId, ); invariant( persistDataBuffer !== undefined, "persist data has not been set, it should be set when initialized", ); const persistData = cbor.decode(persistDataBuffer) as PersistedActor< S, CP, CS, I >; if (persistData.hi) { logger().info("actor restoring", { connections: persistData.c.length, }); // Set initial state this.#setPersist(persistData); // Load connections for (const connPersist of this.#persist.c) { // Create connections const driver = this.__getConnDriver(connPersist.d); const conn = new Conn<S, CP, CS, V, I, AD, DB>( this, connPersist, driver, this.#connStateEnabled, ); this.#connections.set(conn.id, conn); // Register event subscriptions for (const sub of connPersist.su) { this.#addSubscription(sub.n, conn, true); } } } else { logger().info("actor creating"); // Initialize actor state let stateData: unknown; if (this.stateEnabled) { logger().info("actor state initializing"); if ("createState" in this.#config) { this.#config.createState; // Convert state to undefined since state is not defined yet here stateData = await this.#config.createState( this.actorContext as unknown as ActorContext< undefined, undefined, undefined, undefined, undefined, undefined, undefined >, persistData.i!, ); } else if ("state" in this.#config) { stateData = structuredClone(this.#config.state); } else { throw new Error("Both 'createState' or 'state' were not defined"); } } else { logger().debug("state not enabled"); } // Save state and mark as initialized persistData.s = stateData as S; persistData.hi = true; // Update state logger().debug("writing state"); await this.#actorDriver.writePersistedData( this.#actorId, cbor.encode(persistData), ); this.#setPersist(persistData); // Notify creation if (this.#config.onCreate) { await this.#config.onCreate(this.actorContext, persistData.i!); } } } __getConnForId(id: string): Conn<S, CP, CS, V, I, AD, DB> | undefined { return this.#connections.get(id); } /** * Removes a connection and cleans up its resources. */ __removeConn(conn: Conn<S, CP, CS, V, I, AD, DB> | undefined) { if (!conn) { logger().warn("`conn` does not exist"); return; } // Remove from persist & save immediately const connIdx = this.#persist.c.findIndex((c) => c.i === conn.id); if (connIdx !== -1) { this.#persist.c.splice(connIdx, 1); this.saveState({ immediate: true }); } else { logger().warn("could not find persisted connection to remove", { connId: conn.id, }); } // Remove from state this.#connections.delete(conn.id); // Remove subscriptions for (const eventName of [...conn.subscriptions.values()]) { this.#removeSubscription(eventName, conn, true); } this.inspector.emitter.emit("connectionUpdated"); if (this.#config.onDisconnect) { try { const result = this.#config.onDisconnect(this.actorContext, conn); if (result instanceof Promise) { // Handle promise but don't await it to prevent blocking result.catch((error) => { logger().error("error in `onDisconnect`", { error: stringifyError(error), }); }); } } catch (error) { logger().error("error in `onDisconnect`", { error: stringifyError(error), }); } } } async prepareConn( // biome-ignore lint/suspicious/noExplicitAny: TypeScript bug with ExtractActorConnParams<this>, params: any, request?: Request, ): Promise<CS> { // Authenticate connection let connState: CS | undefined; const onBeforeConnectOpts = { request, } satisfies OnConnectOptions; if (this.#config.onBeforeConnect) { await this.#config.onBeforeConnect( this.actorContext, onBeforeConnectOpts, params, ); } if (this.#connStateEnabled) { if ("createConnState" in this.#config) { const dataOrPromise = this.#config.createConnState( this.actorContext as unknown as ActorContext< undefined, undefined, undefined, undefined, undefined, undefined, undefined >, onBeforeConnectOpts, params, ); if (dataOrPromise instanceof Promise) { connState = await deadline( dataOrPromise, this.#config.options.lifecycle.createConnStateTimeout, ); } else { connState = dataOrPromise; } } else if ("connState" in this.#config) { connState = structuredClone(this.#config.connState); } else { throw new Error( "Could not create connection state from 'createConnState' or 'connState'", ); } } return connState as CS; } __getConnDriver(driverId: string): ConnDriver { // Get driver const driver = this.#connectionDrivers[driverId]; if (!driver) throw new Error(`No connection driver: ${driverId}`); return driver; } /** * Called after establishing a connection handshake. */ async createConn( connectionId: string, connectionToken: string, params: CP, state: CS, driverId: string, driverState: unknown, authData: unknown, ): Promise<Conn<S, CP, CS, V, I, AD, DB>> { this.#assertReady(); if (this.#connections.has(connectionId)) { throw new Error(`Connection already exists: ${connectionId}`); } // Create connection const driver = this.__getConnDriver(driverId); const persist: PersistedConn<CP, CS> = { i: connectionId, t: connectionToken, d: driverId, ds: driverState, p: params, s: state, a: authData, su: [], }; const conn = new Conn<S, CP, CS, V, I, AD, DB>( this, persist, driver, this.#connStateEnabled, ); this.#connections.set(conn.id, conn); // Add to persistence & save immediately this.#persist.c.push(persist); this.saveState({ immediate: true }); // Handle connection if (this.#config.onConnect) { try { const result = this.#config.onConnect(this.actorContext, conn); if (result instanceof Promise) { deadline( result, this.#config.options.lifecycle.onConnectTimeout, ).catch((error) => { logger().error("error in `onConnect`, closing socket", { error, }); conn?.disconnect("`onConnect` failed"); }); } } catch (error) { logger().error("error in `onConnect`", { error: stringifyError(error), }); conn?.disconnect("`onConnect` failed"); } } this.inspector.emitter.emit("connectionUpdated"); // Send init message conn._sendMessage( new CachedSerializer<wsToClient.ToClient>({ b: { i: { ai: this.id, ci: conn.id, ct: conn._token, }, }, }), ); return conn; } // MARK: Messages async processMessage( message: wsToServer.ToServer, conn: Conn<S, CP, CS, V, I, AD, DB>, ) { await processMessage(message, this, conn, { onExecuteAction: async (ctx, name, args) => { this.inspector.emitter.emit("eventFired", { type: "action", name, args, connId: conn.id, }); return await this.executeAction(ctx, name, args); }, onSubscribe: async (eventName, conn) => { this.inspector.emitter.emit("eventFired", { type: "subscribe", eventName, connId: conn.id, }); this.#addSubscription(eventName, conn, false); }, onUnsubscribe: async (eventName, conn) => { this.inspector.emitter.emit("eventFired", { type: "unsubscribe", eventName, connId: conn.id, }); this.#removeSubscription(eventName, conn, false); }, }); } // MARK: Events #addSubscription( eventName: string, connection: Conn<S, CP, CS, V, I, AD, DB>, fromPersist: boolean, ) { if (connection.subscriptions.has(eventName)) { logger().debug("connection already has subscription", { eventName }); return; } // Persist subscriptions & save immediately // // Don't update persistence if already restoring from persistence if (!fromPersist) { connection.__persist.su.push({ n: eventName }); this.saveState({ immediate: true }); } // Update subscriptions connection.subscriptions.add(eventName); // Update subscription index let subscribers = this.#subscriptionIndex.get(eventName); if (!subscribers) { subscribers = new Set(); this.#subscriptionIndex.set(eventName, subscribers); } subscribers.add(connection); } #removeSubscription( eventName: string, connection: Conn<S, CP, CS, V, I, AD, DB>, fromRemoveConn: boolean, ) { if (!connection.subscriptions.has(eventName)) { logger().warn("connection does not have subscription", { eventName }); return; } // Persist subscriptions & save immediately // // Don't update the connection itself if the connection is already being removed if (!fromRemoveConn) { connection.subscriptions.delete(eventName); const subIdx = connection.__persist.su.findIndex( (s) => s.n === eventName, ); if (subIdx !== -1) { connection.__persist.su.splice(subIdx, 1); } else { logger().warn("subscription does not exist with name", { eventName }); } this.saveState({ immediate: true }); } // Update scriptions index const subscribers = this.#subscriptionIndex.get(eventName); if (subscribers) { subscribers.delete(connection); if (subscribers.size === 0) { this.#subscriptionIndex.delete(eventName); } } } #assertReady() { if (!this.#ready) throw new errors.InternalError("Actor not ready"); } /** * Check if the actor is ready to handle requests. */ isReady(): boolean { return this.#ready; } /** * Execute an action call from a client. * * This method handles: * 1. Validating the action name * 2. Executing the action function * 3. Processing the result through onBeforeActionResponse (if configured) * 4. Handling timeouts and errors * 5. Saving state changes * * @param ctx The action context * @param actionName The name of the action being called * @param args The arguments passed to the action * @returns The result of the action call * @throws {ActionNotFound} If the action doesn't exist * @throws {ActionTimedOut} If the action times out * @internal */ async executeAction( ctx: ActionContext<S, CP, CS, V, I, AD, DB>, actionName: string, args: unknown[], ): Promise<unknown> { invariant(this.#ready, "exucuting action before ready"); // Prevent calling private or reserved methods if (!(actionName in this.#config.actions)) { logger().warn("action does not exist", { actionName }); throw new errors.ActionNotFound(actionName); } // Check if the method exists on this object const actionFunction = this.#config.actions[actionName]; if (typeof actionFunction !== "function") { logger().warn("action is not a function", { actionName: actionName, type: typeof actionFunction, }); throw new errors.ActionNotFound(actionName); } // TODO: pass abortable to the action to decide when to abort // TODO: Manually call abortable for better error handling // Call the function on this object with those arguments try { // Log when we start executing the action logger().debug("executing action", { actionName: actionName, args }); const outputOrPromise = actionFunction.call(undefined, ctx, ...args); let output: unknown; if (outputOrPromise instanceof Promise) { // Log that we're waiting for an async action logger().debug("awaiting async action", { actionName: actionName }); output = await deadline( outputOrPromise, this.#config.options.action.timeout, ); // Log that async action completed logger().debug("async action completed", { actionName: actionName }); } else { output = outputOrPromise; } // Process the output through onBeforeActionResponse if configured if (this.#config.onBeforeActionResponse) { try { const processedOutput = this.#config.onBeforeActionResponse( this.actorContext, actionName, args, output, ); if (processedOutput instanceof Promise) { logger().debug("awaiting onBeforeActionResponse", { actionName: actionName, }); output = await processedOutput; logger().debug("onBeforeActionResponse completed", { actionName: actionName, }); } else { output = processedOutput; } } catch (error) { logger().error("error in `onBeforeActionResponse`", { error: stringifyError(error), }); } } // Log the output before returning logger().debug("action completed", { actionName: actionName, outputType: typeof output, isPromise: output instanceof Promise, }); // This output *might* reference a part of the state (using onChange), but // that's OK since this value always gets serialized and sent over the // network. return output; } catch (error) { if (error instanceof DeadlineError) { throw new errors.ActionTimedOut(); } logger().error("action error", { actionName: actionName, error: stringifyError(error), }); throw error; } finally { this.#savePersistThrottled(); } } /** * Returns a list of action methods available on this actor. */ get actions(): string[] { return Object.keys(this.#config.actions); } /** * Handles raw HTTP requests to the actor. */ async handleFetch(request: Request, opts: { auth: AD }): Promise<Response> { this.#assertReady(); if (!this.#config.onFetch) { throw new errors.FetchHandlerNotDefined(); } try { const response = await this.#config.onFetch( this.actorContext, request, opts, ); if (!response) { throw new errors.InvalidFetchResponse(); } return response; } catch (error) { logger().error("onFetch error", { error: stringifyError(error), }); throw error; } finally { this.#savePersistThrottled(); } } /** * Handles raw WebSocket connections to the actor. */ async handleWebSocket( websocket: UniversalWebSocket, opts: { request: Request; auth: AD }, ): Promise<void> { this.#assertReady(); if (!this.#config.onWebSocket) { throw new errors.InternalError("onWebSocket handler not defined"); } try { // Set up state tracking to detect changes during WebSocket handling const stateBeforeHandler = this.#persistChanged; await this.#config.onWebSocket(this.actorContext, websocket, opts); // If state changed during the handler, save it if (this.#persistChanged && !stateBeforeHandler) { await this.saveState({ immediate: true }); } } catch (error) { logger().error("onWebSocket error", { error: stringifyError(error), }); throw error; } finally { this.#savePersistThrottled(); } } // MARK: Lifecycle hooks // MARK: Exposed methods /** * Gets the logger instance. */ get log(): Logger { return instanceLogger(); } /** * Gets the name. */ get name(): string { return this.#name; } /** * Gets the key. */ get key(): ActorKey { return this.#key; } /** * Gets the region. */ get region(): string { return this.#region; } /** * Gets the scheduler. */ get schedule(): Schedule { return this.#schedule; } /** * Gets the map of connections. */ get conns(): Map<ConnId, Conn<S, CP, CS, V, I, AD, DB>> { return this.#connections; } /** * Gets the current state. * * Changing properties of this value will automatically be persisted. */ get state(): S { this.#validateStateEnabled(); return this.#persist.s; } /** * Gets the database. * @experimental * @throws {DatabaseNotEnabled} If the database is not enabled. */ get db(): InferDatabaseClient<DB> { if (!this.#db) { throw new errors.DatabaseNotEnabled(); } return this.#db; } /** * Sets the current state. * * This property will automatically be persisted. */ set state(value: S) { this.#validateStateEnabled(); this.#persist.s = value; } get vars(): V { this.#validateVarsEnabled(); invariant(this.#vars !== undefined, "vars not enabled"); return this.#vars; } /** * Broadcasts an event to all connected clients. * @param name - The name of the event. * @param args - The arguments to send with the event. */ _broadcast<Args extends Array<unknown>>(name: string, ...args: Args) { this.#assertReady(); this.inspector.emitter.emit("eventFired", { type: "broadcast", eventName: name, args, }); // Send to all connected clients const subscriptions = this.#subscriptionIndex.get(name); if (!subscriptions) return; const toClientSerializer = new CachedSerializer<wsToClient.ToClient>({ b: { ev: { n: name, a: args, }, }, }); // Send message to clients for (const connection of subscriptions) { connection._sendMessage(toClientSerializer); } } /** * Runs a promise in the background. * * This allows the actor runtime to ensure that a promise completes while * returning from an action request early. * * @param promise - The promise to run in the background. */ _runInBackground(promise: Promise<void>) { this.#assertReady(); // TODO: Should we force save the state? // Add logging to promise and make it non-failable const nonfailablePromise = promise .then(() => { logger().debug("background promise complete"); }) .catch((error) => { logger().error("background promise failed", { error: stringifyError(error), }); }); this.#backgroundPromises.push(nonfailablePromise); } /** * Forces the state to get saved. * * This is helpful if running a long task that may fail later or when * running a background job that updates the state. * * @param opts - Options for saving the state. */ async saveState(opts: SaveStateOptions) { this.#assertReady(); if (this.#persistChanged) { if (opts.immediate) { // Save immediately await this.#savePersistInner(); } else { // Create callback if (!this.#onPersistSavedPromise) { this.#onPersistSavedPromise = Promise.withResolvers(); } // Save state throttled this.#savePersistThrottled(); // Wait for save await this.#onPersistSavedPromise.promise; } } } async stop() { if (this.isStopping) { logger().warn("already stopping actor"); return; } this.isStopping = true; // Write state await this.saveState({ immediate: true }); // Disconnect existing connections const promises: Promise<unknown>[] = []; for (const connection of this.#connections.values()) { promises.push(connection.disconnect()); // TODO: Figure out how to abort HTTP requests on shutdown } // Await all `close` event listeners with 1.5 second timeout const res = Promise.race([ Promise.all(promises).then(() => false), new Promise<boolean>((res) => globalThis.setTimeout(() => res(true), 1500), ), ]); if (await res) { logger().warn( "timed out waiting for connections to close, shutting down anyway", ); } // TODO: //Deno.exit(0); } }