UNPKG

@rivetkit/core

Version:

1,760 lines (1,747 loc) 73.3 kB
import { CachedSerializer, ColumnsSchema, Conn, DeadlineError, ForeignKeysSchema, Lock, PatchSchema, TablesSchema, assertUnreachable as assertUnreachable2, deadline, instanceLogger, logger, processMessage } from "./chunk-IVHL2KCJ.js"; import { logger as logger2 } from "./chunk-EKNB2QDI.js"; import { assertUnreachable, httpUserAgent, isCborSerializable, stringifyError } from "./chunk-WDSX6QCH.js"; import { ActionNotFound, ActionTimedOut, DatabaseNotEnabled, FetchHandlerNotDefined, InternalError, InvalidFetchResponse, InvalidStateType, StateNotEnabled, VarsNotEnabled } from "./chunk-INRCNZ4I.js"; // src/actor/instance.ts import * as cbor 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 } = Promise.withResolvers(); 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 } = Promise.withResolvers(); 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 } = Promise.withResolvers(); 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); } /** * Runs a promise in the background. * * @param promise - The promise to run in the background. */ runInBackground(promise) { this.#actor._runInBackground(promise); return; } }; // 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; 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; /** Raw state without the proxy wrapper */ #persistRaw; #writePersistLock = new Lock(void 0); #lastSaveTime = 0; #pendingSaveTimeout; #vars; #backgroundPromises = []; #config; #connectionDrivers; #actorDriver; #inlineClient; #actorId; #name; #key; #region; #ready = false; #connections = /* @__PURE__ */ new Map(); #subscriptionIndex = /* @__PURE__ */ new Map(); #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.s; }, 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, auth: conn.auth })); }, setState: async (state) => { this.#validateStateEnabled(); this.#persist.s = { ...state }; await this.saveState({ immediate: true }); } }; }); get id() { return this.#actorId; } get inlineClient() { return this.#inlineClient; } get inspector() { return this.#inspector; } /** * 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(connectionDrivers, actorDriver, inlineClient, actorId, name, key, region) { var _a, _b; 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); 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.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; } logger().info("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) }); logger().info("database migration starting"); await ((_b = (_a = this.#config.db).onMigrate) == null ? void 0 : _b.call(_a, client)); logger().info("database migration complete"); this.#db = client; } 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, fn, args) { const eventId = crypto.randomUUID(); const newEvent = { e: eventId, t: timestamp, a: fn, ar: args }; this.actorContext.log.info("scheduling event", { event: eventId, timestamp, action: fn }); 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); } 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 }); 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 }); if (this.#persist.e.length > 0) { await this.#actorDriver.setAlarm(this, this.#persist.e[0].t); } 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 }); const fn = 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}` ); try { await fn.call(void 0, 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 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.state.saveInterval; 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) { await this.#writePersistLock.lock(async () => { logger().debug("saving persist"); this.#persistChanged = false; await this.#actorDriver.writePersistedData( this.#actorId, cbor.encode(this.#persistRaw) ); logger().debug("persist saved"); }); } (_a = this.#onPersistSavedPromise) == null ? void 0 : _a.resolve(); } catch (error) { (_b = this.#onPersistSavedPromise) == null ? void 0 : _b.reject(error); throw error; } } /** * 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) => { 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.s); 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) }); } } }, { 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 persistData = cbor.decode(persistDataBuffer); if (persistData.hi) { logger().info("actor restoring", { connections: persistData.c.length }); this.#setPersist(persistData); for (const connPersist of this.#persist.c) { const driver = this.__getConnDriver(connPersist.d); const conn = new Conn( this, connPersist, driver, this.#connStateEnabled ); this.#connections.set(conn.id, conn); for (const sub of connPersist.su) { this.#addSubscription(sub.n, conn, true); } } } else { logger().info("actor creating"); let stateData; if (this.stateEnabled) { logger().info("actor state initializing"); if ("createState" in this.#config) { this.#config.createState; stateData = await this.#config.createState( this.actorContext, 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"); } persistData.s = stateData; persistData.hi = true; logger().debug("writing state"); await this.#actorDriver.writePersistedData( this.#actorId, cbor.encode(persistData) ); this.#setPersist(persistData); if (this.#config.onCreate) { await this.#config.onCreate(this.actorContext, persistData.i); } } } __getConnForId(id) { return this.#connections.get(id); } /** * Removes a connection and cleans up its resources. */ __removeConn(conn) { if (!conn) { logger().warn("`conn` does not exist"); return; } 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 }); } this.#connections.delete(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) => { logger().error("error in `onDisconnect`", { error: stringifyError(error) }); }); } } catch (error) { logger().error("error in `onDisconnect`", { error: stringifyError(error) }); } } } async prepareConn(params, request) { 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.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; } __getConnDriver(driverId) { 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, connectionToken, params, state, driverId, driverState, authData) { this.#assertReady(); if (this.#connections.has(connectionId)) { throw new Error(`Connection already exists: ${connectionId}`); } const driver = this.__getConnDriver(driverId); const persist = { i: connectionId, t: connectionToken, d: driverId, ds: driverState, p: params, s: state, a: authData, su: [] }; const conn = new Conn( this, persist, driver, this.#connStateEnabled ); this.#connections.set(conn.id, conn); this.#persist.c.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.lifecycle.onConnectTimeout ).catch((error) => { logger().error("error in `onConnect`, closing socket", { error }); conn == null ? void 0 : conn.disconnect("`onConnect` failed"); }); } } catch (error) { logger().error("error in `onConnect`", { error: stringifyError(error) }); conn == null ? void 0 : conn.disconnect("`onConnect` failed"); } } this.inspector.emitter.emit("connectionUpdated"); conn._sendMessage( new CachedSerializer({ b: { i: { ai: this.id, ci: conn.id, ct: conn._token } } }) ); 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)) { logger().debug("connection already has subscription", { eventName }); return; } if (!fromPersist) { connection.__persist.su.push({ n: 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)) { logger().warn("connection does not have subscription", { eventName }); return; } 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 }); } 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 InternalError("Actor not ready"); } /** * 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, "exucuting action before ready"); if (!(actionName in this.#config.actions)) { logger().warn("action does not exist", { actionName }); throw new ActionNotFound(actionName); } const actionFunction = this.#config.actions[actionName]; if (typeof actionFunction !== "function") { logger().warn("action is not a function", { actionName, type: typeof actionFunction }); throw new ActionNotFound(actionName); } try { logger().debug("executing action", { actionName, args }); const outputOrPromise = actionFunction.call(void 0, ctx, ...args); let output; if (outputOrPromise instanceof Promise) { logger().debug("awaiting async action", { actionName }); output = await deadline( outputOrPromise, this.#config.options.action.timeout ); logger().debug("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) { logger().debug("awaiting onBeforeActionResponse", { actionName }); output = await processedOutput; logger().debug("onBeforeActionResponse completed", { actionName }); } else { output = processedOutput; } } catch (error) { logger().error("error in `onBeforeActionResponse`", { error: stringifyError(error) }); } } logger().debug("action completed", { actionName, outputType: typeof output, isPromise: output instanceof Promise }); return output; } catch (error) { if (error instanceof DeadlineError) { throw new ActionTimedOut(); } logger().error("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(); } try { const response = await this.#config.onFetch( this.actorContext, request, opts ); if (!response) { throw new 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, opts) { this.#assertReady(); if (!this.#config.onWebSocket) { throw new InternalError("onWebSocket handler not defined"); } try { const stateBeforeHandler = this.#persistChanged; await this.#config.onWebSocket(this.actorContext, websocket, opts); 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() { return instanceLogger(); } /** * Gets the name. */ get name() { return this.#name; } /** * Gets the key. */ get key() { return this.#key; } /** * Gets the region. */ get region() { return this.#region; } /** * Gets the scheduler. */ get schedule() { return this.#schedule; } /** * Gets the map of connections. */ get conns() { return this.#connections; } /** * Gets the current state. * * Changing properties of this value will automatically be persisted. */ get state() { this.#validateStateEnabled(); return this.#persist.s; } /** * Gets the database. * @experimental * @throws {DatabaseNotEnabled} If the database is not enabled. */ get db() { if (!this.#db) { throw new DatabaseNotEnabled(); } return this.#db; } /** * Sets the current state. * * This property will automatically be persisted. */ set state(value) { this.#validateStateEnabled(); this.#persist.s = value; } get vars() { this.#validateVarsEnabled(); invariant(this.#vars !== void 0, "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(name, ...args) { this.#assertReady(); this.inspector.emitter.emit("eventFired", { type: "broadcast", eventName: name, args }); const subscriptions = this.#subscriptionIndex.get(name); if (!subscriptions) return; const toClientSerializer = new CachedSerializer({ b: { ev: { n: name, a: args } } }); 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) { this.#assertReady(); 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) { this.#assertReady(); if (this.#persistChanged) { if (opts.immediate) { await this.#savePersistInner(); } else { if (!this.#onPersistSavedPromise) { this.#onPersistSavedPromise = Promise.withResolvers(); } this.#savePersistThrottled(); await this.#onPersistSavedPromise.promise; } } } async stop() { if (this.isStopping) { logger().warn("already stopping actor"); return; } this.isStopping = true; await this.saveState({ immediate: true }); const promises = []; for (const connection of this.#connections.values()) { promises.push(connection.disconnect()); } const res = Promise.race([ Promise.all(promises).then(() => false), new Promise( (res2) => globalThis.setTimeout(() => res2(true), 1500) ) ]); if (await res) { logger().warn( "timed out waiting for connections to close, shutting down anyway" ); } } }; // src/actor/definition.ts var ActorDefinition = class { #config; constructor(config) { this.#config = config; } get config() { return this.#config; } instantiate() { return new ActorInstance(this.#config); } }; function lookupInRegistry(registryConfig, name) { const definition = registryConfig.use[name]; if (!definition) throw new Error(`no actor in registry for name ${name}`); return definition; } // src/client/errors.ts var ActorClientError = class extends Error { }; var InternalError2 = class extends ActorClientError { }; var ManagerError = class extends ActorClientError { constructor(error, opts) { super(`Manager error: ${error}`, opts); } }; var MalformedResponseMessage = class extends ActorClientError { constructor(cause) { super(`Malformed response message: ${cause}`, { cause }); } }; var ActorError = class extends ActorClientError { constructor(code, message, metadata) { super(message); this.code = code; this.metadata = metadata; } __type = "ActorError"; }; var HttpRequestError = class extends ActorClientError { constructor(message, opts) { super(`HTTP request error: ${message}`, { cause: opts == null ? void 0 : opts.cause }); } }; var ActorConnDisposed = class extends ActorClientError { constructor() { super("Attempting to interact with a disposed actor connection."); } }; // src/client/actor-conn.ts import * as cbor3 from "cbor-x"; import pRetry from "p-retry"; // src/client/actor-handle.ts import invariant2 from "invariant"; // src/client/raw-utils.ts async function rawHttpFetch(driver, actorQuery, params, input, init) { let path; let mergedInit = init || {}; if (typeof input === "string") { path = input; } else if (input instanceof URL) { path = input.pathname + input.search; } else if (input instanceof Request) { const url = new URL(input.url); path = url.pathname + url.search; const requestHeaders = new Headers(input.headers); const initHeaders = new Headers((init == null ? void 0 : init.headers) || {}); const mergedHeaders = new Headers(requestHeaders); for (const [key, value] of initHeaders) { mergedHeaders.set(key, value); } mergedInit = { method: input.method, body: input.body, mode: input.mode, credentials: input.credentials, redirect: input.redirect, referrer: input.referrer, referrerPolicy: input.referrerPolicy, integrity: input.integrity, keepalive: input.keepalive, signal: input.signal, ...mergedInit, // init overrides Request properties headers: mergedHeaders // headers must be set after spread to ensure proper merge }; if (mergedInit.body) { mergedInit.duplex = "half"; } } else { throw new TypeError("Invalid input type for fetch"); } return await driver.rawHttpRequest( void 0, actorQuery, // Force JSON so it's readable by the user "json", params, path, mergedInit, void 0 ); } async function rawWebSocket(driver, actorQuery, params, path, protocols) { return await driver.rawWebSocket( void 0, actorQuery, // Force JSON so it's readable by the user "json", params, path || "", protocols, void 0 ); } // src/client/actor-handle.ts var ActorHandleRaw = class { #client; #driver; #encodingKind; #actorQuery; #params; /** * Do not call this directly. * * Creates an instance of ActorHandleRaw. * * @protected */ constructor(client, driver, params, encodingKind, actorQuery) { this.#client = client; this.#driver = driver; this.#encodingKind = encodingKind; this.#actorQuery = actorQuery; this.#params = params; } /** * Call a raw action. This method sends an HTTP request to invoke the named action. * * @see {@link ActorHandle} * @template Args - The type of arguments to pass to the action function. * @template Response - The type of the response returned by the action function. */ async action(opts) { return await this.#driver.action( void 0, this.#actorQuery, this.#encodingKind, this.#params, opts.name, opts.args, { signal: opts.signal } ); } /** * Establishes a persistent connection to the actor. * * @template AD The actor class that this connection is for. * @returns {ActorConn<AD>} A connection to the actor. */ connect() { logger2().debug("establishing connection from handle", { query: this.#actorQuery }); const conn = new ActorConnRaw( this.#client, this.#driver, this.#params, this.#encodingKind, this.#actorQuery ); return this.#client[CREATE_ACTOR_CONN_PROXY]( conn ); } /** * Makes a raw HTTP request to the actor. * * @param input - The URL, path, or Request object * @param init - Standard fetch RequestInit options * @returns Promise<Response> - The raw HTTP response */ async fetch(input, init) { return rawHttpFetch( this.#driver, this.#actorQuery, this.#params, input, init ); } /** * Creates a raw WebSocket connection to the actor. * * @param path - The path for the WebSocket connection (e.g., "stream") * @param protocols - Optional WebSocket subprotocols * @returns WebSocket - A raw WebSocket connection */ async websocket(path, protocols) { return rawWebSocket( this.#driver, this.#actorQuery, this.#params, path, protocols ); } /** * Resolves the actor to get its unique actor ID * * @returns {Promise<string>} - A promise that resolves to the actor's ID */ async resolve({ signal } = {}) { if ("getForKey" in this.#actorQuery || "getOrCreateForKey" in this.#actorQuery) { const actorId = await this.#driver.resolveActorId( void 0, this.#actorQuery, this.#encodingKind, this.#params, signal ? { signal } : void 0 ); this.#actorQuery = { getForId: { actorId } }; return actorId; } else if ("getForId" in this.#actorQuery) { return this.#actorQuery.getForId.actorId; } else if ("create" in this.#actorQuery) { invariant2(false, "actorQuery cannot be create"); } else { assertUnreachable2(this.#actorQuery); } } }; // src/client/client.ts var ACTOR_CONNS_SYMBOL = Symbol("actorConns"); var CREATE_ACTOR_CONN_PROXY = Symbol("createActorConnProxy"); var TRANSPORT_SYMBOL = Symbol("transport"); var ClientRaw = class { #disposed = false; [ACTOR_CONNS_SYMBOL] = /* @__PURE__ */ new Set(); #driver; #encodingKind; [TRANSPORT_SYMBOL]; /** * Creates an instance of Client. * * @param {string} managerEndpoint - The manager endpoint. See {@link https://rivet.gg/docs/setup|Initial Setup} for instructions on getting the manager endpoint. * @param {ClientOptions} [opts] - Options for configuring the client. * @see {@link https://rivet.gg/docs/setup|Initial Setup} */ constructor(driver, opts) { this.#driver = driver; this.#encodingKind = (opts == null ? void 0 : opts.encoding) ?? "cbor"; this[TRANSPORT_SYMBOL] = (opts == null ? void 0 : opts.transport) ?? "websocket"; } /** * Gets a stateless handle to a actor by its ID. * * @template AD The actor class that this handle is for. * @param {string} name - The name of the actor. * @param {string} actorId - The ID of the actor. * @param {GetWithIdOptions} [opts] - Options for getting the actor. * @returns {ActorHandle<AD>} - A handle to the actor. */ getForId(name, actorId, opts) { logger2().debug("get handle to actor with id", { name, actorId, params: opts == null ? void 0 : opts.params }); const actorQuery = { getForId: { actorId } }; const handle = this.#createHandle(opts == null ? void 0 : opts.params, actorQuery); return createActorProxy(handle); } /** * Gets a stateless handle to a actor by its key, but does not create the actor if it doesn't exist. * * @template AD The actor class that this handle is for. * @param {string} name - The name of the actor. * @param {string | string[]} [key=[]] - The key to identify the actor. Can be a single string or an array of strings. * @param {GetWithIdOptions} [opts] - Options for getting the actor. * @returns {ActorHandle<AD>} - A handle to the actor. */ get(name, key, opts) { const keyArray = typeof key === "string" ? [key] : key || []; logger2().debug("get handle to actor", { name, key: keyArray, parameters: opts == null ? void 0 : opts.params }); const actorQuery = { getForKey: { name, key: keyArray } }; const handle = this.#createHandle(opts == null ? void 0 : opts.params, actorQuery); return createActorProxy(handle); } /** * Gets a stateless handle to a actor by its key, creating it if necessary. * * @template AD The actor class that this handle is for. * @param {string} name - The name of the actor. * @param {string | string[]} [key=[]] - The key to identify the actor. Can be a single string or an array of strings. * @param {GetOptions} [opts] - Options for getting the actor. * @returns {ActorHandle<AD>} - A handle to the actor. */ getOrCreate(name, key, opts) { const keyArray = typeof key === "string" ? [key] : key || []; logger2().debug("get or create handle to actor", { name, key: keyArray, parameters: opts == null ? void 0 : opts.params, createInRegion: opts == null ? void 0 : opts.createInRegion }); const actorQuery = { getOrCreateForKey: { name, key: keyArray, input: opts == null ? void 0 : opts.createWithInput, region: opts == null ? void 0 : opts.createInRegion } }; const handle = this.#createHandle(opts == null ? void 0 : opts.params, actorQuery); return createActorProxy(handle); } /** * Creates a new actor with the provided key and returns a stateless handle to it. * Resolves the actor ID and returns a handle with getForId query. * * @template AD The actor class that this handle is for. * @param {string} name - The name of the actor. * @param {string | string[]} key - The key to identify the actor. Can be a single string or an array of strings. * @param {CreateOptions} [opts] - Options for creating the actor (excluding name and key). * @returns {Promise<ActorHandle<AD>>} - A promise that resolves to a handle to the actor. */ async create(name, key, opts) { const keyArray = typeof key === "string" ? [key] : key || []; const createQuery = { create: { ...opts, // Do these last to override `opts` name, key: keyArray } }; logger2().debug("create actor handle", { name, key: keyArray, parameters: opts == null ? void 0 : opts.params, create: createQuery.create }); const actorId = await this.#driver.resolveActorId( void 0, createQuery, this.#encodingKind, opts == null ? void 0 : opts.params, (opts == null ? void 0 : opts.signal) ? { signal: opts.signal } : void 0 ); logger2().debug("created actor with ID", { name, key: keyArray, actorId }); const getForIdQuery = { getForId: { actorId } }; const handle = this.#createHandle(opts == null ? void 0 : opts.params, getForIdQuery); const proxy = createActorProxy(handle); return proxy; } #createHandle(params, actorQuery) { return new ActorHandleRaw( this, this.#driver, params, this.#encodingKind, actorQuery ); } [CREATE_ACTOR_CONN_PROXY](conn) { this[ACTOR_CONNS_SYMBOL].add(conn); conn[CONNECT_SYMBOL](); return createActorProxy(conn); } /** * Disconnects from all actors. * * @returns {Promise<void>} A promise that resolves when all connections are closed. */ async dispose() { if (this.#disposed) { logger2().warn("client already disconnected"); return; } this.#disposed = true; logger2().debug("disposing client"); const disposePromises = []; for (const conn of this[ACTOR_CONNS_SYMBOL].values()) { disposePromises.push(conn.dispose()); } await Promise.all(disposePromises); } }; function createClientWithDriver(driver, opts) { const client = new ClientRaw(driver, opts); return new Proxy(client, { get: (target, prop, receiver) => { if (typeof prop === "symbol" || prop in target) { const value = Reflect.get(target, prop, receiver); if (typeof value === "function") { return value.bind(target); } return value; } if (typeof prop === "string") { return { // Handle methods (stateless action) get: (key, opts2) => { return target.get( prop, key, opts2 ); }, getOrCreate: (key, opts2) => { return target.getOrCreate(prop, key, opts2); }, getForId: (actorId, opts2) => { return target.getForId( prop, actorId, opts2 ); }, create: async (key, opts2 = {}) => { return await target.create(prop, key, opts2); } }; }