diff --git a/CLAUDE.md b/CLAUDE.md index f3ec25a531..12b79cbf2f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,13 +104,23 @@ git commit -m "chore(my-pkg): foo bar" - Prefer the Tokio-shaped APIs from `antiox` for concurrency needs. For example, use `antiox/sync/mpsc` for `tx` and `rx` channels, `antiox/task` for spawning tasks, and the matching sync and time modules as needed. - Treat `antiox` as the default choice for any TypeScript concurrency work because it mirrors Rust and Tokio APIs used elsewhere in the codebase. +### RivetKit Type Build Troubleshooting +- If `rivetkit` type or DTS builds fail with missing `@rivetkit/*` declarations, run `pnpm build -F rivetkit` from repo root (Turbo build path) before changing TypeScript `paths`. +- Do not add temporary `@rivetkit/*` path aliases in `rivetkit-typescript/packages/rivetkit/tsconfig.json` to work around stale or missing built declarations. + +### RivetKit Driver Registry Variants +- Keep `rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/registry.ts` as the canonical type anchor for fixtures and test typing. +- Run driver runtime suites through `registry-static.ts` and `registry-dynamic.ts` instead of executing `registry.ts` directly. +- Load static fixture actors with dynamic ESM `import()` from the `fixtures/driver-test-suite/actors/` directory. +- Skip dynamic registry parity only for the explicit nested dynamic harness gate or missing secure-exec dist, and still treat full static and dynamic compatibility as the target for all normal driver suites. + ### SQLite Package - Use `@rivetkit/sqlite` for SQLite WebAssembly support. - Do not use the legacy upstream package directly. `@rivetkit/sqlite` is the maintained fork used in this repository and is sourced from `rivet-dev/wa-sqlite`. - The native SQLite addon (`@rivetkit/sqlite-native`) statically links SQLite via `libsqlite3-sys` with the `bundled` feature. The bundled SQLite version must match the version used by `@rivetkit/sqlite` (WASM). When upgrading either, upgrade both. ### RivetKit Package Resolutions -The root `/package.json` contains `resolutions` that map RivetKit packages to their local workspace versions: +- The root `/package.json` contains `resolutions` that map RivetKit packages to local workspace versions: ```json { @@ -123,7 +133,7 @@ The root `/package.json` contains `resolutions` that map RivetKit packages to th } ``` -When adding RivetKit dependencies to examples in `/examples/`, use `*` as the version. The root resolutions will automatically resolve these to the local workspace packages: +- Use `*` as the dependency version when adding RivetKit packages to `/examples/`, because root resolutions map them to local workspace packages: ```json { @@ -134,7 +144,19 @@ When adding RivetKit dependencies to examples in `/examples/`, use `*` as the ve } ``` -If you need to add a new `@rivetkit/*` package that isn't already in the root resolutions, add it to the `resolutions` object in `/package.json` with `"workspace:*"` as the value. Internal packages like `@rivetkit/workflow-engine` should be re-exported from `rivetkit` subpaths (e.g., `rivetkit/workflow`) rather than added as direct dependencies. +- Add new internal `@rivetkit/*` packages to root `resolutions` with `"workspace:*"` if missing, and prefer re-exporting internal packages (for example `@rivetkit/workflow-engine`) from `rivetkit` subpaths like `rivetkit/workflow` instead of direct dependencies. + +### Dynamic Import Pattern +- For runtime-only dependencies, use dynamic loading so bundlers do not eagerly include them. +- Build the module specifier from string parts (for example with `["pkg", "name"].join("-")` or `["@scope", "pkg"].join("/")`) instead of a single string literal. +- Prefer this pattern for modules like `@rivetkit/sqlite-vfs`, `sandboxed-node`, and `isolated-vm`. +- If loading by resolved file path, resolve first and then import via `pathToFileURL(...).href`. + +### Fail-By-Default Runtime Behavior +- Avoid silent no-ops for required runtime behavior. +- Do not use optional chaining for required lifecycle and bridge operations (for example sleep, destroy, alarm dispatch, ack, and websocket dispatch paths). +- If a capability is required, validate it and throw an explicit error with actionable context instead of returning early. +- Optional chaining is acceptable only for best-effort diagnostics and cleanup paths (for example logging hooks and dispose/release cleanup). ### Rust Dependencies @@ -154,7 +176,7 @@ If you need to add a new `@rivetkit/*` package that isn't already in the root re ### Docs (`website/src/content/docs/**/*.mdx`) -Required frontmatter fields: +- Required frontmatter fields: - `title` (string) - `description` (string) @@ -162,7 +184,7 @@ Required frontmatter fields: ### Blog + Changelog (`website/src/content/posts/**/page.mdx`) -Required frontmatter fields: +- Required frontmatter fields: - `title` (string) - `description` (string) @@ -170,13 +192,13 @@ Required frontmatter fields: - `published` (date string) - `category` (enum: `changelog`, `monthly-update`, `launch-week`, `technical`, `guide`, `frogs`) -Optional frontmatter fields: +- Optional frontmatter fields: - `keywords` (string array) ## Examples -All example READMEs in `/examples/` should follow the format defined in `.claude/resources/EXAMPLE_TEMPLATE.md`. +- All example READMEs in `/examples/` should follow the format defined in `.claude/resources/EXAMPLE_TEMPLATE.md`. ## Agent Working Directory @@ -188,11 +210,12 @@ All agent working files live in `.agent/` at the repo root. - **Notes**: `.agent/notes/` -- general notes and tracking. When the user asks to track something in a note, store it in `.agent/notes/` by default. When something is identified as "do later", add it to `.agent/todo/`. Design documents and interface specs go in `.agent/specs/`. +- When the user asks to update any `CLAUDE.md`, add one-line bullet points only, or add a new section containing one-line bullet points. ## Architecture ### Monorepo Structure -This is a Rust workspace-based monorepo for Rivet. Key packages and components: +- This is a Rust workspace-based monorepo for Rivet with the following key packages and components: - **Core Engine** (`packages/core/engine/`) - Main orchestration service that coordinates all operations - **Workflow Engine** (`packages/common/gasoline/`) - Handles complex multi-step operations with reliability and observability @@ -208,7 +231,7 @@ This is a Rust workspace-based monorepo for Rivet. Key packages and components: - Custom error system at `packages/common/error/` - Uses derive macros with struct-based error definitions -To use custom errors: +- Use this pattern for custom errors: ```rust use rivet_error::*; @@ -237,13 +260,13 @@ let error = AuthInvalidToken.build(); let error_with_meta = ApiRateLimited { limit: 100, reset_at: 1234567890 }.build(); ``` -Key points: +- Key points: - Use `#[derive(RivetError)]` on struct definitions - Use `#[error(group, code, description)]` or `#[error(group, code, description, formatted_message)]` attribute - Group errors by module/domain (e.g., "auth", "actor", "namespace") - Add `Serialize, Deserialize` derives for errors with metadata fields - Always return anyhow errors from failable functions - - For example: `fn foo() -> Result { /* ... */ }` +- For example: `fn foo() -> Result { /* ... */ }` - Do not glob import (`::*`) from anyhow. Instead, import individual types and traits - Prefer anyhow's `.context()` over `anyhow!` macro @@ -283,7 +306,7 @@ Key points: ## Naming Conventions -Data structures often include: +- Data structures often include: - `id` (uuid) - `name` (machine-readable name, must be valid DNS subdomain, convention is using kebab case) @@ -320,6 +343,7 @@ Data structures often include: - **Never use `vi.mock`, `jest.mock`, or module-level mocking.** Write tests against real infrastructure (Docker containers, real databases, real filesystems). For LLM calls, use `@copilotkit/llmock` to run a mock LLM server. For protocol-level test doubles (e.g., ACP adapters), write hand-written scripts that run as real processes. If you need callback tracking, `vi.fn()` for simple callbacks is acceptable. - When running tests, always pipe the test to a file in /tmp/ then grep it in a second step. You can grep test logs multiple times to search for different log lines. - For RivetKit TypeScript tests, run from `rivetkit-typescript/packages/rivetkit` and use `pnpm test ` with `-t` to narrow to specific suites. For example: `pnpm test driver-file-system -t ".*Actor KV.*"`. +- When RivetKit tests need a local engine instance, start the RocksDB engine in the background with `./scripts/run/engine-rocksdb.sh >/tmp/rivet-engine-startup.log 2>&1 &`. - For frontend testing, use the `agent-browser` skill to interact with and test web UIs in examples. This allows automated browser-based testing of frontend applications. - If you modify frontend UI, automatically use the Agent Browser CLI to take updated screenshots and post them to the PR with a short comment before wrapping up the task. @@ -332,7 +356,7 @@ Data structures often include: - When talking about "Rivet Actors" make sure to capitalize "Rivet Actor" as a proper noun and lowercase "actor" as a generic noun ### Documentation Sync -When making changes to the engine or RivetKit, ensure the corresponding documentation is updated: +- Ensure corresponding documentation is updated when making engine or RivetKit changes: - **Limits changes** (e.g., max message sizes, timeouts): Update `website/src/content/docs/actors/limits.mdx` - **Config changes** (e.g., new config options in `engine/packages/config/`): Update `website/src/content/docs/self-hosting/configuration.mdx` - **RivetKit config changes** (e.g., `rivetkit-typescript/packages/rivetkit/src/registry/config/index.ts` or `rivetkit-typescript/packages/rivetkit/src/actor/config.ts`): Update `website/src/content/docs/actors/limits.mdx` if they affect limits/timeouts @@ -359,8 +383,8 @@ When making changes to the engine or RivetKit, ensure the corresponding document #### Common Vercel Example Errors -After regenerating Vercel examples, you may see type check errors like: +- You may see type-check errors like the following after regenerating Vercel examples: ``` error TS2688: Cannot find type definition file for 'vite/client'. ``` -with warnings about `node_modules missing`. This happens because the regenerated examples need their dependencies reinstalled. Fix by running `pnpm install` before running type checks. +- You may also see `node_modules missing` warnings; fix this by running `pnpm install` before type checks because regenerated examples need dependencies reinstalled. diff --git a/docs-internal/rivetkit-typescript/DYNAMIC_ACTORS_ARCHITECTURE.md b/docs-internal/rivetkit-typescript/DYNAMIC_ACTORS_ARCHITECTURE.md new file mode 100644 index 0000000000..306a2333c6 --- /dev/null +++ b/docs-internal/rivetkit-typescript/DYNAMIC_ACTORS_ARCHITECTURE.md @@ -0,0 +1,111 @@ +# Dynamic Actors Architecture + +## Overview + +Dynamic actors let a registry entry resolve actor source code at actor start time. + +Dynamic actors are represented by `dynamicActor({ load, auth?, options? })` +and still participate in normal registry routing and actor lifecycle. + +Driver parity is verified by running the same driver test suites against two +fixture registries: + +- `fixtures/driver-test-suite/registry-static.ts` +- `fixtures/driver-test-suite/registry-dynamic.ts` + +Both registries are built from `fixtures/driver-test-suite/actors/` to keep +actor behavior consistent between static and dynamic execution. + +## Main Components + +- Host runtime manager: + `rivetkit-typescript/packages/rivetkit/src/dynamic/isolate-runtime.ts` + Creates and owns one `NodeProcess` isolate per dynamic actor instance. +- Isolate bootstrap runtime: + `rivetkit-typescript/packages/rivetkit/dynamic-isolate-runtime/src/index.cts` + Runs inside the isolate, parses registry config via + `RegistryConfigSchema.parse`, and exports envelope handlers. +- Runtime bridge: + `rivetkit-typescript/packages/rivetkit/src/dynamic/runtime-bridge.ts` + Shared envelope and callback payload types for host and isolate. +- Driver integration: + `drivers/file-system/global-state.ts` and `drivers/engine/actor-driver.ts` + Branch on definition type, construct dynamic runtime, and proxy fetch and websocket traffic. + +## Lifecycle + +1. Driver resolves actor definition from registry. +2. If definition is dynamic, driver creates `DynamicActorIsolateRuntime`. +3. Runtime calls loader and gets `{ source, sourceFormat?, nodeProcess? }`. +4. Runtime writes source into actor runtime dir: + - `sourceFormat: "esm-js"` -> `dynamic-source.mjs` (written unchanged) + - `sourceFormat: "commonjs-js"` -> `dynamic-source.cjs` (written unchanged) + - default `sourceFormat: "typescript"` -> transpiled to `dynamic-source.cjs` +5. Runtime writes isolate bootstrap entry into actor runtime dir. +6. Runtime builds a locked down sandbox driver and creates `NodeProcess`. +7. Runtime injects host bridge refs and bootstrap config into isolate globals. +8. Runtime loads bootstrap module and captures exported envelope refs. + +Before HTTP and WebSocket traffic is forwarded into the isolate, the host +runtime may run an optional dynamic auth hook. The auth hook receives dynamic +actor metadata, the incoming `Request`, and decoded connection params. Throwing +from auth rejects the request before actor dispatch. HTTP requests return +standard RivetKit error responses and WebSockets close with the derived +`group.code` reason. + +Dynamic actors also expose an internal `PUT /dynamic/reload` control endpoint. +Drivers intercept this request before isolate dispatch, mark the actor for +sleep, and return `200`. The next request wakes the actor through the normal +start path, which calls the dynamic loader again and picks up fresh source. + +Note: isolate bootstrap does not construct `Registry` at runtime. Constructing +`Registry` would auto-start runtime preparation on next tick in non-test mode +and pull default drivers that are not needed for dynamic actor execution. + +## Bridge Contract + +Host to isolate calls: + +- `dynamicFetchEnvelope` +- `dynamicOpenWebSocketEnvelope` +- `dynamicWebSocketSendEnvelope` +- `dynamicWebSocketCloseEnvelope` +- `dynamicDispatchAlarmEnvelope` +- `dynamicStopEnvelope` +- `dynamicGetHibernatingWebSocketsEnvelope` +- `dynamicDisposeEnvelope` + +Isolate to host callbacks: + +- KV: `kvBatchPut`, `kvBatchGet`, `kvBatchDelete`, `kvListPrefix` +- Lifecycle: `setAlarm`, `startSleep`, `startDestroy` +- Networking: `dispatch` for websocket events +- Runner ack path: `ackHibernatableWebSocketMessage` +- Inline client bridge: `clientCall` + +Binary payloads are normalized to `ArrayBuffer` at the host and isolate boundary. + +## Security Model + +- Each dynamic actor runs in its own sandboxed `NodeProcess`. +- Sandbox permissions deny network and child process access. +- Filesystem access is restricted to dynamic runtime root and read only `node_modules` paths. +- Environment is explicitly injected by host config for the isolate process. + +## Module Access Projection + +Dynamic actors use secure-exec `moduleAccess` projection to expose a +read-only `/root/node_modules` view into host dependencies (allow-listing +`rivetkit` and transitive packages). We no longer stage a temporary +`node_modules` tree for runtime bootstrap. + +## Driver Test Skip Gate + +The dynamic registry variant in driver tests has a narrow skip gate for two +cases only: + +- secure-exec dist is not available on the local machine +- nested dynamic harness mode is explicitly enabled for tests + +This gate is only to avoid invalid test harness setups. Static and dynamic +behavior parity remains the expected target for normal driver test execution. diff --git a/docs-internal/rivetkit-typescript/DYNAMIC_ACTOR_FAILED_START_RELOAD_SPEC.md b/docs-internal/rivetkit-typescript/DYNAMIC_ACTOR_FAILED_START_RELOAD_SPEC.md new file mode 100644 index 0000000000..18d8c93d4c --- /dev/null +++ b/docs-internal/rivetkit-typescript/DYNAMIC_ACTOR_FAILED_START_RELOAD_SPEC.md @@ -0,0 +1,691 @@ +# Dynamic Actor Failed-Start Reload Spec + +## Status + +Draft + +## Summary + +Dynamic actors need a first-class failed-start path that still allows a +driver-level `reload` endpoint to recover the actor immediately. + +Today, reload is implemented as "sleep the running actor so the next request +loads fresh code." That works only when there is already a live dynamic actor +runtime. It does not handle the case where dynamic startup fails before the +runtime becomes runnable. + +This spec defines identical behavior for the file-system and engine drivers: + +- Failed dynamic startup leaves a host-side wrapper alive in memory. +- Normal requests receive a sanitized failed-start error. +- Failed starts use exponential backoff. +- Backoff is passive and must not create a background retry loop that keeps the + actor effectively awake forever. +- `reload` bypasses backoff, immediately attempts a fresh startup, and returns + the result. +- `reload` resets failure backoff state before attempting fresh startup. +- Reload on an already sleeping actor is a manager-side no-op so it does not + wake, load, and immediately sleep again. +- All non-obvious lifecycle and routing behavior must be commented in code. + +## Current Behavior + +### Normal Sleep + +Normal actor sleep removes the live in-memory dynamic runtime and host handler. +After sleep there is no live actor process in memory. + +- File-system removes the runtime and actor entry during sleep. +- Engine removes the runtime and handler during stop. + +This means "sleeping" is not a long-lived in-memory actor state. It is a +lifecycle fact plus persisted actor metadata such as `sleepTs`. + +### Failed Start + +Dynamic startup currently fails out of `DynamicActorIsolateRuntime.start()` or +`ensureStarted()`. + +- File-system disposes partial runtime state and rethrows startup failure. +- Engine disposes partial runtime state, stores a transient startup error, and + stops the actor. + +There is no durable or explicit host-side failed-start state machine today. Any +future failed-start state introduced by this spec is intentionally ephemeral, +stored only in memory on the host-side wrapper, and cleared when that wrapper +is removed during normal sleep or stop cleanup. + +### Reload + +Reload is currently implemented as a pre-dispatch overlay route that sleeps a +running dynamic actor. + +- This works for a running dynamic actor. +- This does not recover a failed startup cleanly. +- This also risks an unnecessary double-load if reload is sent to an already + sleeping actor and the driver wakes it before intercepting reload. + +## Goals + +- Make failed-start behavior identical across file-system and engine drivers. +- Preserve the normal actor sleep lifecycle for actors that started + successfully. +- Keep failed-start state in memory only. +- Return the actor's real startup error code to clients. +- Return full error detail only in development. +- Keep full failure detail in logs in all environments. +- Reuse existing exponential backoff logic instead of inventing a new bespoke + retry algorithm. +- Make retry behavior configurable per dynamic actor. +- Add a configurable timeout for dynamic load and startup. +- Document all of this behavior in docs-internal. + +## Non-Goals + +- Persisting failed-start state across process restarts. Backoff state is + intentionally reset on process restart. This means actors retry from initial + backoff after a restart even if they previously reached max backoff. This is + an acceptable trade-off of keeping state in memory only. +- Changing normal static actor lifecycle behavior. +- Hiding all startup failure information from clients. Clients should still + receive a stable failure code. + +## Scope + +This spec requires parity for the file-system and engine drivers. + +The memory driver does not currently participate in the normal sleep lifecycle, +so it is out of scope unless explicitly added as a follow-up. + +## Terminology + +### Dynamic Runtime + +The isolate runtime that loads and executes dynamic actor code. + +### Host Wrapper + +The driver-side in-memory handler or entry that exists outside the dynamic +runtime. This wrapper can outlive a failed startup even when there is no live +dynamic runtime. + +### Failed Start + +Any failure in the dynamic startup pipeline before the actor becomes runnable, +including: + +- loader execution +- source normalization or materialization +- sandbox/bootstrap setup +- `runtime.start()` +- `runtime.ensureStarted()` + +## Required State Model + +Dynamic actors need an explicit host-side runtime state for reload and failure +handling. + +Recommended state shape: + +- `inactive` + The actor is not currently running. This includes the normal sleeping case. + The host wrapper may or may not exist in this state. If the wrapper was + removed by normal sleep cleanup, the actor is still logically inactive but + reload is handled at the manager/gateway level (see Reload While Inactive). +- `starting` + A startup attempt is in flight. +- `running` + Dynamic runtime is live and can serve requests. +- `failed_start` + The last startup attempt failed before the actor became runnable. + +Required metadata: + +- `lastStartErrorCode` — the `ActorError` subclass code (e.g., + `"dynamic_startup_failed"`, `"dynamic_load_timeout"`) +- `lastStartErrorMessage` — the error message string +- `lastStartErrorDetails` — full error details including stack trace. In + production, this field is stored but never serialized into client responses. + Only `lastStartErrorCode` and a sanitized message are returned to clients. +- `lastFailureAt` — timestamp of the last failure +- `retryAt` — timestamp when the next passive retry is allowed +- `retryAttempt` — number of consecutive failed attempts +- `reloadCount` — number of reload calls in the current rate-limit window +- `reloadWindowStart` — timestamp when the current rate-limit window began +- `generation` — monotonic integer counter, incremented synchronously before + each new startup attempt is dispatched. Used to reject stale async + completions. Note: this is distinct from the existing driver-level + `generation` field (UUID in file-system driver, number in engine driver), + which tracks actor identity across destroy/create cycles. This generation + tracks startup attempts within a single actor's in-memory lifetime. +- `startupPromise` — the shared promise for the current in-flight startup + attempt. Created via `promiseWithResolvers` when transitioning to `starting`. + All concurrent requests and reload calls join this promise instead of + starting parallel attempts. + +Rules: + +- This state is host-side and in-memory only. +- It must not be written into persisted actor storage by default. +- It must be cleared or replaced on successful startup. +- It must be cleared when the host-side wrapper is removed by normal sleep or + stop cleanup. +- It must be safe against stale async completion. When a startup attempt + completes, the handler must compare its captured generation against the + current generation. If they differ, the completion is discarded silently. +- Backoff must be represented as recorded metadata such as `retryAt`, not as a + background retry loop. +- `generation` is a per-actor, process-local monotonic counter. It must be + incremented synchronously (before any `await`) when initiating a new startup + attempt. This ensures that concurrent requests arriving during the transition + from `failed_start` to `starting` always join the new attempt rather than + racing to create their own. +- Only one startup attempt may be in flight at a time. The `startupPromise` + field enforces this. When a startup is needed (from `inactive` or expired + `failed_start`), the implementation must: + 1. Synchronously transition to `starting`. + 2. Synchronously increment `generation`. + 3. Synchronously create a new `promiseWithResolvers` and store it as + `startupPromise`. + 4. Begin the async startup work. + 5. Any concurrent request that arrives while in `starting` state awaits the + existing `startupPromise` rather than creating a new one. + +## Reload Authentication + +Reload must be authenticated. The implementation must use both the existing +`DynamicActorAuth` hook and a new `canReload` callback. + +### Auth Flow + +1. The existing `auth` hook on `dynamicActor({ auth })` is called first with + the reload request context. If it throws, the reload is rejected with `403`. +2. If `auth` passes, the `canReload` callback is called. If it returns `false` + or throws, the reload is rejected with `403`. + +### `canReload` Callback + +Add a `canReload` field to `DynamicActorConfigInput`: + +```typescript +export interface DynamicActorConfigInput { + load: DynamicActorLoader; + auth?: DynamicActorAuth; + canReload?: (context: DynamicActorReloadContext) => boolean | Promise; +} + +export interface DynamicActorReloadContext { + actorId: string; + name: string; + key: unknown[]; + request: Request; +} +``` + +If `canReload` is not provided, reload defaults to allowed when `auth` passes +(or when no `auth` is configured, which is only valid in development). + +In development mode without a configured `auth` or `canReload`, reload is +allowed with a warning log, matching the existing inspector auth behavior. + +## Request Behavior + +### Normal Request While Running + +Dispatch normally. + +### Normal Request While Inactive + +Attempt startup immediately. + +If startup succeeds, handle the request normally. + +If startup fails, transition to `failed_start`, log the failure, record retry +metadata, and return the failed-start error. + +### Normal Request While Starting + +Await the existing `startupPromise` rather than starting a new attempt. When +the promise resolves, dispatch the request normally. When it rejects, return +the failed-start error. + +### Normal Request While Failed Start + +If backoff is still active, return the stored failed-start error immediately. + +If backoff has expired, transition synchronously to `starting`, increment +`generation`, create a new `startupPromise` via `promiseWithResolvers`, and +begin one fresh startup attempt. All concurrent requests arriving during this +startup join the same `startupPromise`. + +Retries must be passive. The implementation must not schedule autonomous retry +timers that keep a failed actor spinning in memory until the next attempt. The +wrapper may remain available to return failed-start responses and serve reload, +but startup retries only happen because of an incoming request or explicit +reload. + +### WebSocket Upgrade While Failed Start + +WebSocket upgrade requests during `failed_start` must be rejected before the +WebSocket handshake completes. The server must respond with the same HTTP error +status and body as a normal failed-start HTTP request. The WebSocket upgrade +must not be accepted and then immediately closed. + +If the actor is in `starting` state when a WebSocket upgrade arrives, the +upgrade awaits the `startupPromise`. If startup fails, the upgrade is rejected +with the failed-start HTTP error. If startup succeeds, the upgrade proceeds +normally. + +### WebSocket Connections During Reload + +When reload triggers a sleep on a running actor, open WebSocket connections are +closed as part of the normal sleep lifecycle. The close code must be `1012` +(Service Restart) with a reason string of `"dynamic.reload"`. This tells +clients that the closure is intentional and reconnection is appropriate. + +## Reload Behavior + +Reload must be handled at the manager or host wrapper layer before request +dispatch into dynamic actor code. + +Reload must pass authentication before any state changes occur (see Reload +Authentication above). + +### Reload While Running + +Use the existing sleep-based reload behavior: + +1. Stop the running actor through the normal sleep lifecycle. +2. Return success when the actor is inactive. +3. The next normal request starts the actor with fresh code. + +Note: this means reload does not verify that the new code loads successfully. +The reload caller receives `200` confirming the old code was stopped. Any +startup failure surfaces on the next request that wakes the actor. + +### Reload While Inactive + +Return `200` without waking the actor. + +This is required to prevent the double-load path where reload wakes a sleeping +actor, loads code once, then immediately sleeps it again. + +Note: a reload sent to a nonexistent or misspelled actor ID is rejected at the +engine gateway level with an appropriate error before it reaches the driver. +The driver-level reload handler only sees requests for actors that the gateway +has already resolved. + +### Reload While Starting + +Abort the current startup attempt and immediately begin a fresh one. + +The implementation must pass an `AbortController` signal through the startup +pipeline. When reload is called during `starting`: + +1. Abort the current startup's `AbortController`. This signals the in-flight + `DynamicActorIsolateRuntime.start()` to cancel (e.g., abort the loader + fetch, stop waiting for isolate bootstrap). +2. Synchronously increment `generation`. +3. Create a new `startupPromise` via `promiseWithResolvers`. +4. Create a new `AbortController` for the fresh attempt. +5. Begin the new startup attempt. +6. Any requests that were awaiting the old `startupPromise` receive a + rejection. They then observe the new `starting` state and join the new + `startupPromise`. + +The `AbortController` signal must be threaded through: + +- The user-provided `loader` callback (available as `context.signal`). +- `DynamicActorIsolateRuntime.start()` as a parameter. +- Any internal async operations within the startup pipeline that support + cancellation (e.g., `fetch` calls, file I/O). + +Operations that do not support cancellation (e.g., `isolated-vm` context +creation) will run to completion, but the stale generation check at completion +time will discard their result. + +### Reload While Failed Start + +Reload resets failed-start backoff state (`retryAt`, `retryAttempt`) and +immediately attempts a fresh startup following the same synchronous transition +to `starting` described above. + +If the fresh startup succeeds, return `200`. + +If the fresh startup fails, return the failed-start error immediately and keep +the actor in `failed_start`. + +### Reload Rate Limiting + +Reload bypasses backoff, but the driver must log a warning when reload is +forced more than `N` times in `Y` interval. + +Rate limiting uses a simple bucket system: + +- `reloadCount` tracks the number of reload calls in the current window. +- `reloadWindowStart` tracks when the current window began. +- When a reload is received, if `now - reloadWindowStart > Y`, reset the + bucket: set `reloadCount = 1` and `reloadWindowStart = now`. +- Otherwise, increment `reloadCount`. +- If `reloadCount > N`, log a warning with the actor ID and the count. + +Default values: `N = 10`, `Y = 60_000` (60 seconds). + +The first implementation only needs warning-level logging, not enforcement. + +## Retry Configuration + +Retry behavior must be configurable by the user on dynamic actors. + +### Configuration Interface + +```typescript +export interface DynamicActorConfigInput { + load: DynamicActorLoader; + auth?: DynamicActorAuth; + canReload?: (context: DynamicActorReloadContext) => boolean | Promise; + options?: DynamicActorOptions; +} + +export interface DynamicActorOptions extends GlobalActorOptionsInput { + startup?: DynamicStartupOptions; +} + +export interface DynamicStartupOptions { + /** Timeout for the full startup pipeline in ms. Default: 15_000. */ + timeoutMs?: number; + + /** Initial backoff delay in ms after a failed startup. Default: 1_000. */ + retryInitialDelayMs?: number; + + /** Maximum backoff delay in ms. Default: 30_000. */ + retryMaxDelayMs?: number; + + /** Backoff multiplier. Default: 2. */ + retryMultiplier?: number; + + /** Whether to add jitter to backoff delays. Default: true. */ + retryJitter?: boolean; + + /** + * Maximum number of consecutive failed startup attempts before the host + * wrapper is torn down. After this limit, the actor transitions to a + * terminal failed state and the wrapper is removed from memory. Subsequent + * requests will trigger a fresh startup attempt with no prior backoff + * context, as if the actor had never been loaded. + * + * Default: 20. + * Set to 0 for unlimited retries (wrapper stays alive indefinitely). + */ + maxAttempts?: number; +} +``` + +### Backoff Implementation + +Reuse the `p-retry` exponential backoff algorithm that is already used in +`remote-manager-driver/metadata.ts` and `client/actor-conn.ts`. The +implementation does not need to use `p-retry` directly (since retries are +passive, not loop-driven), but must compute backoff delays using the same +formula: `min(maxDelay, initialDelay * multiplier^attempt)` with optional +jitter. + +### Max Attempts + +When `retryAttempt` exceeds `maxAttempts`, the host wrapper is torn down. The +actor transitions to `inactive` with no in-memory state. The next request for +this actor triggers a completely fresh startup attempt with `retryAttempt = 0`, +as if the actor had never been loaded. This prevents unbounded memory +accumulation from permanently broken actors while still allowing recovery. + +Reload must clear the active retry delay and failure attempt count before +attempting a fresh startup. + +## Error Surfacing + +Failed-start errors use the existing `ActorError` subclass hierarchy. The +error code comes from the `ActorError` subclass (e.g., the `code` field), and +the error details come from the underlying cause (e.g., the secure-exec +process output, the loader exception message, the isolate bootstrap error). + +The following stable error codes must be defined as `ActorError` subclasses for +dynamic startup failures: + +- `DynamicStartupFailed` — general startup failure (catch-all for unclassified + errors from the loader, sandbox, or bootstrap). +- `DynamicLoadTimeout` — the startup pipeline exceeded the configured timeout. + +These codes are always returned to clients. The distinction between what is +sanitized is the error details, not the code. + +Client-facing rules: + +- The `ActorError` code (e.g., `"dynamic_startup_failed"`, + `"dynamic_load_timeout"`) is always returned to clients in both production + and development. +- In production, the message is sanitized to a generic string (e.g., "Dynamic + actor startup failed. Check server logs for details."). The + `lastStartErrorDetails` field is not included in the response. +- In development, the full error message and details (including stack traces + and loader output) are included in the response, matching how internal errors + are currently exposed. +- Full details must always be emitted to logs in all environments. + +This implies the failed-start state must retain enough structured error data to +reconstruct a sanitized or full response without re-running startup. + +## Load Timeout + +The startup pipeline must be wrapped in a configurable timeout. + +### Scope + +The timeout starts when `DynamicActorIsolateRuntime.start()` is called and +ends when `ensureStarted()` resolves. This covers: + +- The user-provided `loader` callback. +- Source normalization and materialization. +- Dynamic module loading (`secure-exec`, `isolated-vm`). +- Sandbox filesystem setup. +- Bootstrap script execution. +- The isolate-side `ensureStarted()` call (actor `onStart` lifecycle hook). + +Note: first-cold-start overhead (loading `secure-exec` and `isolated-vm` +modules for the first time) is included in this timeout. The default of 15 +seconds is chosen to accommodate cold starts. If cold-start overhead is a +concern, the user can increase the timeout via `startup.timeoutMs`. + +### Implementation + +The timeout is implemented via the same `AbortController` used for reload +cancellation. When the timeout fires: + +1. The `AbortController` is aborted with a `DynamicLoadTimeout` error. +2. The startup pipeline observes the abort signal and cancels where possible. +3. The actor transitions to `failed_start` with `lastStartErrorCode` set to + `"dynamic_load_timeout"`. +4. The timeout failure participates in backoff identically to any other startup + failure. + +### Configuration + +The timeout is configured under `dynamicActor({ options: { startup: { timeoutMs } } })`. + +Default: `15_000` (15 seconds). + +## Dynamic Actor Status Endpoint + +A new `GET /dynamic/status` endpoint must be added to expose the host-side +runtime state for observability and debugging. + +### Response Shape + +```typescript +interface DynamicActorStatusResponse { + state: "inactive" | "starting" | "running" | "failed_start"; + generation: number; + + // Present when state is "failed_start" + lastStartErrorCode?: string; + lastStartErrorMessage?: string; // sanitized in production + lastStartErrorDetails?: string; // only in development + lastFailureAt?: number; + retryAt?: number; + retryAttempt?: number; +} +``` + +### Authentication + +The status endpoint uses the same authentication as the inspector endpoints: +Bearer token via `config.inspector.token()`, with timing-safe comparison. In +development mode without a configured token, access is allowed with a warning. + +### Client-Side Support + +Add a `status()` method to `ActorHandleRaw`: + +```typescript +class ActorHandleRaw { + async status(): Promise { + // GET /dynamic/status + } +} +``` + +This method is only meaningful for dynamic actors. Calling it on a static actor +returns `{ state: "running", generation: 0 }`. + +## Sleep Interaction with Failed Start + +When a sleep signal arrives while an actor is in `failed_start`, the failed- +start metadata is cleared and the host wrapper is removed. The actor +transitions to `inactive` with no in-memory state. This is the same as the +`maxAttempts` exhaustion behavior: the next request triggers a completely fresh +startup attempt. + +This is intentional. A sleep on a failed actor is equivalent to a full reset. +If the underlying issue has been fixed, the next request will succeed. If not, +the actor will re-enter `failed_start` with fresh backoff starting from +attempt 0. + +## Documentation Requirements + +The implementation must update docs-internal to describe: + +- the dynamic actor startup state model +- what `failed_start` means +- how normal requests behave during `failed_start` +- how backoff works +- that backoff is passive and does not create an autonomous retry loop +- how `reload` behaves for `running`, `inactive`, `starting`, and + `failed_start` +- that `reload` resets backoff before retrying startup +- why reload on inactive actors is a no-op +- how errors are sanitized in production and expanded in development +- the dynamic load timeout and where it is configured +- the retry configuration and where it is configured +- reload authentication via `auth` and `canReload` +- the `GET /dynamic/status` endpoint +- WebSocket close behavior during reload (`1012`, `"dynamic.reload"`) +- the `maxAttempts` limit and what happens when it is exceeded + +Minimum docs change: + +- expand `docs-internal/rivetkit-typescript/DYNAMIC_ACTORS_ARCHITECTURE.md` + with a dedicated failed-start and reload lifecycle section + +The implementation is not complete until the docs-internal update ships in the +same change. + +## Comment Requirements + +All non-obvious logic introduced by this change must be commented in code. + +Examples that require comments: + +- why failed-start state is kept in the host wrapper instead of persisted actor + state +- why reload on inactive actors is intercepted as a no-op +- how generation invalidation prevents stale startup completions from winning +- why reload bypasses backoff +- why backoff is passive instead of being driven by background timers +- why production errors are sanitized while development errors include details +- why `startupPromise` is created synchronously before the async startup work +- why WebSocket upgrades are rejected before handshake during failed start + +Comments should explain intent and invariants, not implementation history. + +## Implementation Outline + +1. Define `DynamicStartupOptions` interface and add `startup` key to the + existing `DynamicActorOptions` type. +2. Define `DynamicStartupFailed` and `DynamicLoadTimeout` as `ActorError` + subclasses in `actor/errors.ts`. +3. Add `canReload` to `DynamicActorConfigInput` and `DynamicActorReloadContext` + type. +4. Introduce a host-side dynamic runtime status model shared by file-system and + engine driver code, using the state enum and metadata fields defined above. +5. Implement startup coalescing via `promiseWithResolvers`: synchronous state + transition to `starting`, synchronous generation increment, shared promise + for all concurrent waiters. +6. Thread `AbortController` through `DynamicActorIsolateRuntime.start()` and + the user-provided `loader` callback (as `context.signal`). +7. Implement load timeout using the `AbortController` signal with a + `setTimeout` that aborts after `startup.timeoutMs`. +8. Move or extend overlay routing so reload on inactive actors can be handled + before waking actor code. +9. Implement reload authentication: call `auth` then `canReload` before + processing reload. +10. Implement reload-while-starting: abort current `AbortController`, increment + generation, create new `startupPromise`, begin fresh attempt. +11. Preserve existing sleep-based reload only for actors that are already + running. +12. Implement passive failed-start backoff metadata (using the `p-retry` + backoff formula) without background retry timers. +13. Implement `maxAttempts` exhaustion: tear down wrapper and transition to + `inactive` when exceeded. +14. Implement failed-start error replay with sanitized production output and + detailed development output. +15. Add `GET /dynamic/status` endpoint with inspector-style auth. +16. Add `status()` method to `ActorHandleRaw` on the client. +17. Reject WebSocket upgrades during `failed_start` before handshake. Close + WebSockets during reload with code `1012` and reason `"dynamic.reload"`. +18. Implement reload rate-limit bucket (`reloadCount` / `reloadWindowStart`). +19. Update docs-internal architecture docs. +20. Add comments for every non-obvious lifecycle transition and overlay routing + rule. + +## Test Requirements + +All failed-start tests must be added to the shared driver-test-suite +(`src/driver-test-suite/`) so both file-system and engine drivers run the same +test cases. This enforces the parity requirement. + +Add or update tests for: + +- normal request retries startup after backoff expires +- normal request during active backoff returns stored failed-start error +- no background retry loop runs while actor is in failed-start backoff +- reload bypasses backoff and immediately retries startup +- reload resets backoff metadata before retrying +- reload on failed-start actor returns success or error from the immediate + startup attempt +- reload on inactive actor is a no-op and does not cause double-load +- concurrent requests coalesce onto one startup via shared `startupPromise` +- stale startup generation cannot overwrite newer reload-triggered generation +- production response is sanitized (no details, has code) +- development response includes full detail +- dynamic load timeout returns `"dynamic_load_timeout"` error code +- retry options change backoff behavior as configured by the user +- `maxAttempts` exhaustion tears down the wrapper +- request after `maxAttempts` exhaustion triggers fresh startup from attempt 0 +- reload authentication rejects unauthenticated callers with `403` +- `canReload` returning `false` rejects reload with `403` +- WebSocket upgrade during `failed_start` is rejected before handshake +- WebSocket connections receive close code `1012` during reload +- `GET /dynamic/status` returns correct state and metadata +- `GET /dynamic/status` respects inspector auth +- reload-while-starting aborts old attempt and starts new generation +- AbortController signal is delivered to the loader callback +- docs-internal file is updated alongside the implementation diff --git a/docs-internal/rivetkit-typescript/DYNAMIC_ACTOR_SQLITE_PROXY_SPEC.md b/docs-internal/rivetkit-typescript/DYNAMIC_ACTOR_SQLITE_PROXY_SPEC.md new file mode 100644 index 0000000000..0e18b4be9c --- /dev/null +++ b/docs-internal/rivetkit-typescript/DYNAMIC_ACTOR_SQLITE_PROXY_SPEC.md @@ -0,0 +1,275 @@ +# Dynamic Actor SQLite Proxy Spec + +## Problem + +Dynamic actors run in sandboxed `secure-exec` / `isolated-vm` processes. The current SQLite path requires `@rivetkit/sqlite` WASM to load inside the isolate, which isn't set up and is the wrong direction — we plan to add a native SQLite extension on the host side. Dynamic actors need a way to use `db()` and `db()` from `rivetkit/db` and `rivetkit/db/drizzle` without running WASM in the isolate. + +## Approach + +Run SQLite on the **host side** and bridge a thin `execute(sql, params) → rows` RPC from isolate → host. The `ActorDriver` already has `overrideRawDatabaseClient()` and `overrideDrizzleDatabaseClient()` hooks designed for this exact purpose. The `DatabaseProvider.createClient()` already checks for overrides before falling back to KV-backed construction. + +``` +Isolate Host +────── ──── +db.execute(sql, args) ──bridge──► host SQLite (per-actor) + ◄────────── { rows, columns } +``` + +One bridge call per query instead of per KV page. + +## Architecture + +### Host side (manager process) + +Each actor gets a dedicated SQLite database file managed by the host. For the file-system driver, this is already done for KV via `#actorKvDatabases` in `FileSystemGlobalState`. The actor's **application database** is a separate SQLite file alongside the KV database. + +The host exposes two bridge callbacks to the isolate: + +1. **`sqliteExec(actorId, sql, params) → string`** — Executes a SQL statement. Returns JSON-encoded `{ rows: unknown[][], columns: string[] }`. Handles both read and write queries. Params are JSON-serialized across the boundary. + +2. **`sqliteBatch(actorId, statements) → string`** — Executes multiple SQL statements in a single bridge call, wrapped in a transaction. Each statement is `{ sql: string, params: unknown[] }`. Returns JSON-encoded array of `{ rows, columns }` per statement. This is critical for migrations and reduces bridge round-trips. + +### Isolate side (dynamic actor process) + +The isolate-side `actorDriver` (defined in `host-runtime.ts` line 1767) gains: + +- `overrideRawDatabaseClient(actorId)` — Returns a `RawDatabaseClient` whose `exec()` method calls through the bridge to `sqliteExec`. +- `overrideDrizzleDatabaseClient(actorId)` — Returns a drizzle `sqlite-proxy` instance whose async callback calls through the bridge to `sqliteExec`. + +Because the overrides are set, `DatabaseProvider.createClient()` in both `db/mod.ts` and `db/drizzle/mod.ts` will use them instead of trying to construct a KV-backed WASM SQLite. No `createSqliteVfs()` is needed in the dynamic actor driver. + +## Detailed Changes + +### 1. Bridge contract (`src/dynamic/runtime-bridge.ts`) + +Add new bridge global keys: + +```typescript +export const DYNAMIC_HOST_BRIDGE_GLOBAL_KEYS = { + // ... existing keys ... + sqliteExec: "__rivetkitDynamicHostSqliteExec", + sqliteBatch: "__rivetkitDynamicHostSqliteBatch", +} as const; +``` + +### 2. Host-side SQLite pool (`src/drivers/file-system/global-state.ts`) + +Add a **per-actor application database** map alongside the existing KV database map: + +```typescript +#actorAppDatabases = new Map(); +``` + +Add methods: + +```typescript +#getOrCreateActorAppDatabase(actorId: string): SqliteRuntimeDatabase +// Opens/creates a SQLite database file at: /app-databases/.db +// Separate from the KV database. Enables WAL mode for concurrency. + +#closeActorAppDatabase(actorId: string): void +// Called during actor teardown, alongside #closeActorKvDatabase. + +sqliteExec(actorId: string, sql: string, params: unknown[]): { rows: unknown[][], columns: string[] } +// Runs a single statement against the actor's app database. +// Uses the same SqliteRuntime (bun:sqlite / better-sqlite3) already loaded. +// Synchronous — native SQLite is sync, the bridge async wrapper handles the rest. + +sqliteBatch(actorId: string, statements: { sql: string, params: unknown[] }[]): { rows: unknown[][], columns: string[] }[] +// Wraps all statements in BEGIN/COMMIT. Returns results per statement. +``` + +Cleanup: extend `#destroyActorData` and actor teardown to also close and delete app databases. + +### 3. Host bridge wiring — `isolated-vm` path (`src/dynamic/isolate-runtime.ts`) + +In `#setIsolateBridge()` (around line 880), add refs for the new bridge callbacks: + +```typescript +const sqliteExecRef = makeRef( + async (actorId: string, sql: string, paramsJson: string): Promise<{ copy(): string }> => { + const params = JSON.parse(paramsJson); + const result = this.#config.globalState.sqliteExec(actorId, sql, params); + return makeExternalCopy(JSON.stringify(result)); + }, +); + +const sqliteBatchRef = makeRef( + async (actorId: string, statementsJson: string): Promise<{ copy(): string }> => { + const statements = JSON.parse(statementsJson); + const results = this.#config.globalState.sqliteBatch(actorId, statements); + return makeExternalCopy(JSON.stringify(results)); + }, +); + +await context.global.set(DYNAMIC_HOST_BRIDGE_GLOBAL_KEYS.sqliteExec, sqliteExecRef); +await context.global.set(DYNAMIC_HOST_BRIDGE_GLOBAL_KEYS.sqliteBatch, sqliteBatchRef); +``` + +### 4. Host bridge wiring — `secure-exec` path (`src/dynamic/host-runtime.ts`) + +In `#setIsolateBridge()` (around line 586), add the same refs using the same base64/JSON bridge pattern already used for KV: + +```typescript +const sqliteExecRef = makeRef( + async (actorId: string, sql: string, paramsJson: string): Promise => { + const params = JSON.parse(paramsJson); + const result = this.#config.globalState.sqliteExec(actorId, sql, params); + return JSON.stringify(result); + }, +); +// ... same for sqliteBatch + +await context.global.set("__dynamicHostSqliteExec", sqliteExecRef); +await context.global.set("__dynamicHostSqliteBatch", sqliteBatchRef); +``` + +And on the isolate-side `actorDriver` object (line 1767), add: + +```typescript +const actorDriver = { + // ... existing methods ... + + async overrideRawDatabaseClient(actorIdValue) { + return { + exec: async (query, ...args) => { + const resultJson = await bridgeCall( + globalThis.__dynamicHostSqliteExec, + [actorIdValue, query, JSON.stringify(args)] + ); + const { rows, columns } = JSON.parse(resultJson); + return rows.map((row) => { + const obj = {}; + for (let i = 0; i < columns.length; i++) { + obj[columns[i]] = row[i]; + } + return obj; + }); + }, + }; + }, + + async overrideDrizzleDatabaseClient(actorIdValue) { + // Return undefined — let the raw override handle it. + // Drizzle provider will fall back to using the raw override path. + return undefined; + }, +}; +``` + +### 5. Drizzle support + +The drizzle `DatabaseProvider` in `db/drizzle/mod.ts` currently does NOT check for overrides — it always constructs a KV-backed WASM database. This needs to change. + +Add an override check at the top of `createClient`: + +```typescript +createClient: async (ctx) => { + // Check for drizzle override first + if (ctx.overrideDrizzleDatabaseClient) { + const override = await ctx.overrideDrizzleDatabaseClient(); + if (override) { + // Wrap with RawAccess execute/close methods and return + return Object.assign(override, { + execute: async (query, ...args) => { /* delegate to override */ }, + close: async () => {}, + }); + } + } + + // Check for raw override — build drizzle sqlite-proxy on top of it + if (ctx.overrideRawDatabaseClient) { + const rawOverride = await ctx.overrideRawDatabaseClient(); + if (rawOverride) { + const callback = async (sql, params, method) => { + const rows = await rawOverride.exec(sql, ...params); + if (method === "run") return { rows: [] }; + if (method === "get") return { rows: rows[0] ? Object.values(rows[0]) : undefined }; + return { rows: rows.map(r => Object.values(r)) }; + }; + const client = proxyDrizzle(callback, config); + return Object.assign(client, { + execute: async (query, ...args) => rawOverride.exec(query, ...args), + close: async () => {}, + }); + } + } + + // Existing KV-backed path... +} +``` + +This lets dynamic actors use `db()` from `rivetkit/db/drizzle` with migrations working through the bridge. The host runs the actual SQL; the isolate just sends strings. + +### 6. Migrations + +Drizzle inline migrations (`runInlineMigrations`) currently operate on the `@rivetkit/sqlite` `Database` WASM instance directly. For the proxy path, migrations need to run through the same `execute()` bridge. + +Option A (simpler): The raw override's `exec()` already supports multi-statement SQL via the host's `db.exec()`. Migrations can use `execute()` directly. The `sqliteBatch` bridge method handles transactional migration application. + +Option B: Add a dedicated `sqliteMigrate(actorId, migrationSql[])` bridge call that runs all migrations in a single transaction on the host. Cleaner but more surface area. + +**Recommendation**: Option A. The `execute()` path is sufficient. The drizzle provider's `onMigrate` can call `client.execute(migrationSql)` for each pending migration, same as it does today but through the bridge. + +### 7. Engine driver (`src/drivers/engine/actor-driver.ts`) + +The engine driver manages dynamic actors the same way. It needs the same `sqliteExec` / `sqliteBatch` bridge wiring, backed by whatever storage the engine provides for actor application databases. + +For now, this can be deferred — the engine driver can continue using the KV-backed path for static actors and throw a clear error for dynamic actors that try to use `db()` until the engine-side SQLite proxy is implemented. + +## Data model + +Each dynamic actor gets TWO SQLite databases on the host: + +| Database | Purpose | Path | Managed by | +|----------|---------|------|------------| +| KV database | Actor KV state (`kvBatchPut`/`kvBatchGet`) | `/databases/.db` | Existing `#actorKvDatabases` | +| App database | User-defined schema via `db()` / drizzle | `/app-databases/.db` | New `#actorAppDatabases` | + +On actor destroy, both databases are deleted. On actor sleep, both databases are closed (and reopened on wake). + +## Serialization format + +All data crosses the bridge as JSON strings: + +- **Params**: `JSON.stringify(args)` — supports `null`, `number`, `string`, `boolean`. Binary (`Uint8Array`) params are base64-encoded. +- **Results**: `JSON.stringify({ rows: unknown[][], columns: string[] })` — column-oriented format, same as `@rivetkit/sqlite`'s `query()` return shape. +- **Batch**: Array of the above per statement. + +## Error handling + +- SQL errors on the host throw through the bridge. The isolate receives the error message and stack trace as a rejected promise. +- If the actor's app database doesn't exist yet, `sqliteExec` creates it on first use (same lazy-open pattern as KV databases). +- Invalid SQL, constraint violations, etc. surface as normal SQLite errors to the actor code. + +## Testing + +Add a driver test in `src/driver-test-suite/tests/` that: + +1. Creates a dynamic actor that uses `db()` (raw) with a simple schema +2. Runs migrations, inserts rows, queries them back +3. Verifies data persists across actor sleep/wake cycles +4. Creates a dynamic actor that uses `db()` from `rivetkit/db/drizzle` with schema + migrations +5. Verifies drizzle queries work through the proxy + +Add corresponding fixture actors in `fixtures/driver-test-suite/`. + +## Files to modify + +| File | Change | +|------|--------| +| `src/dynamic/runtime-bridge.ts` | Add `sqliteExec`, `sqliteBatch` bridge keys | +| `src/drivers/file-system/global-state.ts` | Add `#actorAppDatabases`, `sqliteExec()`, `sqliteBatch()`, cleanup | +| `src/dynamic/isolate-runtime.ts` | Wire `sqliteExec`/`sqliteBatch` refs in `#setIsolateBridge()` | +| `src/dynamic/host-runtime.ts` | Wire bridge refs + add `overrideRawDatabaseClient` to isolate-side `actorDriver` | +| `src/db/drizzle/mod.ts` | Add override check at top of `createClient` | +| `src/driver-test-suite/tests/` | New test file for dynamic SQLite proxy | +| `fixtures/driver-test-suite/` | New fixture actors using `db()` in dynamic actors | +| `docs-internal/rivetkit-typescript/DYNAMIC_ACTORS_ARCHITECTURE.md` | Document SQLite proxy bridge | + +## Non-goals + +- Running WASM SQLite inside the isolate. +- Implementing this for the engine driver (deferred until engine-side app database support exists). +- Shared/cross-actor databases. +- Direct filesystem access from the isolate. diff --git a/docs-internal/rivetkit-typescript/DYNAMIC_ACTOR_TYPESCRIPT_SOURCE.md b/docs-internal/rivetkit-typescript/DYNAMIC_ACTOR_TYPESCRIPT_SOURCE.md new file mode 100644 index 0000000000..7f401f2e46 --- /dev/null +++ b/docs-internal/rivetkit-typescript/DYNAMIC_ACTOR_TYPESCRIPT_SOURCE.md @@ -0,0 +1,214 @@ +# Dynamic Actor TypeScript Source Compilation + +## Overview + +Add TypeScript source support to dynamic actors via `@secure-exec/typescript`, allowing loaders to return `.ts` source directly instead of requiring pre-transpiled JavaScript. + +## Current State + +- `DynamicActorLoadResult.sourceFormat` accepts `"commonjs-js" | "esm-js"` only +- Loaders must pre-transpile TypeScript before returning source +- secure-exec is loaded dynamically at runtime (not a direct dependency) from `secure-exec` or the legacy `sandboxed-node` package specifier +- The codebase currently resolves secure-exec from a pre-release commit hash (`pkg.pr.new/rivet-dev/secure-exec@7659aba`) in the example, and from local dist paths or npm in the runtime + +## Dependency Update + +Update secure-exec from the pre-release commit hash to the published `0.1.0` release: + +- `secure-exec@0.1.0` — core runtime (published 2026-03-18) +- `@secure-exec/typescript@0.1.0` — TypeScript compiler tools (published 2026-03-18, depends on `secure-exec@0.1.0` and `typescript@^5.9.3`) + +The `@secure-exec/typescript` package provides `createTypeScriptTools()` which runs the TypeScript compiler inside a secure-exec isolate, returning compiled JS and diagnostics. This means type-checking and transpilation happen in a sandboxed environment with memory/CPU limits, matching the existing security model. + +Update locations: +- `examples/ai-generated-actor/package.json` — replace commit hash URL with `secure-exec@0.1.0` +- Any local dist path fallbacks in `isolate-runtime.ts` that reference old directory structures + +## New Source Formats + +Extend `DynamicSourceFormat` in `runtime-bridge.ts`: + +```ts +export type DynamicSourceFormat = + | "commonjs-js" + | "esm-js" + | "esm-ts" // ESM TypeScript, compiled to esm-js before execution + | "commonjs-ts"; // CJS TypeScript, compiled to commonjs-js before execution +``` + +## API: `compileActorSource` + +Exported from `rivetkit/dynamic`. This is a helper that the loader calls explicitly — compilation does not happen implicitly in the runtime. + +### Signature + +```ts +interface CompileActorSourceOptions { + /** TypeScript source text. */ + source: string; + + /** Filename hint for diagnostics (default: "actor.ts"). */ + filename?: string; + + /** Output module format (default: "esm"). */ + format?: "esm" | "commonjs"; + + /** Run the full type checker (default: false). Strip-only when false. */ + typecheck?: boolean; + + /** Additional tsconfig compilerOptions overrides. */ + compilerOptions?: Record; + + /** Memory limit for the compiler isolate in MB (default: 512). */ + memoryLimit?: number; + + /** CPU time limit for the compiler isolate in ms. */ + cpuTimeLimitMs?: number; +} + +interface CompileActorSourceResult { + /** Compiled JavaScript output. Undefined if compilation failed. */ + js?: string; + + /** Source map text, if generated. */ + sourceMap?: string; + + /** Whether compilation succeeded without errors. */ + success: boolean; + + /** TypeScript diagnostics (errors, warnings, suggestions). */ + diagnostics: TypeScriptDiagnostic[]; +} + +interface TypeScriptDiagnostic { + code: number; + category: "error" | "warning" | "suggestion" | "message"; + message: string; + line?: number; + column?: number; +} + +function compileActorSource( + options: CompileActorSourceOptions, +): Promise; +``` + +### Usage in a Loader + +```ts +import { dynamicActor, compileActorSource } from "rivetkit/dynamic"; + +const myActor = dynamicActor({ + load: async (ctx) => { + const tsSource = await fetchActorSource(ctx.name); + + const compiled = await compileActorSource({ + source: tsSource, + typecheck: true, + }); + + if (!compiled.success) { + const errors = compiled.diagnostics + .filter(d => d.category === "error") + .map(d => `${d.line}:${d.column} ${d.message}`) + .join("\n"); + throw new Error(`Actor TypeScript compilation failed:\n${errors}`); + } + + return { + source: compiled.js!, + sourceFormat: "esm-js", + }; + }, +}); +``` + +### Usage Without Type Checking (Fast Path) + +```ts +const compiled = await compileActorSource({ + source: tsSource, + typecheck: false, // strip types only, much faster +}); +``` + +## Implementation Plan + +### 1. Update secure-exec dependency + +- Replace pre-release URLs with `secure-exec@0.1.0` in examples +- Add `@secure-exec/typescript` as an optional peer dependency of rivetkit (dynamically loaded like secure-exec itself) + +### 2. Add `compileActorSource` to `rivetkit/dynamic` + +New file: `src/dynamic/compile.ts` + +Implementation: +1. Dynamically load `@secure-exec/typescript` (same pattern as secure-exec itself — build specifier from parts to avoid bundler eager inclusion) +2. Dynamically load `secure-exec` to get `SystemDriver` and `NodeRuntimeDriverFactory` +3. Call `createTypeScriptTools()` with the secure-exec drivers +4. Call `compileSource()` with the user's source text and compiler options +5. Map the `SourceCompileResult` to `CompileActorSourceResult` + +The key mapping from `@secure-exec/typescript` API to ours: + +| `@secure-exec/typescript` | `compileActorSource` | +|-----------------------------------|------------------------------------| +| `createTypeScriptTools()` | Called internally, cached per call | +| `tools.compileSource()` | Core operation | +| `tools.typecheckSource()` | Used when `typecheck: true` | +| `SourceCompileResult.outputText` | `CompileActorSourceResult.js` | +| `SourceCompileResult.diagnostics` | Passed through directly | + +When `typecheck: false`, use compiler options `{ noCheck: true }` (TS 5.9+ `--noCheck` flag) to strip types without running the checker. This is substantially faster. + +### 3. Add source format aliases (optional convenience) + +Extend `DynamicSourceFormat` with `"esm-ts"` and `"commonjs-ts"`. When the isolate runtime sees a TS format, it calls `compileActorSource` automatically before writing source to the sandbox filesystem. This is a convenience — loaders can always compile explicitly and return `"esm-js"`. + +### 4. Export from `rivetkit/dynamic` + +Add to `src/dynamic/mod.ts`: +```ts +export { compileActorSource } from "./compile"; +export type { + CompileActorSourceOptions, + CompileActorSourceResult, + TypeScriptDiagnostic, +} from "./compile"; +``` + +### 5. Tests + +- Unit test: `compileActorSource` with valid TS returns JS and `success: true` +- Unit test: `compileActorSource` with type errors returns diagnostics and `success: false` +- Unit test: `compileActorSource` with `typecheck: false` strips types without error on invalid types +- Driver test: dynamic actor with `sourceFormat: "esm-ts"` loads and responds to actions +- Driver test: dynamic actor reload with TS source + +## Design Decisions + +**Why a helper function, not automatic compilation in reload/load?** +- Type checking is expensive (spins up a compiler isolate). Loaders should opt in explicitly. +- Loaders may want to cache compiled output, skip type checking in production, or use different compiler options per actor. +- Keeps the runtime path simple — it always receives JS. + +**Why not `transpile` or `prepare`?** +- `compile` is the standard term in the TypeScript ecosystem for TS→JS transformation. +- `transpile` is technically more precise but less commonly used by TS developers. +- `prepare` is too vague. + +**Why run the compiler inside secure-exec?** +- `@secure-exec/typescript` already handles this — the compiler runs in an isolate with memory/CPU limits. +- User-provided source code never touches the host TypeScript installation. +- Consistent with the existing security model where all dynamic actor code runs sandboxed. + +## Files Changed + +| File | Change | +|------|--------| +| `src/dynamic/compile.ts` | New — `compileActorSource` implementation | +| `src/dynamic/mod.ts` | Export `compileActorSource` and types | +| `src/dynamic/runtime-bridge.ts` | Add `"esm-ts"` and `"commonjs-ts"` to `DynamicSourceFormat` | +| `src/dynamic/isolate-runtime.ts` | Handle TS formats by compiling before sandbox write | +| `examples/ai-generated-actor/package.json` | Update secure-exec to `0.1.0` | diff --git a/engine/packages/pegboard/src/workflows/actor/mod.rs b/engine/packages/pegboard/src/workflows/actor/mod.rs index 0dbf5d7750..90da2f2833 100644 --- a/engine/packages/pegboard/src/workflows/actor/mod.rs +++ b/engine/packages/pegboard/src/workflows/actor/mod.rs @@ -624,6 +624,14 @@ pub async fn pegboard_actor(ctx: &mut WorkflowCtx, input: &Input) -> Result<()> )).await?; } Main::Wake(sig) => { + tracing::debug!( + actor_id = ?input.actor_id, + sleeping = state.sleeping, + runner_id = ?state.runner_id, + will_wake = state.will_wake, + "received wake signal" + ); + // Clear alarm if let Some(alarm_ts) = state.alarm_ts { let now = ctx.v(3).activity(GetTsInput {}).await?; diff --git a/engine/sdks/typescript/runner/src/actor.ts b/engine/sdks/typescript/runner/src/actor.ts index 6a1f12455a..852e31892e 100644 --- a/engine/sdks/typescript/runner/src/actor.ts +++ b/engine/sdks/typescript/runner/src/actor.ts @@ -108,6 +108,23 @@ export class RunnerActor { }); } + resetPendingRequestMessageIndex( + gatewayId: protocol.GatewayId, + requestId: protocol.RequestId, + clientMessageIndex: number, + ) { + const existing = this.getPendingRequest(gatewayId, requestId); + if (!existing) { + this.createPendingRequest(gatewayId, requestId, clientMessageIndex); + return; + } + + existing.clientMessageIndex = clientMessageIndex; + existing.actorId = this.actorId; + existing.gatewayId = gatewayId; + existing.requestId = requestId; + } + createPendingRequestWithStreamController( gatewayId: protocol.GatewayId, requestId: protocol.RequestId, diff --git a/engine/sdks/typescript/runner/src/mod.ts b/engine/sdks/typescript/runner/src/mod.ts index 724f8ecd49..6fec2b9b93 100644 --- a/engine/sdks/typescript/runner/src/mod.ts +++ b/engine/sdks/typescript/runner/src/mod.ts @@ -851,72 +851,103 @@ export class Runner { }); ws.addEventListener("message", async (ev) => { - let buf: Uint8Array; - if (ev.data instanceof Blob) { - buf = new Uint8Array(await ev.data.arrayBuffer()); - } else if (Buffer.isBuffer(ev.data)) { - buf = new Uint8Array(ev.data); - } else { - throw new Error(`expected binary data, got ${typeof ev.data}`); - } + try { + if (ws !== this.#pegboardWebSocket) { + this.log?.debug({ + msg: "ignoring runner message from stale websocket", + }); + return; + } - await this.#injectLatency(); + let buf: Uint8Array; + if (ev.data instanceof Blob) { + buf = new Uint8Array(await ev.data.arrayBuffer()); + } else if (Buffer.isBuffer(ev.data)) { + buf = new Uint8Array(ev.data); + } else { + throw new Error(`expected binary data, got ${typeof ev.data}`); + } - // Parse message - const message = protocol.decodeToClient(buf); - this.log?.debug({ - msg: "received runner message", - data: stringifyToClient(message), - }); + await this.#injectLatency(); - // Handle message - if (message.tag === "ToClientInit") { - const init = message.val; + // Parse message + const message = protocol.decodeToClient(buf); + this.log?.debug({ + msg: "received runner message", + data: stringifyToClient(message), + }); - if (this.runnerId !== init.runnerId) { - this.runnerId = init.runnerId; + // Handle message + if (message.tag === "ToClientInit") { + const init = message.val; - // Clear actors if runner id changed - this.#stopAllActors(); - } + if (this.runnerId !== init.runnerId) { + this.runnerId = init.runnerId; - this.#protocolMetadata = init.metadata; + // Clear actors if runner id changed + this.#stopAllActors(); + } - this.log?.info({ - msg: "received init", - protocolMetadata: this.#protocolMetadata, - }); + this.#protocolMetadata = init.metadata; - // Resend pending events - this.#processUnsentKvRequests(); - this.#resendUnacknowledgedEvents(); - this.#tunnel?.resendBufferedEvents(); - - this.#config.onConnected(); - } else if (message.tag === "ToClientCommands") { - const commands = message.val; - this.#handleCommands(commands); - } else if (message.tag === "ToClientAckEvents") { - this.#handleAckEvents(message.val); - } else if (message.tag === "ToClientKvResponse") { - const kvResponse = message.val; - this.#handleKvResponse(kvResponse); - } else if (message.tag === "ToClientTunnelMessage") { - this.#tunnel?.handleTunnelMessage(message.val).catch((err) => { - this.log?.error({ - msg: "error handling tunnel message", - error: stringifyError(err), + this.log?.info({ + msg: "received init", + protocolMetadata: this.#protocolMetadata, }); + + // Resend pending events + this.#processUnsentKvRequests(); + this.#resendUnacknowledgedEvents(); + this.#tunnel?.resendBufferedEvents(); + + this.#config.onConnected(); + } else if (message.tag === "ToClientCommands") { + const commands = message.val; + this.#handleCommands(commands); + } else if (message.tag === "ToClientAckEvents") { + this.#handleAckEvents(message.val); + } else if (message.tag === "ToClientKvResponse") { + const kvResponse = message.val; + this.#handleKvResponse(kvResponse); + } else if (message.tag === "ToClientTunnelMessage") { + this.#tunnel?.handleTunnelMessage(message.val).catch((err) => { + this.log?.error({ + msg: "error handling tunnel message", + error: stringifyError(err), + }); + }); + } else if (message.tag === "ToClientPing") { + this.__sendToServer({ + tag: "ToServerPong", + val: { + ts: message.val.ts, + }, + }); + } else { + unreachable(message); + } + } catch (error) { + if (this.#shutdown || ws.readyState !== ws.OPEN) { + this.log?.debug({ + msg: "ignoring runner websocket message during shutdown", + error: stringifyError(error), + readyState: ws.readyState, + }); + return; + } + + this.log?.error({ + msg: "failed to decode runner websocket message", + error: stringifyError(error), }); - } else if (message.tag === "ToClientPing") { - this.__sendToServer({ - tag: "ToServerPong", - val: { - ts: message.val.ts, - }, - }); - } else { - unreachable(message); + try { + ws.close(1011, "runner.invalid_frame"); + } catch (closeError) { + this.log?.debug({ + msg: "failed closing runner websocket after decode error", + error: stringifyError(closeError), + }); + } } }); @@ -934,6 +965,9 @@ export class Runner { }); ws.addEventListener("close", async (ev) => { + if (this.#pegboardWebSocket === ws) { + this.#pegboardWebSocket = undefined; + } if (!this.#shutdown) { const closeError = parseWebSocketCloseReason(ev.reason); if ( diff --git a/engine/sdks/typescript/runner/src/tunnel.ts b/engine/sdks/typescript/runner/src/tunnel.ts index ad2b650e74..7a503476b8 100644 --- a/engine/sdks/typescript/runner/src/tunnel.ts +++ b/engine/sdks/typescript/runner/src/tunnel.ts @@ -178,6 +178,15 @@ export class Tunnel { meta.headers, ); + // Restored websocket handlers can synchronously send messages + // during onRestore, so the pending request tracking must exist + // before the websocket is handed to user code. + actor.resetPendingRequestMessageIndex( + meta.gatewayId, + meta.requestId, + meta.clientMessageIndex, + ); + // This will call `runner.config.websocket` under the hood to // attach the event listeners to the WebSocket. // Track this operation to ensure it completes @@ -195,16 +204,6 @@ export class Tunnel { false, ) .then(() => { - // Create a PendingRequest entry to track the message index - const actor = this.#runner.getActor(actorId); - if (actor) { - actor.createPendingRequest( - gatewayId, - requestId, - meta.clientMessageIndex, - ); - } - this.log?.info({ msg: "connection successfully restored", actorId, @@ -637,13 +636,14 @@ export class Tunnel { case "ToClientRequestAbort": await this.#handleRequestAbort(gatewayId, requestId); break; - case "ToClientWebSocketOpen": - await this.#handleWebSocketOpen( - gatewayId, - requestId, - message.messageKind.val, - ); - break; + case "ToClientWebSocketOpen": + await this.#handleWebSocketOpen( + gatewayId, + requestId, + message.messageId.messageIndex, + message.messageKind.val, + ); + break; case "ToClientWebSocketMessage": { await this.#handleWebSocketMessage( gatewayId, @@ -912,6 +912,7 @@ export class Tunnel { async #handleWebSocketOpen( gatewayId: GatewayId, requestId: RequestId, + serverMessageIndex: number, open: protocol.ToClientWebSocketOpen, ) { // NOTE: This method is safe to be async since we will not receive any @@ -951,13 +952,18 @@ export class Tunnel { // WebSockets from retransmits. const existingAdapter = actor.getWebSocket(gatewayId, requestId); if (existingAdapter) { - this.log?.warn({ - msg: "closing existing websocket for duplicate open event for the same request id", - requestId: requestIdStr, + const replayAction = + existingAdapter._handleOpenReplay(serverMessageIndex); + if (replayAction === "reset") { + actor.resetPendingRequestMessageIndex(gatewayId, requestId, 0); + } + this.#sendMessage(gatewayId, requestId, { + tag: "ToServerWebSocketOpen", + val: { + canHibernate: existingAdapter[HIBERNATABLE_SYMBOL], + }, }); - // Close without sending a message through the tunnel since the server - // already knows about the new connection - existingAdapter._closeWithoutCallback(1000, "ws.duplicate_open"); + return; } // Create WebSocket @@ -979,15 +985,15 @@ export class Tunnel { // hood to add the event listeners for open, etc. If this handler // throws, then the WebSocket will be closed before sending the // open event. - const adapter = await this.#createWebSocket( - actor.actorId, - gatewayId, - requestId, - requestIdStr, - 0, - canHibernate, - false, - request, + const adapter = await this.#createWebSocket( + actor.actorId, + gatewayId, + requestId, + requestIdStr, + serverMessageIndex, + canHibernate, + false, + request, open.path, Object.fromEntries(open.headers), false, diff --git a/engine/sdks/typescript/runner/src/websocket-tunnel-adapter.ts b/engine/sdks/typescript/runner/src/websocket-tunnel-adapter.ts index a40f0830d9..e2b74e982d 100644 --- a/engine/sdks/typescript/runner/src/websocket-tunnel-adapter.ts +++ b/engine/sdks/typescript/runner/src/websocket-tunnel-adapter.ts @@ -5,6 +5,8 @@ import { MAX_PAYLOAD_SIZE, wrappingAddU16, wrappingLteU16, wrappingSubU16 } from export const HIBERNATABLE_SYMBOL = Symbol("hibernatable"); +export type OpenReplayAction = "ignored" | "advanced" | "reset"; + export class WebSocketTunnelAdapter { #readyState: 0 | 1 | 2 | 3 = 0; #binaryType: "nodebuffer" | "arraybuffer" | "blob" = "nodebuffer"; @@ -104,6 +106,24 @@ export class WebSocketTunnelAdapter { this.#ws.dispatchEvent({ type: "open", rivetRequestId: requestId, target: this.#ws }); } + _handleOpenReplay(serverMessageIndex: number): OpenReplayAction { + if (!this.#hibernatable) { + return "ignored"; + } + if ( + serverMessageIndex === 0 && + this.#serverMessageIndex !== 0 + ) { + this.#serverMessageIndex = 0; + return "reset"; + } + if (wrappingLteU16(serverMessageIndex, this.#serverMessageIndex)) { + return "ignored"; + } + this.#serverMessageIndex = serverMessageIndex; + return "advanced"; + } + // Called by Tunnel when message is received _handleMessage( requestId: ArrayBuffer, diff --git a/examples/CLAUDE.md b/examples/CLAUDE.md index 95ded3203a..07e5e379cf 100644 --- a/examples/CLAUDE.md +++ b/examples/CLAUDE.md @@ -1,10 +1,10 @@ # examples/CLAUDE.md -Guidelines for creating and maintaining examples in this repository. +- Follow these guidelines when creating and maintaining examples in this repository. ## README Format -All example READMEs must follow the template defined in `.claude/resources/EXAMPLE_TEMPLATE.md`. Key requirements: +- All example READMEs must follow `.claude/resources/EXAMPLE_TEMPLATE.md` and meet the key requirements below. - Use exact section headings: `## Getting Started`, `## Features`, `## Implementation`, `## Resources`, `## License` - Include `## Prerequisites` only for non-obvious dependencies (API keys, external services) - Focus features on RivetKit concepts demonstrated, not just app functionality @@ -14,17 +14,17 @@ All example READMEs must follow the template defined in `.claude/resources/EXAMP ### Directory Layout -Examples with frontend: +- Use this layout for examples with frontend (using `vite-plugin-srvx`): ``` example-name/ ├── src/ -│ └── index.ts # Actor definitions, registry setup, and registry.start() +│ ├── actors.ts # Actor definitions and registry setup +│ └── server.ts # Server entry point ├── frontend/ │ ├── App.tsx # Main React component │ └── main.tsx # React entry point ├── tests/ │ └── *.test.ts # Vitest tests -├── public/ # Vite build output (gitignored) ├── index.html # HTML entry point (for Vite) ├── package.json ├── tsconfig.json @@ -34,11 +34,30 @@ example-name/ └── README.md ``` -Backend-only examples: +- Use this layout for examples with separate frontend and backend dev servers: ``` example-name/ ├── src/ -│ └── index.ts # Actor definitions, registry setup, and registry.start() +│ ├── actors.ts # Actor definitions and registry setup +│ └── server.ts # Server entry point +├── frontend/ +│ ├── App.tsx +│ └── main.tsx +├── package.json +├── tsconfig.json +├── tsup.config.ts # For backend bundling +├── vite.config.ts +├── vitest.config.ts # Only if tests exist +├── turbo.json +└── README.md +``` + +- Use this layout for backend-only examples: +``` +example-name/ +├── src/ +│ ├── actors.ts # Actor definitions and registry setup +│ └── server.ts # Server entry point ├── package.json ├── tsconfig.json ├── turbo.json @@ -47,7 +66,8 @@ example-name/ ### Naming Conventions -- Actor definitions and server setup go in `src/index.ts` (single entry point) +- Actor definitions go in `src/actors.ts` +- Server entry point is always `src/server.ts` - Frontend entry is `frontend/main.tsx` with main component in `frontend/App.tsx` - Test files use `.test.ts` extension in `tests/` directory @@ -55,27 +75,44 @@ example-name/ ### Required Scripts -For examples with frontend: +- Use these scripts for examples with frontend (using `vite-plugin-srvx`): +```json +{ + "scripts": { + "dev": "vite", + "check-types": "tsc --noEmit", + "test": "vitest run", + "build": "vite build && vite build --mode server", + "start": "srvx --static=public/ dist/server.js" + } +} +``` + +- Use these scripts for examples with separate frontend and backend dev servers: ```json { "scripts": { - "dev": "concurrently -n server,vite \"tsx --watch src/index.ts\" \"vite\"", - "dev:server": "tsx --watch src/index.ts", + "dev:backend": "srvx --import tsx src/server.ts", + "dev:frontend": "vite", + "dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"", "check-types": "tsc --noEmit", "test": "vitest run", - "build": "vite build", - "start": "tsx src/index.ts" + "build:frontend": "vite build", + "build:backend": "tsup", + "build": "npm run build:backend && npm run build:frontend", + "start": "srvx --static=../frontend/dist dist/server.js" } } ``` -For backend-only examples: +- Use these scripts for backend-only examples: ```json { "scripts": { - "dev": "tsx --watch src/index.ts", + "dev": "npx srvx --import tsx src/server.ts", + "start": "npx srvx --import tsx src/server.ts", "check-types": "tsc --noEmit", - "start": "tsx src/index.ts" + "build": "tsup" } } ``` @@ -104,12 +141,18 @@ For backend-only examples: - Use `"rivetkit": "*"` for the main RivetKit package - Use `"@rivetkit/react": "*"` for React integration - Common dev dependencies: - - `tsx` for running TypeScript in development and production - - `typescript` for type checking - - `vite` and `@vitejs/plugin-react` for frontend (only for examples with frontend) - - `concurrently` for parallel dev servers (only for examples with frontend) - - `vitest` for testing -- `@hono/node-server` and `@hono/node-ws` are bundled in rivetkit and do not need to be added as direct dependencies +- `tsx` for running TypeScript in development +- `typescript` for type checking +- `vite` and `@vitejs/plugin-react` for frontend +- `vite-plugin-srvx` for unified dev server (when using vite-plugin-srvx pattern) +- `vitest` for testing +- `tsup` for bundling (only for separate frontend/backend examples) +- `concurrently` for parallel dev servers (only for separate frontend/backend examples) +- Common production dependencies: +- `hono` for the server framework (required for Vercel detection) +- `srvx` for serving in production (used by `start` script) +- `@hono/node-server` for Node.js HTTP server adapter +- `@hono/node-ws` for Node.js WebSocket support ## Configuration Files @@ -134,33 +177,61 @@ For backend-only examples: } ``` -Notes: +- Notes: - Include `"dom"` in lib for frontend examples - Include `"vite/client"` in types when using Vite - Omit `"frontend/**/*"` and `"tests/**/*"` from include if they don't exist - `allowImportingTsExtensions` and `rewriteRelativeImportExtensions` enable ESM-compliant `.ts` imports -### vite.config.ts +### tsup.config.ts + +- Use `tsup.config.ts` only for examples with separate frontend and backend dev servers (not using `vite-plugin-srvx`). + +```typescript +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { + server: "src/server.ts", + }, + format: ["esm"], + outDir: "dist", + bundle: true, + splitting: false, + shims: true, +}); +``` -Only needed for examples with a frontend: +### vite.config.ts +- Use this `vite.config.ts` for examples using `vite-plugin-srvx` (unified dev): ```typescript import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +import srvx from "vite-plugin-srvx"; + +export default defineConfig({ + plugins: [react(), ...srvx({ entry: "src/server.ts" })], +}); +``` + +- Use this `vite.config.ts` for examples with separate dev servers: +```typescript +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; export default defineConfig({ plugins: [react()], - publicDir: false, + root: "frontend", build: { - outDir: "public", + outDir: "dist", emptyOutDir: true, }, server: { - clearScreen: false, + host: "0.0.0.0", + port: 5173, proxy: { - "/actors": { target: "http://localhost:6420", ws: true }, - "/metadata": { target: "http://localhost:6420" }, - "/health": { target: "http://localhost:6420" }, + "/api/rivet/": "http://localhost:3000", }, }, }); @@ -183,7 +254,7 @@ export default defineConfig({ ### vercel.json -Vercel auto-detects Vite when it sees a `vite.config.ts` and ignores Hono. We must explicitly set the framework to Hono: +- Vercel auto-detects Vite when it sees `vite.config.ts` and ignores Hono, so explicitly set the framework to Hono: ```json { @@ -193,7 +264,7 @@ Vercel auto-detects Vite when it sees a `vite.config.ts` and ignores Hono. We mu ### turbo.json -All examples should extend the root turbo config: +- Extend the root turbo config in all examples: ```json { "$schema": "https://turbo.build/schema.json", @@ -206,14 +277,13 @@ All examples should extend the root turbo config: ``` .actorcore node_modules -public ``` ## Source Code Patterns ### Actor File Structure -Actor definitions (`export const myActor = actor({...})`) must appear at the top of the file, before any helper functions. Helper functions, type definitions used only by helpers, and utilities go after the actor definition. This keeps the actor's public API front-and-center. +- Put actor definitions (`export const myActor = actor({...})`) at the top of the file before helper functions, and place helper-only types and utilities after the actor definition. ```typescript // Good @@ -233,11 +303,9 @@ function helperFunction(...) { ... } export const myActor = actor({...}); ``` -Shared types/interfaces used by both the actor definition and helpers (e.g. `State`, `PlayerEntry`) should go above the actor since the actor definition depends on them. +- Put shared types and interfaces used by both actor definitions and helpers (for example `State` and `PlayerEntry`) above the actor definition. -### Entry Point (src/index.ts) - -The entry point defines actors, sets up the registry, and starts the server. The registry must be exported for client type inference. +### Actor Definitions (src/actors.ts) ```typescript import { actor, setup } from "rivetkit"; @@ -266,20 +334,45 @@ export const chatRoom = actor({ export const registry = setup({ use: { chatRoom }, }); +``` + +### Server Entry Point (src/server.ts) + +- Explicitly import from `"hono"` so Vercel can detect the framework. + +- Include at least: + +```typescript +import { Hono } from "hono"; +import { registry } from "./actors.ts"; -// Start the server on port 6420 -registry.start(); +const app = new Hono(); +app.all("/api/rivet/*", (c) => registry.handler(c.req.raw)); +export default app; ``` -### React Frontend (frontend/App.tsx) +- Use this pattern with additional routes: + +```typescript +import { Hono } from "hono"; +import { registry } from "./actors.ts"; + +const app = new Hono(); -RivetKit runs on port 6420 by default. Pass the endpoint explicitly to the client. +app.get("/api/foo", (c) => c.text("bar")); + +app.all("/api/rivet/*", (c) => registry.handler(c.req.raw)); + +export default app; +``` + +### React Frontend (frontend/App.tsx) ```typescript import { createRivetKit } from "@rivetkit/react"; -import type { registry } from "../src/index.ts"; +import type { registry } from "../src/actors.ts"; -const { useActor } = createRivetKit("http://localhost:6420"); +const { useActor } = createRivetKit(`${location.origin}/api/rivet`); export function App() { const actor = useActor({ @@ -314,7 +407,7 @@ createRoot(root).render( ```typescript import { setupTest } from "rivetkit/test"; import { expect, test } from "vitest"; -import { registry } from "../src/index.ts"; +import { registry } from "../src/actors.ts"; test("Description of test", async (ctx) => { const { client } = await setupTest(ctx, registry); @@ -328,7 +421,7 @@ test("Description of test", async (ctx) => { ## HTML Entry Point -For Vite-based examples: +- Use this HTML pattern for Vite-based examples: ```html @@ -349,7 +442,7 @@ For Vite-based examples: ## ESM Import Requirements -All imports must be ESM-compliant with explicit `.ts` extensions for relative imports: +- Keep all imports ESM-compliant with explicit `.ts` extensions for relative imports: ```typescript // Correct @@ -361,7 +454,7 @@ import { registry } from "./actors"; import { someUtil } from "../utils/helper"; ``` -This is enforced by the tsconfig options `allowImportingTsExtensions` and `rewriteRelativeImportExtensions`. +- This is enforced by `allowImportingTsExtensions` and `rewriteRelativeImportExtensions` in `tsconfig`. ## Best Practices @@ -375,7 +468,7 @@ This is enforced by the tsconfig options `allowImportingTsExtensions` and `rewri ## Vercel Examples -Vercel-optimized versions of examples are automatically generated using the script at `scripts/vercel-examples/generate-vercel-examples.ts`. These examples use the `hono/vercel` adapter and are configured specifically for Vercel serverless deployment. +- Generate Vercel-optimized example variants with `scripts/vercel-examples/generate-vercel-examples.ts`; these variants use `hono/vercel` and Vercel-focused serverless config. ### Generation Script @@ -395,13 +488,13 @@ npx tsx scripts/vercel-examples/generate-vercel-examples.ts --dry-run ### Naming Convention -Vercel examples are placed at `examples/{original-name}-vercel/`. For example: +- Place generated Vercel examples at `examples/{original-name}-vercel/`, for example: - `hello-world` → `hello-world-vercel` - `chat-room` → `chat-room-vercel` ### Directory Layout -Vercel examples with frontend: +- Use this layout for Vercel examples with frontend: ``` example-name-vercel/ ├── api/ @@ -421,7 +514,7 @@ example-name-vercel/ └── README.md # With Vercel-specific note and deploy button ``` -Vercel examples without frontend (API-only): +- Use this layout for Vercel examples without frontend (API-only): ``` example-name-vercel/ ├── api/ @@ -440,7 +533,7 @@ example-name-vercel/ #### api/index.ts -The API entry point uses the Hono Vercel adapter (built into the `hono` package): +- Use the Hono Vercel adapter (built into `hono`) in the API entry point: ```typescript import app from "../src/server.ts"; @@ -450,7 +543,7 @@ export default app; #### vercel.json -For examples with frontend: +- Use this `vercel.json` for examples with frontend: ```json { "framework": "vite", @@ -460,7 +553,7 @@ For examples with frontend: } ``` -For API-only examples: +- Use this `vercel.json` for API-only examples: ```json { "rewrites": [ @@ -471,7 +564,7 @@ For API-only examples: #### package.json -Key differences from origin examples: +- Apply these key differences from origin examples: - Removes `srvx` and `vite-plugin-srvx` - Uses `vercel dev` for development - Simplified build scripts @@ -479,11 +572,11 @@ Key differences from origin examples: #### README.md -Each Vercel example README includes: +- Include the following in each Vercel example README: - A note explaining it's the Vercel-optimized version with a link back to the origin - A "Deploy with Vercel" button for one-click deployment -Example header: +- Use this example header: ```markdown > **Note:** This is the Vercel-optimized version of the [hello-world](../hello-world) example. > It uses the `hono/vercel` adapter and is configured for Vercel deployment. @@ -493,11 +586,11 @@ Example header: ### Skipped Examples -The following example types are not converted to Vercel: +- Do not convert these example types to Vercel: - **Next.js examples** (`*-next-js`): Next.js has its own Vercel integration - **Cloudflare examples** (`*-cloudflare*`): Different runtime environment - **Deno examples**: Different runtime environment -- **Examples without `src/index.ts`**: Cannot be converted +- **Examples without `src/server.ts`**: Cannot be converted ### Workflow @@ -508,7 +601,7 @@ The following example types are not converted to Vercel: ## Frontend Style Guide -Examples should follow these design conventions: +- Follow these design conventions in examples: **Color Palette (Dark Theme)** - Primary accent: `#ff4f00` (orange) for interactive elements and highlights @@ -564,7 +657,7 @@ Examples should follow these design conventions: **Component Patterns** -*Buttons* +- Buttons: - Primary: `#ff4f00` background, white text - Secondary: `#2c2c2e` background, white text - Ghost: transparent background, `#ff4f00` text @@ -572,7 +665,7 @@ Examples should follow these design conventions: - Success: `#30d158` background, white text - Disabled: 50% opacity, `cursor: not-allowed` -*Form Inputs* +- Form Inputs: - Background: `#2c2c2e` - Border: 1px solid `#3a3a3c` - Border radius: 8px @@ -580,21 +673,21 @@ Examples should follow these design conventions: - Focus: border-color `#ff4f00`, box-shadow `0 0 0 3px rgba(255, 79, 0, 0.2)` - Placeholder text: `#6e6e73` -*Cards/Containers* +- Cards and containers: - Background: `#1c1c1e` - Border: 1px solid `#2c2c2e` - Border radius: 8px - Padding: 20px - Box shadow: `0 1px 3px rgba(0, 0, 0, 0.3)` - Header style (when applicable): - - Background: `#2c2c2e` - - Padding: 16px 20px - - Font size: 18px, weight 600 - - Border bottom: 1px solid `#2c2c2e` - - Border radius: 8px 8px 0 0 (top corners only) - - Negative margin to align with card edges: `-20px -20px 20px -20px` - -*Modals/Overlays* +- Background: `#2c2c2e` +- Padding: 16px 20px +- Font size: 18px, weight 600 +- Border bottom: 1px solid `#2c2c2e` +- Border radius: 8px 8px 0 0 (top corners only) +- Negative margin to align with card edges: `-20px -20px 20px -20px` + +- Modals and overlays: - Backdrop: `rgba(0, 0, 0, 0.75)` - Modal background: `#1c1c1e` - Border radius: 8px @@ -602,19 +695,19 @@ Examples should follow these design conventions: - Padding: 24px - Close button: top-right, 8px from edges -*Lists* +- Lists: - Item padding: 12px 16px - Dividers: 1px solid `#2c2c2e` - Hover background: `#2c2c2e` - Selected/active background: `rgba(255, 79, 0, 0.15)` -*Badges/Tags* +- Badges and tags: - Padding: 4px 8px - Border radius: 6px - Font size: 12px - Font weight: 500 -*Tabs* +- Tabs: - Container: `border-bottom: 1px solid #2c2c2e`, flex-wrap for overflow - Tab: `padding: 12px 16px`, no background, `border-radius: 0` - Tab border: `border-bottom: 2px solid transparent`, `margin-bottom: -1px` @@ -625,33 +718,32 @@ Examples should follow these design conventions: **UI States** -*Loading States* +- Loading states: - Spinner: 20px for inline, 32px for page-level - Skeleton placeholders: `#2c2c2e` background with subtle pulse animation - Loading text: "Loading..." in muted color - Button loading: show spinner, disable interaction, keep button width stable -*Empty States* +- Empty states: - Center content vertically and horizontally - Icon: 48px, muted color (`#6e6e73`) - Heading: 18px, primary text color - Description: 14px, muted color - Optional action button below description -*Error States* +- Error states: - Inline errors: `#ff3b30` text below input, 12px font size - Error banners: `#ff3b30` left border (4px), `rgba(255, 59, 48, 0.1)` background - Form validation: highlight input border in `#ff3b30` - Error icon: Lucide `AlertCircle` or `XCircle` -*Disabled States* +- Disabled states: - Opacity: 50% - Cursor: `not-allowed` - No hover/focus effects - Preserve layout (don't collapse or hide) -*Success States* +- Success states: - Color: `#30d158` - Icon: Lucide `CheckCircle` or `Check` - Toast/banner: `rgba(48, 209, 88, 0.1)` background with green left border - diff --git a/examples/ai-generated-actor/.gitignore b/examples/ai-generated-actor/.gitignore new file mode 100644 index 0000000000..dc6f607390 --- /dev/null +++ b/examples/ai-generated-actor/.gitignore @@ -0,0 +1,2 @@ +.actorcore +node_modules diff --git a/examples/ai-generated-actor/README.md b/examples/ai-generated-actor/README.md new file mode 100644 index 0000000000..83dde98af5 --- /dev/null +++ b/examples/ai-generated-actor/README.md @@ -0,0 +1,45 @@ +# AI-Generated Actor + +Use an AI chat to generate and iterate on Rivet Actor code, then deploy and test it live. + +## Getting Started + +```sh +git clone https://github.com/rivet-dev/rivet.git +cd rivet/examples/ai-generated-actor +pnpm install +pnpm dev +``` + +## Prerequisites + +- OpenAI API key set as `OPENAI_API_KEY` +- Install dependencies so the `secure-exec` package is present. This example uses the `secure-exec` package from `pkg.pr.new`. +- If you need to override the runtime package location, set `RIVETKIT_DYNAMIC_SECURE_EXEC_SPECIFIER` to a resolvable `secure-exec` entry file URL. + +## Features + +- AI-driven code generation using GPT-4o via the Vercel AI SDK with streaming responses +- Dynamic actor loading via `dynamicActor` from `rivetkit/dynamic` +- Per-key isolation where each actor key has its own AI agent, generated code, and dynamic actor instance +- Generic actor interface to call arbitrary actions on the generated actor +- Three-column layout: chat, generated code, and actor testing interface + +## Implementation + +The project uses two actors defined in [`src/actors.ts`](https://github.com/rivet-dev/rivet/tree/main/examples/ai-generated-actor/src/actors.ts): + +- `codeAgent` maintains chat history and generated code in actor state. It processes messages via a queue and streams AI responses using the Vercel AI SDK, extracting code blocks from the response to update the current actor source. +- `dynamicRunner` is a dynamic actor that loads its source code from the `codeAgent` with the matching key, executing the AI-generated code in a sandboxed isolate. + +The server in [`src/server.ts`](https://github.com/rivet-dev/rivet/tree/main/examples/ai-generated-actor/src/server.ts) exposes proxy endpoints for calling actions on the dynamic actor by name. + +The frontend in [`frontend/App.tsx`](https://github.com/rivet-dev/rivet/tree/main/examples/ai-generated-actor/frontend/App.tsx) provides a three-column interface for chatting with the AI, viewing generated code, and testing the deployed actor. + +## Resources + +Read more about [dynamic actors](https://rivet.dev/docs/actors/ai-and-user-generated-actors), [queues](https://rivet.dev/docs/actors/queues), [events](https://rivet.dev/docs/actors/events), and [state](https://rivet.dev/docs/actors/state). + +## License + +MIT diff --git a/examples/ai-generated-actor/frontend/App.tsx b/examples/ai-generated-actor/frontend/App.tsx new file mode 100644 index 0000000000..952b1b6dd5 --- /dev/null +++ b/examples/ai-generated-actor/frontend/App.tsx @@ -0,0 +1,479 @@ +import { createRivetKit } from "@rivetkit/react"; +import { createClient } from "rivetkit/client"; +import Editor from "@monaco-editor/react"; +import { useEffect, useRef, useState } from "react"; +import type { + ChatMessage, + CodeAgentState, + CodeUpdateEvent, + ResponseEvent, + registry, +} from "../src/actors/index.ts"; + +const rivetEndpoint = `${location.origin}/api/rivet`; + +const { useActor } = createRivetKit(rivetEndpoint); + +// Raw client for dynamicRunner (actions are unknown at compile time) +const client = createClient({ + endpoint: rivetEndpoint, + encoding: "json", +}); + +const REASONING_OPTIONS = [ + { value: "none", label: "None" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, + { value: "extra_high", label: "Extra High" }, +] as const; + +// Chat column: interacts with the codeAgent actor +function ChatColumn({ + actorKey, + code, + onApiKeyStatus, + onCodeUpdate, +}: { + actorKey: string; + code: string; + onApiKeyStatus: (missing: boolean) => void; + onCodeUpdate: (code: string, revision: number) => void; +}) { + const agent = useActor({ + name: "codeAgent", + key: [actorKey], + }); + const [messages, setMessages] = useState([]); + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(null); + const [input, setInput] = useState(""); + const [reasoning, setReasoning] = useState("none"); + const timelineRef = useRef(null); + + useEffect(() => { + if (!agent.connection) return; + agent.connection.getState().then((state: CodeAgentState) => { + setMessages(state.messages); + setStatus(state.status); + onApiKeyStatus(!state.hasApiKey); + onCodeUpdate(state.code, state.codeRevision); + }); + }, [agent.connection]); + + // Scroll to bottom when messages change + useEffect(() => { + if (timelineRef.current) { + timelineRef.current.scrollTop = timelineRef.current.scrollHeight; + } + }, [messages]); + + agent.useEvent("response", (payload: ResponseEvent) => { + if (payload.error) { + setError(payload.error); + } else if (payload.done) { + setError(null); + } + setMessages((prev) => { + const exists = prev.some((msg) => msg.id === payload.messageId); + if (!exists) { + // Assistant message placeholder from backend; add it. + return [ + ...prev, + { + id: payload.messageId, + role: "assistant" as const, + content: payload.content, + createdAt: Date.now(), + }, + ]; + } + return prev.map((msg) => + msg.id === payload.messageId + ? { ...msg, content: payload.content } + : msg, + ); + }); + }); + + agent.useEvent("codeUpdated", (payload: CodeUpdateEvent) => { + onCodeUpdate(payload.code, payload.revision); + }); + + agent.useEvent("statusChanged", (nextStatus: string) => { + setStatus(nextStatus); + }); + + const sendMessage = async () => { + if (!agent.connection) return; + const trimmed = input.trim(); + if (!trimmed) return; + + setError(null); + // Optimistic add + const userMsg: ChatMessage = { + id: `pending-${Date.now()}`, + role: "user", + content: trimmed, + createdAt: Date.now(), + }; + setMessages((prev) => [...prev, userMsg]); + setInput(""); + + // Send the current editor code along with the message so the AI can + // modify existing code rather than generating from scratch. + await agent.connection.send("chat", { + text: trimmed, + currentCode: code, + reasoning, + }); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + sendMessage(); + } + }; + + return ( +
+
+ Chat +
+ GPT-4o + + +
+
+ {error && ( +
setError(null)}> + {error} +
+ )} +
+ {messages.length === 0 ? ( +

+ Describe the actor you want to build. +

+ ) : ( + messages.map((msg) => ( +
+
+ {msg.role === "user" ? "You" : "AI"} +
+
+ {msg.content || ( + + Thinking... + + )} +
+
+ )) + )} +
+
+