@rivetkit/core
Version:
1,760 lines (1,747 loc) • 73.3 kB
JavaScript
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);
}
};
}