UNPKG

rivetkit

Version:

Lightweight libraries for building stateful actors on edge platforms

1,758 lines (1,749 loc) 121 kB
import { ColumnsSchema, ForeignKeysSchema, PatchSchema, TablesSchema } from "./chunk-KGDZYQYE.js"; import { importWebSocket, logger } from "./chunk-346X2XU4.js"; import { HTTP_ACTION_REQUEST_VERSIONED, HTTP_ACTION_RESPONSE_VERSIONED, HTTP_RESPONSE_ERROR_VERSIONED, PERSISTED_ACTOR_VERSIONED, TO_CLIENT_VERSIONED, TO_SERVER_VERSIONED, inputDataToBuffer, processMessage } from "./chunk-QRFXXTLG.js"; import { CachedSerializer, DeadlineError, HEADER_CONN_ID, HEADER_CONN_PARAMS, HEADER_CONN_TOKEN, HEADER_ENCODING, HEADER_RIVET_ACTOR, HEADER_RIVET_TARGET, HEADER_RIVET_TOKEN, PATH_CONNECT_WEBSOCKET, PATH_RAW_WEBSOCKET_PREFIX, WS_PROTOCOL_ACTOR, WS_PROTOCOL_CONN_ID, WS_PROTOCOL_CONN_PARAMS, WS_PROTOCOL_CONN_TOKEN, WS_PROTOCOL_ENCODING, WS_PROTOCOL_STANDARD, WS_PROTOCOL_TARGET, WS_PROTOCOL_TOKEN, assertUnreachable as assertUnreachable2, contentTypeForEncoding, deadline, deserializeWithEncoding, encodeDataToString, encodingIsBinary, generateRandomString, generateSecureToken, jsonStringifyCompat, serializeWithEncoding, uint8ArrayToBase64 } from "./chunk-MLQIYKAZ.js"; import { getBaseLogger, getIncludeTarget, getLogger } from "./chunk-7E5K3375.js"; import { SinglePromiseQueue, assertUnreachable, bufferToArrayBuffer, combineUrlPath, deconstructError, getEnvUniversal, httpUserAgent, isCborSerializable, noopNext, promiseWithResolvers, stringifyError } from "./chunk-HI55LHM3.js"; import { ActionNotFound, ActionTimedOut, ActorNotFound, ConnStateNotEnabled, DatabaseNotEnabled, FetchHandlerNotDefined, InternalError, InvalidFetchResponse, InvalidRequest, InvalidStateType, StateNotEnabled, VarsNotEnabled } from "./chunk-YPZFLUO6.js"; // src/actor/conn.ts import * as cbor from "cbor-x"; // src/actor/conn-drivers.ts var WEBSOCKET_DRIVER = { sendMessage: (actor, _conn, state, message) => { const serialized = message.serialize(state.encoding); actor.rLog.debug({ msg: "sending websocket message", encoding: state.encoding, dataType: typeof serialized, isUint8Array: serialized instanceof Uint8Array, isArrayBuffer: serialized instanceof ArrayBuffer, dataLength: serialized.byteLength || serialized.length }); if (serialized instanceof Uint8Array) { const buffer = serialized.buffer.slice( serialized.byteOffset, serialized.byteOffset + serialized.byteLength ); if (buffer instanceof SharedArrayBuffer) { const arrayBuffer = new ArrayBuffer(buffer.byteLength); new Uint8Array(arrayBuffer).set(new Uint8Array(buffer)); actor.rLog.debug({ msg: "converted SharedArrayBuffer to ArrayBuffer", byteLength: arrayBuffer.byteLength }); state.websocket.send(arrayBuffer); } else { actor.rLog.debug({ msg: "sending ArrayBuffer", byteLength: buffer.byteLength }); state.websocket.send(buffer); } } else { actor.rLog.debug({ msg: "sending string data", length: serialized.length }); state.websocket.send(serialized); } }, disconnect: async (_actor, _conn, state, reason) => { state.websocket.close(1e3, reason); await state.closePromise.promise; }, getConnectionReadyState: (_actor, _conn, state) => { return state.websocket.readyState; } }; var SSE_DRIVER = { sendMessage: (_actor, _conn, state, message) => { state.stream.writeSSE({ data: encodeDataToString(message.serialize(state.encoding)) }); }, disconnect: async (_actor, _conn, state, _reason) => { state.stream.close(); }, getConnectionReadyState: (_actor, _conn, state) => { if (state.stream.aborted || state.stream.closed) { return 3 /* CLOSED */; } return 1 /* OPEN */; } }; var HTTP_DRIVER = { getConnectionReadyState(_actor, _conn) { return 1 /* OPEN */; }, disconnect: async () => { } }; var CONN_DRIVERS = { [0 /* WEBSOCKET */]: WEBSOCKET_DRIVER, [1 /* SSE */]: SSE_DRIVER, [2 /* HTTP */]: HTTP_DRIVER }; function getConnDriverKindFromState(state) { if (0 /* WEBSOCKET */ in state) return 0 /* WEBSOCKET */; else if (1 /* SSE */ in state) return 1 /* SSE */; else if (2 /* HTTP */ in state) return 2 /* HTTP */; else assertUnreachable(state); } // src/actor/conn.ts function generateConnId() { return crypto.randomUUID(); } function generateConnToken() { return generateSecureToken(32); } function generateConnSocketId() { return crypto.randomUUID(); } var Conn = class { subscriptions = /* @__PURE__ */ new Set(); // TODO: Remove this cyclical reference #actor; /** * The proxied state that notifies of changes automatically. * * Any data that should be stored indefinitely should be held within this object. */ __persist; get __driverState() { var _a; return (_a = this.__socket) == null ? void 0 : _a.driverState; } /** * Socket connected to this connection. * * If undefined, then nothing is connected to this. */ __socket; get __status() { if (this.__socket) { return "connected"; } else { return "reconnecting"; } } get params() { return this.__persist.params; } get __stateEnabled() { return this.#actor.connStateEnabled; } /** * Gets the current state of the connection. * * Throws an error if the state is not enabled. */ get state() { this.#validateStateEnabled(); if (!this.__persist.state) throw new Error("state should exists"); return this.__persist.state; } /** * Sets the state of the connection. * * Throws an error if the state is not enabled. */ set state(value) { this.#validateStateEnabled(); this.__persist.state = value; } /** * Unique identifier for the connection. */ get id() { return this.__persist.connId; } /** * Token used to authenticate this request. */ get _token() { return this.__persist.token; } /** * Status of the connection. */ get status() { return this.__status; } /** * Timestamp of the last time the connection was seen, i.e. the last time the connection was active and checked for liveness. */ get lastSeen() { return this.__persist.lastSeen; } /** * Initializes a new instance of the Connection class. * * This should only be constructed by {@link Actor}. * * @protected */ constructor(actor, persist) { this.#actor = actor; this.__persist = persist; } #validateStateEnabled() { if (!this.__stateEnabled) { throw new ConnStateNotEnabled(); } } /** * Sends a WebSocket message to the client. * * @param message - The message to send. * * @protected */ _sendMessage(message) { if (this.__driverState) { const driverKind = getConnDriverKindFromState(this.__driverState); const driver = CONN_DRIVERS[driverKind]; if (driver.sendMessage) { driver.sendMessage( this.#actor, this, this.__driverState[driverKind], message ); } else { this.#actor.rLog.debug({ msg: "conn driver does not support sending messages", conn: this.id }); } } else { this.#actor.rLog.warn({ msg: "missing connection driver state for send message", conn: this.id }); } } /** * Sends an event with arguments to the client. * * @param eventName - The name of the event. * @param args - The arguments for the event. * @see {@link https://rivet.dev/docs/events|Events Documentation} */ send(eventName, ...args) { this.#actor.inspector.emitter.emit("eventFired", { type: "event", eventName, args, connId: this.id }); this._sendMessage( new CachedSerializer( { body: { tag: "Event", val: { name: eventName, args: bufferToArrayBuffer(cbor.encode(args)) } } }, TO_CLIENT_VERSIONED ) ); } /** * Disconnects the client with an optional reason. * * @param reason - The reason for disconnection. */ async disconnect(reason) { if (this.__socket && this.__driverState) { const driverKind = getConnDriverKindFromState(this.__driverState); const driver = CONN_DRIVERS[driverKind]; if (driver.disconnect) { driver.disconnect( this.#actor, this, this.__driverState[driverKind], reason ); } else { this.#actor.rLog.debug({ msg: "no disconnect handler for conn driver", conn: this.id }); } this.#actor.__connDisconnected(this, true, this.__socket.socketId); } else { this.#actor.rLog.warn({ msg: "missing connection driver state for disconnect", conn: this.id }); } this.__socket = void 0; } }; // src/actor/instance.ts import * as cbor2 from "cbor-x"; import invariant from "invariant"; import onChange from "on-change"; // src/inspector/actor.ts import { sValidator } from "@hono/standard-validator"; import jsonPatch from "@rivetkit/fast-json-patch"; import { Hono } from "hono"; import { streamSSE } from "hono/streaming"; import { createNanoEvents } from "nanoevents"; import z from "zod/v4"; function createActorInspectorRouter() { return new Hono().get("/ping", (c) => { return c.json({ message: "pong" }, 200); }).get("/state", async (c) => { if (await c.var.inspector.accessors.isStateEnabled()) { return c.json( { enabled: true, state: await c.var.inspector.accessors.getState() }, 200 ); } return c.json({ enabled: false, state: null }, 200); }).patch( "/state", sValidator( "json", z.object({ patch: PatchSchema }).or(z.object({ replace: z.any() })) ), async (c) => { if (!await c.var.inspector.accessors.isStateEnabled()) { return c.json({ enabled: false }, 200); } const body = c.req.valid("json"); if ("replace" in body) { await c.var.inspector.accessors.setState(body.replace); return c.json( { enabled: true, state: await c.var.inspector.accessors.getState() }, 200 ); } const state = await c.var.inspector.accessors.getState(); const { newDocument: newState } = jsonPatch.applyPatch( state, body.patch ); await c.var.inspector.accessors.setState(newState); return c.json( { enabled: true, state: await c.var.inspector.accessors.getState() }, 200 ); } ).get("/state/stream", async (c) => { if (!await c.var.inspector.accessors.isStateEnabled()) { return c.json({ enabled: false }, 200); } let id = 0; let unsub; return streamSSE( c, async (stream) => { unsub = c.var.inspector.emitter.on("stateUpdated", async (state) => { stream.writeSSE({ data: JSON.stringify(state) || "", event: "state-update", id: String(id++) }); }); const { promise } = promiseWithResolvers(); return promise; }, async () => { unsub == null ? void 0 : unsub(); } ); }).get("/connections", async (c) => { const connections = await c.var.inspector.accessors.getConnections(); return c.json({ connections }, 200); }).get("/connections/stream", async (c) => { let id = 0; let unsub; return streamSSE( c, async (stream) => { unsub = c.var.inspector.emitter.on("connectionUpdated", async () => { stream.writeSSE({ data: JSON.stringify( await c.var.inspector.accessors.getConnections() ), event: "connection-update", id: String(id++) }); }); const { promise } = promiseWithResolvers(); return promise; }, async () => { unsub == null ? void 0 : unsub(); } ); }).get("/events", async (c) => { const events = c.var.inspector.lastRealtimeEvents; return c.json({ events }, 200); }).post("/events/clear", async (c) => { c.var.inspector.lastRealtimeEvents.length = 0; return c.json({ message: "Events cleared" }, 200); }).get("/events/stream", async (c) => { let id = 0; let unsub; return streamSSE( c, async (stream) => { unsub = c.var.inspector.emitter.on("eventFired", () => { stream.writeSSE({ data: JSON.stringify(c.var.inspector.lastRealtimeEvents), event: "realtime-event", id: String(id++) }); }); const { promise } = promiseWithResolvers(); return promise; }, async () => { unsub == null ? void 0 : unsub(); } ); }).get("/rpcs", async (c) => { const rpcs = await c.var.inspector.accessors.getRpcs(); return c.json({ rpcs }, 200); }).get("/db", async (c) => { if (!await c.var.inspector.accessors.isDbEnabled()) { return c.json({ enabled: false, db: null }, 200); } const db = await c.var.inspector.accessors.getDb(); const rows = await db.execute(`PRAGMA table_list`); const tables = TablesSchema.parse(rows).filter( (table) => table.schema !== "temp" && !table.name.startsWith("sqlite_") ); const tablesInfo = await Promise.all( tables.map((table) => db.execute(`PRAGMA table_info(${table.name})`)) ); const columns = tablesInfo.map((def) => ColumnsSchema.parse(def)); const foreignKeysList = await Promise.all( tables.map( (table) => db.execute(`PRAGMA foreign_key_list(${table.name})`) ) ); const foreignKeys = foreignKeysList.map( (def) => ForeignKeysSchema.parse(def) ); const countInfo = await Promise.all( tables.map( (table) => db.execute(`SELECT COUNT(*) as count FROM ${table.name}`) ) ); const counts = countInfo.map((def) => { return def[0].count || 0; }); return c.json( { enabled: true, db: tablesInfo.map((_, index) => { return { table: tables[index], columns: columns[index], foreignKeys: foreignKeys[index], records: counts[index] }; }) }, 200 ); }).post( "/db", sValidator( "json", z.object({ query: z.string(), params: z.array(z.any()).optional() }) ), async (c) => { if (!await c.var.inspector.accessors.isDbEnabled()) { return c.json({ enabled: false }, 200); } const db = await c.var.inspector.accessors.getDb(); try { const result = await db.execute( c.req.valid("json").query, ...c.req.valid("json").params || [] ); return c.json({ result }, 200); } catch (error) { c; return c.json({ error: error.message }, 500); } } ); } var ActorInspector = class { accessors; emitter = createNanoEvents(); #lastRealtimeEvents = []; get lastRealtimeEvents() { return this.#lastRealtimeEvents; } constructor(accessors) { this.accessors = accessors(); this.emitter.on("eventFired", (event) => { this.#lastRealtimeEvents.push({ id: crypto.randomUUID(), timestamp: Date.now(), ...event }); if (this.#lastRealtimeEvents.length > 100) { this.#lastRealtimeEvents = this.#lastRealtimeEvents.slice(-100); } }); } }; // src/actor/context.ts var ActorContext = class { #actor; constructor(actor) { this.#actor = actor; } /** * Get the actor state */ get state() { return this.#actor.state; } /** * Get the actor variables */ get vars() { return this.#actor.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(name, ...args) { this.#actor._broadcast(name, ...args); return; } /** * Gets the logger instance. */ get log() { return this.#actor.log; } /** * Gets actor ID. */ get actorId() { return this.#actor.id; } /** * Gets the actor name. */ get name() { return this.#actor.name; } /** * Gets the actor key. */ get key() { return this.#actor.key; } /** * Gets the region. */ get region() { return this.#actor.region; } /** * Gets the scheduler. */ get schedule() { return this.#actor.schedule; } /** * Gets the map of connections. */ get conns() { return this.#actor.conns; } /** * Returns the client for the given registry. */ client() { return this.#actor.inlineClient; } /** * Gets the database. * @experimental * @throws {DatabaseNotEnabled} If the database is not enabled. */ get db() { return this.#actor.db; } /** * Forces the state to get saved. * * @param opts - Options for saving the state. */ async saveState(opts) { return this.#actor.saveState(opts); } /** * Prevents the actor from sleeping until promise is complete. */ waitUntil(promise) { this.#actor._waitUntil(promise); } /** * AbortSignal that fires when the actor is stopping. */ get abortSignal() { return this.#actor.abortSignal; } /** * Forces the actor to sleep. * * Not supported on all drivers. * * @experimental */ sleep() { this.#actor._sleep(); } }; // src/actor/keys.ts var EMPTY_KEY = "/"; var KEY_SEPARATOR = "/"; function serializeActorKey(key) { if (key.length === 0) { return EMPTY_KEY; } const escapedParts = key.map((part) => { if (part === "") { return "\\0"; } let escaped = part.replace(/\\/g, "\\\\"); escaped = escaped.replace(/\//g, `\\${KEY_SEPARATOR}`); return escaped; }); return escapedParts.join(KEY_SEPARATOR); } function deserializeActorKey(keyString) { if (keyString === void 0 || keyString === null || keyString === EMPTY_KEY) { return []; } const parts = []; let currentPart = ""; let escaping = false; let isEmptyStringMarker = false; for (let i = 0; i < keyString.length; i++) { const char = keyString[i]; if (escaping) { if (char === "0") { isEmptyStringMarker = true; } else { currentPart += char; } escaping = false; } else if (char === "\\") { escaping = true; } else if (char === KEY_SEPARATOR) { if (isEmptyStringMarker) { parts.push(""); isEmptyStringMarker = false; } else { parts.push(currentPart); } currentPart = ""; } else { currentPart += char; } } if (escaping) { parts.push(currentPart + "\\"); } else if (isEmptyStringMarker) { parts.push(""); } else if (currentPart !== "" || parts.length > 0) { parts.push(currentPart); } return parts; } // src/actor/schedule.ts var Schedule = class { #actor; constructor(actor) { this.#actor = actor; } async after(duration, fn, ...args) { await this.#actor.scheduleEvent(Date.now() + duration, fn, args); } async at(timestamp, fn, ...args) { await this.#actor.scheduleEvent(timestamp, fn, args); } }; // src/actor/instance.ts var ActorInstance = class { // Shared actor context for this instance actorContext; /** Actor log, intended for the user to call */ #log; /** Runtime log, intended for internal actor logs */ #rLog; #sleepCalled = false; #stopCalled = false; get isStopping() { return this.#stopCalled || this.#sleepCalled; } #persistChanged = false; #isInOnStateChange = false; /** * The proxied state that notifies of changes automatically. * * Any data that should be stored indefinitely should be held within this object. */ #persist; /** Raw state without the proxy wrapper */ #persistRaw; #persistWriteQueue = new SinglePromiseQueue(); #alarmWriteQueue = new SinglePromiseQueue(); #lastSaveTime = 0; #pendingSaveTimeout; #vars; #backgroundPromises = []; #abortController = new AbortController(); #config; #actorDriver; #inlineClient; #actorId; #name; #key; #region; #ready = false; #connections = /* @__PURE__ */ new Map(); #subscriptionIndex = /* @__PURE__ */ new Map(); #checkConnLivenessInterval; #sleepTimeout; // Track active raw requests so sleep logic can account for them #activeRawFetchCount = 0; #activeRawWebSockets = /* @__PURE__ */ new Set(); #schedule; #db; #inspector = new ActorInspector(() => { return { isDbEnabled: async () => { return this.#db !== void 0; }, getDb: async () => { return this.db; }, isStateEnabled: async () => { return this.stateEnabled; }, getState: async () => { this.#validateStateEnabled(); return this.#persistRaw.state; }, 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, state: conn.__stateEnabled ? conn.state : void 0 })); }, setState: async (state) => { this.#validateStateEnabled(); this.#persist.state = { ...state }; await this.saveState({ immediate: true }); } }; }); get id() { return this.#actorId; } get inlineClient() { return this.#inlineClient; } get inspector() { return this.#inspector; } get #sleepingSupported() { return this.#actorDriver.sleep !== void 0; } /** * This constructor should never be used directly. * * Constructed in {@link ActorInstance.start}. * * @private */ constructor(config) { this.#config = config; this.actorContext = new ActorContext(this); } async start(actorDriver, inlineClient, actorId, name, key, region) { var _a, _b; const logParams = { actor: name, key: serializeActorKey(key), actorId }; this.#log = getBaseLogger().child( Object.assign(getIncludeTarget() ? { target: "actor" } : {}, logParams) ); this.#rLog = getBaseLogger().child( Object.assign( getIncludeTarget() ? { target: "actor-runtime" } : {}, logParams ) ); this.#actorDriver = actorDriver; this.#inlineClient = inlineClient; this.#actorId = actorId; this.#name = name; this.#key = key; this.#region = region; this.#schedule = new Schedule(this); await this.#initialize(); if (this.#varsEnabled) { let vars; if ("createVars" in this.#config) { const dataOrPromise = this.#config.createVars( this.actorContext, this.#actorDriver.getContext(this.#actorId) ); if (dataOrPromise instanceof Promise) { vars = await deadline( dataOrPromise, this.#config.options.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; } this.#rLog.info({ msg: "actor starting" }); if (this.#config.onStart) { const result = this.#config.onStart(this.actorContext); if (result instanceof Promise) { await result; } } if ("db" in this.#config && this.#config.db) { const client = await this.#config.db.createClient({ getDatabase: () => actorDriver.getDatabase(this.#actorId) }); this.#rLog.info({ msg: "database migration starting" }); await ((_b = (_a = this.#config.db).onMigrate) == null ? void 0 : _b.call(_a, client)); this.#rLog.info({ msg: "database migration complete" }); this.#db = client; } if (this.#persist.scheduledEvents.length > 0) { await this.#queueSetAlarm(this.#persist.scheduledEvents[0].timestamp); } this.#rLog.info({ msg: "actor ready" }); this.#ready = true; this.#resetSleepTimer(); this.#checkConnLivenessInterval = setInterval( this.#checkConnectionsLiveness.bind(this), this.#config.options.connectionLivenessInterval ); this.#checkConnectionsLiveness(); await this._onAlarm(); } async #scheduleEventInner(newEvent) { this.actorContext.log.info({ msg: "scheduling event", ...newEvent }); const insertIndex = this.#persist.scheduledEvents.findIndex( (x) => x.timestamp > newEvent.timestamp ); if (insertIndex === -1) { this.#persist.scheduledEvents.push(newEvent); } else { this.#persist.scheduledEvents.splice(insertIndex, 0, newEvent); } if (insertIndex === 0 || this.#persist.scheduledEvents.length === 1) { this.actorContext.log.info({ msg: "setting alarm", timestamp: newEvent.timestamp, eventCount: this.#persist.scheduledEvents.length }); await this.#queueSetAlarm(newEvent.timestamp); } } /** * Triggers any pending alarms. * * This method is idempotent. It's called automatically when the actor wakes * in order to trigger any pending alarms. */ async _onAlarm() { const now = Date.now(); this.actorContext.log.debug({ msg: "alarm triggered", now, events: this.#persist.scheduledEvents.length }); this.#resetSleepTimer(); const runIndex = this.#persist.scheduledEvents.findIndex( (x) => x.timestamp <= now ); if (runIndex === -1) { this.#rLog.debug({ msg: "no events are due yet" }); if (this.#persist.scheduledEvents.length > 0) { const nextTs = this.#persist.scheduledEvents[0].timestamp; this.actorContext.log.debug({ msg: "alarm fired early, rescheduling for next event", now, nextTs, delta: nextTs - now }); await this.#queueSetAlarm(nextTs); } this.actorContext.log.debug({ msg: "no events to run", now }); return; } const scheduleEvents = this.#persist.scheduledEvents.splice( 0, runIndex + 1 ); this.actorContext.log.debug({ msg: "running events", count: scheduleEvents.length }); if (this.#persist.scheduledEvents.length > 0) { const nextTs = this.#persist.scheduledEvents[0].timestamp; this.actorContext.log.info({ msg: "setting next alarm", nextTs, remainingEvents: this.#persist.scheduledEvents.length }); await this.#queueSetAlarm(nextTs); } for (const event of scheduleEvents) { try { this.actorContext.log.info({ msg: "running action for event", event: event.eventId, timestamp: event.timestamp, action: event.kind.generic.actionName }); const fn = this.#config.actions[event.kind.generic.actionName]; if (!fn) throw new Error( `Missing action for alarm ${event.kind.generic.actionName}` ); if (typeof fn !== "function") throw new Error( `Alarm function lookup for ${event.kind.generic.actionName} returned ${typeof fn}` ); try { const args = event.kind.generic.args ? cbor2.decode(new Uint8Array(event.kind.generic.args)) : []; await fn.call(void 0, this.actorContext, ...args); } catch (error) { this.actorContext.log.error({ msg: "error while running event", error: stringifyError(error), event: event.eventId, timestamp: event.timestamp, action: event.kind.generic.actionName }); } } catch (error) { this.actorContext.log.error({ msg: "internal error while running event", error: stringifyError(error), ...event }); } } } async scheduleEvent(timestamp, action, args) { return this.#scheduleEventInner({ eventId: crypto.randomUUID(), timestamp, kind: { generic: { actionName: action, args: bufferToArrayBuffer(cbor2.encode(args)) } } }); } get stateEnabled() { return "createState" in this.#config || "state" in this.#config; } #validateStateEnabled() { if (!this.stateEnabled) { throw new 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 VarsNotEnabled(); } } /** Promise used to wait for a save to complete. This is required since you cannot await `#saveStateThrottled`. */ #onPersistSavedPromise; /** 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.stateSaveInterval; if (timeSinceLastSave < saveInterval) { if (this.#pendingSaveTimeout === void 0) { this.#pendingSaveTimeout = setTimeout(() => { this.#pendingSaveTimeout = void 0; this.#savePersistInner(); }, saveInterval - timeSinceLastSave); } } else { this.#savePersistInner(); } } /** Saves the state to KV. You probably want to use #saveStateThrottled instead except for a few edge cases. */ async #savePersistInner() { var _a, _b; try { this.#lastSaveTime = Date.now(); if (this.#persistChanged) { const finished = this.#persistWriteQueue.enqueue(async () => { this.#rLog.debug({ msg: "saving persist" }); this.#persistChanged = false; const bareData = this.#convertToBarePersisted(this.#persistRaw); await this.#actorDriver.writePersistedData( this.#actorId, PERSISTED_ACTOR_VERSIONED.serializeWithEmbeddedVersion(bareData) ); this.#rLog.debug({ msg: "persist saved" }); }); await finished; } (_a = this.#onPersistSavedPromise) == null ? void 0 : _a.resolve(); } catch (error) { (_b = this.#onPersistSavedPromise) == null ? void 0 : _b.reject(error); throw error; } } async #queueSetAlarm(timestamp) { await this.#alarmWriteQueue.enqueue(async () => { await this.#actorDriver.setAlarm(this, timestamp); }); } /** * Creates proxy for `#persist` that handles automatically flagging when state needs to be updated. */ #setPersist(target) { this.#persistRaw = target; if (target === null || typeof target !== "object") { let invalidPath = ""; if (!isCborSerializable( target, (path) => { invalidPath = path; }, "" )) { throw new InvalidStateType({ path: invalidPath }); } return target; } if (this.#persist) { onChange.unsubscribe(this.#persist); } this.#persist = onChange( target, // biome-ignore lint/suspicious/noExplicitAny: Don't know types in proxy (path, value, _previousValue, _applyData) => { if (path !== "state" && !path.startsWith("state.")) { return; } let invalidPath = ""; if (!isCborSerializable( value, (invalidPathPart) => { invalidPath = invalidPathPart; }, "" )) { throw new InvalidStateType({ path: path + (invalidPath ? `.${invalidPath}` : "") }); } this.#persistChanged = true; this.inspector.emitter.emit("stateUpdated", this.#persist.state); if (this.#config.onStateChange && this.#ready && !this.#isInOnStateChange) { try { this.#isInOnStateChange = true; this.#config.onStateChange( this.actorContext, this.#persistRaw.state ); } catch (error) { this.#rLog.error({ msg: "error in `_onStateChange`", error: stringifyError(error) }); } finally { this.#isInOnStateChange = false; } } }, { ignoreDetached: true } ); } async #initialize() { const persistDataBuffer = await this.#actorDriver.readPersistedData( this.#actorId ); invariant( persistDataBuffer !== void 0, "persist data has not been set, it should be set when initialized" ); const bareData = PERSISTED_ACTOR_VERSIONED.deserializeWithEmbeddedVersion( persistDataBuffer ); const persistData = this.#convertFromBarePersisted(bareData); if (persistData.hasInitiated) { this.#rLog.info({ msg: "actor restoring", connections: persistData.connections.length }); this.#setPersist(persistData); for (const connPersist of this.#persist.connections) { const conn = new Conn(this, connPersist); this.#connections.set(conn.id, conn); for (const sub of connPersist.subscriptions) { this.#addSubscription(sub.eventName, conn, true); } } } else { this.#rLog.info({ msg: "actor creating" }); let stateData; if (this.stateEnabled) { this.#rLog.info({ msg: "actor state initializing" }); if ("createState" in this.#config) { this.#config.createState; stateData = await this.#config.createState( this.actorContext, persistData.input ); } else if ("state" in this.#config) { stateData = structuredClone(this.#config.state); } else { throw new Error("Both 'createState' or 'state' were not defined"); } } else { this.#rLog.debug({ msg: "state not enabled" }); } persistData.state = stateData; persistData.hasInitiated = true; this.#rLog.debug({ msg: "writing state" }); const bareData2 = this.#convertToBarePersisted(persistData); await this.#actorDriver.writePersistedData( this.#actorId, PERSISTED_ACTOR_VERSIONED.serializeWithEmbeddedVersion(bareData2) ); this.#setPersist(persistData); if (this.#config.onCreate) { await this.#config.onCreate(this.actorContext, persistData.input); } } } __getConnForId(id) { return this.#connections.get(id); } /** * Call when conn is disconnected. Used by transports. * * If a clean diconnect, will be removed immediately. * * If not a clean disconnect, will keep the connection alive for a given interval to wait for reconnect. */ __connDisconnected(conn, wasClean, socketId) { if (socketId && conn.__socket && socketId !== conn.__socket.socketId) { this.#rLog.debug({ msg: "ignoring stale disconnect event", connId: conn.id, eventSocketId: socketId, currentSocketId: conn.__socket.socketId }); return; } if (wasClean) { this.#removeConn(conn); } else { if (!conn.__driverState) { this.rLog.warn("called conn disconnected without driver state"); } conn.__persist.lastSeen = Date.now(); conn.__socket = void 0; this.#resetSleepTimer(); } } /** * Removes a connection and cleans up its resources. */ #removeConn(conn) { const connIdx = this.#persist.connections.findIndex( (c) => c.connId === conn.id ); if (connIdx !== -1) { this.#persist.connections.splice(connIdx, 1); this.saveState({ immediate: true, allowStoppingState: true }); } else { this.#rLog.warn({ msg: "could not find persisted connection to remove", connId: conn.id }); } this.#connections.delete(conn.id); this.#rLog.debug({ msg: "removed conn", connId: conn.id }); 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) { result.catch((error) => { this.#rLog.error({ msg: "error in `onDisconnect`", error: stringifyError(error) }); }); } } catch (error) { this.#rLog.error({ msg: "error in `onDisconnect`", error: stringifyError(error) }); } } this.#resetSleepTimer(); } /** * Called to create a new connection or reconnect an existing one. */ async createConn(socket, params, request, connectionId, connectionToken) { this.#assertReady(); if (connectionId && connectionToken) { this.rLog.debug({ msg: "checking for existing connection", connectionId }); const existingConn = this.#connections.get(connectionId); if (existingConn && existingConn._token === connectionToken) { this.rLog.debug({ msg: "reconnecting existing connection", connectionId }); if (existingConn.__driverState) { const driverKind = getConnDriverKindFromState( existingConn.__driverState ); const driver = CONN_DRIVERS[driverKind]; if (driver.disconnect) { driver.disconnect( this, existingConn, existingConn.__driverState[driverKind], "Reconnecting with new driver state" ); } } existingConn.__socket = socket; existingConn.__persist.lastSeen = Date.now(); this.#resetSleepTimer(); this.inspector.emitter.emit("connectionUpdated"); existingConn._sendMessage( new CachedSerializer( { body: { tag: "Init", val: { actorId: this.id, connectionId: existingConn.id, connectionToken: existingConn._token } } }, TO_CLIENT_VERSIONED ) ); return existingConn; } this.rLog.debug({ msg: "connection not found or token mismatch, creating new connection", connectionId }); } const newConnId = generateConnId(); const newConnToken = generateConnToken(); if (this.#connections.has(newConnId)) { throw new Error(`Connection already exists: ${newConnId}`); } let connState; const onBeforeConnectOpts = { request }; 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, onBeforeConnectOpts, params ); if (dataOrPromise instanceof Promise) { connState = await deadline( dataOrPromise, this.#config.options.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'" ); } } const persist = { connId: newConnId, token: newConnToken, params, state: connState, lastSeen: Date.now(), subscriptions: [] }; const conn = new Conn(this, persist); conn.__socket = socket; this.#connections.set(conn.id, conn); this.#resetSleepTimer(); this.#persist.connections.push(persist); this.saveState({ immediate: true }); if (this.#config.onConnect) { try { const result = this.#config.onConnect(this.actorContext, conn); if (result instanceof Promise) { deadline(result, this.#config.options.onConnectTimeout).catch( (error) => { this.#rLog.error({ msg: "error in `onConnect`, closing socket", error }); conn == null ? void 0 : conn.disconnect("`onConnect` failed"); } ); } } catch (error) { this.#rLog.error({ msg: "error in `onConnect`", error: stringifyError(error) }); conn == null ? void 0 : conn.disconnect("`onConnect` failed"); } } this.inspector.emitter.emit("connectionUpdated"); conn._sendMessage( new CachedSerializer( { body: { tag: "Init", val: { actorId: this.id, connectionId: conn.id, connectionToken: conn._token } } }, TO_CLIENT_VERSIONED ) ); return conn; } // MARK: Messages async processMessage(message, conn) { 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, conn2) => { this.inspector.emitter.emit("eventFired", { type: "subscribe", eventName, connId: conn2.id }); this.#addSubscription(eventName, conn2, false); }, onUnsubscribe: async (eventName, conn2) => { this.inspector.emitter.emit("eventFired", { type: "unsubscribe", eventName, connId: conn2.id }); this.#removeSubscription(eventName, conn2, false); } }); } // MARK: Events #addSubscription(eventName, connection, fromPersist) { if (connection.subscriptions.has(eventName)) { this.#rLog.debug({ msg: "connection already has subscription", eventName }); return; } if (!fromPersist) { connection.__persist.subscriptions.push({ eventName }); this.saveState({ immediate: true }); } connection.subscriptions.add(eventName); let subscribers = this.#subscriptionIndex.get(eventName); if (!subscribers) { subscribers = /* @__PURE__ */ new Set(); this.#subscriptionIndex.set(eventName, subscribers); } subscribers.add(connection); } #removeSubscription(eventName, connection, fromRemoveConn) { if (!connection.subscriptions.has(eventName)) { this.#rLog.warn({ msg: "connection does not have subscription", eventName }); return; } if (!fromRemoveConn) { connection.subscriptions.delete(eventName); const subIdx = connection.__persist.subscriptions.findIndex( (s) => s.eventName === eventName ); if (subIdx !== -1) { connection.__persist.subscriptions.splice(subIdx, 1); } else { this.#rLog.warn({ msg: "subscription does not exist with name", eventName }); } this.saveState({ immediate: true }); } const subscribers = this.#subscriptionIndex.get(eventName); if (subscribers) { subscribers.delete(connection); if (subscribers.size === 0) { this.#subscriptionIndex.delete(eventName); } } } #assertReady(allowStoppingState = false) { if (!this.#ready) throw new InternalError("Actor not ready"); if (!allowStoppingState && this.#sleepCalled) throw new InternalError("Actor is going to sleep"); if (!allowStoppingState && this.#stopCalled) throw new InternalError("Actor is stopping"); } /** * Check the liveness of all connections. * Sets up a recurring check based on the configured interval. */ #checkConnectionsLiveness() { this.#rLog.debug({ msg: "checking connections liveness" }); for (const conn of this.#connections.values()) { if (conn.__status === "connected") { this.#rLog.debug({ msg: "connection is alive", connId: conn.id }); } else { const lastSeen = conn.__persist.lastSeen; const sinceLastSeen = Date.now() - lastSeen; if (sinceLastSeen < this.#config.options.connectionLivenessTimeout) { this.#rLog.debug({ msg: "connection might be alive, will check later", connId: conn.id, lastSeen, sinceLastSeen }); continue; } this.#rLog.info({ msg: "connection is dead, removing", connId: conn.id, lastSeen }); this.#removeConn(conn); } } } /** * Check if the actor is ready to handle requests. */ isReady() { 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, actionName, args) { invariant(this.#ready, "executing action before ready"); if (!(actionName in this.#config.actions)) { this.#rLog.warn({ msg: "action does not exist", actionName }); throw new ActionNotFound(actionName); } const actionFunction = this.#config.actions[actionName]; if (typeof actionFunction !== "function") { this.#rLog.warn({ msg: "action is not a function", actionName, type: typeof actionFunction }); throw new ActionNotFound(actionName); } try { this.#rLog.debug({ msg: "executing action", actionName, args }); const outputOrPromise = actionFunction.call(void 0, ctx, ...args); let output; if (outputOrPromise instanceof Promise) { this.#rLog.debug({ msg: "awaiting async action", actionName }); output = await deadline( outputOrPromise, this.#config.options.actionTimeout ); this.#rLog.debug({ msg: "async action completed", actionName }); } else { output = outputOrPromise; } if (this.#config.onBeforeActionResponse) { try { const processedOutput = this.#config.onBeforeActionResponse( this.actorContext, actionName, args, output ); if (processedOutput instanceof Promise) { this.#rLog.debug({ msg: "awaiting onBeforeActionResponse", actionName }); output = await processedOutput; this.#rLog.debug({ msg: "onBeforeActionResponse completed", actionName }); } else { output = processedOutput; } } catch (error) { this.#rLog.error({ msg: "error in `onBeforeActionResponse`", error: stringifyError(error) }); } } this.#rLog.debug({ msg: "action completed", actionName, outputType: typeof output, isPromise: output instanceof Promise }); return output; } catch (error) { if (error instanceof DeadlineError) { throw new ActionTimedOut(); } this.#rLog.error({ msg: "action error", actionName, error: stringifyError(error) }); throw error; } finally { this.#savePersistThrottled(); } } /** * Returns a list of action methods available on this actor. */ get actions() { return Object.keys(this.#config.actions); } /** * Handles raw HTTP requests to the actor. */ async handleFetch(request, opts) { this.#assertReady(); if (!this.#config.onFetch) { throw new FetchHandlerNotDefined(); } this.#activeRawFetchCount++; this.#resetSleepTimer(); try { const response = await this.#config.onFetch( this.actorContext, request, opts ); if (!response) { throw new InvalidFetchResponse(); } return response; } catch (error) { this.#rLog.error({ msg: "onFetch error", error: stringifyError(error) }); throw error; } finally { this.#activeRawFetchCount = Math.max(0, this.#activeRawFetchCount - 1); this.#resetSleepTimer(); this.#savePersistThrottled(); } } /** * Handles raw WebSocket connections to the actor. */ async handleWebSocket(websocket, opts) { this.#assertReady(); if (!this.#config.onWebSocket) { throw new InternalError("onWebSocket handler not defined"); } try { const stateBeforeHandler = this.#persistChanged; this.#activeRawWebSockets.add(we