diff --git a/examples/counter-serverless/README.md b/examples/counter-serverless/README.md new file mode 100644 index 000000000..bc11eb08c --- /dev/null +++ b/examples/counter-serverless/README.md @@ -0,0 +1,41 @@ +# Counter (Serverless) for RivetKit + +Example project demonstrating serverless actor deployment with automatic engine configuration using [RivetKit](https://rivetkit.org). + +[Learn More →](https://github.com/rivet-dev/rivetkit) + +[Discord](https://rivet.dev/discord) — [Documentation](https://rivetkit.org) — [Issues](https://github.com/rivet-dev/rivetkit/issues) + +## Getting Started + +### Prerequisites + +- Node.js +- RIVET_TOKEN environment variable (for serverless configuration) + +### Installation + +```sh +git clone https://github.com/rivet-dev/rivetkit +cd rivetkit/examples/counter-serverless +npm install +``` + +### Development + +Set your Rivet token and run the development server: + +```sh +export RIVET_TOKEN=your-token-here +npm run dev +``` + +Run the connect script to interact with the counter: + +```sh +tsx scripts/connect.ts +``` + +## License + +Apache 2.0 \ No newline at end of file diff --git a/examples/counter-serverless/package.json b/examples/counter-serverless/package.json new file mode 100644 index 000000000..d9fd50d8e --- /dev/null +++ b/examples/counter-serverless/package.json @@ -0,0 +1,20 @@ +{ + "name": "example-counter-serverless", + "version": "2.0.8", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx src/server.ts", + "check-types": "tsc --noEmit", + "connect": "tsx scripts/connect.ts", + "test": "vitest run" + }, + "devDependencies": { + "rivetkit": "workspace:*", + "@types/node": "^22.13.9", + "tsx": "^3.12.7", + "typescript": "^5.7.3", + "vitest": "^3.1.1" + }, + "stableVersion": "0.8.0" +} diff --git a/examples/counter-serverless/scripts/connect.ts b/examples/counter-serverless/scripts/connect.ts new file mode 100644 index 000000000..805b957ad --- /dev/null +++ b/examples/counter-serverless/scripts/connect.ts @@ -0,0 +1,22 @@ +import { createClient } from "rivetkit/client"; +import type { Registry } from "../src/registry"; + +async function main() { + const client = createClient("http://localhost:6420"); + + const counter = client.counter.getOrCreate().connect(); + + counter.on("newCount", (count: number) => console.log("Event:", count)); + + for (let i = 0; i < 5; i++) { + const out = await counter.increment(5); + console.log("RPC:", out); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + + await new Promise((resolve) => setTimeout(resolve, 10000)); + await counter.dispose(); +} + +main(); diff --git a/examples/counter-serverless/src/registry.ts b/examples/counter-serverless/src/registry.ts new file mode 100644 index 000000000..44707e2fe --- /dev/null +++ b/examples/counter-serverless/src/registry.ts @@ -0,0 +1,23 @@ +import { actor, setup } from "rivetkit"; + +const counter = actor({ + state: { + count: 0, + }, + actions: { + increment: (c, x: number) => { + c.state.count += x; + c.broadcast("newCount", c.state.count); + return c.state.count; + }, + getCount: (c) => { + return c.state.count; + }, + }, +}); + +export const registry = setup({ + use: { counter }, +}); + +export type Registry = typeof registry; diff --git a/examples/counter-serverless/src/server.ts b/examples/counter-serverless/src/server.ts new file mode 100644 index 000000000..d66d25754 --- /dev/null +++ b/examples/counter-serverless/src/server.ts @@ -0,0 +1,7 @@ +import { registry } from "./registry"; + +registry.start({ + runnerKind: "serverless", + runEngine: true, + autoConfigureServerless: true, +}); diff --git a/examples/counter-serverless/tests/counter.test.ts b/examples/counter-serverless/tests/counter.test.ts new file mode 100644 index 000000000..89ba4667e --- /dev/null +++ b/examples/counter-serverless/tests/counter.test.ts @@ -0,0 +1,35 @@ +import { setupTest } from "rivetkit/test"; +import { expect, test } from "vitest"; +import { registry } from "../src/registry"; + +test("it should count", async (test) => { + const { client } = await setupTest(test, registry); + const counter = client.counter.getOrCreate().connect(); + + // Test initial count + expect(await counter.getCount()).toBe(0); + + // Test event emission + let eventCount = -1; + counter.on("newCount", (count: number) => { + eventCount = count; + }); + + // Test increment + const incrementAmount = 5; + const result = await counter.increment(incrementAmount); + expect(result).toBe(incrementAmount); + + // Verify event was emitted with correct count + expect(eventCount).toBe(incrementAmount); + + // Test multiple increments + for (let i = 1; i <= 3; i++) { + const newCount = await counter.increment(incrementAmount); + expect(newCount).toBe(incrementAmount * (i + 1)); + expect(eventCount).toBe(incrementAmount * (i + 1)); + } + + // Verify final count + expect(await counter.getCount()).toBe(incrementAmount * 4); +}); diff --git a/examples/counter-serverless/tsconfig.json b/examples/counter-serverless/tsconfig.json new file mode 100644 index 000000000..df33a97c3 --- /dev/null +++ b/examples/counter-serverless/tsconfig.json @@ -0,0 +1,43 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + + /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "target": "esnext", + /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + "lib": ["esnext"], + /* Specify what JSX code is generated. */ + "jsx": "react-jsx", + + /* Specify what module code is generated. */ + "module": "esnext", + /* Specify how TypeScript looks up a file from a given module specifier. */ + "moduleResolution": "bundler", + /* Specify type package names to be included without being referenced in a source file. */ + "types": ["node"], + /* Enable importing .json files */ + "resolveJsonModule": true, + + /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ + "allowJs": true, + /* Enable error reporting in type-checked JavaScript files. */ + "checkJs": false, + + /* Disable emitting files from a compilation. */ + "noEmit": true, + + /* Ensure that each file can be safely transpiled without relying on other imports. */ + "isolatedModules": true, + /* Allow 'import x from y' when a module doesn't have a default export. */ + "allowSyntheticDefaultImports": true, + /* Ensure that casing is correct in imports. */ + "forceConsistentCasingInFileNames": true, + + /* Enable all strict type-checking options. */ + "strict": true, + + /* Skip type checking all .d.ts files. */ + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts", "tests/**/*.ts"] +} diff --git a/examples/counter-serverless/turbo.json b/examples/counter-serverless/turbo.json new file mode 100644 index 000000000..95960709b --- /dev/null +++ b/examples/counter-serverless/turbo.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"] +} diff --git a/packages/rivetkit/src/manager/router.ts b/packages/rivetkit/src/manager/router.ts index b788dccc6..1e0e53fb1 100644 --- a/packages/rivetkit/src/manager/router.ts +++ b/packages/rivetkit/src/manager/router.ts @@ -53,6 +53,7 @@ import { RivetIdSchema } from "@/manager-api/common"; import type { ServerlessActorDriverBuilder } from "@/mod"; import type { RegistryConfig } from "@/registry/config"; import type { RunnerConfig } from "@/registry/run-config"; +import { VERSION } from "@/utils"; import type { ActorOutput, ManagerDriver } from "./driver"; import { actorGateway, createTestWebSocketProxy } from "./gateway"; import { logger } from "./log"; @@ -159,7 +160,11 @@ function addServerlessRoutes( }); router.get("/health", (c) => { - return c.text("ok"); + return c.json({ + status: "ok", + runtime: "rivetkit", + version: VERSION, + }); }); } @@ -588,7 +593,12 @@ function addManagerRoutes( } router.get("/health", (c) => { - return c.text("ok"); + return c.json({ + status: "ok", + rivetkit: { + version: packageJson.version, + }, + }); }); managerDriver.modifyManagerRouter?.( diff --git a/packages/rivetkit/src/registry/mod.ts b/packages/rivetkit/src/registry/mod.ts index f4c4b456c..367a00a6c 100644 --- a/packages/rivetkit/src/registry/mod.ts +++ b/packages/rivetkit/src/registry/mod.ts @@ -57,6 +57,13 @@ export class Registry { public start(inputConfig?: RunnerConfigInput): ServerOutput { const config = RunnerConfigSchema.parse(inputConfig); + // Validate autoConfigureServerless is only used with serverless runner + if (config.autoConfigureServerless && config.runnerKind !== "serverless") { + throw new Error( + "autoConfigureServerless can only be configured when runnerKind is 'serverless'", + ); + } + // Promise for any async operations we need to wait to complete const readyPromises = []; @@ -68,13 +75,12 @@ export class Registry { }); // Set config to point to the engine - config.disableDefaultServer = true; - config.overrideServerAddress = ENGINE_ENDPOINT; invariant( config.endpoint === undefined, "cannot specify 'endpoint' with 'runEngine'", ); config.endpoint = ENGINE_ENDPOINT; + config.disableActorDriver = true; // Start the engine const engineProcessPromise = ensureEngineProcess({ @@ -85,6 +91,12 @@ export class Registry { readyPromises.push(engineProcessPromise); } + // Configure for serverless + if (config.runnerKind === "serverless") { + config.defaultServerPort = 8080; + config.overrideServerAddress = config.endpoint; + } + // Configure logger if (config.logging?.baseLogger) { // Use provided base logger @@ -98,10 +110,14 @@ export class Registry { // Choose the driver based on configuration const driver = chooseDefaultDriver(config); - // TODO: Find cleaner way of disabling by default + // Set defaults based on the driver if (driver.name === "engine") { config.inspector.enabled = { manager: false, actor: true }; - config.disableDefaultServer = true; + + // We need to leave the default server enabled for dev + if (config.runnerKind !== "serverless") { + config.disableDefaultServer = true; + } } if (driver.name === "cloudflare-workers") { config.inspector.enabled = { manager: false, actor: true }; @@ -109,9 +125,6 @@ export class Registry { config.disableActorDriver = true; config.noWelcome = true; } - if (config.runnerKind === "serverless") { - config.disableActorDriver = true; - } // Configure getUpgradeWebSocket lazily so we can assign it in crossPlatformServe let upgradeWebSocket: any; @@ -161,18 +174,26 @@ export class Registry { console.log(); } - let serverlessActorDriverBuilder: undefined | ServerlessActorDriverBuilder; // HACK: We need to find a better way to let the driver itself decide when to start the actor driver // Create runner // - // Even though we do not use the return value, this is required to start the code that will handle incoming actors + // Even though we do not use the returned ActorDriver, this is required to start the code that will handle incoming actors if (!config.disableActorDriver) { - Promise.all(readyPromises).then(() => { - logger().debug("ready promises finished, starting actor driver"); - + Promise.all(readyPromises).then(async () => { driver.actor(this.#config, config, managerDriver, client); }); - } else { + } + + // Setup serverless driver + let serverlessActorDriverBuilder: undefined | ServerlessActorDriverBuilder; + if (config.runnerKind === "serverless") { + // Configure serverless runner if enabled when actor driver is disabled + if (config.autoConfigureServerless) { + Promise.all(readyPromises).then(async () => { + await configureServerlessRunner(config); + }); + } + serverlessActorDriverBuilder = ( token, totalSlots, @@ -200,7 +221,7 @@ export class Registry { // Start server if (!config.disableDefaultServer) { (async () => { - const out = await crossPlatformServe(hono, undefined); + const out = await crossPlatformServe(config, hono, undefined); upgradeWebSocket = out.upgradeWebSocket; })(); } @@ -212,6 +233,80 @@ export class Registry { } } +async function configureServerlessRunner(config: RunnerConfig): Promise { + try { + // Ensure we have required config values + if (!config.runnerName) { + throw new Error("runnerName is required for serverless configuration"); + } + if (!config.namespace) { + throw new Error("namespace is required for serverless configuration"); + } + if (!config.endpoint) { + throw new Error("endpoint is required for serverless configuration"); + } + + // Prepare the configuration + const customConfig = + typeof config.autoConfigureServerless === "object" + ? config.autoConfigureServerless + : {}; + + // Build the request body + const requestBody = { + serverless: { + url: + customConfig.url || + `http://localhost:${config.defaultServerPort}/start`, + headers: customConfig.headers || {}, + max_runners: customConfig.maxRunners ?? 100, + min_runners: customConfig.minRunners ?? 0, + request_lifespan: customConfig.requestLifespan ?? 15 * 60_000, + runners_margin: customConfig.runnersMargin ?? 0, + slots_per_runner: + customConfig.slotsPerRunner ?? config.totalSlots ?? 10000, + }, + }; + + // Make the request to configure the serverless runner + const configUrl = `${config.endpoint}/runner-configs/${config.runnerName}?namespace=${config.namespace}`; + + logger().debug({ + msg: "configuring serverless runner", + url: configUrl, + config: requestBody.serverless, + }); + + const response = await fetch(configUrl, { + method: "PUT", + headers: { + "Content-Type": "application/json", + ...(config.token ? { Authorization: `Bearer ${config.token}` } : {}), + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `failed to configure serverless runner: ${response.status} ${response.statusText} - ${errorText}`, + ); + } + + logger().info({ + msg: "serverless runner configured successfully", + runnerName: config.runnerName, + namespace: config.namespace, + }); + } catch (error) { + logger().error({ + msg: "failed to configure serverless runner", + error, + }); + throw error; + } +} + export function setup( input: RegistryConfigInput, ): Registry { diff --git a/packages/rivetkit/src/registry/run-config.ts b/packages/rivetkit/src/registry/run-config.ts index 4d6fe13e8..5561545a5 100644 --- a/packages/rivetkit/src/registry/run-config.ts +++ b/packages/rivetkit/src/registry/run-config.ts @@ -37,6 +37,9 @@ export const RunnerConfigSchema = z /** @experimental */ disableDefaultServer: z.boolean().optional().default(false), + /** @experimental */ + defaultServerPort: z.number().default(6420), + /** @experimental */ runEngine: z .boolean() @@ -98,6 +101,28 @@ export const RunnerConfigSchema = z .optional() .default({}), + /** + * @experimental + * + * Automatically configure serverless runners in the engine. + * Can only be used when runnerKind is "serverless". + * If true, uses default configuration. Can also provide custom configuration. + */ + autoConfigureServerless: z + .union([ + z.boolean(), + z.object({ + url: z.string().optional(), + headers: z.record(z.string(), z.string()).optional(), + maxRunners: z.number().optional(), + minRunners: z.number().optional(), + requestLifespan: z.number().optional(), + runnersMargin: z.number().optional(), + slotsPerRunner: z.number().optional(), + }), + ]) + .optional(), + // This is a function to allow for lazy configuration of upgradeWebSocket on the // fly. This is required since the dependencies that upgradeWebSocket // (specifically Node.js) can sometimes only be specified after the router is diff --git a/packages/rivetkit/src/registry/serve.ts b/packages/rivetkit/src/registry/serve.ts index c35f10b6c..44b2b0989 100644 --- a/packages/rivetkit/src/registry/serve.ts +++ b/packages/rivetkit/src/registry/serve.ts @@ -1,7 +1,9 @@ import { Hono } from "hono"; import { logger } from "./log"; +import type { RunnerConfig } from "./run-config"; export async function crossPlatformServe( + runConfig: RunnerConfig, rivetKitRouter: Hono, userRouter: Hono | undefined, ) { @@ -47,7 +49,7 @@ export async function crossPlatformServe( }); // Start server - const port = 6420; + const port = runConfig.defaultServerPort; const server = serve({ fetch: app.fetch, port }, () => logger().info({ msg: "server listening", port }), ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a7c7a739..0b98a7363 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -289,6 +289,24 @@ importers: specifier: ^3.1.1 version: 3.2.4(@types/node@22.15.32)(lightningcss@1.30.2)(terser@5.44.0)(tsx@3.14.0)(yaml@2.8.0) + examples/counter-serverless: + devDependencies: + '@types/node': + specifier: ^22.13.9 + version: 22.15.32 + rivetkit: + specifier: workspace:* + version: link:../../packages/rivetkit + tsx: + specifier: ^3.12.7 + version: 3.14.0 + typescript: + specifier: ^5.7.3 + version: 5.8.3 + vitest: + specifier: ^3.1.1 + version: 3.2.4(@types/node@22.15.32)(lightningcss@1.30.2)(terser@5.44.0)(tsx@3.14.0)(yaml@2.8.0) + examples/crdt: dependencies: '@rivetkit/react':