diff --git a/docs/workers/actions.mdx b/docs/actors/actions.mdx similarity index 100% rename from docs/workers/actions.mdx rename to docs/actors/actions.mdx diff --git a/docs/workers/authentication.mdx b/docs/actors/authentication.mdx similarity index 100% rename from docs/workers/authentication.mdx rename to docs/actors/authentication.mdx diff --git a/docs/workers/connections.mdx b/docs/actors/connections.mdx similarity index 100% rename from docs/workers/connections.mdx rename to docs/actors/connections.mdx diff --git a/docs/workers/events.mdx b/docs/actors/events.mdx similarity index 100% rename from docs/workers/events.mdx rename to docs/actors/events.mdx diff --git a/docs/workers/lifecycle.mdx b/docs/actors/lifecycle.mdx similarity index 100% rename from docs/workers/lifecycle.mdx rename to docs/actors/lifecycle.mdx diff --git a/docs/workers/metadata.mdx b/docs/actors/metadata.mdx similarity index 100% rename from docs/workers/metadata.mdx rename to docs/actors/metadata.mdx diff --git a/docs/workers/overview.mdx b/docs/actors/overview.mdx similarity index 100% rename from docs/workers/overview.mdx rename to docs/actors/overview.mdx diff --git a/docs/workers/quickstart-frontend.mdx b/docs/actors/quickstart-frontend.mdx similarity index 100% rename from docs/workers/quickstart-frontend.mdx rename to docs/actors/quickstart-frontend.mdx diff --git a/docs/workers/quickstart.mdx b/docs/actors/quickstart.mdx similarity index 84% rename from docs/workers/quickstart.mdx rename to docs/actors/quickstart.mdx index 6725736af..08a4c003a 100644 --- a/docs/workers/quickstart.mdx +++ b/docs/actors/quickstart.mdx @@ -41,23 +41,16 @@ export const registry = setup({ ```ts Hono import { registry } from "./registry"; import { Hono } from "hono"; -import { serve } from "@hono/node-server"; -import { createNodeWebSocket } from '@hono/node-ws' - -// Setup server -const app = new Hono(); // Start RivetKit // // State is stored in memory, this can be configured later -const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app }) // TODO: do this before app -const { client, hono } = registry.run({ - getUpgradeWebSocket: () => upgradeWebSocket, -}); +const { client, serve } = registry.server(); -// Expose RivetKit to the frontend (optional) -app.route("/registry", hono); +// Setup server +const app = new Hono(); +// Example endpoint app.post("/increment/:name", async (c) => { const name = c.req.param("name"); @@ -68,12 +61,11 @@ app.post("/increment/:name", async (c) => { return c.text(`New Count: ${newCount}`); }); -serve({ fetch: app.fetch, port: 8080 }, (x) => - console.log("Listening at http://localhost:8080"), -); -injectWebSocket(server) +// Start server +serve(app); ``` + ```ts Express.js TODO ``` @@ -82,33 +74,6 @@ TODO TODO ``` -```ts Hono -import { registry } from "./registry"; -import { Hono } from "hono"; - -// Start RivetKit -// -// State is stored in memory, this can be configured later -const { client, serve } = registry.server(); - -// Setup server -const app = new Hono(); - -// Example endpoint -app.post("/increment/:name", async (c) => { - const name = c.req.param("name"); - - // Communicate with actor - const counter = client.counter.getOrCreate(name); - const newCount = await counter.increment(1); - - return c.text(`New Count: ${newCount}`); -}); - -// Start server -serve(app); -``` - TODO: How to serve without registry helper TODO: Why we need to use our own custom serve fn @@ -165,7 +130,7 @@ curl -X POST localhost:8080/increment/foo -```json +```json rivet.json { "rivetkit": { "registry": "src/registry.ts", diff --git a/docs/workers/schedule.mdx b/docs/actors/schedule.mdx similarity index 100% rename from docs/workers/schedule.mdx rename to docs/actors/schedule.mdx diff --git a/docs/workers/state.mdx b/docs/actors/state.mdx similarity index 100% rename from docs/workers/state.mdx rename to docs/actors/state.mdx diff --git a/docs/workers/types.mdx b/docs/actors/types.mdx similarity index 100% rename from docs/workers/types.mdx rename to docs/actors/types.mdx diff --git a/examples/cloudflare-workers-hono/README.md b/examples/cloudflare-workers-hono/README.md new file mode 100644 index 000000000..1e0b0097c --- /dev/null +++ b/examples/cloudflare-workers-hono/README.md @@ -0,0 +1,63 @@ +# Cloudflare Workers with Hono for RivetKit + +Example project demonstrating Cloudflare Workers deployment with Hono router using [RivetKit](https://rivetkit.org). + +[Learn More →](https://github.com/rivet-gg/rivetkit) + +[Discord](https://rivet.gg/discord) — [Documentation](https://rivetkit.org) — [Issues](https://github.com/rivet-gg/rivetkit/issues) + +## Getting Started + +### Prerequisites + +- Node.js +- Cloudflare account with Actors enabled +- Wrangler CLI installed globally (`npm install -g wrangler`) + +### Installation + +```sh +git clone https://github.com/rivet-gg/rivetkit +cd rivetkit/examples/cloudflare-workers-hono +npm install +``` + +### Development + +```sh +npm run dev +``` + +This will start the Cloudflare Workers development server locally at http://localhost:8787. + +### Testing the Application + +You can test the Hono router endpoint by making a POST request to increment a counter: + +```sh +curl -X POST http://localhost:8787/increment/my-counter +``` + +Or run the client script to interact with your actors: + +```sh +npm run client +``` + +### Deploy to Cloudflare + +First, authenticate with Cloudflare: + +```sh +wrangler login +``` + +Then deploy: + +```sh +npm run deploy +``` + +## License + +Apache 2.0 diff --git a/examples/cloudflare-workers-hono/package.json b/examples/cloudflare-workers-hono/package.json new file mode 100644 index 000000000..ae7441bb4 --- /dev/null +++ b/examples/cloudflare-workers-hono/package.json @@ -0,0 +1,25 @@ +{ + "name": "example-cloudflare-workers-hono", + "version": "0.9.0-rc.1", + "private": true, + "type": "module", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "check-types": "tsc --noEmit", + "client": "tsx scripts/client.ts" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250129.0", + "@rivetkit/actor": "workspace:*", + "@types/node": "^22.13.9", + "tsx": "^3.12.7", + "typescript": "^5.5.2", + "wrangler": "^3.0.0" + }, + "dependencies": { + "@rivetkit/cloudflare-workers": "workspace:*", + "hono": "^4.8.0" + }, + "stableVersion": "0.8.0" +} diff --git a/examples/cloudflare-workers-hono/scripts/client.ts b/examples/cloudflare-workers-hono/scripts/client.ts new file mode 100644 index 000000000..cedf891fe --- /dev/null +++ b/examples/cloudflare-workers-hono/scripts/client.ts @@ -0,0 +1,9 @@ +async function main() { + const endpoint = process.env.RIVETKIT_ENDPOINT || "http://localhost:8787"; + const res = await fetch(`${endpoint}/increment/foo`, { + method: "POST" + }); + console.log("Output:", await res.text()); +} + +main(); diff --git a/examples/cloudflare-workers-hono/src/index.ts b/examples/cloudflare-workers-hono/src/index.ts new file mode 100644 index 000000000..8553d7ac8 --- /dev/null +++ b/examples/cloudflare-workers-hono/src/index.ts @@ -0,0 +1,22 @@ +import { createServer } from "@rivetkit/cloudflare-workers"; +import { Hono } from "hono"; +import { registry } from "./registry"; + +const { client, createHandler } = createServer(registry); + +// Setup router +const app = new Hono(); + +// Example HTTP endpoint +app.post("/increment/:name", async (c) => { + const name = c.req.param("name"); + + const counter = client.counter.getOrCreate(name); + const newCount = await counter.increment(1); + + return c.text(`New Count: ${newCount}`); +}); + +const { handler, ActorHandler } = createHandler(app); + +export { handler as default, ActorHandler }; diff --git a/examples/cloudflare-workers-hono/src/registry.ts b/examples/cloudflare-workers-hono/src/registry.ts new file mode 100644 index 000000000..6bf067574 --- /dev/null +++ b/examples/cloudflare-workers-hono/src/registry.ts @@ -0,0 +1,19 @@ +import { actor, setup } from "@rivetkit/actor"; + +export const counter = actor({ + onAuth: () => { + // Configure auth here + }, + state: { count: 0 }, + actions: { + increment: (c, x: number) => { + c.state.count += x; + return c.state.count; + }, + }, +}); + +export const registry = setup({ + use: { counter }, +}); + diff --git a/packages/platforms/cloudflare-workers/public/tsconfig.json b/examples/cloudflare-workers-hono/tsconfig.json similarity index 95% rename from packages/platforms/cloudflare-workers/public/tsconfig.json rename to examples/cloudflare-workers-hono/tsconfig.json index 85f0e9048..b8a0a7676 100644 --- a/packages/platforms/cloudflare-workers/public/tsconfig.json +++ b/examples/cloudflare-workers-hono/tsconfig.json @@ -1,5 +1,4 @@ { - "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { /* Visit https://aka.ms/tsconfig.json to read more about this file */ @@ -40,5 +39,5 @@ /* Skip type checking all .d.ts files. */ "skipLibCheck": true }, - "include": ["**/*.ts"] + "include": ["src/**/*"] } diff --git a/examples/cloudflare-workers-hono/wrangler.json b/examples/cloudflare-workers-hono/wrangler.json new file mode 100644 index 000000000..29b055cf3 --- /dev/null +++ b/examples/cloudflare-workers-hono/wrangler.json @@ -0,0 +1,30 @@ +{ + "name": "rivetkit-cloudflare-workers-example", + "main": "src/index.ts", + "compatibility_date": "2025-01-20", + "compatibility_flags": ["nodejs_compat"], + "migrations": [ + { + "tag": "v1", + "new_classes": ["ActorHandler"] + } + ], + "durable_objects": { + "bindings": [ + { + "name": "ACTOR_DO", + "class_name": "ActorHandler" + } + ] + }, + "kv_namespaces": [ + { + "binding": "ACTOR_KV", + "id": "example_namespace", + "preview_id": "example_namespace_preview" + } + ], + "observability": { + "enabled": true + } +} diff --git a/examples/cloudflare-workers/scripts/client.ts b/examples/cloudflare-workers/scripts/client.ts index 8169bb478..face220e1 100644 --- a/examples/cloudflare-workers/scripts/client.ts +++ b/examples/cloudflare-workers/scripts/client.ts @@ -1,8 +1,10 @@ import { createClient } from "@rivetkit/actor/client"; -import type { Registry } from "../src/registry.js"; +import type { registry } from "../src/registry"; // Create RivetKit client -const client = createClient("http://localhost:8787"); +const client = createClient( + process.env.RIVETKIT_ENDPOINT ?? "http://localhost:8787", +); async function main() { console.log("🚀 Cloudflare Workers Client Demo"); diff --git a/examples/cloudflare-workers/src/index.ts b/examples/cloudflare-workers/src/index.ts index 134284ea2..7ba3bf029 100644 --- a/examples/cloudflare-workers/src/index.ts +++ b/examples/cloudflare-workers/src/index.ts @@ -1,6 +1,5 @@ -// import { createHandler } from "@rivetkit/cloudflare-workers"; -// import { registry } from "./registry"; -// -// const { handler, ActorHandler } = createHandler(registry); -// -// export { handler as default, ActorHandler }; +import { createServerHandler } from "@rivetkit/cloudflare-workers"; +import { registry } from "./registry"; + +const { handler, ActorHandler } = createServerHandler(registry); +export { handler as default, ActorHandler }; diff --git a/examples/cloudflare-workers/src/registry.ts b/examples/cloudflare-workers/src/registry.ts index a6d8a837e..6bf067574 100644 --- a/examples/cloudflare-workers/src/registry.ts +++ b/examples/cloudflare-workers/src/registry.ts @@ -17,4 +17,3 @@ export const registry = setup({ use: { counter }, }); -export type Registry = typeof registry; diff --git a/examples/hono/package.json b/examples/hono/package.json index ac4f77661..de03a726e 100644 --- a/examples/hono/package.json +++ b/examples/hono/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "dev": "tsx --watch src/server.ts", - "check-types": "tsc --noEmit" + "check-types": "tsc --noEmit", + "client": "tsx scripts/client.ts" }, "devDependencies": { "@types/node": "^22.13.9", diff --git a/examples/hono/scripts/client.ts b/examples/hono/scripts/client.ts new file mode 100644 index 000000000..f56f596d6 --- /dev/null +++ b/examples/hono/scripts/client.ts @@ -0,0 +1,9 @@ +async function main() { + const endpoint = process.env.RIVETKIT_ENDPOINT || "http://localhost:8080"; + const res = await fetch(`${endpoint}/increment/foo`, { + method: "POST" + }); + console.log("Output:", await res.text()); +} + +main(); \ No newline at end of file diff --git a/examples/hono/src/server.ts b/examples/hono/src/server.ts index ccca193e1..9c6f0adec 100644 --- a/examples/hono/src/server.ts +++ b/examples/hono/src/server.ts @@ -1,29 +1,20 @@ -// import { registry } from "./registry"; -// import { Hono } from "hono"; -// import { serve } from "@hono/node-server"; -// import { createMemoryDriver } from "@rivetkit/memory"; -// -// // Start RivetKit -// const { client, hono } = registry.server({ -// driver: createMemoryDriver(), -// }); -// -// // Setup router -// const app = new Hono(); -// -// // Expose RivetKit to the frontend (optinoal) -// app.route("/registry", hono); -// -// // Example HTTP endpoint -// app.post("/increment/:name", async (c) => { -// const name = c.req.param("name"); -// -// const counter = client.counter.getOrCreate(name); -// const newCount = await counter.increment(1); -// -// return c.text(`New Count: ${newCount}`); -// }); -// -// serve({ fetch: app.fetch, port: 6420 }, (x) => -// console.log("Listening at http://localhost:6420"), -// ); +import { registry } from "./registry"; +import { Hono } from "hono"; + +// Start RivetKit +const { client, serve } = registry.createServer(); + +// Setup router +const app = new Hono(); + +// Example HTTP endpoint +app.post("/increment/:name", async (c) => { + const name = c.req.param("name"); + + const counter = client.counter.getOrCreate(name); + const newCount = await counter.increment(1); + + return c.text(`New Count: ${newCount}`); +}); + +serve(app); diff --git a/examples/rivet/src/server.ts b/examples/rivet/src/server.ts index 3a1785274..11163905a 100644 --- a/examples/rivet/src/server.ts +++ b/examples/rivet/src/server.ts @@ -1,4 +1,3 @@ import { registry } from "./registry"; -console.log("new version"); registry.runServer(); diff --git a/packages/core/src/driver-helpers/mod.ts b/packages/core/src/driver-helpers/mod.ts index 80d32a8eb..f6c107062 100644 --- a/packages/core/src/driver-helpers/mod.ts +++ b/packages/core/src/driver-helpers/mod.ts @@ -1,5 +1,3 @@ -import { PersistedActor } from "@/actor/persisted"; - export type { ActorInstance, AnyActorInstance } from "@/actor/instance"; export type { AttemptAcquireLease, @@ -29,17 +27,5 @@ export { HEADER_CONN_TOKEN, } from "@/actor/router-endpoints"; export { RunConfigSchema, DriverConfigSchema } from "@/registry/run-config"; -import * as cbor from "cbor-x"; - -export function serializeEmptyPersistData( - input: unknown | undefined, -): Uint8Array { - const persistData: PersistedActor = { - i: input, - hi: false, - s: undefined, - c: [], - e: [], - }; - return cbor.encode(persistData); -} +export { serializeEmptyPersistData } from "./utils"; +export type { ConnRoutingHandler } from "@/actor/conn-routing-handler"; diff --git a/packages/core/src/driver-helpers/utils.ts b/packages/core/src/driver-helpers/utils.ts new file mode 100644 index 000000000..56ea3822b --- /dev/null +++ b/packages/core/src/driver-helpers/utils.ts @@ -0,0 +1,15 @@ +import { PersistedActor } from "@/actor/persisted"; +import * as cbor from "cbor-x"; + +export function serializeEmptyPersistData( + input: unknown | undefined, +): Uint8Array { + const persistData: PersistedActor = { + i: input, + hi: false, + s: undefined, + c: [], + e: [], + }; + return cbor.encode(persistData); +} diff --git a/packages/core/src/registry/mod.ts b/packages/core/src/registry/mod.ts index 638a9e474..8c5d2531a 100644 --- a/packages/core/src/registry/mod.ts +++ b/packages/core/src/registry/mod.ts @@ -49,7 +49,7 @@ export class Registry { /** * Runs the registry for a server. */ - public server(inputConfig?: RunConfigInput): ServerOutput { + public createServer(inputConfig?: RunConfigInput): ServerOutput { const config = RunConfigSchema.parse(inputConfig); // Setup topology @@ -86,14 +86,14 @@ export class Registry { * Runs the registry as a standalone server. */ public async runServer(inputConfig?: RunConfigInput) { - const { serve } = this.server(inputConfig); + const { serve } = this.createServer(inputConfig); serve(); } /** - * Runs the registry for a actor node. + * Creates a worker for the registry. */ - public actorNode(inputConfig?: RunConfigInput): ActorNodeOutput { + public createWorker(inputConfig?: RunConfigInput): ActorNodeOutput { const config = RunConfigSchema.parse(inputConfig); // Setup topology @@ -117,10 +117,10 @@ export class Registry { } /** - * Runs the standalone actor node. + * Runs the standalone worker. */ - public async runActorNode(inputConfig?: RunConfigInput) { - const { serve } = this.actorNode(inputConfig); + public async runWorker(inputConfig?: RunConfigInput) { + const { serve } = this.createWorker(inputConfig); serve(); } } diff --git a/packages/core/src/registry/serve.ts b/packages/core/src/registry/serve.ts index 25ed34f94..af6a1ccc2 100644 --- a/packages/core/src/registry/serve.ts +++ b/packages/core/src/registry/serve.ts @@ -22,11 +22,6 @@ export async function crossPlatformServe( process.exit(1); } - app.use("*", async (c, next) => { - logger().info("request", { path: c.req.path }); - await next(); - }); - // Mount registry app.route("/registry", rivetKitRouter); diff --git a/packages/platforms/cloudflare-workers/src/actor-driver.ts b/packages/platforms/cloudflare-workers/src/actor-driver.ts index 072c8d1b6..3fa6c3044 100644 --- a/packages/platforms/cloudflare-workers/src/actor-driver.ts +++ b/packages/platforms/cloudflare-workers/src/actor-driver.ts @@ -1,70 +1,66 @@ -// import type { ActorDriver, AnyActorInstance } from "rivetkit/driver-helpers"; -// import invariant from "invariant"; -// import { KEYS } from "./actor-handler-do"; -// -// interface DurableObjectGlobalState { -// ctx: DurableObjectState; -// env: unknown; -// } -// -// /** -// * Cloudflare DO can have multiple DO running within the same global scope. -// * -// * This allows for storing the actor context globally and looking it up by ID in `CloudflareActorsActorDriver`. -// */ -// export class CloudflareDurableObjectGlobalState { -// // Single map for all actor state -// #dos: Map = new Map(); -// -// getDOState(actorId: string): DurableObjectGlobalState { -// const state = this.#dos.get(actorId); -// invariant(state !== undefined, "durable object state not in global state"); -// return state; -// } -// -// setDOState(actorId: string, state: DurableObjectGlobalState) { -// this.#dos.set(actorId, state); -// } -// } -// -// export interface ActorDriverContext { -// ctx: DurableObjectState; -// env: unknown; -// } -// -// export class CloudflareActorsActorDriver implements ActorDriver { -// #globalState: CloudflareDurableObjectGlobalState; -// -// constructor(globalState: CloudflareDurableObjectGlobalState) { -// this.#globalState = globalState; -// } -// -// #getDOCtx(actorId: string) { -// return this.#globalState.getDOState(actorId).ctx; -// } -// -// getContext(actorId: string): ActorDriverContext { -// const state = this.#globalState.getDOState(actorId); -// return { ctx: state.ctx, env: state.env }; -// } -// -// async readInput(actorId: string): Promise { -// return await this.#getDOCtx(actorId).storage.get(KEYS.INPUT); -// } -// -// async readPersistedData(actorId: string): Promise { -// return await this.#getDOCtx(actorId).storage.get(KEYS.PERSISTED_DATA); -// } -// -// async writePersistedData(actorId: string, data: unknown): Promise { -// await this.#getDOCtx(actorId).storage.put(KEYS.PERSISTED_DATA, data); -// } -// -// async setAlarm(actor: AnyActorInstance, timestamp: number): Promise { -// await this.#getDOCtx(actor.id).storage.setAlarm(timestamp); -// } -// -// async getDatabase(actorId: string): Promise { -// return this.#getDOCtx(actorId).storage.sql; -// } -// } +import type { ActorDriver, AnyActorInstance } from "@rivetkit/core/driver-helpers"; +import invariant from "invariant"; +import { KEYS } from "./actor-handler-do"; + +interface DurableObjectGlobalState { + ctx: DurableObjectState; + env: unknown; +} + +/** + * Cloudflare DO can have multiple DO running within the same global scope. + * + * This allows for storing the actor context globally and looking it up by ID in `CloudflareActorsActorDriver`. + */ +export class CloudflareDurableObjectGlobalState { + // Single map for all actor state + #dos: Map = new Map(); + + getDOState(actorId: string): DurableObjectGlobalState { + const state = this.#dos.get(actorId); + invariant(state !== undefined, "durable object state not in global state"); + return state; + } + + setDOState(actorId: string, state: DurableObjectGlobalState) { + this.#dos.set(actorId, state); + } +} + +export interface ActorDriverContext { + ctx: DurableObjectState; + env: unknown; +} + +export class CloudflareActorsActorDriver implements ActorDriver { + #globalState: CloudflareDurableObjectGlobalState; + + constructor(globalState: CloudflareDurableObjectGlobalState) { + this.#globalState = globalState; + } + + #getDOCtx(actorId: string) { + return this.#globalState.getDOState(actorId).ctx; + } + + getContext(actorId: string): ActorDriverContext { + const state = this.#globalState.getDOState(actorId); + return { ctx: state.ctx, env: state.env }; + } + + async readPersistedData(actorId: string): Promise { + return await this.#getDOCtx(actorId).storage.get(KEYS.PERSIST_DATA); + } + + async writePersistedData(actorId: string, data: Uint8Array): Promise { + await this.#getDOCtx(actorId).storage.put(KEYS.PERSIST_DATA, data); + } + + async setAlarm(actor: AnyActorInstance, timestamp: number): Promise { + await this.#getDOCtx(actor.id).storage.setAlarm(timestamp); + } + + async getDatabase(actorId: string): Promise { + return this.#getDOCtx(actorId).storage.sql; + } +} diff --git a/packages/platforms/cloudflare-workers/src/actor-handler-do.ts b/packages/platforms/cloudflare-workers/src/actor-handler-do.ts index 7e578053a..1ac235797 100644 --- a/packages/platforms/cloudflare-workers/src/actor-handler-do.ts +++ b/packages/platforms/cloudflare-workers/src/actor-handler-do.ts @@ -1,186 +1,184 @@ -// import { DurableObject } from "cloudflare:actors"; -// import type { Registry, RunConfig, ActorKey } from "@rivetkit/core"; -// import { logger } from "./log"; -// import { PartitionTopologyActor } from "@rivetkit/core/topologies/partition"; -// import { -// CloudflareDurableObjectGlobalState, -// CloudflareActorsActorDriver, -// } from "./actor-driver"; -// import { Bindings, CF_AMBIENT_ENV } from "./handler"; -// import { ExecutionContext } from "hono"; -// -// export const KEYS = { -// INITIALIZED: "rivetkit:initialized", -// NAME: "rivetkit:name", -// KEY: "rivetkit:key", -// INPUT: "rivetkit:input", -// PERSISTED_DATA: "rivetkit:data", -// }; -// -// export interface ActorHandlerInterface extends DurableObject { -// initialize(req: ActorInitRequest): Promise; -// } -// -// export interface ActorInitRequest { -// name: string; -// key: ActorKey; -// input?: unknown; -// } -// -// interface InitializedData { -// name: string; -// key: ActorKey; -// } -// -// export type DurableObjectConstructor = new ( -// ...args: ConstructorParameters> -// ) => DurableObject; -// -// interface LoadedActor { -// actorTopology: PartitionTopologyActor; -// } -// -// export function createActorDurableObject( -// registry: Registry, -// runConfig: RunConfig, -// ): DurableObjectConstructor { -// const globalState = new CloudflareDurableObjectGlobalState(); -// -// /** -// * Startup steps: -// * 1. If not already created call `initialize`, otherwise check KV to ensure it's initialized -// * 2. Load actor -// * 3. Start service requests -// */ -// return class ActorHandler -// extends DurableObject -// implements ActorHandlerInterface -// { -// #initialized?: InitializedData; -// #initializedPromise?: PromiseWithResolvers; -// -// #actor?: LoadedActor; -// -// async #loadActor(): Promise { -// // This is always called from another context using CF_AMBIENT_ENV -// -// // Wait for init -// if (!this.#initialized) { -// // Wait for init -// if (this.#initializedPromise) { -// await this.#initializedPromise.promise; -// } else { -// this.#initializedPromise = Promise.withResolvers(); -// const res = await this.ctx.storage.get([ -// KEYS.INITIALIZED, -// KEYS.NAME, -// KEYS.KEY, -// ]); -// if (res.get(KEYS.INITIALIZED)) { -// const name = res.get(KEYS.NAME) as string; -// if (!name) throw new Error("missing actor name"); -// const key = res.get(KEYS.KEY) as ActorKey; -// if (!key) throw new Error("missing actor key"); -// -// logger().debug("already initialized", { name, key }); -// -// this.#initialized = { name, key }; -// this.#initializedPromise.resolve(); -// } else { -// logger().debug("waiting to initialize"); -// } -// } -// } -// -// // Check if already loaded -// if (this.#actor) { -// return this.#actor; -// } -// -// if (!this.#initialized) throw new Error("Not initialized"); -// -// // Configure actor driver -// runConfig.driver.actor = new CloudflareActorsActorDriver(globalState); -// -// const actorTopology = new PartitionTopologyActor( -// registry.config, -// runConfig, -// ); -// -// // Register DO with global state -// // HACK: This leaks the DO context, but DO does not provide a native way -// // of knowing when the DO shuts down. We're making a broad assumption -// // that DO will boot a new isolate frequenlty enough that this is not an issue. -// const actorId = this.ctx.id.toString(); -// globalState.setDOState(actorId, { ctx: this.ctx, env: this.env }); -// -// // Save actor -// this.#actor = { -// actorTopology, -// }; -// -// // Start actor -// await actorTopology.start( -// actorId, -// this.#initialized.name, -// this.#initialized.key, -// // TODO: -// "unknown", -// ); -// -// return this.#actor; -// } -// -// /** RPC called by the service that creates the DO to initialize it. */ -// async initialize(req: ActorInitRequest) { -// // TODO: Need to add this to a core promise that needs to be resolved before start -// -// return await CF_AMBIENT_ENV.run(this.env, async () => { -// await this.ctx.storage.put({ -// [KEYS.INITIALIZED]: true, -// [KEYS.NAME]: req.name, -// [KEYS.KEY]: req.key, -// [KEYS.INPUT]: req.input, -// }); -// this.#initialized = { -// name: req.name, -// key: req.key, -// }; -// -// logger().debug("initialized actor", { key: req.key }); -// -// // Preemptively actor so the lifecycle hooks are called -// await this.#loadActor(); -// }); -// } -// -// async fetch(request: Request): Promise { -// return await CF_AMBIENT_ENV.run(this.env, async () => { -// const { actorTopology } = await this.#loadActor(); -// -// const ctx = this.ctx; -// return await actorTopology.router.fetch( -// request, -// this.env, -// // Implement execution context so we can wait on requests -// { -// waitUntil(promise: Promise) { -// ctx.waitUntil(promise); -// }, -// passThroughOnException() { -// // Do nothing -// }, -// props: {}, -// } satisfies ExecutionContext, -// ); -// }); -// } -// -// async alarm(): Promise { -// return await CF_AMBIENT_ENV.run(this.env, async () => { -// const { actorTopology } = await this.#loadActor(); -// await actorTopology.actor.onAlarm(); -// }); -// } -// }; -// } +import { DurableObject } from "cloudflare:workers"; +import type { Registry, RunConfig, ActorKey } from "@rivetkit/core"; +import { serializeEmptyPersistData } from "@rivetkit/core/driver-helpers"; +import { logger } from "./log"; +import { PartitionTopologyActor } from "@rivetkit/core/topologies/partition"; +import { + CloudflareDurableObjectGlobalState, + CloudflareActorsActorDriver, +} from "./actor-driver"; +import { Bindings, CF_AMBIENT_ENV } from "./handler"; +import { ExecutionContext } from "hono"; + +export const KEYS = { + NAME: "rivetkit:name", + KEY: "rivetkit:key", + PERSIST_DATA: "rivetkit:data", +}; + +export interface ActorHandlerInterface extends DurableObject { + initialize(req: ActorInitRequest): Promise; +} + +export interface ActorInitRequest { + name: string; + key: ActorKey; + input?: unknown; +} + +interface InitializedData { + name: string; + key: ActorKey; +} + +export type DurableObjectConstructor = new ( + ...args: ConstructorParameters> +) => DurableObject; + +interface LoadedActor { + actorTopology: PartitionTopologyActor; +} + +export function createActorDurableObject( + registry: Registry, + runConfig: RunConfig, +): DurableObjectConstructor { + const globalState = new CloudflareDurableObjectGlobalState(); + + /** + * Startup steps: + * 1. If not already created call `initialize`, otherwise check KV to ensure it's initialized + * 2. Load actor + * 3. Start service requests + */ + return class ActorHandler + extends DurableObject + implements ActorHandlerInterface + { + #initialized?: InitializedData; + #initializedPromise?: PromiseWithResolvers; + + #actor?: LoadedActor; + + async #loadActor(): Promise { + // This is always called from another context using CF_AMBIENT_ENV + + // Wait for init + if (!this.#initialized) { + // Wait for init + if (this.#initializedPromise) { + await this.#initializedPromise.promise; + } else { + this.#initializedPromise = Promise.withResolvers(); + const res = await this.ctx.storage.get([ + KEYS.NAME, + KEYS.KEY, + KEYS.PERSIST_DATA, + ]); + if (res.get(KEYS.PERSIST_DATA)) { + const name = res.get(KEYS.NAME) as string; + if (!name) throw new Error("missing actor name"); + const key = res.get(KEYS.KEY) as ActorKey; + if (!key) throw new Error("missing actor key"); + + logger().debug("already initialized", { name, key }); + + this.#initialized = { name, key }; + this.#initializedPromise.resolve(); + } else { + logger().debug("waiting to initialize"); + } + } + } + + // Check if already loaded + if (this.#actor) { + return this.#actor; + } + + if (!this.#initialized) throw new Error("Not initialized"); + + // Configure actor driver + runConfig.driver.actor = new CloudflareActorsActorDriver(globalState); + + const actorTopology = new PartitionTopologyActor( + registry.config, + runConfig, + ); + + // Register DO with global state + // HACK: This leaks the DO context, but DO does not provide a native way + // of knowing when the DO shuts down. We're making a broad assumption + // that DO will boot a new isolate frequenlty enough that this is not an issue. + const actorId = this.ctx.id.toString(); + globalState.setDOState(actorId, { ctx: this.ctx, env: this.env }); + + // Save actor + this.#actor = { + actorTopology, + }; + + // Start actor + await actorTopology.start( + actorId, + this.#initialized.name, + this.#initialized.key, + // TODO: + "unknown", + ); + + return this.#actor; + } + + /** RPC called by the service that creates the DO to initialize it. */ + async initialize(req: ActorInitRequest) { + // TODO: Need to add this to a core promise that needs to be resolved before start + + return await CF_AMBIENT_ENV.run(this.env, async () => { + await this.ctx.storage.put({ + [KEYS.NAME]: req.name, + [KEYS.KEY]: req.key, + [KEYS.PERSIST_DATA]: serializeEmptyPersistData(req.input), + }); + this.#initialized = { + name: req.name, + key: req.key, + }; + + logger().debug("initialized actor", { key: req.key }); + + // Preemptively actor so the lifecycle hooks are called + await this.#loadActor(); + }); + } + + async fetch(request: Request): Promise { + return await CF_AMBIENT_ENV.run(this.env, async () => { + const { actorTopology } = await this.#loadActor(); + + const ctx = this.ctx; + return await actorTopology.router.fetch( + request, + this.env, + // Implement execution context so we can wait on requests + { + waitUntil(promise: Promise) { + ctx.waitUntil(promise); + }, + passThroughOnException() { + // Do nothing + }, + props: {}, + } satisfies ExecutionContext, + ); + }); + } + + async alarm(): Promise { + return await CF_AMBIENT_ENV.run(this.env, async () => { + const { actorTopology } = await this.#loadActor(); + await actorTopology.actor.onAlarm(); + }); + } + }; +} diff --git a/packages/platforms/cloudflare-workers/src/config.ts b/packages/platforms/cloudflare-workers/src/config.ts index c451ea7de..bd5a3ec56 100644 --- a/packages/platforms/cloudflare-workers/src/config.ts +++ b/packages/platforms/cloudflare-workers/src/config.ts @@ -1,6 +1,12 @@ -// import { RunConfigSchema } from "@rivetkit/core/driver-helpers"; -// import { z } from "zod"; -// -// export const ConfigSchema = RunConfigSchema.omit({ driver: true, getUpgradeWebSocket: true }).default({}); -// export type InputConfig = z.input; -// export type Config = z.infer; +import { RunConfigSchema } from "@rivetkit/core/driver-helpers"; +import { Hono } from "hono"; +import { z } from "zod"; + +export const ConfigSchema = RunConfigSchema.removeDefault() + .omit({ driver: true, getUpgradeWebSocket: true }) + .extend({ + app: z.custom().optional(), + }) + .default({}); +export type InputConfig = z.input; +export type Config = z.infer; diff --git a/packages/platforms/cloudflare-workers/src/handler.ts b/packages/platforms/cloudflare-workers/src/handler.ts index 99f71ac4a..47b663be3 100644 --- a/packages/platforms/cloudflare-workers/src/handler.ts +++ b/packages/platforms/cloudflare-workers/src/handler.ts @@ -1,230 +1,98 @@ -// import { -// type DurableObjectConstructor, -// type ActorHandlerInterface, -// createActorDurableObject, -// } from "./actor-handler-do"; -// import { ConfigSchema, type InputConfig } from "./config"; -// import { assertUnreachable } from "@rivetkit/core/utils"; -// import { -// HEADER_AUTH_DATA, -// HEADER_CONN_PARAMS, -// HEADER_ENCODING, -// HEADER_EXPOSE_INTERNAL_ERROR, -// } from "@rivetkit/core/driver-helpers"; -// import type { Hono } from "hono"; -// import { PartitionTopologyManager } from "@rivetkit/core/topologies/partition"; -// import { logger } from "./log"; -// import { CloudflareActorsManagerDriver } from "./manager-driver"; -// import { Encoding, Registry, RunConfig } from "@rivetkit/core"; -// import { upgradeWebSocket } from "./websocket"; -// import invariant from "invariant"; -// import { AsyncLocalStorage } from "node:async_hooks"; -// import { InternalError } from "@rivetkit/core/errors"; -// -// /** Cloudflare Workers env */ -// export interface Bindings { -// ACTOR_KV: KVNamespace; -// ACTOR_DO: DurableObjectNamespace; -// } -// -// /** -// * Stores the env for the current request. Required since some contexts like the inline client driver does not have access to the Hono context. -// * -// * Use getCloudflareAmbientEnv unless using CF_AMBIENT_ENV.run. -// */ -// export const CF_AMBIENT_ENV = new AsyncLocalStorage(); -// -// const STANDARD_WEBSOCKET_HEADERS = [ -// "connection", -// "upgrade", -// "sec-websocket-key", -// "sec-websocket-version", -// "sec-websocket-protocol", -// "sec-websocket-extensions", -// ]; -// -// export function getCloudflareAmbientEnv(): Bindings { -// const env = CF_AMBIENT_ENV.getStore(); -// invariant(env, "missing CF_AMBIENT_ENV"); -// return env; -// } -// -// export function createHandler( -// registry: Registry, -// inputConfig?: InputConfig, -// ): { -// handler: ExportedHandler; -// ActorHandler: DurableObjectConstructor; -// } { -// // Create router -// const { router, ActorHandler } = createRouter(registry, inputConfig); -// -// // Create Cloudflare handler -// const handler = { -// fetch: (request, env, ctx) => { -// return CF_AMBIENT_ENV.run(env, () => router.fetch(request, env, ctx)); -// }, -// } satisfies ExportedHandler; -// -// return { handler, ActorHandler }; -// } -// -// export function createRouter( -// registry: Registry, -// inputConfig?: InputConfig, -// ): { -// router: Hono<{ Bindings: Bindings }>; -// ActorHandler: DurableObjectConstructor; -// } { -// const config = ConfigSchema.parse(inputConfig); -// const runConfig = { -// driver: { -// topology: "partition", -// manager: new CloudflareActorsManagerDriver(), -// // HACK: We can't build the actor driver until we're inside the Druable Object -// actor: undefined as any, -// }, -// getUpgradeWebSocket: () => upgradeWebSocket, -// ...config, -// } satisfies RunConfig; -// -// // Create Durable Object -// const ActorHandler = createActorDurableObject(registry, runConfig); -// -// const managerTopology = new PartitionTopologyManager( -// registry.config, -// runConfig, -// { -// sendRequest: async (actorId, actorRequest): Promise => { -// const env = getCloudflareAmbientEnv(); -// -// logger().debug("sending request to durable object", { -// actorId, -// method: actorRequest.method, -// url: actorRequest.url, -// }); -// -// const id = env.ACTOR_DO.idFromString(actorId); -// const stub = env.ACTOR_DO.get(id); -// -// return await stub.fetch(actorRequest); -// }, -// -// openWebSocket: async ( -// actorId, -// encodingKind: Encoding, -// params: unknown, -// ): Promise => { -// const env = getCloudflareAmbientEnv(); -// -// logger().debug("opening websocket to durable object", { actorId }); -// -// // Make a fetch request to the Durable Object with WebSocket upgrade -// const id = env.ACTOR_DO.idFromString(actorId); -// const stub = env.ACTOR_DO.get(id); -// -// const headers: Record = { -// Upgrade: "websocket", -// Connection: "Upgrade", -// [HEADER_EXPOSE_INTERNAL_ERROR]: "true", -// [HEADER_ENCODING]: encodingKind, -// }; -// if (params) { -// headers[HEADER_CONN_PARAMS] = JSON.stringify(params); -// } -// // HACK: See packages/platforms/cloudflare-workers/src/websocket.ts -// headers["sec-websocket-protocol"] = "rivetkit"; -// -// const response = await stub.fetch("http://actor/connect/websocket", { -// headers, -// }); -// const webSocket = response.webSocket; -// -// if (!webSocket) { -// throw new InternalError( -// "missing websocket connection in response from DO", -// ); -// } -// -// logger().debug("durable object websocket connection open", { -// actorId, -// }); -// -// webSocket.accept(); -// -// // TODO: Is this still needed? -// // HACK: Cloudflare does not call onopen automatically, so we need -// // to call this on the next tick -// setTimeout(() => { -// (webSocket as any).onopen?.(new Event("open")); -// }, 0); -// -// return webSocket as unknown as WebSocket; -// }, -// -// proxyRequest: async (c, actorRequest, actorId): Promise => { -// logger().debug("forwarding request to durable object", { -// actorId, -// method: actorRequest.method, -// url: actorRequest.url, -// }); -// -// const id = c.env.ACTOR_DO.idFromString(actorId); -// const stub = c.env.ACTOR_DO.get(id); -// -// return await stub.fetch(actorRequest); -// }, -// proxyWebSocket: async (c, path, actorId, encoding, params, authData) => { -// logger().debug("forwarding websocket to durable object", { -// actorId, -// path, -// }); -// -// // Validate upgrade -// const upgradeHeader = c.req.header("Upgrade"); -// if (!upgradeHeader || upgradeHeader !== "websocket") { -// return new Response("Expected Upgrade: websocket", { -// status: 426, -// }); -// } -// -// // TODO: strip headers -// const newUrl = new URL(`http://actor${path}`); -// const actorRequest = new Request(newUrl, c.req.raw); -// -// // Always build fresh request to prevent forwarding unwanted headers -// // HACK: Since we can't build a new request, we need to remove -// // non-standard headers manually -// const headerKeys: string[] = []; -// actorRequest.headers.forEach((v, k) => headerKeys.push(k)); -// for (const k of headerKeys) { -// if (!STANDARD_WEBSOCKET_HEADERS.includes(k)) { -// actorRequest.headers.delete(k); -// } -// } -// -// // Add RivetKit headers -// actorRequest.headers.set(HEADER_EXPOSE_INTERNAL_ERROR, "true"); -// actorRequest.headers.set(HEADER_ENCODING, encoding); -// if (params) { -// actorRequest.headers.set(HEADER_CONN_PARAMS, JSON.stringify(params)); -// } -// if (authData) { -// actorRequest.headers.set(HEADER_AUTH_DATA, JSON.stringify(authData)); -// } -// -// const id = c.env.ACTOR_DO.idFromString(actorId); -// const stub = c.env.ACTOR_DO.get(id); -// -// return await stub.fetch(actorRequest); -// }, -// }, -// ); -// -// // Force the router to have access to the Cloudflare bindings -// const router = managerTopology.router as unknown as Hono<{ -// Bindings: Bindings; -// }>; -// -// return { router, ActorHandler }; -// } +import { + type DurableObjectConstructor, + type ActorHandlerInterface, + createActorDurableObject, +} from "./actor-handler-do"; +import { ConfigSchema, type Config, type InputConfig } from "./config"; +import { Hono } from "hono"; +import { PartitionTopologyManager } from "@rivetkit/core/topologies/partition"; +import type { Client } from "@rivetkit/core/client"; +import { CloudflareActorsManagerDriver } from "./manager-driver"; +import { DriverConfig, Registry, RunConfig } from "@rivetkit/core"; +import { upgradeWebSocket } from "./websocket"; +import invariant from "invariant"; +import { AsyncLocalStorage } from "node:async_hooks"; + +/** Cloudflare Workers env */ +export interface Bindings { + ACTOR_KV: KVNamespace; + ACTOR_DO: DurableObjectNamespace; +} + +/** + * Stores the env for the current request. Required since some contexts like the inline client driver does not have access to the Hono context. + * + * Use getCloudflareAmbientEnv unless using CF_AMBIENT_ENV.run. + */ +export const CF_AMBIENT_ENV = new AsyncLocalStorage(); + +export function getCloudflareAmbientEnv(): Bindings { + const env = CF_AMBIENT_ENV.getStore(); + invariant(env, "missing CF_AMBIENT_ENV"); + return env; +} + +interface Handler { + handler: ExportedHandler; + ActorHandler: DurableObjectConstructor; +} + +interface SetupOutput> { + client: Client; + createHandler: (hono?: Hono) => Handler; +} + +export function createServerHandler>( + registry: R, + inputConfig?: InputConfig, +): Handler { + const { createHandler } = createServer(registry, inputConfig); + return createHandler(); +} + +export function createServer>( + registry: R, + inputConfig?: InputConfig, +): SetupOutput { + const config = ConfigSchema.parse(inputConfig); + + // Create config + const runConfig = { + driver: { + topology: "partition", + manager: new CloudflareActorsManagerDriver(), + // HACK: We can't build the actor driver until we're inside the Druable Object + actor: undefined as any, + }, + getUpgradeWebSocket: () => upgradeWebSocket, + ...config, + } satisfies RunConfig; + + // Create Durable Object + const ActorHandler = createActorDurableObject(registry, runConfig); + + const managerTopology = new PartitionTopologyManager( + registry.config, + runConfig, + ); + + return { + client: managerTopology.inlineClient as Client, + createHandler: (hono) => { + // Build base router + const app = hono ?? new Hono(); + + // Mount registry + app.route("/registry", managerTopology.router); + + // Create Cloudflare handler + const handler = { + fetch: (request, env, ctx) => { + return CF_AMBIENT_ENV.run(env, () => app.fetch(request, env, ctx)); + }, + } satisfies ExportedHandler; + + return { handler, ActorHandler }; + }, + }; +} diff --git a/packages/platforms/cloudflare-workers/src/log.ts b/packages/platforms/cloudflare-workers/src/log.ts index 0a2419302..0f64c76ec 100644 --- a/packages/platforms/cloudflare-workers/src/log.ts +++ b/packages/platforms/cloudflare-workers/src/log.ts @@ -1,7 +1,7 @@ -// import { getLogger } from "@rivetkit/core/log"; -// -// export const LOGGER_NAME = "driver-cloudflare-workers"; -// -// export function logger() { -// return getLogger(LOGGER_NAME); -// } +import { getLogger } from "@rivetkit/core/log"; + +export const LOGGER_NAME = "driver-cloudflare-workers"; + +export function logger() { + return getLogger(LOGGER_NAME); +} diff --git a/packages/platforms/cloudflare-workers/src/manager-driver.ts b/packages/platforms/cloudflare-workers/src/manager-driver.ts index 089d47853..3de0f6799 100644 --- a/packages/platforms/cloudflare-workers/src/manager-driver.ts +++ b/packages/platforms/cloudflare-workers/src/manager-driver.ts @@ -1,175 +1,335 @@ -// import type { -// ManagerDriver, -// GetForIdInput, -// GetWithKeyInput, -// ActorOutput, -// CreateInput, -// GetOrCreateWithKeyInput, -// } from "@rivetkit/core/driver-helpers"; -// import { ActorAlreadyExists } from "@rivetkit/core/errors"; -// import { Bindings } from "./mod"; -// import { logger } from "./log"; -// import { serializeNameAndKey, serializeKey } from "./util"; -// import { getCloudflareAmbientEnv } from "./handler"; -// -// // Actor metadata structure -// interface ActorData { -// name: string; -// key: string[]; -// } -// -// // Key constants similar to Redis implementation -// const KEYS = { -// ACTOR: { -// // Combined key for actor metadata (name and key) -// metadata: (actorId: string) => `actor:${actorId}:metadata`, -// -// // Key index function for actor lookup -// keyIndex: (name: string, key: string[] = []) => { -// // Use serializeKey for consistent handling of all keys -// return `actor_key:${serializeKey(key)}`; -// }, -// }, -// }; -// -// export class CloudflareActorsManagerDriver implements ManagerDriver { -// async getForId({ -// c, -// actorId, -// }: GetForIdInput<{ Bindings: Bindings }>): Promise { -// const env = getCloudflareAmbientEnv(); -// -// // Get actor metadata from KV (combined name and key) -// const actorData = (await env.ACTOR_KV.get(KEYS.ACTOR.metadata(actorId), { -// type: "json", -// })) as ActorData | null; -// -// // If the actor doesn't exist, return undefined -// if (!actorData) { -// return undefined; -// } -// -// return { -// actorId, -// name: actorData.name, -// key: actorData.key, -// }; -// } -// -// async getWithKey({ -// c, -// name, -// key, -// }: GetWithKeyInput<{ Bindings: Bindings }>): Promise< -// ActorOutput | undefined -// > { -// const env = getCloudflareAmbientEnv(); -// -// logger().debug("getWithKey: searching for actor", { name, key }); -// -// // Generate deterministic ID from the name and key -// // This is aligned with how createActor generates IDs -// const nameKeyString = serializeNameAndKey(name, key); -// const actorId = env.ACTOR_DO.idFromName(nameKeyString).toString(); -// -// // Check if the actor metadata exists -// const actorData = await env.ACTOR_KV.get(KEYS.ACTOR.metadata(actorId), { -// type: "json", -// }); -// -// if (!actorData) { -// logger().debug("getWithKey: no actor found with matching name and key", { -// name, -// key, -// actorId, -// }); -// return undefined; -// } -// -// logger().debug("getWithKey: found actor with matching name and key", { -// actorId, -// name, -// key, -// }); -// return this.#buildActorOutput(c, actorId); -// } -// -// async getOrCreateWithKey( -// input: GetOrCreateWithKeyInput, -// ): Promise { -// // TODO: Prevent race condition here -// const getOutput = await this.getWithKey(input); -// if (getOutput) { -// return getOutput; -// } else { -// return await this.createActor(input); -// } -// } -// -// async createActor({ -// c, -// name, -// key, -// input, -// }: CreateInput<{ Bindings: Bindings }>): Promise { -// const env = getCloudflareAmbientEnv(); -// -// // Check if actor with the same name and key already exists -// const existingActor = await this.getWithKey({ c, name, key }); -// if (existingActor) { -// throw new ActorAlreadyExists(name, key); -// } -// -// // Create a deterministic ID from the actor name and key -// // This ensures that actors with the same name and key will have the same ID -// const nameKeyString = serializeNameAndKey(name, key); -// const doId = env.ACTOR_DO.idFromName(nameKeyString); -// const actorId = doId.toString(); -// -// // Init actor -// const actor = env.ACTOR_DO.get(doId); -// await actor.initialize({ -// name, -// key, -// input, -// }); -// -// // Store combined actor metadata (name and key) -// const actorData: ActorData = { name, key }; -// await env.ACTOR_KV.put( -// KEYS.ACTOR.metadata(actorId), -// JSON.stringify(actorData), -// ); -// -// // Add to key index for lookups by name and key -// await env.ACTOR_KV.put(KEYS.ACTOR.keyIndex(name, key), actorId); -// -// return { -// actorId, -// name, -// key, -// }; -// } -// -// // Helper method to build actor output from an ID -// async #buildActorOutput( -// c: any, -// actorId: string, -// ): Promise { -// const env = getCloudflareAmbientEnv(); -// -// const actorData = (await env.ACTOR_KV.get(KEYS.ACTOR.metadata(actorId), { -// type: "json", -// })) as ActorData | null; -// -// if (!actorData) { -// return undefined; -// } -// -// return { -// actorId, -// name: actorData.name, -// key: actorData.key, -// }; -// } -// } +import { + type ManagerDriver, + type GetForIdInput, + type GetWithKeyInput, + type ActorOutput, + type CreateInput, + type GetOrCreateWithKeyInput, + type ConnRoutingHandler, + HEADER_EXPOSE_INTERNAL_ERROR, + HEADER_ENCODING, + HEADER_CONN_PARAMS, + HEADER_AUTH_DATA, +} from "@rivetkit/core/driver-helpers"; +import { ActorAlreadyExists, InternalError } from "@rivetkit/core/errors"; +import { Bindings } from "./mod"; +import { logger } from "./log"; +import { serializeNameAndKey, serializeKey } from "./util"; +import { getCloudflareAmbientEnv } from "./handler"; +import { Encoding } from "@rivetkit/core"; + +// Actor metadata structure +interface ActorData { + name: string; + key: string[]; +} + +// Key constants similar to Redis implementation +const KEYS = { + ACTOR: { + // Combined key for actor metadata (name and key) + metadata: (actorId: string) => `actor:${actorId}:metadata`, + + // Key index function for actor lookup + keyIndex: (name: string, key: string[] = []) => { + // Use serializeKey for consistent handling of all keys + return `actor_key:${serializeKey(key)}`; + }, + }, +}; + +const STANDARD_WEBSOCKET_HEADERS = [ + "connection", + "upgrade", + "sec-websocket-key", + "sec-websocket-version", + "sec-websocket-protocol", + "sec-websocket-extensions", +]; + +export class CloudflareActorsManagerDriver implements ManagerDriver { + connRoutingHandler: ConnRoutingHandler; + + constructor() { + this.connRoutingHandler = { + custom: { + sendRequest: async (actorId, actorRequest): Promise => { + const env = getCloudflareAmbientEnv(); + + logger().debug("sending request to durable object", { + actorId, + method: actorRequest.method, + url: actorRequest.url, + }); + + const id = env.ACTOR_DO.idFromString(actorId); + const stub = env.ACTOR_DO.get(id); + + return await stub.fetch(actorRequest); + }, + + openWebSocket: async ( + actorId, + encodingKind: Encoding, + params: unknown, + ): Promise => { + const env = getCloudflareAmbientEnv(); + + logger().debug("opening websocket to durable object", { actorId }); + + // Make a fetch request to the Durable Object with WebSocket upgrade + const id = env.ACTOR_DO.idFromString(actorId); + const stub = env.ACTOR_DO.get(id); + + const headers: Record = { + Upgrade: "websocket", + Connection: "Upgrade", + [HEADER_EXPOSE_INTERNAL_ERROR]: "true", + [HEADER_ENCODING]: encodingKind, + }; + if (params) { + headers[HEADER_CONN_PARAMS] = JSON.stringify(params); + } + // HACK: See packages/platforms/cloudflare-workers/src/websocket.ts + headers["sec-websocket-protocol"] = "rivetkit"; + + const response = await stub.fetch("http://actor/connect/websocket", { + headers, + }); + const webSocket = response.webSocket; + + if (!webSocket) { + throw new InternalError( + "missing websocket connection in response from DO", + ); + } + + logger().debug("durable object websocket connection open", { + actorId, + }); + + webSocket.accept(); + + // TODO: Is this still needed? + // HACK: Cloudflare does not call onopen automatically, so we need + // to call this on the next tick + setTimeout(() => { + (webSocket as any).onopen?.(new Event("open")); + }, 0); + + return webSocket as unknown as WebSocket; + }, + + proxyRequest: async (c, actorRequest, actorId): Promise => { + logger().debug("forwarding request to durable object", { + actorId, + method: actorRequest.method, + url: actorRequest.url, + }); + + const id = c.env.ACTOR_DO.idFromString(actorId); + const stub = c.env.ACTOR_DO.get(id); + + return await stub.fetch(actorRequest); + }, + proxyWebSocket: async ( + c, + path, + actorId, + encoding, + params, + authData, + ) => { + logger().debug("forwarding websocket to durable object", { + actorId, + path, + }); + + // Validate upgrade + const upgradeHeader = c.req.header("Upgrade"); + if (!upgradeHeader || upgradeHeader !== "websocket") { + return new Response("Expected Upgrade: websocket", { + status: 426, + }); + } + + // TODO: strip headers + const newUrl = new URL(`http://actor${path}`); + const actorRequest = new Request(newUrl, c.req.raw); + + // Always build fresh request to prevent forwarding unwanted headers + // HACK: Since we can't build a new request, we need to remove + // non-standard headers manually + const headerKeys: string[] = []; + actorRequest.headers.forEach((v, k) => headerKeys.push(k)); + for (const k of headerKeys) { + if (!STANDARD_WEBSOCKET_HEADERS.includes(k)) { + actorRequest.headers.delete(k); + } + } + + // Add RivetKit headers + actorRequest.headers.set(HEADER_EXPOSE_INTERNAL_ERROR, "true"); + actorRequest.headers.set(HEADER_ENCODING, encoding); + if (params) { + actorRequest.headers.set( + HEADER_CONN_PARAMS, + JSON.stringify(params), + ); + } + if (authData) { + actorRequest.headers.set( + HEADER_AUTH_DATA, + JSON.stringify(authData), + ); + } + + const id = c.env.ACTOR_DO.idFromString(actorId); + const stub = c.env.ACTOR_DO.get(id); + + return await stub.fetch(actorRequest); + }, + }, + }; + } + + async getForId({ + c, + actorId, + }: GetForIdInput<{ Bindings: Bindings }>): Promise { + const env = getCloudflareAmbientEnv(); + + // Get actor metadata from KV (combined name and key) + const actorData = (await env.ACTOR_KV.get(KEYS.ACTOR.metadata(actorId), { + type: "json", + })) as ActorData | null; + + // If the actor doesn't exist, return undefined + if (!actorData) { + return undefined; + } + + return { + actorId, + name: actorData.name, + key: actorData.key, + }; + } + + async getWithKey({ + c, + name, + key, + }: GetWithKeyInput<{ Bindings: Bindings }>): Promise< + ActorOutput | undefined + > { + const env = getCloudflareAmbientEnv(); + + logger().debug("getWithKey: searching for actor", { name, key }); + + // Generate deterministic ID from the name and key + // This is aligned with how createActor generates IDs + const nameKeyString = serializeNameAndKey(name, key); + const actorId = env.ACTOR_DO.idFromName(nameKeyString).toString(); + + // Check if the actor metadata exists + const actorData = await env.ACTOR_KV.get(KEYS.ACTOR.metadata(actorId), { + type: "json", + }); + + if (!actorData) { + logger().debug("getWithKey: no actor found with matching name and key", { + name, + key, + actorId, + }); + return undefined; + } + + logger().debug("getWithKey: found actor with matching name and key", { + actorId, + name, + key, + }); + return this.#buildActorOutput(c, actorId); + } + + async getOrCreateWithKey( + input: GetOrCreateWithKeyInput, + ): Promise { + // TODO: Prevent race condition here + const getOutput = await this.getWithKey(input); + if (getOutput) { + return getOutput; + } else { + return await this.createActor(input); + } + } + + async createActor({ + c, + name, + key, + input, + }: CreateInput<{ Bindings: Bindings }>): Promise { + const env = getCloudflareAmbientEnv(); + + // Check if actor with the same name and key already exists + const existingActor = await this.getWithKey({ c, name, key }); + if (existingActor) { + throw new ActorAlreadyExists(name, key); + } + + // Create a deterministic ID from the actor name and key + // This ensures that actors with the same name and key will have the same ID + const nameKeyString = serializeNameAndKey(name, key); + const doId = env.ACTOR_DO.idFromName(nameKeyString); + const actorId = doId.toString(); + + // Init actor + const actor = env.ACTOR_DO.get(doId); + await actor.initialize({ + name, + key, + input, + }); + + // Store combined actor metadata (name and key) + const actorData: ActorData = { name, key }; + await env.ACTOR_KV.put( + KEYS.ACTOR.metadata(actorId), + JSON.stringify(actorData), + ); + + // Add to key index for lookups by name and key + await env.ACTOR_KV.put(KEYS.ACTOR.keyIndex(name, key), actorId); + + return { + actorId, + name, + key, + }; + } + + // Helper method to build actor output from an ID + async #buildActorOutput( + c: any, + actorId: string, + ): Promise { + const env = getCloudflareAmbientEnv(); + + const actorData = (await env.ACTOR_KV.get(KEYS.ACTOR.metadata(actorId), { + type: "json", + })) as ActorData | null; + + if (!actorData) { + return undefined; + } + + return { + actorId, + name: actorData.name, + key: actorData.key, + }; + } +} diff --git a/packages/platforms/cloudflare-workers/src/mod.ts b/packages/platforms/cloudflare-workers/src/mod.ts index a8365bea4..4c11cb9b0 100644 --- a/packages/platforms/cloudflare-workers/src/mod.ts +++ b/packages/platforms/cloudflare-workers/src/mod.ts @@ -1,2 +1,2 @@ -// export { type Bindings, createHandler, createRouter } from "./handler"; -// export type { InputConfig as Config } from "./config"; +export { type Bindings, createServer, createServerHandler } from "./handler"; +export type { InputConfig as Config } from "./config"; diff --git a/packages/platforms/cloudflare-workers/src/util.ts b/packages/platforms/cloudflare-workers/src/util.ts index 77549ca90..8239d6cde 100644 --- a/packages/platforms/cloudflare-workers/src/util.ts +++ b/packages/platforms/cloudflare-workers/src/util.ts @@ -1,105 +1,105 @@ -// // Constants for key handling -// export const EMPTY_KEY = "(none)"; -// export const KEY_SEPARATOR = ","; -// -// /** -// * Serializes an array of key strings into a single string for use with idFromName -// * -// * @param name The actor name -// * @param key Array of key strings to serialize -// * @returns A single string containing the serialized name and key -// */ -// export function serializeNameAndKey(name: string, key: string[]): string { -// // Escape colons in the name -// const escapedName = name.replace(/:/g, "\\:"); -// -// // For empty keys, just return the name and a marker -// if (key.length === 0) { -// return `${escapedName}:${EMPTY_KEY}`; -// } -// -// // Serialize the key array -// const serializedKey = serializeKey(key); -// -// // Combine name and serialized key -// return `${escapedName}:${serializedKey}`; -// } -// -// /** -// * Serializes an array of key strings into a single string -// * -// * @param key Array of key strings to serialize -// * @returns A single string containing the serialized key -// */ -// export function serializeKey(key: string[]): string { -// // Use a special marker for empty key arrays -// if (key.length === 0) { -// return EMPTY_KEY; -// } -// -// // Escape each key part to handle the separator and the empty key marker -// const escapedParts = key.map(part => { -// // First check if it matches our empty key marker -// if (part === EMPTY_KEY) { -// return `\\${EMPTY_KEY}`; -// } -// -// // Escape backslashes first, then commas -// let escaped = part.replace(/\\/g, "\\\\"); -// escaped = escaped.replace(/,/g, "\\,"); -// return escaped; -// }); -// -// return escapedParts.join(KEY_SEPARATOR); -// } -// -// /** -// * Deserializes a key string back into an array of key strings -// * -// * @param keyString The serialized key string -// * @returns Array of key strings -// */ -// export function deserializeKey(keyString: string): string[] { -// // Handle empty values -// if (!keyString) { -// return []; -// } -// -// // Check for special empty key marker -// if (keyString === EMPTY_KEY) { -// return []; -// } -// -// // Split by unescaped commas and unescape the escaped characters -// const parts: string[] = []; -// let currentPart = ''; -// let escaping = false; -// -// for (let i = 0; i < keyString.length; i++) { -// const char = keyString[i]; -// -// if (escaping) { -// // This is an escaped character, add it directly -// currentPart += char; -// escaping = false; -// } else if (char === '\\') { -// // Start of an escape sequence -// escaping = true; -// } else if (char === KEY_SEPARATOR) { -// // This is a separator -// parts.push(currentPart); -// currentPart = ''; -// } else { -// // Regular character -// currentPart += char; -// } -// } -// -// // Add the last part if it exists -// if (currentPart || parts.length > 0) { -// parts.push(currentPart); -// } -// -// return parts; -// } -// +// Constants for key handling +export const EMPTY_KEY = "(none)"; +export const KEY_SEPARATOR = ","; + +/** + * Serializes an array of key strings into a single string for use with idFromName + * + * @param name The actor name + * @param key Array of key strings to serialize + * @returns A single string containing the serialized name and key + */ +export function serializeNameAndKey(name: string, key: string[]): string { + // Escape colons in the name + const escapedName = name.replace(/:/g, "\\:"); + + // For empty keys, just return the name and a marker + if (key.length === 0) { + return `${escapedName}:${EMPTY_KEY}`; + } + + // Serialize the key array + const serializedKey = serializeKey(key); + + // Combine name and serialized key + return `${escapedName}:${serializedKey}`; +} + +/** + * Serializes an array of key strings into a single string + * + * @param key Array of key strings to serialize + * @returns A single string containing the serialized key + */ +export function serializeKey(key: string[]): string { + // Use a special marker for empty key arrays + if (key.length === 0) { + return EMPTY_KEY; + } + + // Escape each key part to handle the separator and the empty key marker + const escapedParts = key.map(part => { + // First check if it matches our empty key marker + if (part === EMPTY_KEY) { + return `\\${EMPTY_KEY}`; + } + + // Escape backslashes first, then commas + let escaped = part.replace(/\\/g, "\\\\"); + escaped = escaped.replace(/,/g, "\\,"); + return escaped; + }); + + return escapedParts.join(KEY_SEPARATOR); +} + +/** + * Deserializes a key string back into an array of key strings + * + * @param keyString The serialized key string + * @returns Array of key strings + */ +export function deserializeKey(keyString: string): string[] { + // Handle empty values + if (!keyString) { + return []; + } + + // Check for special empty key marker + if (keyString === EMPTY_KEY) { + return []; + } + + // Split by unescaped commas and unescape the escaped characters + const parts: string[] = []; + let currentPart = ''; + let escaping = false; + + for (let i = 0; i < keyString.length; i++) { + const char = keyString[i]; + + if (escaping) { + // This is an escaped character, add it directly + currentPart += char; + escaping = false; + } else if (char === '\\') { + // Start of an escape sequence + escaping = true; + } else if (char === KEY_SEPARATOR) { + // This is a separator + parts.push(currentPart); + currentPart = ''; + } else { + // Regular character + currentPart += char; + } + } + + // Add the last part if it exists + if (currentPart || parts.length > 0) { + parts.push(currentPart); + } + + return parts; +} + diff --git a/packages/platforms/cloudflare-workers/src/websocket.ts b/packages/platforms/cloudflare-workers/src/websocket.ts index 284272eae..8c43b9197 100644 --- a/packages/platforms/cloudflare-workers/src/websocket.ts +++ b/packages/platforms/cloudflare-workers/src/websocket.ts @@ -1,70 +1,70 @@ -// // Modified from https://github.com/honojs/hono/blob/40ea0eee58e39b31053a0246c595434f1094ad31/src/adapter/cloudflare-workers/websocket.ts#L17 -// // -// // This version calls the open event by default +// Modified from https://github.com/honojs/hono/blob/40ea0eee58e39b31053a0246c595434f1094ad31/src/adapter/cloudflare-workers/websocket.ts#L17 // -// import { WSContext, defineWebSocketHelper } from "hono/ws"; -// import type { UpgradeWebSocket, WSEvents, WSReadyState } from "hono/ws"; -// -// // Based on https://github.com/honojs/hono/issues/1153#issuecomment-1767321332 -// export const upgradeWebSocket: UpgradeWebSocket< -// WebSocket, -// // eslint-disable-next-line @typescript-eslint/no-explicit-any -// any, -// WSEvents -// > = defineWebSocketHelper(async (c, events) => { -// const upgradeHeader = c.req.header("Upgrade"); -// if (upgradeHeader !== "websocket") { -// return; -// } -// -// const webSocketPair = new WebSocketPair(); -// const client: WebSocket = webSocketPair[0]; -// const server: WebSocket = webSocketPair[1]; -// -// const wsContext = new WSContext({ -// close: (code, reason) => server.close(code, reason), -// get protocol() { -// return server.protocol; -// }, -// raw: server, -// get readyState() { -// return server.readyState as WSReadyState; -// }, -// url: server.url ? new URL(server.url) : null, -// send: (source) => server.send(source), -// }); -// -// if (events.onClose) { -// server.addEventListener("close", (evt: CloseEvent) => -// events.onClose?.(evt, wsContext), -// ); -// } -// if (events.onMessage) { -// server.addEventListener("message", (evt: MessageEvent) => -// events.onMessage?.(evt, wsContext), -// ); -// } -// if (events.onError) { -// server.addEventListener("error", (evt: Event) => -// events.onError?.(evt, wsContext), -// ); -// } -// -// server.accept?.(); -// -// // note: cloudflare actors doesn't support 'open' event, so we call it immediately with a fake event -// // -// // we have to do this after `server.accept() is called` -// events.onOpen?.(new Event("open"), wsContext); -// -// return new Response(null, { -// status: 101, -// headers: { -// // HACK: Required in order for Cloudflare to not error with "Network connection lost" -// // -// // This bug undocumented. Cannot easily reproduce outside of RivetKit. -// "Sec-WebSocket-Protocol": "rivetkit", -// }, -// webSocket: client, -// }); -// }); +// This version calls the open event by default + +import { WSContext, defineWebSocketHelper } from "hono/ws"; +import type { UpgradeWebSocket, WSEvents, WSReadyState } from "hono/ws"; + +// Based on https://github.com/honojs/hono/issues/1153#issuecomment-1767321332 +export const upgradeWebSocket: UpgradeWebSocket< + WebSocket, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any, + WSEvents +> = defineWebSocketHelper(async (c, events) => { + const upgradeHeader = c.req.header("Upgrade"); + if (upgradeHeader !== "websocket") { + return; + } + + const webSocketPair = new WebSocketPair(); + const client: WebSocket = webSocketPair[0]; + const server: WebSocket = webSocketPair[1]; + + const wsContext = new WSContext({ + close: (code, reason) => server.close(code, reason), + get protocol() { + return server.protocol; + }, + raw: server, + get readyState() { + return server.readyState as WSReadyState; + }, + url: server.url ? new URL(server.url) : null, + send: (source) => server.send(source), + }); + + if (events.onClose) { + server.addEventListener("close", (evt: CloseEvent) => + events.onClose?.(evt, wsContext), + ); + } + if (events.onMessage) { + server.addEventListener("message", (evt: MessageEvent) => + events.onMessage?.(evt, wsContext), + ); + } + if (events.onError) { + server.addEventListener("error", (evt: Event) => + events.onError?.(evt, wsContext), + ); + } + + server.accept?.(); + + // note: cloudflare actors doesn't support 'open' event, so we call it immediately with a fake event + // + // we have to do this after `server.accept() is called` + events.onOpen?.(new Event("open"), wsContext); + + return new Response(null, { + status: 101, + headers: { + // HACK: Required in order for Cloudflare to not error with "Network connection lost" + // + // This bug undocumented. Cannot easily reproduce outside of RivetKit. + "Sec-WebSocket-Protocol": "rivetkit", + }, + webSocket: client, + }); +}); diff --git a/packages/platforms/cloudflare-workers/tests/driver-tests.test.ts b/packages/platforms/cloudflare-workers/tests/driver-tests.test.ts index 4e710bc49..085d8f54b 100644 --- a/packages/platforms/cloudflare-workers/tests/driver-tests.test.ts +++ b/packages/platforms/cloudflare-workers/tests/driver-tests.test.ts @@ -1,194 +1,194 @@ -// import { runDriverTests } from "@rivetkit/core/driver-test-suite"; -// import fs from "node:fs/promises"; -// import path from "node:path"; -// import os from "node:os"; -// import { spawn, exec } from "node:child_process"; -// import crypto from "node:crypto"; -// import { promisify } from "node:util"; -// import { getPort } from "@rivetkit/core/test"; -// -// const execPromise = promisify(exec); -// -// // Bypass createTestRuntime by providing an endpoint directly -// runDriverTests({ -// useRealTimers: true, -// HACK_skipCleanupNet: true, -// async start(projectPath: string) { -// // Setup project -// if (!setupProjectOnce) { -// setupProjectOnce = setupProject(projectPath); -// } -// const projectDir = await setupProjectOnce; -// -// console.log("project dir", projectDir); -// -// // Get an available port -// const port = await getPort(); -// const inspectorPort = await getPort(); -// -// // Start wrangler dev -// const wranglerProcess = spawn( -// "pnpm", -// [ -// "start", -// "src/index.ts", -// "--port", -// `${port}`, -// "--inspector-port", -// `${inspectorPort}`, -// "--persist-to", -// `/tmp/actors-test-${crypto.randomUUID()}`, -// ], -// { -// cwd: projectDir, -// stdio: "pipe", -// }, -// ); -// -// // Wait for wrangler to start -// await new Promise((resolve, reject) => { -// let isResolved = false; -// const timeout = setTimeout(() => { -// if (!isResolved) { -// isResolved = true; -// wranglerProcess.kill(); -// reject(new Error("Timeout waiting for wrangler to start")); -// } -// }, 30000); -// -// wranglerProcess.stdout?.on("data", (data) => { -// const output = data.toString(); -// console.log(`wrangler: ${output}`); -// if (output.includes(`Ready on http://localhost:${port}`)) { -// if (!isResolved) { -// isResolved = true; -// clearTimeout(timeout); -// resolve(); -// } -// } -// }); -// -// wranglerProcess.stderr?.on("data", (data) => { -// console.error(`wrangler: ${data}`); -// }); -// -// wranglerProcess.on("error", (error) => { -// if (!isResolved) { -// isResolved = true; -// clearTimeout(timeout); -// reject(error); -// } -// }); -// -// wranglerProcess.on("exit", (code) => { -// if (!isResolved && code !== 0) { -// isResolved = true; -// clearTimeout(timeout); -// reject(new Error(`wrangler exited with code ${code}`)); -// } -// }); -// }); -// -// return { -// endpoint: `http://localhost:${port}`, -// async cleanup() { -// // Shut down wrangler process -// wranglerProcess.kill(); -// }, -// }; -// }, -// }); -// -// let setupProjectOnce: Promise | undefined = undefined; -// -// async function setupProject(projectPath: string) { -// // Create a temporary directory for the test -// const uuid = crypto.randomUUID(); -// const tmpDir = path.join(os.tmpdir(), `rivetkit-test-${uuid}`); -// await fs.mkdir(tmpDir, { recursive: true }); -// -// // Create package.json with workspace dependencies -// const packageJson = { -// name: "rivetkit-test", -// private: true, -// version: "1.0.0", -// type: "module", -// scripts: { -// start: "wrangler dev", -// }, -// dependencies: { -// wrangler: "4.8.0", -// "@rivetkit/cloudflare-workers": "workspace:*", -// rivetkit: "workspace:*", -// }, -// packageManager: -// "pnpm@10.7.1+sha512.2d92c86b7928dc8284f53494fb4201f983da65f0fb4f0d40baafa5cf628fa31dae3e5968f12466f17df7e97310e30f343a648baea1b9b350685dafafffdf5808", -// }; -// await fs.writeFile( -// path.join(tmpDir, "package.json"), -// JSON.stringify(packageJson, null, 2), -// ); -// -// // Get the current workspace root path and link the workspace -// const workspaceRoot = path.resolve(__dirname, "../../../.."); -// await execPromise(`pnpm link -A ${workspaceRoot}`, { cwd: tmpDir }); -// -// // Install deps -// await execPromise("pnpm install", { cwd: tmpDir }); -// -// // Create a wrangler.json file -// const wranglerConfig = { -// name: "rivetkit-test", -// compatibility_date: "2025-01-29", -// compatibility_flags: ["nodejs_compat"], -// migrations: [ -// { -// new_classes: ["ActorHandler"], -// tag: "v1", -// }, -// ], -// durable_objects: { -// bindings: [ -// { -// class_name: "ActorHandler", -// name: "ACTOR_DO", -// }, -// ], -// }, -// kv_namespaces: [ -// { -// binding: "ACTOR_KV", -// id: "test", // Will be replaced with a mock in dev mode -// }, -// ], -// observability: { -// enabled: true, -// }, -// }; -// await fs.writeFile( -// path.join(tmpDir, "wrangler.json"), -// JSON.stringify(wranglerConfig, null, 2), -// ); -// -// // Copy project to test directory -// const projectDestDir = path.join(tmpDir, "src", "actors"); -// await fs.cp(projectPath, projectDestDir, { recursive: true }); -// -// // Write script -// const indexContent = `import { createHandler } from "@rivetkit/cloudflare-workers"; -// import { registry } from "./actors/registry"; -// -// // TODO: Find a cleaner way of flagging an registry as test mode (ideally not in the config itself) -// // Force enable test -// registry.config.test.enabled = true; -// -// // Create handlers for Cloudflare Workers -// const { handler, ActorHandler } = createHandler(registry); -// -// // Export the handlers for Cloudflare -// export { handler as default, ActorHandler }; -// `; -// await fs.writeFile(path.join(tmpDir, "src/index.ts"), indexContent); -// -// return tmpDir; -// } +import { runDriverTests } from "@rivetkit/core/driver-test-suite"; +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { spawn, exec } from "node:child_process"; +import crypto from "node:crypto"; +import { promisify } from "node:util"; +import { getPort } from "@rivetkit/core/test"; + +const execPromise = promisify(exec); + +// Bypass createTestRuntime by providing an endpoint directly +runDriverTests({ + useRealTimers: true, + HACK_skipCleanupNet: true, + async start(projectPath: string) { + // Setup project + if (!setupProjectOnce) { + setupProjectOnce = setupProject(projectPath); + } + const projectDir = await setupProjectOnce; + + console.log("project dir", projectDir); + + // Get an available port + const port = await getPort(); + const inspectorPort = await getPort(); + + // Start wrangler dev + const wranglerProcess = spawn( + "pnpm", + [ + "start", + "src/index.ts", + "--port", + `${port}`, + "--inspector-port", + `${inspectorPort}`, + "--persist-to", + `/tmp/actors-test-${crypto.randomUUID()}`, + ], + { + cwd: projectDir, + stdio: "pipe", + }, + ); + + // Wait for wrangler to start + await new Promise((resolve, reject) => { + let isResolved = false; + const timeout = setTimeout(() => { + if (!isResolved) { + isResolved = true; + wranglerProcess.kill(); + reject(new Error("Timeout waiting for wrangler to start")); + } + }, 30000); + + wranglerProcess.stdout?.on("data", (data) => { + const output = data.toString(); + console.log(`wrangler: ${output}`); + if (output.includes(`Ready on http://localhost:${port}`)) { + if (!isResolved) { + isResolved = true; + clearTimeout(timeout); + resolve(); + } + } + }); + + wranglerProcess.stderr?.on("data", (data) => { + console.error(`wrangler: ${data}`); + }); + + wranglerProcess.on("error", (error) => { + if (!isResolved) { + isResolved = true; + clearTimeout(timeout); + reject(error); + } + }); + + wranglerProcess.on("exit", (code) => { + if (!isResolved && code !== 0) { + isResolved = true; + clearTimeout(timeout); + reject(new Error(`wrangler exited with code ${code}`)); + } + }); + }); + + return { + endpoint: `http://localhost:${port}`, + async cleanup() { + // Shut down wrangler process + wranglerProcess.kill(); + }, + }; + }, +}); + +let setupProjectOnce: Promise | undefined = undefined; + +async function setupProject(projectPath: string) { + // Create a temporary directory for the test + const uuid = crypto.randomUUID(); + const tmpDir = path.join(os.tmpdir(), `rivetkit-test-${uuid}`); + await fs.mkdir(tmpDir, { recursive: true }); + + // Create package.json with workspace dependencies + const packageJson = { + name: "rivetkit-test", + private: true, + version: "1.0.0", + type: "module", + scripts: { + start: "wrangler dev", + }, + dependencies: { + wrangler: "4.8.0", + "@rivetkit/cloudflare-workers": "workspace:*", + rivetkit: "workspace:*", + }, + packageManager: + "pnpm@10.7.1+sha512.2d92c86b7928dc8284f53494fb4201f983da65f0fb4f0d40baafa5cf628fa31dae3e5968f12466f17df7e97310e30f343a648baea1b9b350685dafafffdf5808", + }; + await fs.writeFile( + path.join(tmpDir, "package.json"), + JSON.stringify(packageJson, null, 2), + ); + + // Get the current workspace root path and link the workspace + const workspaceRoot = path.resolve(__dirname, "../../../.."); + await execPromise(`pnpm link -A ${workspaceRoot}`, { cwd: tmpDir }); + + // Install deps + await execPromise("pnpm install", { cwd: tmpDir }); + + // Create a wrangler.json file + const wranglerConfig = { + name: "rivetkit-test", + compatibility_date: "2025-01-29", + compatibility_flags: ["nodejs_compat"], + migrations: [ + { + new_classes: ["ActorHandler"], + tag: "v1", + }, + ], + durable_objects: { + bindings: [ + { + class_name: "ActorHandler", + name: "ACTOR_DO", + }, + ], + }, + kv_namespaces: [ + { + binding: "ACTOR_KV", + id: "test", // Will be replaced with a mock in dev mode + }, + ], + observability: { + enabled: true, + }, + }; + await fs.writeFile( + path.join(tmpDir, "wrangler.json"), + JSON.stringify(wranglerConfig, null, 2), + ); + + // Copy project to test directory + const projectDestDir = path.join(tmpDir, "src", "actors"); + await fs.cp(projectPath, projectDestDir, { recursive: true }); + + // Write script + const indexContent = `import { createHandler } from "@rivetkit/cloudflare-workers"; +import { registry } from "./actors/registry"; + +// TODO: Find a cleaner way of flagging an registry as test mode (ideally not in the config itself) +// Force enable test +registry.config.test.enabled = true; + +// Create handlers for Cloudflare Workers +const { handler, ActorHandler } = createHandler(registry); + +// Export the handlers for Cloudflare +export { handler as default, ActorHandler }; +`; + await fs.writeFile(path.join(tmpDir, "src/index.ts"), indexContent); + + return tmpDir; +} diff --git a/packages/platforms/cloudflare-workers/tests/id-generation.test.ts b/packages/platforms/cloudflare-workers/tests/id-generation.test.ts index 6e2e12083..98356e474 100644 --- a/packages/platforms/cloudflare-workers/tests/id-generation.test.ts +++ b/packages/platforms/cloudflare-workers/tests/id-generation.test.ts @@ -1,41 +1,41 @@ -// import { describe, test, expect, vi } from "vitest"; -// import { serializeNameAndKey } from "../src/util"; -// import { CloudflareActorsManagerDriver } from "../src/manager-driver"; -// -// describe("Deterministic ID generation", () => { -// test("should generate consistent IDs for the same name and key", () => { -// const name = "test-actor"; -// const key = ["key1", "key2"]; -// -// // Test that serializeNameAndKey produces a consistent string -// const serialized1 = serializeNameAndKey(name, key); -// const serialized2 = serializeNameAndKey(name, key); -// -// expect(serialized1).toBe(serialized2); -// expect(serialized1).toBe("test-actor:key1,key2"); -// }); -// -// test("should properly escape special characters in keys", () => { -// const name = "test-actor"; -// const key = ["key,with,commas", "normal-key"]; -// -// const serialized = serializeNameAndKey(name, key); -// expect(serialized).toBe("test-actor:key\\,with\\,commas,normal-key"); -// }); -// -// test("should properly escape colons in actor names", () => { -// const name = "test:actor:with:colons"; -// const key = ["key1", "key2"]; -// -// const serialized = serializeNameAndKey(name, key); -// expect(serialized).toBe("test\\:actor\\:with\\:colons:key1,key2"); -// }); -// -// test("should handle empty key arrays", () => { -// const name = "test-actor"; -// const key: string[] = []; -// -// const serialized = serializeNameAndKey(name, key); -// expect(serialized).toBe("test-actor:(none)"); -// }); -// }); +import { describe, test, expect, vi } from "vitest"; +import { serializeNameAndKey } from "../src/util"; +import { CloudflareActorsManagerDriver } from "../src/manager-driver"; + +describe("Deterministic ID generation", () => { + test("should generate consistent IDs for the same name and key", () => { + const name = "test-actor"; + const key = ["key1", "key2"]; + + // Test that serializeNameAndKey produces a consistent string + const serialized1 = serializeNameAndKey(name, key); + const serialized2 = serializeNameAndKey(name, key); + + expect(serialized1).toBe(serialized2); + expect(serialized1).toBe("test-actor:key1,key2"); + }); + + test("should properly escape special characters in keys", () => { + const name = "test-actor"; + const key = ["key,with,commas", "normal-key"]; + + const serialized = serializeNameAndKey(name, key); + expect(serialized).toBe("test-actor:key\\,with\\,commas,normal-key"); + }); + + test("should properly escape colons in actor names", () => { + const name = "test:actor:with:colons"; + const key = ["key1", "key2"]; + + const serialized = serializeNameAndKey(name, key); + expect(serialized).toBe("test\\:actor\\:with\\:colons:key1,key2"); + }); + + test("should handle empty key arrays", () => { + const name = "test-actor"; + const key: string[] = []; + + const serialized = serializeNameAndKey(name, key); + expect(serialized).toBe("test-actor:(none)"); + }); +}); diff --git a/packages/platforms/cloudflare-workers/tests/key-indexes.test.ts b/packages/platforms/cloudflare-workers/tests/key-indexes.test.ts index 6324f4d81..5302da883 100644 --- a/packages/platforms/cloudflare-workers/tests/key-indexes.test.ts +++ b/packages/platforms/cloudflare-workers/tests/key-indexes.test.ts @@ -1,42 +1,42 @@ -// import { describe, test, expect } from "vitest"; -// import { serializeKey, serializeNameAndKey } from "../src/util"; -// -// // Access internal KEYS directly -// // Since KEYS is a private constant in manager_driver.ts, we'll redefine it here for testing -// const KEYS = { -// ACTOR: { -// metadata: (actorId: string) => `actor:${actorId}:metadata`, -// keyIndex: (name: string, key: string[] = []) => { -// // Use serializeKey for consistent handling of all keys -// return `actor_key:${serializeKey(key)}`; -// }, -// }, -// }; -// -// describe("Key index functions", () => { -// test("keyIndex handles empty key array", () => { -// expect(KEYS.ACTOR.keyIndex("test-actor")).toBe("actor_key:(none)"); -// expect(KEYS.ACTOR.keyIndex("actor:with:colons")).toBe("actor_key:(none)"); -// }); -// -// test("keyIndex handles single-item key arrays", () => { -// // Note: keyIndex ignores the name parameter -// expect(KEYS.ACTOR.keyIndex("test-actor", ["key1"])).toBe("actor_key:key1"); -// expect(KEYS.ACTOR.keyIndex("actor:with:colons", ["key:with:colons"])) -// .toBe("actor_key:key:with:colons"); -// }); -// -// test("keyIndex handles multi-item array keys", () => { -// // Note: keyIndex ignores the name parameter -// expect(KEYS.ACTOR.keyIndex("test-actor", ["key1", "key2"])) -// .toBe(`actor_key:key1,key2`); -// -// // Test with special characters -// expect(KEYS.ACTOR.keyIndex("test-actor", ["key,with,commas"])) -// .toBe("actor_key:key\\,with\\,commas"); -// }); -// -// test("metadata key creates proper pattern", () => { -// expect(KEYS.ACTOR.metadata("123-456")).toBe("actor:123-456:metadata"); -// }); -// }); +import { describe, test, expect } from "vitest"; +import { serializeKey, serializeNameAndKey } from "../src/util"; + +// Access internal KEYS directly +// Since KEYS is a private constant in manager_driver.ts, we'll redefine it here for testing +const KEYS = { + ACTOR: { + metadata: (actorId: string) => `actor:${actorId}:metadata`, + keyIndex: (name: string, key: string[] = []) => { + // Use serializeKey for consistent handling of all keys + return `actor_key:${serializeKey(key)}`; + }, + }, +}; + +describe("Key index functions", () => { + test("keyIndex handles empty key array", () => { + expect(KEYS.ACTOR.keyIndex("test-actor")).toBe("actor_key:(none)"); + expect(KEYS.ACTOR.keyIndex("actor:with:colons")).toBe("actor_key:(none)"); + }); + + test("keyIndex handles single-item key arrays", () => { + // Note: keyIndex ignores the name parameter + expect(KEYS.ACTOR.keyIndex("test-actor", ["key1"])).toBe("actor_key:key1"); + expect(KEYS.ACTOR.keyIndex("actor:with:colons", ["key:with:colons"])) + .toBe("actor_key:key:with:colons"); + }); + + test("keyIndex handles multi-item array keys", () => { + // Note: keyIndex ignores the name parameter + expect(KEYS.ACTOR.keyIndex("test-actor", ["key1", "key2"])) + .toBe(`actor_key:key1,key2`); + + // Test with special characters + expect(KEYS.ACTOR.keyIndex("test-actor", ["key,with,commas"])) + .toBe("actor_key:key\\,with\\,commas"); + }); + + test("metadata key creates proper pattern", () => { + expect(KEYS.ACTOR.metadata("123-456")).toBe("actor:123-456:metadata"); + }); +}); diff --git a/packages/platforms/cloudflare-workers/tests/key-serialization.test.ts b/packages/platforms/cloudflare-workers/tests/key-serialization.test.ts index 34c8e6e48..3719486d0 100644 --- a/packages/platforms/cloudflare-workers/tests/key-serialization.test.ts +++ b/packages/platforms/cloudflare-workers/tests/key-serialization.test.ts @@ -1,222 +1,222 @@ -// import { describe, test, expect } from "vitest"; -// import { -// serializeKey, -// deserializeKey, -// serializeNameAndKey, -// EMPTY_KEY, -// KEY_SEPARATOR -// } from "../src/util"; -// -// describe("Key serialization and deserialization", () => { -// // Test key serialization -// describe("serializeKey", () => { -// test("serializes empty key array", () => { -// expect(serializeKey([])).toBe(EMPTY_KEY); -// }); -// -// test("serializes single key", () => { -// expect(serializeKey(["test"])).toBe("test"); -// }); -// -// test("serializes multiple keys", () => { -// expect(serializeKey(["a", "b", "c"])).toBe(`a${KEY_SEPARATOR}b${KEY_SEPARATOR}c`); -// }); -// -// test("escapes commas in keys", () => { -// expect(serializeKey(["a,b"])).toBe("a\\,b"); -// expect(serializeKey(["a,b", "c"])).toBe(`a\\,b${KEY_SEPARATOR}c`); -// }); -// -// test("escapes empty key marker in keys", () => { -// expect(serializeKey([EMPTY_KEY])).toBe(`\\${EMPTY_KEY}`); -// }); -// -// test("handles complex keys", () => { -// expect(serializeKey(["a,b", EMPTY_KEY, "c,d"])).toBe(`a\\,b${KEY_SEPARATOR}\\${EMPTY_KEY}${KEY_SEPARATOR}c\\,d`); -// }); -// }); -// -// // Test key deserialization -// describe("deserializeKey", () => { -// test("deserializes empty string", () => { -// expect(deserializeKey("")).toEqual([]); -// }); -// -// test("deserializes undefined/null", () => { -// expect(deserializeKey(undefined as unknown as string)).toEqual([]); -// expect(deserializeKey(null as unknown as string)).toEqual([]); -// }); -// -// test("deserializes empty key marker", () => { -// expect(deserializeKey(EMPTY_KEY)).toEqual([]); -// }); -// -// test("deserializes single key", () => { -// expect(deserializeKey("test")).toEqual(["test"]); -// }); -// -// test("deserializes multiple keys", () => { -// expect(deserializeKey(`a${KEY_SEPARATOR}b${KEY_SEPARATOR}c`)).toEqual(["a", "b", "c"]); -// }); -// -// test("deserializes keys with escaped commas", () => { -// expect(deserializeKey("a\\,b")).toEqual(["a,b"]); -// expect(deserializeKey(`a\\,b${KEY_SEPARATOR}c`)).toEqual(["a,b", "c"]); -// }); -// -// test("deserializes keys with escaped empty key marker", () => { -// expect(deserializeKey(`\\${EMPTY_KEY}`)).toEqual([EMPTY_KEY]); -// }); -// -// test("deserializes complex keys", () => { -// expect(deserializeKey(`a\\,b${KEY_SEPARATOR}\\${EMPTY_KEY}${KEY_SEPARATOR}c\\,d`)).toEqual(["a,b", EMPTY_KEY, "c,d"]); -// }); -// }); -// -// // Test name+key serialization -// describe("serializeNameAndKey", () => { -// test("serializes name with empty key array", () => { -// expect(serializeNameAndKey("test", [])).toBe(`test:${EMPTY_KEY}`); -// }); -// -// test("serializes name with single key", () => { -// expect(serializeNameAndKey("test", ["key1"])).toBe("test:key1"); -// }); -// -// test("serializes name with multiple keys", () => { -// expect(serializeNameAndKey("test", ["a", "b", "c"])).toBe(`test:a${KEY_SEPARATOR}b${KEY_SEPARATOR}c`); -// }); -// -// test("escapes commas in keys", () => { -// expect(serializeNameAndKey("test", ["a,b"])).toBe("test:a\\,b"); -// }); -// -// test("handles complex keys with name", () => { -// expect(serializeNameAndKey("actor", ["a,b", EMPTY_KEY, "c,d"])) -// .toBe(`actor:a\\,b${KEY_SEPARATOR}\\${EMPTY_KEY}${KEY_SEPARATOR}c\\,d`); -// }); -// }); -// -// // Removed createIndexKey tests as function was moved to KEYS.INDEX in manager_driver.ts -// -// // Test roundtrip -// describe("roundtrip", () => { -// const testKeys = [ -// [], -// ["test"], -// ["a", "b", "c"], -// ["a,b", "c"], -// [EMPTY_KEY], -// ["a,b", EMPTY_KEY, "c,d"], -// ["special\\chars", "more:complex,keys", "final key"] -// ]; -// -// testKeys.forEach(key => { -// test(`roundtrip: ${JSON.stringify(key)}`, () => { -// const serialized = serializeKey(key); -// const deserialized = deserializeKey(serialized); -// expect(deserialized).toEqual(key); -// }); -// }); -// -// test("handles all test cases in a large batch", () => { -// for (const key of testKeys) { -// const serialized = serializeKey(key); -// const deserialized = deserializeKey(serialized); -// expect(deserialized).toEqual(key); -// } -// }); -// }); -// -// // Test edge cases -// describe("edge cases", () => { -// test("handles backslash at the end", () => { -// const key = ["abc\\"]; -// const serialized = serializeKey(key); -// const deserialized = deserializeKey(serialized); -// expect(deserialized).toEqual(key); -// }); -// -// test("handles backslashes in middle of string", () => { -// const keys = [ -// ["abc\\def"], -// ["abc\\\\def"], -// ["path\\to\\file"] -// ]; -// -// for (const key of keys) { -// const serialized = serializeKey(key); -// const deserialized = deserializeKey(serialized); -// expect(deserialized).toEqual(key); -// } -// }); -// -// test("handles commas at the end of strings", () => { -// const serialized = serializeKey(["abc\\,"]); -// expect(deserializeKey(serialized)).toEqual(["abc\\,"]); -// }); -// -// test("handles mixed backslashes and commas", () => { -// const keys = [ -// ["path\\to\\file,dir"], -// ["file\\with,comma"], -// ["path\\to\\file", "with,comma"] -// ]; -// -// for (const key of keys) { -// const serialized = serializeKey(key); -// const deserialized = deserializeKey(serialized); -// expect(deserialized).toEqual(key); -// } -// }); -// -// test("handles multiple consecutive commas", () => { -// const key = ["a,,b"]; -// const serialized = serializeKey(key); -// const deserialized = deserializeKey(serialized); -// expect(deserialized).toEqual(key); -// }); -// -// test("handles special characters", () => { -// const key = ["a💻b", "c🔑d"]; -// const serialized = serializeKey(key); -// const deserialized = deserializeKey(serialized); -// expect(deserialized).toEqual(key); -// }); -// }); -// -// // Test exact key matching -// describe("exact key matching", () => { -// test("differentiates [a,b] from [a,b,c]", () => { -// const key1 = ["a", "b"]; -// const key2 = ["a", "b", "c"]; -// -// const serialized1 = serializeKey(key1); -// const serialized2 = serializeKey(key2); -// -// expect(serialized1).not.toBe(serialized2); -// }); -// -// test("differentiates [a,b] from [a]", () => { -// const key1 = ["a", "b"]; -// const key2 = ["a"]; -// -// const serialized1 = serializeKey(key1); -// const serialized2 = serializeKey(key2); -// -// expect(serialized1).not.toBe(serialized2); -// }); -// -// test("differentiates [a,b] from [a:b]", () => { -// const key1 = ["a,b"]; -// const key2 = ["a", "b"]; -// -// const serialized1 = serializeKey(key1); -// const serialized2 = serializeKey(key2); -// -// expect(serialized1).not.toBe(serialized2); -// expect(deserializeKey(serialized1)).toEqual(key1); -// expect(deserializeKey(serialized2)).toEqual(key2); -// }); -// }); -// }); +import { describe, test, expect } from "vitest"; +import { + serializeKey, + deserializeKey, + serializeNameAndKey, + EMPTY_KEY, + KEY_SEPARATOR +} from "../src/util"; + +describe("Key serialization and deserialization", () => { + // Test key serialization + describe("serializeKey", () => { + test("serializes empty key array", () => { + expect(serializeKey([])).toBe(EMPTY_KEY); + }); + + test("serializes single key", () => { + expect(serializeKey(["test"])).toBe("test"); + }); + + test("serializes multiple keys", () => { + expect(serializeKey(["a", "b", "c"])).toBe(`a${KEY_SEPARATOR}b${KEY_SEPARATOR}c`); + }); + + test("escapes commas in keys", () => { + expect(serializeKey(["a,b"])).toBe("a\\,b"); + expect(serializeKey(["a,b", "c"])).toBe(`a\\,b${KEY_SEPARATOR}c`); + }); + + test("escapes empty key marker in keys", () => { + expect(serializeKey([EMPTY_KEY])).toBe(`\\${EMPTY_KEY}`); + }); + + test("handles complex keys", () => { + expect(serializeKey(["a,b", EMPTY_KEY, "c,d"])).toBe(`a\\,b${KEY_SEPARATOR}\\${EMPTY_KEY}${KEY_SEPARATOR}c\\,d`); + }); + }); + + // Test key deserialization + describe("deserializeKey", () => { + test("deserializes empty string", () => { + expect(deserializeKey("")).toEqual([]); + }); + + test("deserializes undefined/null", () => { + expect(deserializeKey(undefined as unknown as string)).toEqual([]); + expect(deserializeKey(null as unknown as string)).toEqual([]); + }); + + test("deserializes empty key marker", () => { + expect(deserializeKey(EMPTY_KEY)).toEqual([]); + }); + + test("deserializes single key", () => { + expect(deserializeKey("test")).toEqual(["test"]); + }); + + test("deserializes multiple keys", () => { + expect(deserializeKey(`a${KEY_SEPARATOR}b${KEY_SEPARATOR}c`)).toEqual(["a", "b", "c"]); + }); + + test("deserializes keys with escaped commas", () => { + expect(deserializeKey("a\\,b")).toEqual(["a,b"]); + expect(deserializeKey(`a\\,b${KEY_SEPARATOR}c`)).toEqual(["a,b", "c"]); + }); + + test("deserializes keys with escaped empty key marker", () => { + expect(deserializeKey(`\\${EMPTY_KEY}`)).toEqual([EMPTY_KEY]); + }); + + test("deserializes complex keys", () => { + expect(deserializeKey(`a\\,b${KEY_SEPARATOR}\\${EMPTY_KEY}${KEY_SEPARATOR}c\\,d`)).toEqual(["a,b", EMPTY_KEY, "c,d"]); + }); + }); + + // Test name+key serialization + describe("serializeNameAndKey", () => { + test("serializes name with empty key array", () => { + expect(serializeNameAndKey("test", [])).toBe(`test:${EMPTY_KEY}`); + }); + + test("serializes name with single key", () => { + expect(serializeNameAndKey("test", ["key1"])).toBe("test:key1"); + }); + + test("serializes name with multiple keys", () => { + expect(serializeNameAndKey("test", ["a", "b", "c"])).toBe(`test:a${KEY_SEPARATOR}b${KEY_SEPARATOR}c`); + }); + + test("escapes commas in keys", () => { + expect(serializeNameAndKey("test", ["a,b"])).toBe("test:a\\,b"); + }); + + test("handles complex keys with name", () => { + expect(serializeNameAndKey("actor", ["a,b", EMPTY_KEY, "c,d"])) + .toBe(`actor:a\\,b${KEY_SEPARATOR}\\${EMPTY_KEY}${KEY_SEPARATOR}c\\,d`); + }); + }); + + // Removed createIndexKey tests as function was moved to KEYS.INDEX in manager_driver.ts + + // Test roundtrip + describe("roundtrip", () => { + const testKeys = [ + [], + ["test"], + ["a", "b", "c"], + ["a,b", "c"], + [EMPTY_KEY], + ["a,b", EMPTY_KEY, "c,d"], + ["special\\chars", "more:complex,keys", "final key"] + ]; + + testKeys.forEach(key => { + test(`roundtrip: ${JSON.stringify(key)}`, () => { + const serialized = serializeKey(key); + const deserialized = deserializeKey(serialized); + expect(deserialized).toEqual(key); + }); + }); + + test("handles all test cases in a large batch", () => { + for (const key of testKeys) { + const serialized = serializeKey(key); + const deserialized = deserializeKey(serialized); + expect(deserialized).toEqual(key); + } + }); + }); + + // Test edge cases + describe("edge cases", () => { + test("handles backslash at the end", () => { + const key = ["abc\\"]; + const serialized = serializeKey(key); + const deserialized = deserializeKey(serialized); + expect(deserialized).toEqual(key); + }); + + test("handles backslashes in middle of string", () => { + const keys = [ + ["abc\\def"], + ["abc\\\\def"], + ["path\\to\\file"] + ]; + + for (const key of keys) { + const serialized = serializeKey(key); + const deserialized = deserializeKey(serialized); + expect(deserialized).toEqual(key); + } + }); + + test("handles commas at the end of strings", () => { + const serialized = serializeKey(["abc\\,"]); + expect(deserializeKey(serialized)).toEqual(["abc\\,"]); + }); + + test("handles mixed backslashes and commas", () => { + const keys = [ + ["path\\to\\file,dir"], + ["file\\with,comma"], + ["path\\to\\file", "with,comma"] + ]; + + for (const key of keys) { + const serialized = serializeKey(key); + const deserialized = deserializeKey(serialized); + expect(deserialized).toEqual(key); + } + }); + + test("handles multiple consecutive commas", () => { + const key = ["a,,b"]; + const serialized = serializeKey(key); + const deserialized = deserializeKey(serialized); + expect(deserialized).toEqual(key); + }); + + test("handles special characters", () => { + const key = ["a💻b", "c🔑d"]; + const serialized = serializeKey(key); + const deserialized = deserializeKey(serialized); + expect(deserialized).toEqual(key); + }); + }); + + // Test exact key matching + describe("exact key matching", () => { + test("differentiates [a,b] from [a,b,c]", () => { + const key1 = ["a", "b"]; + const key2 = ["a", "b", "c"]; + + const serialized1 = serializeKey(key1); + const serialized2 = serializeKey(key2); + + expect(serialized1).not.toBe(serialized2); + }); + + test("differentiates [a,b] from [a]", () => { + const key1 = ["a", "b"]; + const key2 = ["a"]; + + const serialized1 = serializeKey(key1); + const serialized2 = serializeKey(key2); + + expect(serialized1).not.toBe(serialized2); + }); + + test("differentiates [a,b] from [a:b]", () => { + const key1 = ["a,b"]; + const key2 = ["a", "b"]; + + const serialized1 = serializeKey(key1); + const serialized2 = serializeKey(key2); + + expect(serialized1).not.toBe(serialized2); + expect(deserializeKey(serialized1)).toEqual(key1); + expect(deserializeKey(serialized2)).toEqual(key2); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3c78ed21..4758a9823 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -150,6 +150,34 @@ importers: specifier: ^3.0.0 version: 3.114.9(@cloudflare/workers-types@4.20250619.0) + examples/cloudflare-workers-hono: + dependencies: + '@rivetkit/cloudflare-workers': + specifier: workspace:* + version: link:../../packages/platforms/cloudflare-workers + hono: + specifier: ^4.8.0 + version: 4.8.0 + devDependencies: + '@cloudflare/workers-types': + specifier: ^4.20250129.0 + version: 4.20250619.0 + '@rivetkit/actor': + specifier: workspace:* + version: link:../../packages/actor + '@types/node': + specifier: ^22.13.9 + version: 22.15.32 + tsx: + specifier: ^3.12.7 + version: 3.14.0 + typescript: + specifier: ^5.5.2 + version: 5.8.3 + wrangler: + specifier: ^3.0.0 + version: 3.114.9(@cloudflare/workers-types@4.20250619.0) + examples/counter: devDependencies: '@rivetkit/actor': @@ -5377,14 +5405,6 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@6.3.5(@types/node@22.15.32)(tsx@3.14.0)(yaml@2.8.0))': - dependencies: - '@vitest/spy': 3.2.4 - estree-walker: 3.0.3 - magic-string: 0.30.17 - optionalDependencies: - vite: 6.3.5(@types/node@22.15.32)(tsx@3.14.0)(yaml@2.8.0) - '@vitest/mocker@3.2.4(vite@6.3.5(@types/node@24.0.4)(tsx@4.20.3)(yaml@2.8.0))': dependencies: '@vitest/spy': 3.2.4 @@ -7522,7 +7542,7 @@ snapshots: dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@6.3.5(@types/node@22.15.32)(tsx@3.14.0)(yaml@2.8.0)) + '@vitest/mocker': 3.2.4(vite@6.3.5(@types/node@24.0.4)(tsx@4.20.3)(yaml@2.8.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4