rivetkit
Version:
Lightweight libraries for building stateful actors on edge platforms
555 lines (493 loc) • 16.6 kB
text/typescript
import type { AnyActorDefinition } from "@/actor/definition";
import type { Transport } from "@/actor/protocol/old";
import type { Encoding } from "@/actor/protocol/serde";
import type { ManagerDriver } from "@/driver-helpers/mod";
import type { ActorQuery } from "@/manager/protocol/query";
import type { Registry } from "@/registry/mod";
import type { ActorActionFunction } from "./actor-common";
import {
type ActorConn,
type ActorConnRaw,
CONNECT_SYMBOL,
} from "./actor-conn";
import { type ActorHandle, ActorHandleRaw } from "./actor-handle";
import { queryActor } from "./actor-query";
import type { ClientConfig } from "./config";
import { logger } from "./log";
export type { ClientConfig, ClientConfigInput } from "./config";
/** Extract the actor registry from the registry definition. */
export type ExtractActorsFromRegistry<A extends Registry<any>> =
A extends Registry<infer Actors> ? Actors : never;
/** Extract the registry definition from the client. */
export type ExtractRegistryFromClient<C extends Client<Registry<{}>>> =
C extends Client<infer A> ? A : never;
/**
* Represents a actor accessor that provides methods to interact with a specific actor.
*/
export interface ActorAccessor<AD extends AnyActorDefinition> {
/**
* Gets a stateless handle to a actor by its key, but does not create the actor if it doesn't exist.
* The actor name is automatically injected from the property accessor.
*
* @template AD The actor class that this handle is for.
* @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(key?: string | string[], opts?: GetWithIdOptions): ActorHandle<AD>;
/**
* Gets a stateless handle to a actor by its key, creating it if necessary.
* The actor name is automatically injected from the property accessor.
*
* @template AD The actor class that this handle is for.
* @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(
key?: string | string[],
opts?: GetOrCreateOptions,
): ActorHandle<AD>;
/**
* Gets a stateless handle to a actor by its ID.
*
* @template AD The actor class that this handle is for.
* @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(actorId: string, opts?: GetWithIdOptions): ActorHandle<AD>;
/**
* Creates a new actor with the name automatically injected from the property accessor,
* and returns a stateless handle to it with the actor ID resolved.
*
* @template AD The actor class that this handle is for.
* @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.
*/
create(
key?: string | string[],
opts?: CreateOptions,
): Promise<ActorHandle<AD>>;
}
/**
* Options for querying actors.
* @typedef {Object} QueryOptions
* @property {unknown} [parameters] - Parameters to pass to the connection.
*/
export interface QueryOptions {
/** Parameters to pass to the connection. */
params?: unknown;
/** Signal to abort the request. */
signal?: AbortSignal;
}
/**
* Options for getting a actor by ID.
* @typedef {QueryOptions} GetWithIdOptions
*/
export interface GetWithIdOptions extends QueryOptions {}
/**
* Options for getting a actor.
* @typedef {QueryOptions} GetOptions
*/
export interface GetOptions extends QueryOptions {}
/**
* Options for getting or creating a actor.
* @typedef {QueryOptions} GetOrCreateOptions
* @property {string} [createInRegion] - Region to create the actor in if it doesn't exist.
*/
export interface GetOrCreateOptions extends QueryOptions {
/** Region to create the actor in if it doesn't exist. */
createInRegion?: string;
/** Input data to pass to the actor. */
createWithInput?: unknown;
}
/**
* Options for creating a actor.
* @typedef {QueryOptions} CreateOptions
* @property {string} [region] - The region to create the actor in.
*/
export interface CreateOptions extends QueryOptions {
/** The region to create the actor in. */
region?: string;
/** Input data to pass to the actor. */
input?: unknown;
}
/**
* Represents a region to connect to.
* @typedef {Object} Region
* @property {string} id - The region ID.
* @property {string} name - The region name.
* @see {@link https://rivet.dev/docs/edge|Edge Networking}
* @see {@link https://rivet.dev/docs/regions|Available Regions}
*/
export interface Region {
/**
* The region slug.
*/
id: string;
/**
* The human-friendly region name.
*/
name: string;
}
export const ACTOR_CONNS_SYMBOL = Symbol("actorConns");
export const CREATE_ACTOR_CONN_PROXY = Symbol("createActorConnProxy");
export const TRANSPORT_SYMBOL = Symbol("transport");
/**
* Client for managing & connecting to actors.
*
* @template A The actors map type that defines the available actors.
* @see {@link https://rivet.dev/docs/manage|Create & Manage Actors}
*/
export class ClientRaw {
#disposed = false;
[ACTOR_CONNS_SYMBOL] = new Set<ActorConnRaw>();
#driver: ManagerDriver;
#encodingKind: Encoding;
[TRANSPORT_SYMBOL]: Transport;
/**
* Creates an instance of Client.
*
* @param {string} managerEndpoint - The manager endpoint. See {@link https://rivet.dev/docs/setup|Initial Setup} for instructions on getting the manager endpoint.
* @param {ClientConfig} [opts] - Options for configuring the client.
* @see {@link https://rivet.dev/docs/setup|Initial Setup}
*/
public constructor(driver: ManagerDriver, opts?: ClientConfig) {
this.#driver = driver;
this.#encodingKind = opts?.encoding ?? "bare";
this[TRANSPORT_SYMBOL] = 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<AD extends AnyActorDefinition>(
name: string,
actorId: string,
opts?: GetWithIdOptions,
): ActorHandle<AD> {
logger().debug({
msg: "get handle to actor with id",
name,
actorId,
params: opts?.params,
});
const actorQuery: ActorQuery = {
getForId: {
name,
actorId,
},
};
const handle = this.#createHandle(opts?.params, actorQuery);
return createActorProxy(handle) as ActorHandle<AD>;
}
/**
* 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<AD extends AnyActorDefinition>(
name: string,
key?: string | string[],
opts?: GetWithIdOptions,
): ActorHandle<AD> {
// Convert string to array of strings
const keyArray: string[] = typeof key === "string" ? [key] : key || [];
logger().debug({
msg: "get handle to actor",
name,
key: keyArray,
parameters: opts?.params,
});
const actorQuery: ActorQuery = {
getForKey: {
name,
key: keyArray,
},
};
const handle = this.#createHandle(opts?.params, actorQuery);
return createActorProxy(handle) as ActorHandle<AD>;
}
/**
* 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<AD extends AnyActorDefinition>(
name: string,
key?: string | string[],
opts?: GetOrCreateOptions,
): ActorHandle<AD> {
// Convert string to array of strings
const keyArray: string[] = typeof key === "string" ? [key] : key || [];
logger().debug({
msg: "get or create handle to actor",
name,
key: keyArray,
parameters: opts?.params,
createInRegion: opts?.createInRegion,
});
const actorQuery: ActorQuery = {
getOrCreateForKey: {
name,
key: keyArray,
input: opts?.createWithInput,
region: opts?.createInRegion,
},
};
const handle = this.#createHandle(opts?.params, actorQuery);
return createActorProxy(handle) as ActorHandle<AD>;
}
/**
* 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<AD extends AnyActorDefinition>(
name: string,
key?: string | string[],
opts?: CreateOptions,
): Promise<ActorHandle<AD>> {
// Convert string to array of strings
const keyArray: string[] = typeof key === "string" ? [key] : key || [];
const createQuery = {
create: {
...opts,
// Do these last to override `opts`
name,
key: keyArray,
},
} satisfies ActorQuery;
logger().debug({
msg: "create actor handle",
name,
key: keyArray,
parameters: opts?.params,
create: createQuery.create,
});
// Create the actor
const { actorId } = await queryActor(undefined, createQuery, this.#driver);
logger().debug({
msg: "created actor with ID",
name,
key: keyArray,
actorId,
});
// Create handle with actor ID
const getForIdQuery = {
getForId: {
name,
actorId,
},
} satisfies ActorQuery;
const handle = this.#createHandle(opts?.params, getForIdQuery);
const proxy = createActorProxy(handle) as ActorHandle<AD>;
return proxy;
}
#createHandle(params: unknown, actorQuery: ActorQuery): ActorHandleRaw {
return new ActorHandleRaw(
this,
this.#driver,
params,
this.#encodingKind,
actorQuery,
);
}
[CREATE_ACTOR_CONN_PROXY]<AD extends AnyActorDefinition>(
conn: ActorConnRaw,
): ActorConn<AD> {
// Save to connection list
this[ACTOR_CONNS_SYMBOL].add(conn);
// Start connection
conn[CONNECT_SYMBOL]();
return createActorProxy(conn) as ActorConn<AD>;
}
/**
* Disconnects from all actors.
*
* @returns {Promise<void>} A promise that resolves when all connections are closed.
*/
async dispose(): Promise<void> {
if (this.#disposed) {
logger().warn({ msg: "client already disconnected" });
return;
}
this.#disposed = true;
logger().debug({ msg: "disposing client" });
const disposePromises = [];
// Dispose all connections
for (const conn of this[ACTOR_CONNS_SYMBOL].values()) {
disposePromises.push(conn.dispose());
}
await Promise.all(disposePromises);
}
}
/**
* Client type with actor accessors.
* This adds property accessors for actor names to the ClientRaw base class.
*
* @template A The actor registry type.
*/
export type Client<A extends Registry<any>> = ClientRaw & {
[K in keyof ExtractActorsFromRegistry<A>]: ActorAccessor<
ExtractActorsFromRegistry<A>[K]
>;
};
export type AnyClient = Client<Registry<any>>;
export function createClientWithDriver<A extends Registry<any>>(
driver: ManagerDriver,
config?: ClientConfig,
): Client<A> {
const client = new ClientRaw(driver, config);
// Create proxy for accessing actors by name
return new Proxy(client, {
get: (target: ClientRaw, prop: string | symbol, receiver: unknown) => {
// Get the real property if it exists
if (typeof prop === "symbol" || prop in target) {
const value = Reflect.get(target, prop, receiver);
// Preserve method binding
if (typeof value === "function") {
return value.bind(target);
}
return value;
}
// Handle actor accessor for string properties (actor names)
if (typeof prop === "string") {
// Return actor accessor object with methods
return {
// Handle methods (stateless action)
get: (
key?: string | string[],
opts?: GetWithIdOptions,
): ActorHandle<ExtractActorsFromRegistry<A>[typeof prop]> => {
return target.get<ExtractActorsFromRegistry<A>[typeof prop]>(
prop,
key,
opts,
);
},
getOrCreate: (
key?: string | string[],
opts?: GetOptions,
): ActorHandle<ExtractActorsFromRegistry<A>[typeof prop]> => {
return target.getOrCreate<
ExtractActorsFromRegistry<A>[typeof prop]
>(prop, key, opts);
},
getForId: (
actorId: string,
opts?: GetWithIdOptions,
): ActorHandle<ExtractActorsFromRegistry<A>[typeof prop]> => {
return target.getForId<ExtractActorsFromRegistry<A>[typeof prop]>(
prop,
actorId,
opts,
);
},
create: async (
key: string | string[],
opts: CreateOptions = {},
): Promise<
ActorHandle<ExtractActorsFromRegistry<A>[typeof prop]>
> => {
return await target.create<
ExtractActorsFromRegistry<A>[typeof prop]
>(prop, key, opts);
},
} as ActorAccessor<ExtractActorsFromRegistry<A>[typeof prop]>;
}
return undefined;
},
}) as Client<A>;
}
/**
* Creates a proxy for a actor that enables calling actions without explicitly using `.action`.
**/
function createActorProxy<AD extends AnyActorDefinition>(
handle: ActorHandleRaw | ActorConnRaw,
): ActorHandle<AD> | ActorConn<AD> {
// Stores returned action functions for faster calls
const methodCache = new Map<string, ActorActionFunction>();
return new Proxy(handle, {
get(target: ActorHandleRaw, prop: string | symbol, receiver: unknown) {
// Handle built-in Symbol properties
if (typeof prop === "symbol") {
return Reflect.get(target, prop, receiver);
}
// Handle built-in Promise methods and existing properties
if (prop === "constructor" || prop in target) {
const value = Reflect.get(target, prop, target);
// Preserve method binding
if (typeof value === "function") {
return value.bind(target);
}
return value;
}
// Create action function that preserves 'this' context
if (typeof prop === "string") {
// If JS is attempting to calling this as a promise, ignore it
if (prop === "then") return undefined;
let method = methodCache.get(prop);
if (!method) {
method = (...args: unknown[]) => target.action({ name: prop, args });
methodCache.set(prop, method);
}
return method;
}
},
// Support for 'in' operator
has(target: ActorHandleRaw, prop: string | symbol) {
// All string properties are potentially action functions
if (typeof prop === "string") {
return true;
}
// For symbols, defer to the target's own has behavior
return Reflect.has(target, prop);
},
// Support instanceof checks
getPrototypeOf(target: ActorHandleRaw) {
return Reflect.getPrototypeOf(target);
},
// Prevent property enumeration of non-existent action methods
ownKeys(target: ActorHandleRaw) {
return Reflect.ownKeys(target);
},
// Support proper property descriptors
getOwnPropertyDescriptor(target: ActorHandleRaw, prop: string | symbol) {
const targetDescriptor = Reflect.getOwnPropertyDescriptor(target, prop);
if (targetDescriptor) {
return targetDescriptor;
}
if (typeof prop === "string") {
// Make action methods appear non-enumerable
return {
configurable: true,
enumerable: false,
writable: false,
value: (...args: unknown[]) => target.action({ name: prop, args }),
};
}
return undefined;
},
}) as ActorHandle<AD> | ActorConn<AD>;
}