diff --git a/CLAUDE.md b/CLAUDE.md index 7db3338543..8aa580cc53 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -215,6 +215,13 @@ When the user asks to track something in a note, store it in `.agent/notes/` by - Reserve `tokio::time::sleep` for: per-call timeouts via `tokio::select!`, retry/reconnect backoff, deliberate debounce windows, or `sleep_until(deadline)` arms in an event-select loop. If it is inside a `loop { check; sleep }` body, it is polling and should be event-driven instead. - Never add unexplained wall-clock defers like `sleep(1ms)` to decouple a spawn from its caller. Use `tokio::task::yield_now().await` or rely on the spawn itself. +## Memory Leaks + +- Never call `Box::leak` inside a per-request, per-error, or per-call code path. If the leak is for a `'static` reference required by an upstream API (e.g. `RivetErrorSchema`), intern the leaked value through a process-global `LazyLock>` keyed on its identity so each unique value is leaked at most once. Examples: `BRIDGE_RIVET_ERROR_SCHEMAS` in `rivetkit-typescript/packages/rivetkit-napi/src/actor_factory.rs`. +- If every field in a leaked struct is a compile-time constant, use a `static`/`const` instead of `Box::leak(Box::new(...))`. +- `std::mem::forget` is only acceptable when an FFI handle cannot be dropped in the current context (e.g. napi `Ref::unref` requires an `Env`). Document the constraint inline and ensure the leak is bounded per actor/connection lifetime, not per call. Prefer routing the drop through an Env-bearing thread when possible. +- Spawned futures that capture JS callbacks or other heavy resources must have a guaranteed completion path (e.g. a `CancellationToken` whose clones are guaranteed to drop). A `spawn_local(async move { token.cancelled().await; ... })` only drains if every clone of the token is dropped or cancelled. + ## Async Rust Locks - Async Rust code defaults to `tokio::sync::Mutex` / `tokio::sync::RwLock`. Do not use `std::sync::Mutex` / `std::sync::RwLock`. diff --git a/Cargo.lock b/Cargo.lock index 25275eba4f..a47ca94494 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5398,6 +5398,7 @@ dependencies = [ "js-sys", "rivet-error", "rivetkit-core", + "scc", "serde", "serde-wasm-bindgen", "serde_json", diff --git a/docs-internal/engine/napi-bridge.md b/docs-internal/engine/napi-bridge.md index 185b06f9a7..1bbda8b1bb 100644 --- a/docs-internal/engine/napi-bridge.md +++ b/docs-internal/engine/napi-bridge.md @@ -34,6 +34,15 @@ Rules for `rivetkit-typescript/packages/rivetkit-napi/`. The bridge is pure plum - Receive-loop `SerializeState` handling stays inline in `napi_actor_events.rs`, reuses the shared `state_deltas_from_payload(...)` converter from `actor_context.rs`, and only cancels the adapter abort token on `Destroy` or final adapter teardown, not on `Sleep`. - Receive-loop NAPI optional callbacks preserve the TypeScript runtime defaults: missing `onBeforeSubscribe` allows the subscription, missing workflow callbacks reply `None`, and missing connection lifecycle hooks still accept the connection while leaving the existing empty conn state untouched. +## Runtime-state reference cleanup + +- `ActorContextShared::runtime_state` stores a N-API `Ref<()>` for the JS-only actor runtime state bag. `Ref::unref(env)` and reference deletion require an `Env`, but `reset_runtime_state()` runs from receive-loop worker paths and `Drop for ActorContextShared` may run without an active JS callback frame. +- The current `mem::forget` fallback in `actor_context.rs` keeps debug and release behavior aligned when no `Env` is available, but it leaks one JS object reference per actor wake cycle that created runtime state. +- The intended fix is to create an actor-shared cleanup `ThreadsafeFunction` the first time `runtime_state(env)` has an `Env`. Stale `Ref<()>` values should be wrapped in a payload whose `Drop` forgets the reference only if it was never successfully unreffed, then queued to that TSF from `reset_runtime_state()` and `Drop`. +- The TSF callback must run on the JS thread, call `ref.unref(ctx.env)`, and avoid invoking user callbacks. The TSF itself should be unreffed from the event loop so it does not keep Node alive. +- Shutdown is the hard edge: if the TSF is closing or Node supplies a null `Env` during addon teardown, the payload must fall back to the existing bounded process-lifetime leak instead of dropping a live `Ref<()>` and tripping napi-rs debug assertions. +- Before replacing the fallback, add a NAPI integration test that creates runtime state across many actor wake/destroy cycles, waits for the cleanup TSF to drain, and verifies native reference counts return to zero. + ## Cancellation bridging - For non-idempotent native waits like `queue.enqueueAndWait()`, bridge JS `AbortSignal` through a standalone native `CancellationToken`. Timeout-slicing is only safe for receive-style polling calls like `waitForNames()`. diff --git a/engine/artifacts/errors/wasm.invalid_config.json b/engine/artifacts/errors/wasm.invalid_config.json new file mode 100644 index 0000000000..b127e23f81 --- /dev/null +++ b/engine/artifacts/errors/wasm.invalid_config.json @@ -0,0 +1,5 @@ +{ + "code": "invalid_config", + "group": "wasm", + "message": "Invalid wasm configuration." +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42b5d106ad..60a55f9a6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4375,6 +4375,9 @@ importers: typescript: specifier: ^5.9.2 version: 5.9.3 + wasm-pack: + specifier: 0.14.0 + version: 0.14.0 rivetkit-typescript/packages/sql-loader: devDependencies: @@ -11007,6 +11010,9 @@ packages: resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==} engines: {node: '>=4'} + axios@0.26.1: + resolution: {integrity: sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==} + axios@1.13.2: resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} @@ -11211,6 +11217,11 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + binary-install@1.1.2: + resolution: {integrity: sha512-ZS2cqFHPZOy4wLxvzqfQvDjCOifn+7uCPqNmYRIBM/03+yllON+4fNnsD0VJdW0p97y+E+dTRNPStWNqMBq+9g==} + engines: {node: '>=10'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} @@ -11498,6 +11509,10 @@ packages: chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + chownr@2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -13173,6 +13188,10 @@ packages: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} + fs-minipass@2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -14822,14 +14841,26 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + minipass@4.2.8: resolution: {integrity: sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==} engines: {node: '>=8'} + minipass@5.0.0: + resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} + engines: {node: '>=8'} + minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + minizlib@2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + minizlib@3.1.0: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} @@ -16953,6 +16984,11 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} + tar@6.2.1: + resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} + engines: {node: '>=10'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + tar@7.5.11: resolution: {integrity: sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==} engines: {node: '>=18'} @@ -17953,6 +17989,10 @@ packages: warn-once@0.1.1: resolution: {integrity: sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==} + wasm-pack@0.14.0: + resolution: {integrity: sha512-7uKj+483b6ETTnuWHK3zKNB3Ca3M159tPZ5shyXxI4j7i9Lk82rL2ck/L6E9O5VMWk9JgowdtTBOSfWmGBRFtw==} + hasBin: true + watchpack@2.5.1: resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} engines: {node: '>=10.13.0'} @@ -26181,6 +26221,12 @@ snapshots: axe-core@4.11.1: {} + axios@0.26.1: + dependencies: + follow-redirects: 1.15.11 + transitivePeerDependencies: + - debug + axios@1.13.2: dependencies: follow-redirects: 1.15.11 @@ -26414,6 +26460,14 @@ snapshots: binary-extensions@2.3.0: {} + binary-install@1.1.2: + dependencies: + axios: 0.26.1 + rimraf: 3.0.2 + tar: 6.2.1 + transitivePeerDependencies: + - debug + bindings@1.5.0: dependencies: file-uri-to-path: 1.0.0 @@ -26791,6 +26845,8 @@ snapshots: chownr@1.1.4: {} + chownr@2.0.0: {} + chownr@3.0.0: {} chrome-launcher@0.15.2: @@ -28563,6 +28619,10 @@ snapshots: jsonfile: 4.0.0 universalify: 0.1.2 + fs-minipass@2.1.0: + dependencies: + minipass: 3.3.6 + fs.realpath@1.0.0: {} fsevents@2.3.2: @@ -30820,10 +30880,21 @@ snapshots: minimist@1.2.8: {} + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + minipass@4.2.8: {} + minipass@5.0.0: {} + minipass@7.1.3: {} + minizlib@2.1.2: + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + minizlib@3.1.0: dependencies: minipass: 7.1.3 @@ -33350,6 +33421,15 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + tar@6.2.1: + dependencies: + chownr: 2.0.0 + fs-minipass: 2.1.0 + minipass: 5.0.0 + minizlib: 2.1.2 + mkdirp: 1.0.4 + yallist: 4.0.0 + tar@7.5.11: dependencies: '@isaacs/fs-minipass': 4.0.1 @@ -34807,6 +34887,12 @@ snapshots: warn-once@0.1.1: {} + wasm-pack@0.14.0: + dependencies: + binary-install: 1.1.2 + transitivePeerDependencies: + - debug + watchpack@2.5.1: dependencies: glob-to-regexp: 0.4.1 diff --git a/rivetkit-rust/packages/rivetkit-core/CLAUDE.md b/rivetkit-rust/packages/rivetkit-core/CLAUDE.md index 82539335ce..7c072d2b98 100644 --- a/rivetkit-rust/packages/rivetkit-core/CLAUDE.md +++ b/rivetkit-rust/packages/rivetkit-core/CLAUDE.md @@ -3,11 +3,13 @@ ## Module layout - Actor subsystem implementations belong under `src/actor/`; keep root module aliases only for compatibility with existing public callers. +- Public HTTP status promotion for bridged runtime errors belongs in `src/error.rs::public_error_status_code`; NAPI and wasm adapters should call core instead of duplicating mappings. ## Sleep state invariants - Any mutation that changes a `can_sleep` input must call `ActorContext::reset_sleep_timer()` so the `ActorTask` sleep deadline is re-evaluated. Inputs are: `ready`/`started`, `no_sleep`, `active_http_request_count`, `sleep_keep_awake_count`, `sleep_internal_keep_awake_count`, `pending_disconnect_count`, `conns()`, and `websocket_callback_count`. Missing this call leaves the sleep timer armed against stale state and triggers the `"sleep idle deadline elapsed but actor stayed awake"` warning on the next tick. - `ActorContext::set_prevent_sleep(...)` / `prevent_sleep()` are deprecated no-ops kept for NAPI bridge compatibility. Use `keep_awake(future)` (holds counter while awaited) or `wait_until(future)` (tracked shutdown task) instead. Do not reintroduce a `prevent_sleep` field, a `CanSleep::PreventSleep` variant, or branches that read it. +- Runtime-owned promises that must drain during shutdown should use `ActorContext::register_task(...)`, not public `wait_until(...)`, so metrics and runtime intent stay distinct. Registered tasks must race user work against `shutdown_deadline_token()` so shutdown cannot hang forever. - `ctx.sleep()` and `ctx.destroy()` return `Result<()>`. They error with `ActorLifecycleError::Starting` when called before startup completes and `ActorLifecycleError::Stopping` if the requested flag has already been set this generation (atomic `swap(true, ...)`). Internal idle-timer paths log and suppress the already-requested error. - The grace deadline path (`on_sleep_grace_deadline`) aborts the user `run` handle and cancels `shutdown_deadline_token()`. Foreign-runtime adapters running `onSleep` / `onDestroy` must observe that token via `tokio::select!` so SQLite teardown does not race user cleanup work. - Counter `register_zero_notify(&idle_notify)` hooks only drive shutdown drain waits. They are not a substitute for the activity-dirty notification, so any new sleep-affecting counter must also notify on transitions that change `can_sleep`. diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/context.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/context.rs index d93f502603..492c91eafe 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/context.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/context.rs @@ -552,6 +552,14 @@ impl ActorContext { }); } + #[cfg(not(feature = "wasm-runtime"))] + pub fn register_task(&self, future: impl Future + Send + 'static) { + let ctx = self.clone(); + self.track_shutdown_task(async move { + Self::run_registered_task(ctx, future).await; + }); + } + #[cfg(feature = "wasm-runtime")] pub fn wait_until(&self, future: impl Future + 'static) { let counter = self.0.sleep.work.shutdown_counter.clone(); @@ -568,6 +576,34 @@ impl ActorContext { }); } + #[cfg(feature = "wasm-runtime")] + pub fn register_task(&self, future: impl Future + 'static) { + let ctx = self.clone(); + self.track_shutdown_task(async move { + Self::run_registered_task(ctx, future).await; + }); + } + + async fn run_registered_task(ctx: ActorContext, future: F) + where + F: Future, + { + let shutdown_deadline = ctx.shutdown_deadline_token(); + ctx.record_user_task_started(UserTaskKind::RegisteredTask); + let started_at = Instant::now(); + tokio::select! { + _ = future => {} + _ = shutdown_deadline.cancelled() => { + tracing::warn!( + actor_id = %ctx.actor_id(), + reason = "shutdown_deadline_elapsed", + "registered task cancelled by shutdown deadline" + ); + } + } + ctx.record_user_task_finished(UserTaskKind::RegisteredTask, started_at.elapsed()); + } + pub async fn keep_awake(&self, future: F) -> F::Output where F: Future, diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/task_types.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/task_types.rs index 67c3bdbf47..4d61f4cb84 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/task_types.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/task_types.rs @@ -39,12 +39,13 @@ pub enum UserTaskKind { ScheduledAction, DisconnectCallback, WaitUntil, + RegisteredTask, SleepFinalize, DestroyRequest, } impl UserTaskKind { - pub(crate) const ALL: [Self; 10] = [ + pub(crate) const ALL: [Self; 11] = [ Self::Action, Self::Http, Self::WebSocketLifetime, @@ -53,6 +54,7 @@ impl UserTaskKind { Self::ScheduledAction, Self::DisconnectCallback, Self::WaitUntil, + Self::RegisteredTask, Self::SleepFinalize, Self::DestroyRequest, ]; @@ -67,6 +69,7 @@ impl UserTaskKind { Self::ScheduledAction => "scheduled_action", Self::DisconnectCallback => "disconnect_callback", Self::WaitUntil => "wait_until", + Self::RegisteredTask => "registered_task", Self::SleepFinalize => "sleep_finalize", Self::DestroyRequest => "destroy_request", } diff --git a/rivetkit-rust/packages/rivetkit-core/src/error.rs b/rivetkit-rust/packages/rivetkit-core/src/error.rs index acdcd5f78f..672771b8f6 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/error.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/error.rs @@ -1,6 +1,29 @@ use rivet_error::*; use serde::{Deserialize, Serialize}; +pub fn public_error_status_code(group: &str, code: &str) -> Option { + match (group, code) { + ("auth", "forbidden") => Some(403), + ("actor", "action_not_found") => Some(404), + ("actor", "action_timed_out") => Some(408), + ("actor", "aborted") => Some(400), + ("message", "incoming_too_long" | "outgoing_too_long") => Some(400), + ( + "queue", + "full" + | "message_too_large" + | "message_invalid" + | "invalid_payload" + | "invalid_completion_payload" + | "already_completed" + | "previous_message_not_completed" + | "complete_not_configured" + | "timed_out", + ) => Some(400), + _ => None, + } +} + #[derive(RivetError, Debug, Clone, Deserialize, Serialize)] #[error("actor")] pub enum ActorLifecycle { diff --git a/rivetkit-rust/packages/rivetkit-core/tests/sleep.rs b/rivetkit-rust/packages/rivetkit-core/tests/sleep.rs index 792cee1ddd..8ea1c17b18 100644 --- a/rivetkit-rust/packages/rivetkit-core/tests/sleep.rs +++ b/rivetkit-rust/packages/rivetkit-core/tests/sleep.rs @@ -19,18 +19,27 @@ mod moved_tests { #[derive(Default)] struct MessageVisitor { message: Option, + actor_id: Option, + reason: Option, } impl Visit for MessageVisitor { fn record_str(&mut self, field: &Field, value: &str) { - if field.name() == "message" { - self.message = Some(value.to_owned()); + match field.name() { + "message" => self.message = Some(value.to_owned()), + "actor_id" => self.actor_id = Some(value.to_owned()), + "reason" => self.reason = Some(value.to_owned()), + _ => {} } } fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { - if field.name() == "message" { - self.message = Some(format!("{value:?}").trim_matches('"').to_owned()); + let value = format!("{value:?}").trim_matches('"').to_owned(); + match field.name() { + "message" => self.message = Some(value), + "actor_id" => self.actor_id = Some(value), + "reason" => self.reason = Some(value), + _ => {} } } } @@ -40,6 +49,11 @@ mod moved_tests { count: Arc, } + #[derive(Clone)] + struct RegisteredTaskDeadlineLayer { + count: Arc, + } + impl Layer for ShutdownTaskRefusedLayer where S: Subscriber, @@ -59,6 +73,27 @@ mod moved_tests { } } + impl Layer for RegisteredTaskDeadlineLayer + where + S: Subscriber, + { + fn on_event(&self, event: &Event<'_>, _ctx: LayerContext<'_, S>) { + if *event.metadata().level() != tracing::Level::WARN { + return; + } + + let mut visitor = MessageVisitor::default(); + event.record(&mut visitor); + if visitor.message.as_deref() + == Some("registered task cancelled by shutdown deadline") + && visitor.actor_id.as_deref() == Some("actor-register-task-deadline") + && visitor.reason.as_deref() == Some("shutdown_deadline_elapsed") + { + self.count.fetch_add(1, Ordering::SeqCst); + } + } + } + struct NotifyOnDrop(DropMutex>>); impl NotifyOnDrop { @@ -162,6 +197,33 @@ mod moved_tests { assert_eq!(warning_count.load(Ordering::SeqCst), 1); } + #[tokio::test(start_paused = true)] + async fn register_task_exits_when_shutdown_deadline_cancels() { + let ctx = ActorContext::new_for_sleep_tests("actor-register-task-deadline"); + let warning_count = Arc::new(AtomicUsize::new(0)); + let subscriber = Registry::default().with(RegisteredTaskDeadlineLayer { + count: warning_count.clone(), + }); + let _guard = tracing::subscriber::set_default(subscriber); + + ctx.register_task(futures::future::pending::<()>()); + assert_eq!(ctx.shutdown_task_count(), 1); + + ctx.cancel_shutdown_deadline(); + + assert!( + ctx.0 + .sleep + .work + .shutdown_counter + .wait_zero(Instant::now() + Duration::from_millis(1)) + .await, + "registered task should stop waiting after the shutdown deadline" + ); + assert_eq!(ctx.shutdown_task_count(), 0); + assert_eq!(warning_count.load(Ordering::SeqCst), 1); + } + #[tokio::test(start_paused = true)] async fn sleep_then_destroy_signal_tasks_do_not_leak_after_teardown() { let ctx = ActorContext::new_for_sleep_tests("actor-sleep-destroy"); diff --git a/rivetkit-typescript/CLAUDE.md b/rivetkit-typescript/CLAUDE.md index a549591167..4c9f8be830 100644 --- a/rivetkit-typescript/CLAUDE.md +++ b/rivetkit-typescript/CLAUDE.md @@ -20,6 +20,8 @@ - Keep `CoreRuntime` byte payloads on `RuntimeBytes`/`Uint8Array`; NAPI-only `Buffer` conversion belongs inside `NapiCoreRuntime`. - Shared actor glue in `packages/rivetkit/src/registry/native.ts` should construct `RuntimeBytes`/`Uint8Array`; leave `Buffer` creation to `NapiCoreRuntime`. - Wasm bindings for NAPI-supported runtime APIs should forward to `rivetkit-core`; avoid placeholder returns that break runtime parity. +- Wasm `registerTask` must forward to core `ActorContext::register_task(...)`, not `waitUntil`, so shutdown-drain semantics match NAPI without conflating public `waitUntil` work. +- Bridge error HTTP status promotion belongs in `rivetkit-core`; TS native/wasm runtime adapters should decode encoded status fields instead of maintaining promotion tables. - Use public `sqlite` config for runtime SQLite backend selection; wasm defaults unset SQLite to remote and must reject local before runtime construction. ## Native SQLite v2 @@ -124,6 +126,7 @@ The script installs each drizzle-orm version, typechecks `scripts/drizzle-compat ## Test Harness - Shared local `rivet-engine` lifecycle for TypeScript tests lives in `packages/rivetkit/tests/shared-engine.ts`; driver and platform tests should reuse it instead of launching a separate engine. +- Runtime parity tests can instantiate `NapiCoreRuntime` and `WasmCoreRuntime` with fake binding classes, then drive shared actor glue through `buildNativeFactory(...)` without requiring generated NAPI or wasm artifacts. - Platform wasm smoke tests should reuse `packages/rivetkit/tests/platforms/shared-registry.ts` for the raw-SQL SQLite counter actor and public wasm `setup(...)` shape. - Platform smoke tests should use `packages/rivetkit/tests/platforms/shared-platform-harness.ts` for serverless runner setup, app process logging, temp app dirs, health checks, and pinned `pnpm dlx` CLI launches. @@ -138,7 +141,12 @@ Cloudflare Workers forbid `setTimeout`, `fetch`, `connect`, and other async I/O - Keep wasm runtime adapter byte normalization on `Uint8Array`; do not add Node `Buffer` dependencies to `packages/rivetkit/src/registry/wasm-runtime.ts`. - Pass platform wasm bindings through `setup({ wasm: { bindings, initInput } })`; do not add hidden `globalThis` binding hooks. - Wasm `CoreRegistry` serverless startup must use a `BuildingServerless` waiter state; shutdown during build must wake waiters and drain any newly built runtime. +- Validate wasm-only serve config constraints before converting to core `ServeConfig` or starting registry side effects. - Run `pnpm --filter @rivetkit/rivetkit-wasm run check:package` after wasm package export or files changes to verify the published tarball includes the root entrypoint and wasm artifacts. +- Build `@rivetkit/rivetkit-wasm` with its package-local pinned `wasm-pack` dependency. Do not use `npx -y wasm-pack`. +- Intern wasm bridged `RivetErrorSchema` values by `(group, code)` only; live per-error messages must stay on `RivetError.message` instead of expanding the schema cache key. +- Track wasm websocket callback regions in a map keyed by active region IDs and remove entries on end so repeated callbacks do not retain empty slots. +- Treat wasm websocket callback region ID `0` as the untracked sentinel; wrap `u32` allocation by skipping live IDs instead of panicking. ## Workflow Context Actor Access Guards diff --git a/rivetkit-typescript/packages/rivetkit-napi/src/lib.rs b/rivetkit-typescript/packages/rivetkit-napi/src/lib.rs index 3f202a959f..856854890c 100644 --- a/rivetkit-typescript/packages/rivetkit-napi/src/lib.rs +++ b/rivetkit-typescript/packages/rivetkit-napi/src/lib.rs @@ -14,6 +14,7 @@ pub mod websocket; use std::sync::Once; use rivet_error::RivetError as RivetTransportError; +use rivetkit_core::error::public_error_status_code; static INIT_TRACING: Once = Once::new(); pub(crate) const BRIDGE_RIVET_ERROR_PREFIX: &str = "__RIVET_ERROR_JSON__:"; @@ -43,13 +44,35 @@ pub(crate) struct NapiInvalidState { } pub(crate) fn napi_anyhow_error(error: anyhow::Error) -> napi::Error { + let payload = anyhow_to_bridge_rivet_error_payload(error); + napi::Error::from_reason(format!("{BRIDGE_RIVET_ERROR_PREFIX}{}", payload)) +} + +fn anyhow_to_bridge_rivet_error_payload(error: anyhow::Error) -> serde_json::Value { let error_chain = error.chain().map(ToString::to_string).collect::>(); let bridge_context = error .chain() .find_map(|cause| cause.downcast_ref::()); let error = RivetTransportError::extract(&error); - let public_ = bridge_context.and_then(|context| context.public_); - let status_code = bridge_context.and_then(|context| context.status_code); + let promoted_status_code = public_error_status_code(error.group(), error.code()); + let should_promote = promoted_status_code.is_some_and(|_| match bridge_context { + Some(context) => { + context.public_ != Some(true) + || context.status_code.is_none() + || context.status_code == Some(500) + }, + None => true, + }); + let status_code = if should_promote { + promoted_status_code + } else { + bridge_context.and_then(|context| context.status_code) + }; + let public_ = if should_promote { + Some(true) + } else { + bridge_context.and_then(|context| context.public_) + }; let payload = serde_json::json!({ "group": error.group(), "code": error.code(), @@ -69,7 +92,7 @@ pub(crate) fn napi_anyhow_error(error: anyhow::Error) -> napi::Error { ?status_code, "encoded structured bridge error" ); - napi::Error::from_reason(format!("{BRIDGE_RIVET_ERROR_PREFIX}{}", payload)) + payload } pub(crate) fn init_tracing(log_level: Option<&str>) { diff --git a/rivetkit-typescript/packages/rivetkit-napi/src/napi_actor_events.rs b/rivetkit-typescript/packages/rivetkit-napi/src/napi_actor_events.rs index 049f0625d9..c6f4c2e6c6 100644 --- a/rivetkit-typescript/packages/rivetkit-napi/src/napi_actor_events.rs +++ b/rivetkit-typescript/packages/rivetkit-napi/src/napi_actor_events.rs @@ -1,4 +1,4 @@ -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; @@ -9,6 +9,7 @@ use rivetkit_core::{ ActorContext as CoreActorContext, ActorEvent, ActorEvents, ActorLifecycle, ActorStart, QueueSendResult, QueueSendStatus, Reply, SerializeStateReason, StateDelta, }; +use scc::HashMap as SccHashMap; use tokio::sync::mpsc::{UnboundedReceiver, unbounded_channel}; use tokio::task::JoinHandle; use tokio::task::JoinSet; @@ -86,6 +87,18 @@ static CALLBACK_TIMED_OUT_SCHEMA: RivetErrorSchema = RivetErrorSchema { _macro_marker: MacroMarker { _private: () }, }; +static ACTION_NOT_FOUND_SCHEMA: RivetErrorSchema = RivetErrorSchema { + group: "actor", + code: "action_not_found", + default_message: "Action not found", + meta_type: None, + _macro_marker: MacroMarker { _private: () }, +}; + +static STRUCTURED_TIMEOUT_SCHEMAS: LazyLock< + SccHashMap<(&'static str, &'static str), &'static RivetErrorSchema>, +> = LazyLock::new(SccHashMap::new); + pub(crate) async fn run_adapter_loop( bindings: Arc, config: Arc, @@ -858,13 +871,20 @@ fn structured_timeout_schema( match (group, code) { ("actor", "action_timed_out") => &ACTION_TIMED_OUT_SCHEMA, ("actor", "callback_timed_out") => &CALLBACK_TIMED_OUT_SCHEMA, - _ => Box::leak(Box::new(RivetErrorSchema { - group, - code, - default_message: Box::leak(message.to_owned().into_boxed_str()), - meta_type: None, - _macro_marker: MacroMarker { _private: () }, - })), + _ => match STRUCTURED_TIMEOUT_SCHEMAS.entry_sync((group, code)) { + scc::hash_map::Entry::Occupied(entry) => *entry.get(), + scc::hash_map::Entry::Vacant(entry) => { + let schema = Box::leak(Box::new(RivetErrorSchema { + group, + code, + default_message: Box::leak(message.to_owned().into_boxed_str()), + meta_type: None, + _macro_marker: MacroMarker { _private: () }, + })); + entry.insert_entry(schema); + schema + } + }, } } @@ -1266,16 +1286,8 @@ async fn call_on_disconnect_final( } fn action_not_found(name: String) -> anyhow::Error { - let schema = Box::leak(Box::new(RivetErrorSchema { - group: "actor", - code: "action_not_found", - default_message: "Action not found", - meta_type: None, - _macro_marker: MacroMarker { _private: () }, - })); - anyhow::Error::new(RivetTransportError { - schema, + schema: &ACTION_NOT_FOUND_SCHEMA, meta: None, message: Some(format!("Action `{name}` was not found.")), }) diff --git a/rivetkit-typescript/packages/rivetkit-napi/tests/actor_factory.rs b/rivetkit-typescript/packages/rivetkit-napi/tests/actor_factory.rs index ce268ed5d5..65bee59ce7 100644 --- a/rivetkit-typescript/packages/rivetkit-napi/tests/actor_factory.rs +++ b/rivetkit-typescript/packages/rivetkit-napi/tests/actor_factory.rs @@ -6,12 +6,20 @@ mod moved_tests { use std::sync::Arc; use parking_lot::Mutex; - use rivet_error::{RivetError, RivetErrorSchema}; + use rivet_error::{MacroMarker, RivetError, RivetErrorSchema}; use tracing::Level; use tracing_subscriber::fmt::MakeWriter; use super::{BRIDGE_RIVET_ERROR_PREFIX, parse_bridge_rivet_error}; + static AUTH_FORBIDDEN_SCHEMA: RivetErrorSchema = RivetErrorSchema { + group: "auth", + code: "forbidden", + default_message: "Forbidden", + meta_type: None, + _macro_marker: MacroMarker { _private: () }, + }; + #[derive(Clone, Default)] struct LogCapture(Arc>>); @@ -68,6 +76,31 @@ mod moved_tests { assert_eq!(schema_ptr(&first), schema_ptr(&second)); } + #[test] + fn napi_bridge_payload_promotes_known_core_error_status() { + let payload = crate::anyhow_to_bridge_rivet_error_payload(anyhow::Error::new( + RivetError { + schema: &AUTH_FORBIDDEN_SCHEMA, + meta: None, + message: None, + }, + )); + + assert_eq!(payload.get("group").and_then(|value| value.as_str()), Some("auth")); + assert_eq!( + payload.get("code").and_then(|value| value.as_str()), + Some("forbidden") + ); + assert_eq!( + payload.get("public").and_then(|value| value.as_bool()), + Some(true) + ); + assert_eq!( + payload.get("statusCode").and_then(|value| value.as_u64()), + Some(403) + ); + } + #[test] fn parse_bridge_rivet_error_warns_for_malformed_payload() { let capture = LogCapture::default(); diff --git a/rivetkit-typescript/packages/rivetkit-napi/tests/napi_actor_events.rs b/rivetkit-typescript/packages/rivetkit-napi/tests/napi_actor_events.rs index df3e352fb9..0f05555f7a 100644 --- a/rivetkit-typescript/packages/rivetkit-napi/tests/napi_actor_events.rs +++ b/rivetkit-typescript/packages/rivetkit-napi/tests/napi_actor_events.rs @@ -5,7 +5,7 @@ mod moved_tests { use std::sync::Arc as StdArc; use std::time::Duration; - use rivet_error::RivetError as RivetTransportError; + use rivet_error::{RivetError as RivetTransportError, RivetErrorSchema}; use rivetkit_core::Kv; use rivetkit_core::actor::state::{PERSIST_DATA_KEY, PersistedActor}; use tokio::sync::oneshot; @@ -61,6 +61,14 @@ mod moved_tests { assert_eq!(error.code(), code); } + fn schema_ptr(error: &anyhow::Error) -> *const RivetErrorSchema { + error + .chain() + .find_map(|cause| cause.downcast_ref::()) + .map(|error| error.schema as *const RivetErrorSchema) + .expect("expected rivet error") + } + #[tokio::test(flavor = "current_thread")] async fn with_dispatch_cancel_token_cleans_up_after_success() { let cancel_token = with_dispatch_cancel_token(|cancel_token| async move { @@ -167,6 +175,17 @@ mod moved_tests { assert_eq!(error.code(), "action_not_found"); } + #[test] + fn action_not_found_reuses_static_schema() { + let first = action_not_found("missing-first".to_owned()); + let first_schema = schema_ptr(&first); + + for i in 0..100 { + let error = action_not_found(format!("missing-{i}")); + assert_eq!(schema_ptr(&error), first_schema); + } + } + #[tokio::test] async fn subscribe_request_without_guard_is_allowed() { let bindings = Arc::new(empty_bindings()); @@ -369,6 +388,16 @@ mod moved_tests { assert_eq!(error.message(), "Action timed out"); } + #[test] + fn unknown_structured_timeout_schema_is_interned_by_group_and_code() { + let first = structured_timeout_schema("test", "slow_callback", "first message"); + + for i in 0..100 { + let schema = structured_timeout_schema("test", "slow_callback", &format!("message {i}")); + assert!(std::ptr::eq(schema, first)); + } + } + #[tokio::test] async fn run_adapter_loop_resets_stale_shared_end_reason_before_wake() { let bindings = Arc::new(empty_bindings()); diff --git a/rivetkit-typescript/packages/rivetkit-wasm/Cargo.toml b/rivetkit-typescript/packages/rivetkit-wasm/Cargo.toml index d590e11f26..4ef552c663 100644 --- a/rivetkit-typescript/packages/rivetkit-wasm/Cargo.toml +++ b/rivetkit-typescript/packages/rivetkit-wasm/Cargo.toml @@ -14,6 +14,7 @@ anyhow.workspace = true js-sys = "0.3" rivet-error.workspace = true rivetkit-core = { path = "../../../rivetkit-rust/packages/rivetkit-core", default-features = false, features = ["wasm-runtime", "sqlite-remote"] } +scc.workspace = true serde.workspace = true serde_json.workspace = true serde-wasm-bindgen = "0.6" diff --git a/rivetkit-typescript/packages/rivetkit-wasm/package.json b/rivetkit-typescript/packages/rivetkit-wasm/package.json index 7716e031a7..ed34a4701f 100644 --- a/rivetkit-typescript/packages/rivetkit-wasm/package.json +++ b/rivetkit-typescript/packages/rivetkit-wasm/package.json @@ -36,6 +36,7 @@ "prepack": "node scripts/build.mjs" }, "devDependencies": { - "typescript": "^5.9.2" + "typescript": "^5.9.2", + "wasm-pack": "0.14.0" } } diff --git a/rivetkit-typescript/packages/rivetkit-wasm/scripts/build.mjs b/rivetkit-typescript/packages/rivetkit-wasm/scripts/build.mjs index f9d42eb3f2..9796f78f1a 100644 --- a/rivetkit-typescript/packages/rivetkit-wasm/scripts/build.mjs +++ b/rivetkit-typescript/packages/rivetkit-wasm/scripts/build.mjs @@ -1,11 +1,27 @@ #!/usr/bin/env node import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const packageDir = dirname(dirname(fileURLToPath(import.meta.url))); const pkgDir = join(packageDir, "pkg"); +const require = createRequire(import.meta.url); + +function resolveWasmPackBin() { + try { + const packageJsonPath = require.resolve("wasm-pack/package.json", { + paths: [packageDir], + }); + return join(dirname(packageJsonPath), "run.js"); + } catch (err) { + throw new Error( + "Missing pinned wasm-pack dependency. Run pnpm install before building @rivetkit/rivetkit-wasm.", + { cause: err }, + ); + } +} if (["1", "true"].includes(process.env.SKIP_WASM_BUILD ?? "")) { const hasPkg = existsSync(join(pkgDir, "rivetkit_wasm.js")); @@ -33,7 +49,6 @@ if (!outDir) { } const cmd = [ - "wasm-pack", "build", "--target", target, @@ -43,5 +58,7 @@ const cmd = [ "rivetkit_wasm", ]; -console.log(`[rivetkit-wasm/build] running: ${cmd.join(" ")}`); -execFileSync("npx", ["-y", ...cmd], { stdio: "inherit" }); +const wasmPackBin = resolveWasmPackBin(); + +console.log(`[rivetkit-wasm/build] running: wasm-pack ${cmd.join(" ")}`); +execFileSync(process.execPath, [wasmPackBin, ...cmd], { stdio: "inherit" }); diff --git a/rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs b/rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs index 3a8da92cf8..a6bda3e433 100644 --- a/rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs +++ b/rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs @@ -1,8 +1,8 @@ -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::collections::HashMap; use std::path::PathBuf; use std::rc::Rc; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use std::time::Duration; use anyhow::{Result, anyhow}; @@ -19,7 +19,9 @@ use rivetkit_core::{ SerializeStateReason, ServeConfig, ServerlessRequest, StateDelta, WebSocket, WebSocketCallbackRegion, WsMessage, }; +use rivetkit_core::error::public_error_status_code; use rivetkit_core::inspector::InspectorAuth; +use scc::HashMap as SccHashMap; use tokio::sync::oneshot; use tokio_util::sync::CancellationToken as CoreCancellationToken; use wasm_bindgen::prelude::*; @@ -28,6 +30,12 @@ use wasm_bindgen_futures::{JsFuture, spawn_local}; const BRIDGE_RIVET_ERROR_PREFIX: &str = "__RIVET_ERROR_JSON__:"; +type BridgeRivetErrorSchemaKey = (String, String); + +static BRIDGE_RIVET_ERROR_SCHEMAS: LazyLock< + SccHashMap, +> = LazyLock::new(SccHashMap::new); + #[derive(rivet_error::RivetError, serde::Serialize)] #[error( "wasm", @@ -40,6 +48,18 @@ struct WasmInvalidState { reason: String, } +#[derive(rivet_error::RivetError, serde::Serialize)] +#[error( + "wasm", + "invalid_config", + "Invalid wasm configuration.", + "Invalid wasm configuration field '{field}': {reason}" +)] +struct WasmInvalidConfig { + field: String, + reason: String, +} + #[derive(serde::Deserialize)] struct BridgeRivetErrorPayload { group: String, @@ -93,7 +113,7 @@ pub fn start() { console_error_panic_hook::set_once(); } -#[derive(serde::Deserialize)] +#[derive(serde::Deserialize, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct WasmServeConfig { pub version: u32, @@ -113,6 +133,17 @@ pub struct WasmServeConfig { pub serverless_max_start_payload_bytes: u32, } +fn validate_wasm_serve_config(config: &WasmServeConfig) -> Result<()> { + if config.engine_binary_path.is_some() { + return Err(WasmInvalidConfig { + field: "engine_binary_path".to_owned(), + reason: "wasm runtimes cannot spawn an engine binary; omit this field and connect to an existing engine endpoint".to_owned(), + } + .build()); + } + Ok(()) +} + impl From for ServeConfig { fn from(config: WasmServeConfig) -> Self { Self { @@ -122,20 +153,20 @@ impl From for ServeConfig { namespace: config.namespace, pool_name: config.pool_name, engine_binary_path: config.engine_binary_path.map(PathBuf::from), - handle_inspector_http_in_runtime: config - .handle_inspector_http_in_runtime - .unwrap_or(false), - serverless_base_path: config.serverless_base_path, - serverless_package_version: config.serverless_package_version, - serverless_client_endpoint: config.serverless_client_endpoint, - serverless_client_namespace: config.serverless_client_namespace, - serverless_client_token: config.serverless_client_token, - serverless_validate_endpoint: config.serverless_validate_endpoint, - serverless_max_start_payload_bytes: config.serverless_max_start_payload_bytes as usize, - serverless_cache_envoy: false, - } + handle_inspector_http_in_runtime: config + .handle_inspector_http_in_runtime + .unwrap_or(false), + serverless_base_path: config.serverless_base_path, + serverless_package_version: config.serverless_package_version, + serverless_client_endpoint: config.serverless_client_endpoint, + serverless_client_namespace: config.serverless_client_namespace, + serverless_client_token: config.serverless_client_token, + serverless_validate_endpoint: config.serverless_validate_endpoint, + serverless_max_start_payload_bytes: config.serverless_max_start_payload_bytes as usize, + serverless_cache_envoy: false, } } +} #[derive(Clone, Default, serde::Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -279,6 +310,7 @@ impl WasmCoreRegistry { #[wasm_bindgen] pub async fn serve(&self, config: JsValue) -> Result<(), JsValue> { let config: WasmServeConfig = serde_wasm_bindgen::from_value(config)?; + validate_wasm_serve_config(&config).map_err(anyhow_to_js_error)?; rivetkit_core::inspector::set_test_inspector_token_override( config.inspector_test_token.clone(), ); @@ -354,6 +386,7 @@ impl WasmCoreRegistry { async fn serverless_runtime(&self, config: JsValue) -> Result { let config: WasmServeConfig = serde_wasm_bindgen::from_value(config)?; + validate_wasm_serve_config(&config).map_err(anyhow_to_js_error)?; rivetkit_core::inspector::set_test_inspector_token_override( config.inspector_test_token.clone(), ); @@ -1029,7 +1062,8 @@ pub struct WasmActorContext { inner: rivetkit_core::ActorContext, callbacks: WasmCallbacks, runtime_state: JsValue, - websocket_callback_regions: Rc>>>, + websocket_callback_regions: Rc>>, + next_websocket_callback_region_id: Rc>, } impl WasmActorContext { @@ -1038,8 +1072,30 @@ impl WasmActorContext { inner, callbacks, runtime_state: Object::new().into(), - websocket_callback_regions: Rc::new(RefCell::new(Vec::new())), + websocket_callback_regions: Rc::new(RefCell::new(HashMap::new())), + next_websocket_callback_region_id: Rc::new(Cell::new(0)), + } + } + + fn allocate_websocket_callback_region_id( + &self, + regions: &HashMap, + ) -> Option { + let start_id = self.next_websocket_callback_region_id.get(); + let mut region_id = start_id; + + for _ in 0..u32::MAX { + region_id = region_id.wrapping_add(1); + if region_id == 0 { + region_id = 1; + } + if !regions.contains_key(®ion_id) { + self.next_websocket_callback_region_id.set(region_id); + return Some(region_id); + } } + + None } } @@ -1143,11 +1199,13 @@ impl WasmActorContext { } else { serde_wasm_bindgen::from_value(opts)? }; - self.inner.request_save(RequestSaveOpts { - immediate: opts.immediate.unwrap_or(false), - max_wait_ms: opts.max_wait_ms, - }); - Ok(()) + self.inner + .request_save_and_wait(RequestSaveOpts { + immediate: opts.immediate.unwrap_or(false), + max_wait_ms: opts.max_wait_ms, + }) + .await + .map_err(anyhow_to_js_error) } #[wasm_bindgen(js_name = saveState)] @@ -1308,7 +1366,15 @@ impl WasmActorContext { #[wasm_bindgen(js_name = registerTask)] pub fn register_task(&self, promise: Promise) { - self.wait_until(promise); + let actor_id = self.inner.actor_id().to_owned(); + self.inner.register_task(async move { + if let Err(error) = JsFuture::from(promise).await { + console_error(&format!( + "actor registered task promise rejected for actor {actor_id}: {}", + js_value_to_anyhow(error) + )); + } + }); } #[wasm_bindgen(js_name = restartRunHandler)] @@ -1319,8 +1385,12 @@ impl WasmActorContext { #[wasm_bindgen(js_name = beginWebsocketCallback)] pub fn begin_websocket_callback(&self) -> u32 { let mut regions = self.websocket_callback_regions.borrow_mut(); - regions.push(Some(self.inner.websocket_callback_region())); - regions.len() as u32 + let Some(region_id) = self.allocate_websocket_callback_region_id(®ions) else { + console_error("failed to begin websocket callback region: no region ids available"); + return 0; + }; + regions.insert(region_id, self.inner.websocket_callback_region()); + region_id } #[wasm_bindgen(js_name = endWebsocketCallback)] @@ -1328,13 +1398,7 @@ impl WasmActorContext { if region_id == 0 { return; } - if let Some(region) = self - .websocket_callback_regions - .borrow_mut() - .get_mut(region_id as usize - 1) - { - region.take(); - } + self.websocket_callback_regions.borrow_mut().remove(®ion_id); } #[wasm_bindgen] @@ -2559,13 +2623,21 @@ fn leak_str(value: String) -> &'static str { } fn bridge_rivet_error_schema(payload: &BridgeRivetErrorPayload) -> &'static RivetErrorSchema { - Box::leak(Box::new(RivetErrorSchema { - group: leak_str(payload.group.clone()), - code: leak_str(payload.code.clone()), - default_message: leak_str(payload.message.clone()), - meta_type: None, - _macro_marker: MacroMarker { _private: () }, - })) + let key = (payload.group.clone(), payload.code.clone()); + match BRIDGE_RIVET_ERROR_SCHEMAS.entry_sync(key) { + scc::hash_map::Entry::Occupied(entry) => *entry.get(), + scc::hash_map::Entry::Vacant(entry) => { + let schema = Box::leak(Box::new(RivetErrorSchema { + group: leak_str(payload.group.clone()), + code: leak_str(payload.code.clone()), + default_message: leak_str(payload.message.clone()), + meta_type: None, + _macro_marker: MacroMarker { _private: () }, + })); + entry.insert_entry(schema); + schema + } + } } fn parse_bridge_rivet_error(reason: &str) -> Option { @@ -2643,19 +2715,319 @@ fn registry_shut_down_error() -> JsValue { } fn anyhow_to_js_error(error: anyhow::Error) -> JsValue { + let payload = anyhow_to_bridge_rivet_error_payload(error); + js_sys::Error::new(&format!("{BRIDGE_RIVET_ERROR_PREFIX}{payload}")).into() +} + +fn anyhow_to_bridge_rivet_error_payload(error: anyhow::Error) -> serde_json::Value { let bridge_context = error .chain() .find_map(|cause| cause.downcast_ref::()); let error = RivetTransportError::extract(&error); - let public_ = bridge_context.and_then(|context| context.public_); - let status_code = bridge_context.and_then(|context| context.status_code); - let payload = serde_json::json!({ + let promoted_status_code = public_error_status_code(error.group(), error.code()); + let should_promote = promoted_status_code.is_some_and(|_| match bridge_context { + Some(context) => { + context.public_ != Some(true) + || context.status_code.is_none() + || context.status_code == Some(500) + }, + None => true, + }); + let status_code = if should_promote { + promoted_status_code + } else { + bridge_context.and_then(|context| context.status_code) + }; + let public_ = if should_promote { + Some(true) + } else { + bridge_context.and_then(|context| context.public_) + }; + serde_json::json!({ "group": error.group(), "code": error.code(), "message": error.message(), "metadata": error.metadata(), "public": public_, "statusCode": status_code, - }); - js_sys::Error::new(&format!("{BRIDGE_RIVET_ERROR_PREFIX}{payload}")).into() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_serve_config(engine_binary_path: Option) -> WasmServeConfig { + WasmServeConfig { + version: 1, + endpoint: "http://127.0.0.1:6420".to_owned(), + token: None, + namespace: "default".to_owned(), + pool_name: "default".to_owned(), + engine_binary_path, + handle_inspector_http_in_runtime: None, + inspector_test_token: None, + serverless_base_path: None, + serverless_package_version: "0.0.0-test".to_owned(), + serverless_client_endpoint: None, + serverless_client_namespace: None, + serverless_client_token: None, + serverless_validate_endpoint: true, + serverless_max_start_payload_bytes: 1024, + } + } + + fn bridge_reason(group: &str, code: &str, message: &str) -> String { + let payload = serde_json::json!({ + "group": group, + "code": code, + "message": message, + "metadata": { "case": "schema-cache" }, + "public": true, + "statusCode": 418, + }); + format!("{BRIDGE_RIVET_ERROR_PREFIX}{payload}") + } + + static AUTH_FORBIDDEN_SCHEMA: RivetErrorSchema = RivetErrorSchema { + group: "auth", + code: "forbidden", + default_message: "Forbidden", + meta_type: None, + _macro_marker: MacroMarker { _private: () }, + }; + + fn transport_schema(error: &anyhow::Error) -> &'static RivetErrorSchema { + error + .chain() + .find_map(|cause| cause.downcast_ref::()) + .expect("bridge error should carry RivetTransportError") + .schema + } + + fn transport_message(error: &anyhow::Error) -> String { + error + .chain() + .find_map(|cause| cause.downcast_ref::()) + .expect("bridge error should carry RivetTransportError") + .message() + .to_owned() + } + + #[test] + fn parse_bridge_rivet_error_reuses_interned_schema() { + // This test owns the `(wasm_schema_cache_test, same_payload)` cache key so + // concurrent tests do not perturb the global schema-count delta. + let initial_count = BRIDGE_RIVET_ERROR_SCHEMAS.len(); + let reason = bridge_reason( + "wasm_schema_cache_test", + "same_payload", + "same payload message", + ); + let first = parse_bridge_rivet_error(&reason).expect("bridge error should decode"); + let first_schema = transport_schema(&first) as *const RivetErrorSchema; + + for _ in 0..100 { + let error = parse_bridge_rivet_error(&reason).expect("bridge error should decode"); + let schema = transport_schema(&error) as *const RivetErrorSchema; + assert_eq!(schema, first_schema); + } + + assert_eq!(BRIDGE_RIVET_ERROR_SCHEMAS.len(), initial_count + 1); + + let different_message = bridge_reason( + "wasm_schema_cache_test", + "same_payload", + "different default message", + ); + let error = + parse_bridge_rivet_error(&different_message).expect("bridge error should decode"); + let schema = transport_schema(&error) as *const RivetErrorSchema; + + assert_eq!(schema, first_schema); + assert_eq!(transport_message(&error), "different default message"); + assert_eq!(BRIDGE_RIVET_ERROR_SCHEMAS.len(), initial_count + 1); + + let different_code = bridge_reason( + "wasm_schema_cache_test", + "different_code", + "different code message", + ); + let error = parse_bridge_rivet_error(&different_code).expect("bridge error should decode"); + let schema = transport_schema(&error) as *const RivetErrorSchema; + + assert_ne!(schema, first_schema); + assert_eq!(BRIDGE_RIVET_ERROR_SCHEMAS.len(), initial_count + 2); + } + + #[test] + fn wasm_bridge_payload_promotes_known_core_error_status() { + let payload = anyhow_to_bridge_rivet_error_payload(anyhow::Error::new( + RivetTransportError { + schema: &AUTH_FORBIDDEN_SCHEMA, + meta: None, + message: None, + }, + )); + + assert_eq!(payload.get("group").and_then(|value| value.as_str()), Some("auth")); + assert_eq!( + payload.get("code").and_then(|value| value.as_str()), + Some("forbidden") + ); + assert_eq!( + payload.get("public").and_then(|value| value.as_bool()), + Some(true) + ); + assert_eq!( + payload.get("statusCode").and_then(|value| value.as_u64()), + Some(403) + ); + } + + #[cfg(target_arch = "wasm32")] + #[test] + fn websocket_callback_regions_are_removed_after_end() { + let context = WasmActorContext::from_core( + rivetkit_core::ActorContext::new( + "websocket-region-test", + "websocket-region-test", + Vec::new(), + "local", + ), + WasmCallbacks::new(Object::new().into()), + ); + let mut previous_id = 0; + + for _ in 0..1000 { + let region_id = context.begin_websocket_callback(); + assert!(region_id > previous_id); + assert_eq!(context.websocket_callback_regions.borrow().len(), 1); + + context.end_websocket_callback(region_id); + assert!(context.websocket_callback_regions.borrow().is_empty()); + + previous_id = region_id; + } + } + + #[cfg(target_arch = "wasm32")] + #[test] + fn websocket_callback_region_ids_wrap_without_collision() { + let context = WasmActorContext::from_core( + rivetkit_core::ActorContext::new( + "websocket-region-wrap-test", + "websocket-region-wrap-test", + Vec::new(), + "local", + ), + WasmCallbacks::new(Object::new().into()), + ); + + let first_id = context.begin_websocket_callback(); + context + .next_websocket_callback_region_id + .set(u32::MAX - 1); + let max_id = context.begin_websocket_callback(); + let wrapped_id = context.begin_websocket_callback(); + + assert_eq!(first_id, 1); + assert_eq!(max_id, u32::MAX); + assert_eq!(wrapped_id, 2); + assert_eq!(context.websocket_callback_regions.borrow().len(), 3); + assert!( + context + .websocket_callback_regions + .borrow() + .contains_key(&first_id) + ); + assert!( + context + .websocket_callback_regions + .borrow() + .contains_key(&max_id) + ); + assert!( + context + .websocket_callback_regions + .borrow() + .contains_key(&wrapped_id) + ); + + context.end_websocket_callback(first_id); + context.end_websocket_callback(max_id); + context.end_websocket_callback(wrapped_id); + assert!(context.websocket_callback_regions.borrow().is_empty()); + } + + #[test] + fn engine_binary_path_error_is_typed_with_field_metadata() { + let error = validate_wasm_serve_config(&test_serve_config(Some( + "/usr/local/bin/rivet-engine".to_owned(), + ))) + .expect_err("engine_binary_path should be rejected"); + let error = RivetTransportError::extract(&error); + + assert_eq!(error.group(), "wasm"); + assert_eq!(error.code(), "invalid_config"); + assert_eq!(error.message(), "Invalid wasm configuration."); + assert_eq!( + error + .metadata() + .as_ref() + .and_then(|metadata| metadata.get("field")) + .and_then(|field| field.as_str()), + Some("engine_binary_path") + ); + } + + #[cfg(target_arch = "wasm32")] + #[test] + fn serve_rejects_engine_binary_path_before_core_setup() { + use std::future::Future; + use std::pin::pin; + use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker}; + + fn raw_waker() -> RawWaker { + fn clone(_: *const ()) -> RawWaker { + raw_waker() + } + fn wake(_: *const ()) {} + fn wake_by_ref(_: *const ()) {} + fn drop(_: *const ()) {} + RawWaker::new( + std::ptr::null(), + &RawWakerVTable::new(clone, wake, wake_by_ref, drop), + ) + } + + let registry = WasmCoreRegistry::new(); + let config = serde_wasm_bindgen::to_value(&test_serve_config(Some( + "/usr/local/bin/rivet-engine".to_owned(), + ))) + .expect("serve config should encode"); + let future = registry.serve(config); + let mut future = pin!(future); + let waker = unsafe { Waker::from_raw(raw_waker()) }; + let mut cx = Context::from_waker(&waker); + + match Future::poll(future.as_mut(), &mut cx) { + Poll::Ready(Err(error)) => { + let error = js_value_to_anyhow(error); + let error = RivetTransportError::extract(&error); + assert_eq!(error.group(), "wasm"); + assert_eq!(error.code(), "invalid_config"); + assert_eq!( + error + .metadata() + .as_ref() + .and_then(|metadata| metadata.get("field")) + .and_then(|field| field.as_str()), + Some("engine_binary_path") + ); + } + Poll::Ready(Ok(())) => panic!("serve should reject engine_binary_path"), + Poll::Pending => panic!("serve should reject before awaiting core setup"), + } + } } diff --git a/rivetkit-typescript/packages/rivetkit/src/actor/errors.ts b/rivetkit-typescript/packages/rivetkit/src/actor/errors.ts index 265a9096de..f87c001070 100644 --- a/rivetkit-typescript/packages/rivetkit/src/actor/errors.ts +++ b/rivetkit-typescript/packages/rivetkit/src/actor/errors.ts @@ -69,81 +69,6 @@ function errorMessage(error: unknown, fallback = String(error)): string { return fallback; } -function normalizeDecodedBridgePayload( - payload: RivetErrorLike, -): RivetErrorLike { - if (payload.public !== undefined || payload.statusCode !== undefined) { - return payload; - } - - if (payload.group === "auth" && payload.code === "forbidden") { - return { - ...payload, - public: true, - statusCode: 403, - }; - } - - if (payload.group === "actor" && payload.code === "action_not_found") { - return { - ...payload, - public: true, - statusCode: 404, - }; - } - - if (payload.group === "actor" && payload.code === "action_timed_out") { - return { - ...payload, - public: true, - statusCode: 408, - }; - } - - if (payload.group === "actor" && payload.code === "aborted") { - return { - ...payload, - public: true, - statusCode: 400, - }; - } - - if ( - payload.group === "message" && - (payload.code === "incoming_too_long" || - payload.code === "outgoing_too_long") - ) { - return { - ...payload, - public: true, - statusCode: 400, - }; - } - - if ( - payload.group === "queue" && - [ - "full", - "message_too_large", - "message_invalid", - "invalid_payload", - "invalid_completion_payload", - "already_completed", - "previous_message_not_completed", - "complete_not_configured", - "timed_out", - ].includes(payload.code) - ) { - return { - ...payload, - public: true, - statusCode: 400, - }; - } - - return payload; -} - export function isRivetErrorLike( error: unknown, ): error is RivetError | DeconstructedError | RivetErrorLike { @@ -197,8 +122,7 @@ export class RivetError extends Error { this.code = code; this.public = normalized.public ?? false; this.metadata = normalized.metadata; - this.statusCode = - normalized.statusCode ?? (this.public ? 400 : 500); + this.statusCode = normalized.statusCode ?? (this.public ? 400 : 500); } toString() { @@ -269,19 +193,15 @@ export function encodeBridgeRivetError(error: RivetErrorLike): string { })}`; } -export function decodeBridgeRivetError( - value: string, -): RivetError | undefined { +export function decodeBridgeRivetError(value: string): RivetError | undefined { if (!value.startsWith(BRIDGE_RIVET_ERROR_PREFIX)) { return undefined; } try { - const payload = normalizeDecodedBridgePayload( - JSON.parse( + const payload = JSON.parse( value.slice(BRIDGE_RIVET_ERROR_PREFIX.length), - ) as RivetErrorLike, - ); + ) as RivetErrorLike; if (!isRivetErrorLike(payload)) { return undefined; } @@ -301,7 +221,9 @@ export function isRivetErrorCode( group: string, code: string, ): error is RivetError { - return isRivetErrorLike(error) && error.group === group && error.code === code; + return ( + isRivetErrorLike(error) && error.group === group && error.code === code + ); } export function internalError( diff --git a/rivetkit-typescript/packages/rivetkit/src/registry/native.ts b/rivetkit-typescript/packages/rivetkit/src/registry/native.ts index 899bffa495..a2e58c7c73 100644 --- a/rivetkit-typescript/packages/rivetkit/src/registry/native.ts +++ b/rivetkit-typescript/packages/rivetkit/src/registry/native.ts @@ -594,111 +594,14 @@ function unwrapTsfnPayload(error: unknown, payload: T): T { } function normalizeNativeBridgeError(error: unknown): unknown { - const promoteKnownBridgeError = (value: unknown): unknown => { - if (!isRivetErrorLike(value)) { - return value; - } - - if ( - value.group === "auth" && - value.code === "forbidden" && - (!value.public || value.statusCode === 500) - ) { - return new RivetError(value.group, value.code, value.message, { - public: true, - statusCode: 403, - metadata: value.metadata, - cause: value instanceof Error ? value.cause : undefined, - }); - } - - if ( - value.group === "actor" && - value.code === "action_not_found" && - (!value.public || value.statusCode === 500) - ) { - return new RivetError(value.group, value.code, value.message, { - public: true, - statusCode: 404, - metadata: value.metadata, - cause: value instanceof Error ? value.cause : undefined, - }); - } - - if ( - value.group === "actor" && - value.code === "action_timed_out" && - (!value.public || value.statusCode === 500) - ) { - return new RivetError(value.group, value.code, value.message, { - public: true, - statusCode: 408, - metadata: value.metadata, - cause: value instanceof Error ? value.cause : undefined, - }); - } - - if ( - value.group === "actor" && - value.code === "aborted" && - (!value.public || value.statusCode === 500) - ) { - return new RivetError(value.group, value.code, value.message, { - public: true, - statusCode: 400, - metadata: value.metadata, - cause: value instanceof Error ? value.cause : undefined, - }); - } - - if ( - value.group === "message" && - (value.code === "incoming_too_long" || - value.code === "outgoing_too_long") && - (!value.public || value.statusCode === 500) - ) { - return new RivetError(value.group, value.code, value.message, { - public: true, - statusCode: 400, - metadata: value.metadata, - cause: value instanceof Error ? value.cause : undefined, - }); - } - - if ( - value.group === "queue" && - [ - "full", - "message_too_large", - "message_invalid", - "invalid_payload", - "invalid_completion_payload", - "already_completed", - "previous_message_not_completed", - "complete_not_configured", - "timed_out", - ].includes(value.code) && - (!value.public || value.statusCode === 500) - ) { - return new RivetError(value.group, value.code, value.message, { - public: true, - statusCode: 400, - metadata: value.metadata, - cause: value instanceof Error ? value.cause : undefined, - }); - } - - return value; - }; - if (typeof error === "string") { - return promoteKnownBridgeError(decodeBridgeRivetError(error) ?? error); + return decodeBridgeRivetError(error) ?? error; } if (error instanceof Error) { const bridged = decodeBridgeRivetError(error.message); if (bridged) { - return promoteKnownBridgeError(bridged); + return bridged; } } @@ -710,11 +613,11 @@ function normalizeNativeBridgeError(error: unknown): unknown { ) { const bridged = decodeBridgeRivetError(error.reason); if (bridged) { - return promoteKnownBridgeError(bridged); + return bridged; } } - return promoteKnownBridgeError(error); + return error; } function isStructuredBridgeError( diff --git a/rivetkit-typescript/packages/rivetkit/src/registry/wasm-runtime.ts b/rivetkit-typescript/packages/rivetkit/src/registry/wasm-runtime.ts index bb57f100e2..9628107047 100644 --- a/rivetkit-typescript/packages/rivetkit/src/registry/wasm-runtime.ts +++ b/rivetkit-typescript/packages/rivetkit/src/registry/wasm-runtime.ts @@ -6,11 +6,7 @@ import type { CoreRegistry as WasmCoreRegistry, WebSocketHandle as WasmWebSocketHandle, } from "@rivetkit/rivetkit-wasm"; -import { - decodeBridgeRivetError, - isRivetErrorLike, - RivetError, -} from "@/actor/errors"; +import { decodeBridgeRivetError, RivetError } from "@/actor/errors"; import type { WasmRuntimeBindings, WasmRuntimeConfig, @@ -140,112 +136,15 @@ function normalizeQueueMessage( }; } -function promoteKnownBridgeError(value: unknown): unknown { - if (!isRivetErrorLike(value)) { - return value; - } - - if ( - value.group === "auth" && - value.code === "forbidden" && - (!value.public || value.statusCode === 500) - ) { - return new RivetError(value.group, value.code, value.message, { - public: true, - statusCode: 403, - metadata: value.metadata, - cause: value instanceof Error ? value.cause : undefined, - }); - } - - if ( - value.group === "actor" && - value.code === "action_not_found" && - (!value.public || value.statusCode === 500) - ) { - return new RivetError(value.group, value.code, value.message, { - public: true, - statusCode: 404, - metadata: value.metadata, - cause: value instanceof Error ? value.cause : undefined, - }); - } - - if ( - value.group === "actor" && - value.code === "action_timed_out" && - (!value.public || value.statusCode === 500) - ) { - return new RivetError(value.group, value.code, value.message, { - public: true, - statusCode: 408, - metadata: value.metadata, - cause: value instanceof Error ? value.cause : undefined, - }); - } - - if ( - value.group === "actor" && - value.code === "aborted" && - (!value.public || value.statusCode === 500) - ) { - return new RivetError(value.group, value.code, value.message, { - public: true, - statusCode: 400, - metadata: value.metadata, - cause: value instanceof Error ? value.cause : undefined, - }); - } - - if ( - value.group === "message" && - (value.code === "incoming_too_long" || - value.code === "outgoing_too_long") && - (!value.public || value.statusCode === 500) - ) { - return new RivetError(value.group, value.code, value.message, { - public: true, - statusCode: 400, - metadata: value.metadata, - cause: value instanceof Error ? value.cause : undefined, - }); - } - - if ( - value.group === "queue" && - [ - "full", - "message_too_large", - "message_invalid", - "invalid_payload", - "invalid_completion_payload", - "already_completed", - "previous_message_not_completed", - "complete_not_configured", - "timed_out", - ].includes(value.code) && - (!value.public || value.statusCode === 500) - ) { - return new RivetError(value.group, value.code, value.message, { - public: true, - statusCode: 400, - metadata: value.metadata, - cause: value instanceof Error ? value.cause : undefined, - }); - } - - return value; -} - function normalizeWasmBridgeError(error: unknown): unknown { if (typeof error === "string") { - return promoteKnownBridgeError(decodeBridgeRivetError(error) ?? error); + return decodeBridgeRivetError(error) ?? error; } if (error instanceof Error) { const bridged = decodeBridgeRivetError(error.message); if (bridged) { - return promoteKnownBridgeError(bridged); + return bridged; } } @@ -257,11 +156,11 @@ function normalizeWasmBridgeError(error: unknown): unknown { ) { const bridged = decodeBridgeRivetError(error.reason); if (bridged) { - return promoteKnownBridgeError(bridged); + return bridged; } } - return promoteKnownBridgeError(error); + return error; } async function callWasm(invoke: () => Promise): Promise { diff --git a/rivetkit-typescript/packages/rivetkit/tests/runtime-parity.test.ts b/rivetkit-typescript/packages/rivetkit/tests/runtime-parity.test.ts new file mode 100644 index 0000000000..12e508e1d3 --- /dev/null +++ b/rivetkit-typescript/packages/rivetkit/tests/runtime-parity.test.ts @@ -0,0 +1,440 @@ +import { describe, expect, test } from "vitest"; +import { + BRIDGE_RIVET_ERROR_PREFIX, + decodeBridgeRivetError, + type RivetError, +} from "@/actor/errors"; +import { actor } from "@/actor/mod"; +import { type RegistryConfig, RegistryConfigSchema } from "@/registry/config"; +import { NapiCoreRuntime } from "@/registry/napi-runtime"; +import { buildNativeFactory } from "@/registry/native"; +import type { + ActorContextHandle, + CoreRuntime, + RuntimeServeConfig, +} from "@/registry/runtime"; +import { type WasmBindings, WasmCoreRuntime } from "@/registry/wasm-runtime"; +import { decodeCborCompat, encodeCborCompat } from "@/serde"; + +const serveConfig: RuntimeServeConfig = { + version: 4, + endpoint: "https://api.rivet.dev", + token: "parity-token", + namespace: "parity-namespace", + poolName: "parity-pool", + serverlessPackageVersion: "0.0.0", + serverlessValidateEndpoint: true, + serverlessMaxStartPayloadBytes: 1024, +}; + +type NativeCallbacks = { + createState?: ( + error: unknown, + payload: { + ctx: ActorContextHandle; + input?: Uint8Array; + }, + ) => Promise; + actions: Record< + string, + ( + error: unknown, + payload: { + ctx: ActorContextHandle; + conn: null; + name: string; + args: Uint8Array; + cancelToken: FakeCancellationToken; + }, + ) => Promise + >; +}; + +type RuntimeCase = { + kind: CoreRuntime["kind"]; + runtime: CoreRuntime; + scenario: ParityScenario; +}; + +type PromotedStatusCase = { + group: string; + code: string; + statusCode: number; +}; + +class Gate { + #started!: () => void; + #released!: () => void; + + readonly started = new Promise((resolve) => { + this.#started = resolve; + }); + readonly released = new Promise((resolve) => { + this.#released = resolve; + }); + + markStarted(): void { + this.#started(); + } + + release(): void { + this.#released(); + } +} + +class ParityScenario { + readonly save = new Gate(); + readonly registerTask = new Gate(); + readonly saves: unknown[] = []; + registerTaskCompleted = false; +} + +class FakeActorContext { + stateBytes = Buffer.alloc(0); + readonly runtimeBag = {}; + readonly registeredTasks: Array> = []; + readonly abortController = new AbortController(); + + constructor( + private readonly scenario: ParityScenario, + private readonly saveError?: PromotedStatusCase, + ) {} + + state(): Buffer { + return this.stateBytes; + } + + beginOnStateChange(): void {} + + endOnStateChange(): void {} + + requestSave(opts?: unknown): void { + this.scenario.saves.push(opts); + } + + async requestSaveAndWait(opts?: unknown): Promise { + this.scenario.saves.push(opts); + if (this.saveError) { + throw bridgeError(this.saveError); + } + this.scenario.save.markStarted(); + await this.scenario.save.released; + } + + registerTask(promise: Promise): void { + this.registeredTasks.push(Promise.resolve(promise).then(() => {})); + } + + async drainRegisteredTasks(): Promise { + while (this.registeredTasks.length > 0) { + const tasks = this.registeredTasks.splice(0); + await Promise.all(tasks); + } + } + + runtimeState(): object { + return this.runtimeBag; + } + + actorId(): string { + return "parity-actor"; + } + + name(): string { + return "parity"; + } + + key(): Array<{ kind: string; stringValue: string }> { + return [{ kind: "string", stringValue: "key" }]; + } + + region(): string { + return "local"; + } + + conns(): unknown[] { + return []; + } + + abortSignal(): AbortSignal { + return this.abortController.signal; + } +} + +class FakeCancellationToken { + #cancelled = false; + #callbacks: Array<() => void> = []; + + aborted(): boolean { + return this.#cancelled; + } + + cancel(): void { + this.#cancelled = true; + for (const callback of this.#callbacks) { + callback(); + } + } + + onCancelled(callback: () => void): void { + this.#callbacks.push(callback); + } +} + +class FakeActorFactory { + constructor( + readonly callbacks: NativeCallbacks, + readonly config: Record | null | undefined, + ) {} +} + +class FakeCoreRegistry { + readonly registered = new Map(); + activeCtx?: FakeActorContext; + + constructor(private readonly scenario: ParityScenario) {} + + register(name: string, factory: FakeActorFactory): void { + this.registered.set(name, factory); + } + + async serve(): Promise { + const factory = this.registered.get("parity"); + if (!factory) { + throw new Error("parity actor was not registered"); + } + + const ctx = new FakeActorContext(this.scenario); + this.activeCtx = ctx; + const stateBytes = await factory.callbacks.createState?.(null, { + ctx, + input: encodeValue(null), + }); + ctx.stateBytes = Buffer.from(stateBytes ?? encodeValue({ count: 0 })); + + let actionSettled = false; + const actionPromise = factory.callbacks.actions.lifecycle(null, { + ctx, + conn: null, + name: "lifecycle", + args: encodeValue([]), + cancelToken: new FakeCancellationToken(), + }); + void actionPromise.finally(() => { + actionSettled = true; + }); + + await this.scenario.save.started; + await Promise.resolve(); + expect(actionSettled).toBe(false); + this.scenario.save.release(); + + expect(decodeValue<{ count: number }>(await actionPromise)).toEqual({ + count: 1, + }); + } + + async shutdown(): Promise { + await this.activeCtx?.drainRegisteredTasks(); + } +} + +function bridgeError(error: PromotedStatusCase): Error { + return new Error( + `${BRIDGE_RIVET_ERROR_PREFIX}${JSON.stringify({ + group: error.group, + code: error.code, + message: `${error.group}.${error.code}`, + metadata: null, + public: true, + statusCode: error.statusCode, + })}`, + ); +} + +function encodeValue(value: unknown): Uint8Array { + return encodeCborCompat(value); +} + +function decodeValue(value: Uint8Array): T { + return decodeCborCompat(value); +} + +function fakeNapiBindings(scenario: ParityScenario) { + return { + CoreRegistry: class extends FakeCoreRegistry { + constructor() { + super(scenario); + } + }, + NapiActorFactory: FakeActorFactory, + CancellationToken: FakeCancellationToken, + ActorContext: class {}, + }; +} + +function fakeWasmBindings(scenario: ParityScenario): WasmBindings { + return { + CoreRegistry: class extends FakeCoreRegistry { + constructor() { + super(scenario); + } + }, + ActorFactory: FakeActorFactory, + CancellationToken: FakeCancellationToken, + ActorContext: class {}, + ConnHandle: class {}, + WebSocketHandle: class {}, + bridgeRivetErrorPrefix: () => BRIDGE_RIVET_ERROR_PREFIX, + roundTripBytes: (bytes: Uint8Array) => bytes, + uint8ArrayFromBytes: (bytes: Uint8Array) => bytes, + awaitPromise: async (promise: Promise) => await promise, + default: async () => {}, + } as unknown as WasmBindings; +} + +function createRuntimeCase(kind: CoreRuntime["kind"]): RuntimeCase { + const scenario = new ParityScenario(); + return { + kind, + scenario, + runtime: + kind === "napi" + ? new NapiCoreRuntime(fakeNapiBindings(scenario) as never) + : new WasmCoreRuntime(fakeWasmBindings(scenario)), + }; +} + +function registryConfig(definition: ReturnType): RegistryConfig { + return RegistryConfigSchema.parse({ + use: { parity: definition }, + endpoint: serveConfig.endpoint, + token: serveConfig.token, + namespace: serveConfig.namespace, + noWelcome: true, + startEngine: false, + test: { + enabled: true, + sqliteBackend: "remote", + }, + }); +} + +async function runLifecycleScenario(runtimeCase: RuntimeCase): Promise { + const { runtime, scenario } = runtimeCase; + const registry = runtime.createRegistry(); + const definition = actor({ + state: { count: 0 }, + actions: { + lifecycle: async (c) => { + c.state.count += 1; + await c.saveState({ immediate: true }); + void ( + c as unknown as { + internalKeepAwake(run: () => Promise): Promise; + } + ).internalKeepAwake(async () => { + scenario.registerTask.markStarted(); + await scenario.registerTask.released; + scenario.registerTaskCompleted = true; + }); + return { count: c.state.count }; + }, + }, + }); + + runtime.registerActor( + registry, + "parity", + buildNativeFactory(runtime, registryConfig(definition), definition), + ); + await runtime.serveRegistry(registry, serveConfig); + await scenario.registerTask.started; + expect(scenario.saves).toContainEqual({ immediate: true }); + + let shutdownSettled = false; + const shutdownPromise = runtime.shutdownRegistry(registry).then(() => { + shutdownSettled = true; + }); + await Promise.resolve(); + expect(shutdownSettled).toBe(false); + expect(scenario.registerTaskCompleted).toBe(false); + + scenario.registerTask.release(); + await shutdownPromise; + expect(shutdownSettled).toBe(true); + expect(scenario.registerTaskCompleted).toBe(true); +} + +async function invokePromotedStatus( + runtimeCase: RuntimeCase, + promoted: PromotedStatusCase, +): Promise { + const { runtime, scenario } = runtimeCase; + const definition = actor({ + state: {}, + actions: { + status: async (c) => { + await c.saveState({ immediate: true }); + }, + }, + }); + const factory = buildNativeFactory( + runtime, + registryConfig(definition), + definition, + ) as unknown as FakeActorFactory; + const ctx = new FakeActorContext(scenario, promoted); + const stateBytes = await factory.callbacks.createState?.(null, { + ctx, + input: encodeValue(null), + }); + ctx.stateBytes = Buffer.from(stateBytes ?? encodeValue({})); + + try { + await factory.callbacks.actions.status(null, { + ctx, + conn: null, + name: "status", + args: encodeValue([]), + cancelToken: new FakeCancellationToken(), + }); + throw new Error("expected status action to fail"); + } catch (error) { + if (!(error instanceof Error)) { + throw error; + } + const decoded = decodeBridgeRivetError(error.message); + if (!decoded) { + throw error; + } + return decoded; + } +} + +describe("CoreRuntime NAPI and wasm parity", () => { + test.each([ + "napi", + "wasm", + ] as const)("%s waits for durable saves and drains registered tasks", async (kind) => { + await runLifecycleScenario(createRuntimeCase(kind)); + }); + + test.each([ + { group: "auth", code: "forbidden", statusCode: 403 }, + { group: "actor", code: "action_not_found", statusCode: 404 }, + { group: "actor", code: "action_timed_out", statusCode: 408 }, + ])("preserves promoted $group.$code statusCode across NAPI and wasm", async (promoted) => { + const nativeError = await invokePromotedStatus( + createRuntimeCase("napi"), + promoted, + ); + const wasmError = await invokePromotedStatus( + createRuntimeCase("wasm"), + promoted, + ); + + expect(nativeError).toMatchObject(promoted); + expect(wasmError).toMatchObject(promoted); + }); +}); diff --git a/rivetkit-typescript/packages/rivetkit/tests/wasm-host-smoke.test.ts b/rivetkit-typescript/packages/rivetkit/tests/wasm-host-smoke.test.ts index 3b985d18f4..3a6162ae92 100644 --- a/rivetkit-typescript/packages/rivetkit/tests/wasm-host-smoke.test.ts +++ b/rivetkit-typescript/packages/rivetkit/tests/wasm-host-smoke.test.ts @@ -51,6 +51,9 @@ class SmokeGate { class SmokeScenario { readonly actionReconnect = new SmokeGate(); readonly remoteWriteReconnect = new SmokeGate(); + readonly save = new SmokeGate(); + readonly registerTask = new SmokeGate(); + registerTaskCompleted = false; } class SmokeHost { @@ -217,11 +220,15 @@ class SmokeKv { class SmokeActorContext { stateBytes = Buffer.alloc(0); readonly runtimeBag = {}; + readonly registeredTasks: Array> = []; readonly kvHandle: SmokeKv; readonly sqlHandle: SmokeSql; readonly abortController = new AbortController(); - constructor(private readonly host: SmokeHost) { + constructor( + private readonly host: SmokeHost, + private readonly scenario: SmokeScenario, + ) { this.kvHandle = new SmokeKv(host); this.sqlHandle = new SmokeSql(host); } @@ -240,6 +247,19 @@ class SmokeActorContext { async requestSaveAndWait(opts?: unknown): Promise { this.host.saves.push(opts); + this.scenario.save.markStarted(); + await this.scenario.save.released; + } + + registerTask(promise: Promise): void { + this.registeredTasks.push(Promise.resolve(promise).then(() => {})); + } + + async drainRegisteredTasks(): Promise { + while (this.registeredTasks.length > 0) { + const tasks = this.registeredTasks.splice(0); + await Promise.all(tasks); + } } takePendingHibernationChanges(): string[] { @@ -320,6 +340,7 @@ function fakeWasmBindings( ): WasmBindings { class FakeCoreRegistry { registered = new Map(); + activeCtx?: SmokeActorContext; register(name: string, factory: FakeActorFactory): void { this.registered.set(name, factory); @@ -337,13 +358,15 @@ function fakeWasmBindings( remoteSqlite: true, }); - const ctx = new SmokeActorContext(host); + const ctx = new SmokeActorContext(host, scenario); + this.activeCtx = ctx; const initialState = await factory.callbacks.createState(null, { ctx, input: encodeValue({ host: host.kind }), }); ctx.stateBytes = Buffer.from(initialState); + let actionSettled = false; const actionPromise = factory.callbacks.actions.smoke(null, { ctx, conn: null, @@ -351,6 +374,14 @@ function fakeWasmBindings( args: encodeValue([host.kind]), cancelToken: new FakeCancellationToken(), }); + void actionPromise.then( + () => { + actionSettled = true; + }, + () => { + actionSettled = true; + }, + ); await scenario.actionReconnect.started; host.reconnect(config, "during-action"); @@ -360,6 +391,11 @@ function fakeWasmBindings( host.reconnect(config, "during-remote-write-sql"); scenario.remoteWriteReconnect.release(); + await scenario.save.started; + await Promise.resolve(); + expect(actionSettled).toBe(false); + scenario.save.release(); + const output = decodeValue<{ stateCount: number; kvValue: string; @@ -380,7 +416,9 @@ function fakeWasmBindings( }); } - async shutdown(): Promise {} + async shutdown(): Promise { + await this.activeCtx?.drainRegisteredTasks(); + } } return { @@ -457,6 +495,15 @@ async function runHostSmoke(kind: HostKind): Promise { [label], ); await c.saveState({ immediate: true }); + void ( + c as unknown as { + internalKeepAwake(run: () => Promise): Promise; + } + ).internalKeepAwake(async () => { + scenario.registerTask.markStarted(); + await scenario.registerTask.released; + scenario.registerTaskCompleted = true; + }); return { stateCount: c.state.count, @@ -474,6 +521,20 @@ async function runHostSmoke(kind: HostKind): Promise { buildNativeFactory(runtime, config, definition), ); await runtime.serveRegistry(registry, serveConfig); + await scenario.registerTask.started; + + let shutdownSettled = false; + const shutdownPromise = runtime.shutdownRegistry(registry).then(() => { + shutdownSettled = true; + }); + await Promise.resolve(); + expect(shutdownSettled).toBe(false); + expect(scenario.registerTaskCompleted).toBe(false); + + scenario.registerTask.release(); + await shutdownPromise; + expect(shutdownSettled).toBe(true); + expect(scenario.registerTaskCompleted).toBe(true); return host; } diff --git a/scripts/ralph/.last-branch b/scripts/ralph/.last-branch index 528a3f97a7..31651f0677 100644 --- a/scripts/ralph/.last-branch +++ b/scripts/ralph/.last-branch @@ -1 +1 @@ -04-29-chore_rivetkit_wasm_support +05-02-fix_rivetkit-wasm_fix_mem_leaks diff --git a/scripts/ralph/archive/2026-05-02-04-29-chore_rivetkit_wasm_support/prd.json b/scripts/ralph/archive/2026-05-02-04-29-chore_rivetkit_wasm_support/prd.json new file mode 100644 index 0000000000..5f26f9c7d6 --- /dev/null +++ b/scripts/ralph/archive/2026-05-02-04-29-chore_rivetkit_wasm_support/prd.json @@ -0,0 +1,120 @@ +{ + "project": "RivetKit Wasm Support Review Fixes", + "branchName": "05-02-fix_rivetkit-wasm_fix_mem_leaks", + "description": "Address correctness, durability, parity, and build robustness issues identified in code review of PR #4860 (chore(rivetkit): wasm support). Each story targets one concrete defect or divergence between the wasm and NAPI runtimes so the wasm path matches the NAPI contract that the rest of rivetkit-core relies on.", + "userStories": [ + { + "id": "US-001", + "title": "Make WasmActorContext.requestSaveAndWait actually wait", + "description": "As an actor author running on wasm, I want `ctx.requestSaveAndWait({ immediate: true })` to resolve only after the save has completed so durability promises hold on Cloudflare Workers and Supabase Functions.", + "acceptanceCriteria": [ + "`WasmActorContext::request_save_and_wait` in `rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs` calls `self.inner.request_save_and_wait(opts).await` instead of `self.inner.request_save(opts)`", + "Errors from core `request_save_and_wait` propagate to JS via `anyhow_to_js_error` so the bridged RivetError is preserved", + "NAPI and wasm both expose the same `requestSaveAndWait` semantics (resolves after save completes, rejects on save failure)", + "Add or extend a wasm host smoke test in `rivetkit-typescript/packages/rivetkit/tests/wasm-host-smoke.test.ts` that verifies `requestSaveAndWait` does not resolve before the save completes", + "Typecheck passes", + "Tests pass" + ], + "priority": 1, + "passes": false, + "notes": "" + }, + { + "id": "US-002", + "title": "Cache bridged RivetErrorSchema to stop per-error memory leak", + "description": "As an operator running a long-lived wasm worker, I want bridged JS error decoding to stop leaking unbounded `String`s and `RivetErrorSchema` boxes so memory does not grow with bridged-error volume.", + "acceptanceCriteria": [ + "`bridge_rivet_error_schema` in `rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs` deduplicates schema entries by `(group, code, default_message)` so each unique tuple is leaked at most once for the process lifetime", + "Use a process-global cache (for example `OnceCell>` or equivalent allowed concurrent map) instead of `Box::leak` on every call", + "`parse_bridge_rivet_error` continues to return an `anyhow::Error` carrying a `RivetTransportError` plus `BridgeRivetErrorContext`", + "Add a wasm crate test that decodes the same bridge error payload many times and asserts only one schema instance is interned", + "Typecheck passes", + "Tests pass" + ], + "priority": 2, + "passes": false, + "notes": "" + }, + { + "id": "US-003", + "title": "Reject engine_binary_path on wasm runtimes", + "description": "As a wasm user, I want a clear error if I pass `engine_binary_path` so I do not get an obscure failure deep in core when wasm cannot spawn an engine binary anyway.", + "acceptanceCriteria": [ + "`From for ServeConfig` in `rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs` returns or surfaces a typed error when `engine_binary_path` is set", + "Or the wasm `serve` and `serverless_runtime` entrypoints validate `engine_binary_path.is_none()` up front and return a `RivetError` with group `wasm` and an actionable message", + "Error metadata includes the rejected field name so users can find it", + "Add a wasm crate test that calls `serve` with `engine_binary_path: Some(...)` and expects the typed configuration error", + "Indentation inside the `From for ServeConfig` struct literal is corrected to single tabs", + "Typecheck passes", + "Tests pass" + ], + "priority": 3, + "passes": false, + "notes": "" + }, + { + "id": "US-004", + "title": "Wire wasm registerTask to core register_task", + "description": "As an actor on wasm, I want `ctx.registerTask(promise)` to behave the same as on NAPI so sleep counters and shutdown semantics stay consistent between runtimes.", + "acceptanceCriteria": [ + "`WasmActorContext::register_task` in `rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs` calls the dedicated `register_task` path on `rivetkit_core::ActorContext` instead of forwarding to `wait_until`", + "If core does not yet expose a wasm-friendly `register_task`, expose one in `rivetkit-rust/packages/rivetkit-core/src/actor/context.rs` behind the existing `wasm-runtime` feature flag", + "Sleep counter and shutdown drain semantics for `registerTask` match between NAPI and wasm", + "Add a wasm host smoke test that exercises `registerTask` and asserts the registered task is awaited during shutdown drain", + "Typecheck passes", + "Tests pass" + ], + "priority": 4, + "passes": false, + "notes": "" + }, + { + "id": "US-005", + "title": "Move bridge error status promotion into rivetkit-core", + "description": "As a maintainer, I want HTTP status promotion for known RivetError codes to live in core so NAPI and wasm return identical statusCodes for the same underlying error, per the single-source-of-truth rule in CLAUDE.md.", + "acceptanceCriteria": [ + "Identify the canonical promotion list currently in `promoteKnownBridgeError` in `rivetkit-typescript/packages/rivetkit/src/registry/wasm-runtime.ts`", + "Move the `(group, code) -> statusCode` mapping into `rivetkit-rust/packages/rivetkit-core/src/error.rs` or the existing RivetError build path so core sets `statusCode` correctly on extract", + "Remove `promoteKnownBridgeError` from `wasm-runtime.ts` (and any `callWasm` wrapping that exists only to call it)", + "Add a unit test (Rust or TS) that decodes a bridged `auth.forbidden` and asserts statusCode 403 from both NAPI and wasm paths", + "Typecheck passes", + "Tests pass" + ], + "priority": 5, + "passes": false, + "notes": "" + }, + { + "id": "US-006", + "title": "Pin wasm-pack instead of fetching via npx -y", + "description": "As a release engineer, I want the wasm publish job to not depend on a live npm registry fetch on every build so a flaky network does not block publishes.", + "acceptanceCriteria": [ + "`rivetkit-typescript/packages/rivetkit-wasm/scripts/build.mjs` no longer relies on `npx -y wasm-pack`", + "Add `wasm-pack` as a pinned `devDependency` in `rivetkit-typescript/packages/rivetkit-wasm/package.json` or invoke a vendored binary", + "Build script resolves the pinned `wasm-pack` binary path locally and falls back with an explicit error message if missing, rather than silently re-fetching", + "`pnpm --filter @rivetkit/rivetkit-wasm build` succeeds offline once dependencies are installed", + "`pnpm --filter @rivetkit/rivetkit-wasm run check:package` still passes", + "Typecheck passes" + ], + "priority": 6, + "passes": false, + "notes": "" + }, + { + "id": "US-007", + "title": "Add wasm-runtime parity tests for save, registerTask, status promotion", + "description": "As a maintainer, I want a parity test that runs the same actor scenarios under NAPI and wasm so future regressions in either adapter are caught.", + "acceptanceCriteria": [ + "Add a parity test file under `rivetkit-typescript/packages/rivetkit/tests/` that runs the same scenario through `NapiCoreRuntime` and `WasmCoreRuntime` (using the existing wasm host smoke harness)", + "Cover: durable save via `requestSaveAndWait`, `registerTask` shutdown drain, and HTTP statusCode promotion for at least `auth.forbidden`, `actor.action_not_found`, `actor.action_timed_out`", + "Each scenario asserts the same observable result on both runtimes", + "Tests skip gracefully if the wasm package artifact is absent rather than failing CI on environments without `wasm-pack`", + "Typecheck passes", + "Tests pass" + ], + "priority": 7, + "passes": false, + "notes": "" + } + ] +} diff --git a/scripts/ralph/archive/2026-05-02-04-29-chore_rivetkit_wasm_support/progress.txt b/scripts/ralph/archive/2026-05-02-04-29-chore_rivetkit_wasm_support/progress.txt new file mode 100644 index 0000000000..8386e1e3e4 --- /dev/null +++ b/scripts/ralph/archive/2026-05-02-04-29-chore_rivetkit_wasm_support/progress.txt @@ -0,0 +1,12 @@ +# Ralph Progress Log + +## Codebase Patterns +- Current branch: `04-29-chore_rivetkit_wasm_support`. +- The NAPI and wasm TypeScript adapters implement the shared `CoreRuntime` contract in `rivetkit-typescript/packages/rivetkit/src/registry/`. +- Keep raw `@rivetkit/rivetkit-napi` and `@rivetkit/rivetkit-wasm` imports inside runtime adapter modules (`napi-runtime.ts`, `wasm-runtime.ts`); enforced by `tests/runtime-import-guard.test.ts`. +- Wasm cannot use local SQLite. Valid SQLite runtime cells are native/local, native/remote, and wasm/remote. +- Cross-boundary error sanitization belongs in `rivetkit-core`. The TS bridge passes raw errors through; status code promotion should not live in `wasm-runtime.ts`. +- Use `rivetkit-typescript/packages/rivetkit/tests/shared-engine.ts` for any TS test that needs a local `rivet-engine`. +- The wasm crate uses `RuntimeSpawner::spawn` which maps to `tokio::task::spawn_local` under `wasm-runtime` and `tokio::spawn` otherwise. +- Bridged JS errors round-trip via `__RIVET_ERROR_JSON__:` prefix in the message string; decoded with `parse_bridge_rivet_error` and re-encoded with `anyhow_to_js_error`. +- Wasm `JsValue` callbacks are not Send/Sync; `WasmFunction` uses an unsafe Send/Sync impl because the wasm runtime is single-threaded. diff --git a/scripts/ralph/archive/2026-05-02-wasm-implementation/prd.json b/scripts/ralph/archive/2026-05-02-wasm-implementation/prd.json new file mode 100644 index 0000000000..99894bb656 --- /dev/null +++ b/scripts/ralph/archive/2026-05-02-wasm-implementation/prd.json @@ -0,0 +1,337 @@ +{ + "project": "RivetKit Runtime Boundary Cleanup", + "branchName": "04-29-chore_rivetkit_wasm_support", + "description": "Clean up the RivetKit TypeScript runtime boundary so NAPI and WebAssembly use the same portable CoreRuntime contract, wasm initialization is explicit, invalid SQLite/runtime combinations fail clearly, and Cloudflare Workers, Supabase Functions, and Deno are covered by first-class platform smoke tests.", + "userStories": [ + { + "id": "US-001", + "title": "Extract shared engine test harness", + "description": "As a test maintainer, I need the driver suite and platform tests to share one engine startup mechanism so that platform smoke tests do not duplicate process and port management.", + "acceptanceCriteria": [ + "Extract the engine start, health check, ref-count, and release logic from `rivetkit-typescript/packages/rivetkit/tests/driver/shared-harness.ts` into a shared test utility if it cannot be reused directly", + "Update driver tests to keep using the same engine behavior through the shared utility or existing exported helper", + "Expose a helper that platform tests can use to start and release a local `rivet-engine`", + "Do not introduce a second independent engine launcher for platform tests", + "Typecheck passes", + "Tests pass" + ], + "priority": 1, + "passes": true, + "notes": "" + }, + { + "id": "US-002", + "title": "Use runtime.kind for runtime normalization", + "description": "As a maintainer, I want runtime selection to depend on the `CoreRuntime` contract rather than concrete adapter classes so that duplicate modules and future adapters remain compatible.", + "acceptanceCriteria": [ + "`loadedRuntimeKind` switches on `runtime.kind`", + "No production runtime selection logic depends on `instanceof NapiCoreRuntime` or `instanceof WasmCoreRuntime`", + "Tests can use plain object `CoreRuntime` fakes with `kind: \"napi\"` or `kind: \"wasm\"`", + "Config resolution order remains setup config, then `RIVETKIT_RUNTIME`, then `auto`", + "Typecheck passes", + "Tests pass" + ], + "priority": 2, + "passes": true, + "notes": "" + }, + { + "id": "US-003", + "title": "Define portable SQL runtime types", + "description": "As a runtime adapter author, I need SQL params and results to use shared plain TypeScript structs so that NAPI and wasm do not depend on NAPI-specific database types.", + "acceptanceCriteria": [ + "`rivetkit-typescript/packages/rivetkit/src/registry/runtime.ts` no longer imports `JsNativeDatabaseLike`", + "Define explicit runtime SQL bind param, query result, execute result, and run result types using portable values and `Uint8Array` for blobs", + "NAPI and wasm SQL adapters both implement the same explicit SQL result types", + "Existing `wrapJsNativeDatabase` behavior remains unchanged for user-facing database APIs", + "Bigint, boolean, string, number, null, undefined, and `Uint8Array` SQL parameter normalization still works", + "User-facing SQL integer result behavior remains unchanged from the current TypeScript API", + "Typecheck passes", + "Tests pass" + ], + "priority": 3, + "passes": true, + "notes": "" + }, + { + "id": "US-004", + "title": "Replace CoreRuntime Buffer fields with Uint8Array", + "description": "As an edge runtime user, I want the shared CoreRuntime byte boundary to use portable byte types so that wasm hosts do not need Node `Buffer` globals.", + "acceptanceCriteria": [ + "`rivetkit-typescript/packages/rivetkit/src/registry/runtime.ts` no longer references `Buffer` in the `CoreRuntime` boundary", + "HTTP bodies, state deltas, KV keys and values, queue payloads, SQL blobs, websocket binary messages, connection bytes, and inspector bytes use `Uint8Array` or a portable alias", + "NAPI-only `Buffer` conversion remains inside `NapiCoreRuntime` where native bindings require it", + "Wasm runtime boundary normalization does not require `Buffer.from`, `Buffer.alloc`, or `Buffer.isBuffer`", + "Typecheck passes", + "Tests pass" + ], + "priority": 4, + "passes": true, + "notes": "" + }, + { + "id": "US-005", + "title": "Add explicit wasm bindings loader config", + "description": "As an edge runtime integrator, I want wasm bindings passed through configuration so that Cloudflare, Supabase, and Deno do not depend on hidden global mutation.", + "acceptanceCriteria": [ + "Add a typed `wasm.bindings?: typeof import(\"@rivetkit/rivetkit-wasm\")` field to RivetKit TypeScript registry config", + "`loadWasmRuntime` uses configured `wasm.bindings` before falling back to importing `@rivetkit/rivetkit-wasm`", + "`wasm.initInput` continues to accept Worker-friendly and Deno-friendly wasm module, bytes, URL, or response inputs", + "`loadWasmRuntime` does not read `globalThis.__rivetkitWasmBindings`", + "Add tests proving configured bindings are used instead of hidden globals", + "Typecheck passes", + "Tests pass" + ], + "priority": 5, + "passes": true, + "notes": "" + }, + { + "id": "US-006", + "title": "Publish one public wasm package import path", + "description": "As an application developer, I want `@rivetkit/rivetkit-wasm` to expose a supported public import path so that platform apps do not import repo-relative generated files.", + "acceptanceCriteria": [ + "`@rivetkit/rivetkit-wasm` exposes one default public import path that can be passed as `wasm.bindings`", + "`package.json` exports and files include the JavaScript, declaration, and wasm artifacts needed by the public import path", + "Do not add `@rivetkit/rivetkit-wasm/cloudflare` or `@rivetkit/rivetkit-wasm/deno` exports unless platform tests prove the single export cannot work", + "If a specialized export becomes necessary, document the packaging failure that requires it", + "No platform app imports repo-relative `pkg`, `pkg-deno`, or `dist/tsup` paths", + "Typecheck passes", + "Tests pass" + ], + "priority": 6, + "passes": true, + "notes": "" + }, + { + "id": "US-007", + "title": "Make wasm serverless startup concurrency-safe", + "description": "As a serverless runtime maintainer, I want concurrent first requests to share wasm serverless startup so that edge hosts do not fail during cold-start races.", + "acceptanceCriteria": [ + "Wasm registry implements a `BuildingServerless` equivalent to match the NAPI serverless state pattern", + "Concurrent first serverless requests share one build or wait for the build to finish instead of returning an already-serving or wrong-mode error", + "Shutdown during wasm serverless build leaves the registry in a terminal state and does not orphan a newly built runtime", + "NAPI and wasm return equivalent wrong-mode or shutdown errors for serve/serverless mode conflicts", + "Add focused tests for concurrent first serverless requests and shutdown during build using deterministic ordering where needed", + "Typecheck passes", + "Tests pass" + ], + "priority": 7, + "passes": true, + "notes": "" + }, + { + "id": "US-008", + "title": "Restore wasm queue API parity", + "description": "As a Rivet Actor developer, I want queue APIs to return the same values through NAPI and wasm so that runtime selection does not change behavior.", + "acceptanceCriteria": [ + "`WasmQueue.maxSize()` returns the real core queue max size instead of `0`", + "Add parity coverage for queue max size through both NAPI and wasm adapters", + "Unsupported wasm runtime methods fail with an explicit structured unsupported-runtime error", + "Typecheck passes", + "Tests pass" + ], + "priority": 8, + "passes": true, + "notes": "" + }, + { + "id": "US-009", + "title": "Fail fast for explicit wasm local SQLite", + "description": "As a test runner user, I want impossible wasm/local SQLite selections to fail clearly so that requested coverage is not silently dropped.", + "acceptanceCriteria": [ + "Default driver matrix excludes `runtime=wasm` with `sqlite=local`", + "`RIVETKIT_DRIVER_TEST_RUNTIME=wasm` with `RIVETKIT_DRIVER_TEST_SQLITE=local` fails fast with a clear configuration error", + "Valid matrix cells remain native/local/all encodings, native/remote/all encodings, and wasm/remote/all encodings", + "`shared-matrix.test.ts` asserts the fail-fast behavior for explicit wasm/local selection", + "Typecheck passes", + "Tests pass" + ], + "priority": 9, + "passes": true, + "notes": "" + }, + { + "id": "US-010", + "title": "Enforce wasm SQLite config invariants in setup", + "description": "As a RivetKit user, I want `setup()` to reject local SQLite for wasm so that the unsupported configuration fails before runtime work begins.", + "acceptanceCriteria": [ + "`setup({ runtime: \"wasm\" })` defaults SQLite to remote when SQLite config is unset", + "`setup({ runtime: \"wasm\", sqlite: remote })` is allowed", + "`setup({ runtime: \"wasm\", sqlite: local })` fails with a clear configuration error", + "Native runtime keeps its current SQLite default and allows both local and remote SQLite", + "Add tests for wasm default remote SQLite, wasm remote allowed, wasm local rejected, and native behavior unchanged", + "Typecheck passes", + "Tests pass" + ], + "priority": 10, + "passes": true, + "notes": "" + }, + { + "id": "US-011", + "title": "Add shared platform SQLite counter registry", + "description": "As a platform test maintainer, I need one tiny shared registry so that Cloudflare, Supabase, and Deno tests verify the same public wasm API without duplicating actor code.", + "acceptanceCriteria": [ + "Create `rivetkit-typescript/packages/rivetkit/tests/platforms/shared-registry.ts`", + "Define a SQLite-backed counter actor with `increment` and `getCount` actions", + "The actor uses raw SQL rather than Drizzle", + "The registry factory uses public `setup({ runtime: \"wasm\", wasm: { bindings, initInput }, use })` shape", + "The registry uses remote SQLite and does not allow local SQLite", + "Typecheck passes", + "Tests pass" + ], + "priority": 11, + "passes": true, + "notes": "" + }, + { + "id": "US-012", + "title": "Add shared platform test harness", + "description": "As a platform test maintainer, I need common process and client helpers so each platform smoke test can focus on its host runtime.", + "acceptanceCriteria": [ + "Create `rivetkit-typescript/packages/rivetkit/tests/platforms/shared-platform-harness.ts`", + "The harness can create a namespace and serverless runner config against the shared test engine", + "The harness can create a RivetKit client for the shared SQLite counter registry", + "The harness provides helpers for temporary app directories, child process logging, health checks, and cleanup", + "Platform CLI commands are launched through pinned `pnpm dlx` versions where a CLI is needed", + "Platform tests are exposed through an explicit script such as `test:platforms` and are not included in the default test command", + "Typecheck passes", + "Tests pass" + ], + "priority": 12, + "passes": true, + "notes": "" + }, + { + "id": "US-013", + "title": "Add Cloudflare Workers wasm platform smoke test", + "description": "As a release owner, I want a real local Cloudflare Workers smoke test so that wasm runtime packaging works in workerd.", + "acceptanceCriteria": [ + "Create `rivetkit-typescript/packages/rivetkit/tests/platforms/cloudflare-workers.test.ts`", + "The test runs real local workerd through pinned `pnpm dlx wrangler@... dev --local`", + "The test imports only public `rivetkit` and `@rivetkit/rivetkit-wasm` package exports from platform app code", + "The test performs multiple requests to the same SQLite counter actor and verifies persisted readback", + "The test verifies actor sleep and wake for the SQLite counter actor", + "The test runs multiple separate actor IDs in parallel on the same platform instance", + "The test does not cover raw HTTP, raw WebSocket, workflows, queues, or the full driver suite", + "Typecheck passes", + "Tests pass" + ], + "priority": 13, + "passes": true, + "notes": "" + }, + { + "id": "US-014", + "title": "Add Deno wasm platform smoke test", + "description": "As a release owner, I want a plain Deno smoke test so that wasm runtime packaging works without the Supabase CLI wrapper.", + "acceptanceCriteria": [ + "Create `rivetkit-typescript/packages/rivetkit/tests/platforms/deno.test.ts`", + "The test runs the platform app with real local Deno and does not use the Supabase CLI wrapper", + "The test imports only public `rivetkit` and `@rivetkit/rivetkit-wasm` package exports from platform app code", + "The test performs multiple requests to the same SQLite counter actor and verifies persisted readback", + "The test verifies actor sleep and wake for the SQLite counter actor", + "The test runs multiple separate actor IDs in parallel on the same platform instance", + "The test does not cover raw HTTP, raw WebSocket, workflows, queues, or the full driver suite", + "Typecheck passes", + "Tests pass" + ], + "priority": 14, + "passes": true, + "notes": "" + }, + { + "id": "US-015", + "title": "Add Supabase Functions wasm platform smoke test", + "description": "As a release owner, I want a real local Supabase Functions smoke test so that wasm runtime packaging works through `supabase functions serve`.", + "acceptanceCriteria": [ + "Create `rivetkit-typescript/packages/rivetkit/tests/platforms/supabase-functions.test.ts`", + "The test runs real local Supabase Functions through pinned `pnpm dlx supabase@... functions serve`", + "The test imports only public `rivetkit` and `@rivetkit/rivetkit-wasm` package exports from function app code", + "The test performs multiple requests to the same SQLite counter actor and verifies persisted readback", + "The test verifies actor sleep and wake for the SQLite counter actor", + "The test runs multiple separate actor IDs in parallel on the same platform instance", + "The test does not cover raw HTTP, raw WebSocket, workflows, queues, or the full driver suite", + "Typecheck passes", + "Tests pass" + ], + "priority": 15, + "passes": true, + "notes": "" + }, + { + "id": "US-016", + "title": "Document wasm runtime setup for Cloudflare and Supabase", + "description": "As an application developer, I want public docs for wasm runtime setup on Cloudflare Workers and Supabase Functions so that I can copy the same API used by the tests.", + "acceptanceCriteria": [ + "Update quickstart docs to point users at edge and serverless wasm setup", + "Add or update `website/src/content/docs/connect/cloudflare.mdx` for Cloudflare Workers", + "Replace the placeholder in `website/src/content/docs/connect/supabase.mdx` with Supabase Edge Functions setup", + "Update the sidebar source used by `website/src/sitemap/mod.ts` if a new Connect page is added", + "Docs show `setup({ runtime: \"wasm\", wasm: { bindings, initInput }, use })`", + "Docs explain that wasm cannot use local SQLite and defaults to remote SQLite when SQLite config is unset", + "Docs mention `runtime: \"wasm\"` and `RIVETKIT_RUNTIME=wasm`", + "Docs do not mention hidden globals, private generated paths, or lower-level registry builders", + "Quickstart and Connect pages link to each other where appropriate", + "Typecheck passes" + ], + "priority": 16, + "passes": true, + "notes": "" + }, + { + "id": "US-017", + "title": "Remove Buffer from shared actor runtime glue", + "description": "As an edge runtime maintainer, I want the shared TypeScript actor glue to use portable bytes so that wasm hosts do not rely on Node `Buffer` outside the NAPI adapter.", + "acceptanceCriteria": [ + "Audit `rivetkit-typescript/packages/rivetkit/src/registry/native.ts` for `Buffer` usage that is part of shared actor glue rather than NAPI-only adapter conversion", + "Replace shared actor glue byte construction and decoding with `Uint8Array`, `ArrayBuffer`, `TextEncoder`, `TextDecoder`, or portable helper functions", + "Keep required Node `Buffer` conversion inside `rivetkit-typescript/packages/rivetkit/src/registry/napi-runtime.ts` or clearly NAPI-only code paths", + "`WasmCoreRuntime` and wasm platform registry paths do not require `globalThis.Buffer`", + "Add or update tests that exercise wasm runtime behavior without installing a `Buffer` global", + "Typecheck passes", + "Tests pass" + ], + "priority": 17, + "passes": true, + "notes": "" + }, + { + "id": "US-018", + "title": "Tighten runtime SQL boundary types", + "description": "As a runtime adapter author, I want SQL boundary types to be exact discriminated unions so that NAPI and wasm cannot accidentally pass malformed SQL params or route strings.", + "acceptanceCriteria": [ + "Change `RuntimeSqlBindParam` to a discriminated union with exactly one value field per kind", + "Use `RuntimeBytes` or `Uint8Array` consistently for SQL blob params and results", + "Change `RuntimeSqlExecuteResult.route` to the exact union `\"read\" | \"write\" | \"writeFallback\"`", + "Update NAPI and wasm SQL adapters to satisfy the stricter runtime SQL types without casts that hide invalid shapes", + "Add type or unit coverage for null, int, float, text, blob, and route result normalization", + "User-facing SQL integer result behavior remains unchanged from the current TypeScript API", + "Typecheck passes", + "Tests pass" + ], + "priority": 18, + "passes": true, + "notes": "" + }, + { + "id": "US-019", + "title": "Make platform fixtures match public docs code", + "description": "As an application developer, I want platform smoke fixtures to use the same user-friendly code shown in docs so that tests validate the copy-paste Cloudflare, Supabase, and Deno setup.", + "acceptanceCriteria": [ + "Platform app code does not import helper names like `createPlatformSqliteCounterRegistry` from test-only modules", + "Each generated platform app includes a docs-shaped registry file that imports `actor`, `setup`, and `@rivetkit/rivetkit-wasm` directly through public package exports", + "Each generated platform app calls `setup({ runtime: \"wasm\", wasm: { bindings, initInput }, sqlite: \"remote\", use })` or the documented equivalent inline", + "Shared test utilities may generate or copy the docs-shaped registry source, but the app code itself must look like user documentation rather than a test harness API", + "Cloudflare Workers, Supabase Functions, and Deno platform tests all use the same docs-shaped SQLite counter actor source with only platform bootstrapping differences", + "No platform app uses hidden globals, lower-level registry builders, private generated wasm paths, or test-only registry wrappers", + "Typecheck passes", + "Tests pass" + ], + "priority": 19, + "passes": true, + "notes": "" + } + ] +} diff --git a/scripts/ralph/archive/2026-05-02-wasm-implementation/progress.txt b/scripts/ralph/archive/2026-05-02-wasm-implementation/progress.txt new file mode 100644 index 0000000000..e2736160c3 --- /dev/null +++ b/scripts/ralph/archive/2026-05-02-wasm-implementation/progress.txt @@ -0,0 +1,266 @@ +# Ralph Progress Log + +## Codebase Patterns +- Current branch: `04-29-chore_rivetkit_wasm_support`. +- The NAPI and wasm TypeScript adapters implement the shared `CoreRuntime` contract in `rivetkit-typescript/packages/rivetkit/src/registry/`. +- Keep raw `@rivetkit/rivetkit-napi` and `@rivetkit/rivetkit-wasm` imports inside runtime adapter modules or explicit edge entrypoints. +- Wasm cannot use local SQLite. Valid SQLite runtime cells are native/local, native/remote, and wasm/remote. +- Edge smoke coverage should eventually validate public package exports, not only repo-relative generated wasm-pack output. +- Reuse `rivetkit-typescript/packages/rivetkit/tests/shared-engine.ts` for TypeScript tests that need a local `rivet-engine`; do not add separate engine launchers in driver or platform tests. +- Runtime normalization should use `CoreRuntime.kind`, not adapter `instanceof` checks. Map `kind: "napi"` to native and `kind: "wasm"` to wasm. +- `CoreRuntime` SQL methods use the portable `RuntimeSql*` structs from `src/registry/runtime.ts`; keep NAPI `Buffer` conversion inside `NapiCoreRuntime`. +- Keep runtime SQL bind params as exact discriminated unions and normalize adapter execute routes to `read`, `write`, or `writeFallback`. +- `CoreRuntime` byte payloads use `RuntimeBytes`/`Uint8Array`; keep Node `Buffer` conversion inside `NapiCoreRuntime` and out of `wasm-runtime.ts`. +- Shared actor glue in `src/registry/native.ts` should construct `RuntimeBytes`/`Uint8Array`; leave Node `Buffer` creation to `NapiCoreRuntime`. +- Pass wasm bindings through `setup({ wasm: { bindings, initInput } })`; do not rely on hidden `globalThis` wasm binding hooks. +- Use `pnpm --filter @rivetkit/rivetkit-wasm run check:package` after wasm package export/files changes; wasm-pack's generated `.gitignore` can otherwise hide required `pkg` artifacts from npm tarballs. +- Wasm `CoreRegistry` serverless startup uses a `BuildingServerless` waiter state; shutdown during build must wake waiters and drain any newly built runtime. +- Wasm bindings should forward supported parity APIs to `rivetkit-core`; do not leave placeholder returns for NAPI-supported APIs. +- Driver matrix env overrides that explicitly request `runtime=wasm` with `sqlite=local` should fail fast in `tests/driver/shared-matrix.ts`. +- Use public `setup({ sqlite: "local" | "remote" })` for runtime SQLite backend selection; wasm defaults unset SQLite to remote and rejects local during config parsing. +- Platform wasm smoke clients can use `tests/platforms/shared-registry.ts` for registry typing, while generated platform apps should use docs-shaped local registry source. +- Platform smoke tests should use `tests/platforms/shared-platform-harness.ts` for shared engine namespaces, serverless runner configs, clients, temp app dirs, health checks, child logs, and pinned `pnpm dlx` launches. +- Use `buildPlatformSqliteCounterRegistrySource(...)` to generate the shared docs-shaped platform SQLite counter registry source for Cloudflare, Deno, and Supabase apps. +- Platform tests that import public package exports must build `rivetkit` first because package exports point at `dist/tsup`. +- Raw `ctx.sql` platform fixtures still need a `db` provider so runtime SQLite is enabled. +- Cloudflare Workers platform fixtures need a fetch-upgrade `WebSocket` shim for wasm envoy connections. +- Do not duplicate engine-owned serverless start headers in platform runner config; Cloudflare may combine duplicate headers into comma-separated values. +- Avoid `sqlite_` table prefixes in platform SQLite fixtures because SQLite reserves them. +- Deno platform fixtures need `--allow-sys` because public `rivetkit` imports `pino`, which reads `os.hostname()`. +- Deno platform fixtures can pass wasm bytes from the public `@rivetkit/rivetkit-wasm/rivetkit_wasm_bg.wasm` export using `import.meta.resolve` plus `Deno.readFile`. +- Supabase Functions platform fixtures run inside Docker; advertise local engines through the Docker bridge IP when `docker0` exists and fall back to `host.docker.internal`. +- Supabase Functions Edge Runtime needs function-local package metadata and copied package trees for public bare package imports. +- Supabase Functions fixtures should use Edge Runtime `per_worker` policy and avoid serverless runner prewarm settings so long-lived `/start` streams coexist with metadata and wake requests. +- Connect docs cards and sidebar entries are generated from `frontend/packages/shared-data/src/deploy.ts`. +- Website docs code-block typechecking runs during `pnpm --filter rivet-website build`; `c.kv.listRange` and `c.kv.deleteRange` range bounds must be `Uint8Array`. + +Started: Fri May 01 2026 +--- + +## 2026-05-01 21:56 PDT - US-013 +- Added a real Cloudflare Workers wasm platform smoke test that launches pinned local `wrangler dev --local`. +- The generated Worker app imports only public `rivetkit` and `@rivetkit/rivetkit-wasm` exports, wires wasm through public `setup`, and exercises SQLite counter persistence, cold wake, and parallel actor IDs. +- Added the Cloudflare fetch-upgrade `WebSocket` shim, public wasm asset export, serverless header cleanup, and SQLite fixture adjustments needed for the workerd runtime. +- Files changed: `rivetkit-typescript/packages/rivetkit/tests/platforms/cloudflare-workers.test.ts`, `rivetkit-typescript/packages/rivetkit/tests/platforms/shared-platform-harness.ts`, `rivetkit-typescript/packages/rivetkit/tests/platforms/shared-registry.ts`, `rivetkit-typescript/packages/rivetkit/tests/platforms/CLAUDE.md`, `rivetkit-typescript/packages/rivetkit/tests/platforms/AGENTS.md`, `rivetkit-typescript/packages/rivetkit/package.json`, `rivetkit-typescript/packages/rivetkit-wasm/package.json`, `rivetkit-typescript/packages/rivetkit/tests/shared-engine.ts`, `rivetkit-rust/packages/rivetkit-core/src/serverless.rs`, `rivetkit-rust/packages/rivetkit-core/tests/serverless.rs`, `rivetkit-rust/packages/rivetkit-core/tests/context.rs`, `rivetkit-rust/packages/rivetkit-core/tests/sleep.rs`, `rivetkit-rust/packages/rivetkit-core/tests/task.rs`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. +- Checks: `pnpm --filter rivetkit run test:platforms` passed; `pnpm --filter rivetkit run check-types` passed; `pnpm --filter @rivetkit/rivetkit-wasm run check:package` passed; `pnpm --filter @rivetkit/rivetkit-wasm run check-types` passed; `cargo test -p rivetkit-core matches_combined_duplicate_endpoint_headers` passed. +- **Learnings for future iterations:** + - Cloudflare workerd does not provide a browser-compatible `new WebSocket("ws://...")` path, so the fixture must bridge wasm envoy connections through `fetch(..., { headers: { Upgrade: "websocket" } })`. + - Platform app code that uses `ctx.sql` must still declare `db` so the actor config enables runtime SQLite. + - The platform test command should build first because generated apps resolve public package exports to `dist/tsup`. + - Serverless start headers should be owned by the engine and harness, not duplicated in platform runner config. +--- + +## 2026-05-01 19:50 PDT - US-001 +- Extracted the shared local `rivet-engine` lifecycle from the driver harness into `rivetkit-typescript/packages/rivetkit/tests/shared-engine.ts`. +- Kept driver runtime setup on the existing `shared-harness.ts` API by delegating `getOrStartSharedEngine` and `releaseSharedEngine` to the shared utility. +- Added a reusable harness note to `rivetkit-typescript/AGENTS.md` and marked US-001 passing in `prd.json`. +- Files changed: `rivetkit-typescript/packages/rivetkit/tests/shared-engine.ts`, `rivetkit-typescript/packages/rivetkit/tests/driver/shared-harness.ts`, `rivetkit-typescript/AGENTS.md`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. +- Checks: targeted Biome passed for touched files; `pnpm run check-types` passed in `rivetkit-typescript/packages/rivetkit`; `pnpm exec vitest run tests/driver/shared-matrix.test.ts` passed. Full `pnpm run lint` is blocked by existing fixture lint failures outside this story. +- **Learnings for future iterations:** + - Use `tests/shared-engine.ts` for any TypeScript test that needs a shared local `rivet-engine`. + - `driver/shared-harness.ts` should stay focused on namespace, runner config, and driver runtime process setup. + - Package-wide lint currently reports many pre-existing issues under `tests/fixtures/driver-test-suite`. +--- + +## 2026-05-01 19:53 PDT - US-002 +- Updated runtime normalization to switch on the portable `CoreRuntime.kind` field instead of concrete NAPI/wasm adapter classes. +- Added runtime-selection coverage for plain object `CoreRuntime` fakes with `kind: "napi"` and `kind: "wasm"`. +- Kept config precedence behavior covered by the existing setup config, `RIVETKIT_RUNTIME`, and auto-selection tests. +- Files changed: `rivetkit-typescript/packages/rivetkit/src/registry/native.ts`, `rivetkit-typescript/packages/rivetkit/tests/runtime-selection.test.ts`, `rivetkit-typescript/CLAUDE.md` via the `AGENTS.md` symlink, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. +- Checks: `pnpm exec biome check src/registry/native.ts tests/runtime-selection.test.ts` passed; `pnpm exec vitest run tests/runtime-selection.test.ts` passed; `pnpm run check-types` passed. +- **Learnings for future iterations:** + - `CoreRuntime.kind` is the stable adapter boundary for runtime-specific behavior; avoid class identity checks so duplicate modules and future adapters still work. + - The public config still calls the NAPI runtime `native`, while the portable runtime contract uses `kind: "napi"`. + - `native.ts` may contain older lint-sensitive code, so touched-file Biome checks can surface nearby cleanup needs. +--- + +## 2026-05-01 19:57 PDT - US-003 +- Defined explicit portable SQL bind, query, execute, exec, run, and database types in `rivetkit-typescript/packages/rivetkit/src/registry/runtime.ts`. +- Updated NAPI and wasm SQL adapters to implement the shared runtime SQL shape, with NAPI converting `Uint8Array` blob params to `Buffer` only at the native binding call. +- Added bind normalization coverage for bigint, boolean, string, number, null, undefined, and `Uint8Array`. +- Files changed: `rivetkit-typescript/packages/rivetkit/src/registry/runtime.ts`, `rivetkit-typescript/packages/rivetkit/src/registry/napi-runtime.ts`, `rivetkit-typescript/packages/rivetkit/src/registry/wasm-runtime.ts`, `rivetkit-typescript/packages/rivetkit/src/common/database/native-database.test.ts`, `rivetkit-typescript/CLAUDE.md` via the `AGENTS.md` symlink, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. +- Checks: `pnpm exec biome check src/registry/runtime.ts src/registry/napi-runtime.ts src/registry/wasm-runtime.ts src/common/database/native-database.test.ts` passed; `pnpm run check-types` passed; `pnpm exec vitest run src/common/database/native-database.test.ts` passed; `pnpm exec vitest run tests/wasm-host-smoke.test.ts` passed. +- **Learnings for future iterations:** + - The shared runtime SQL boundary should stay independent of `JsNativeDatabaseLike`. + - NAPI generated bindings still use `Buffer` for SQL blobs, so adapt at the NAPI runtime edge instead of changing `CoreRuntime`. + - `wrapJsNativeDatabase` remains the user-facing SQL behavior boundary and is the right place to guard bind normalization. +--- + +## 2026-05-01 20:01 PDT - US-004 +- Replaced the shared `CoreRuntime` byte boundary with `RuntimeBytes`/`Uint8Array` for HTTP bodies, state deltas, KV keys and values, queue payloads, websocket bytes, connection bytes, and inspector bytes. +- Kept NAPI `Buffer` coercion inside `NapiCoreRuntime`, including serverless requests, HTTP requests, state deltas, KV batches, queue completions, schedules, connections, websockets, and SQL blob params. +- Removed Node `Buffer` normalization from `wasm-runtime.ts` so wasm byte handling stays portable. +- Files changed: `rivetkit-typescript/packages/rivetkit/src/registry/runtime.ts`, `rivetkit-typescript/packages/rivetkit/src/registry/napi-runtime.ts`, `rivetkit-typescript/packages/rivetkit/src/registry/wasm-runtime.ts`, `rivetkit-typescript/packages/rivetkit/src/registry/index.ts`, `rivetkit-typescript/CLAUDE.md`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. +- Checks: `pnpm exec biome check src/registry/runtime.ts src/registry/napi-runtime.ts src/registry/wasm-runtime.ts src/registry/index.ts` passed; `pnpm run check-types` passed; `pnpm exec vitest run tests/wasm-host-smoke.test.ts tests/runtime-selection.test.ts` passed. +- **Learnings for future iterations:** + - Use `RuntimeBytes` from `src/registry/runtime.ts` for shared runtime byte payloads. + - NAPI generated bindings still require `Buffer`, so convert at the `NapiCoreRuntime` call boundary rather than widening the shared contract. + - Wasm runtime code should avoid Node byte helpers such as `Buffer.from`, `Buffer.alloc`, and `Buffer.isBuffer`. +--- + +## 2026-05-01 20:04 PDT - US-005 +- Added typed `wasm.bindings` runtime config alongside `wasm.initInput`. +- Updated runtime loading so explicit and auto wasm selection pass the full wasm config object into `loadWasmRuntime`. +- Removed production `globalThis.__rivetkitWasmBindings` lookup; configured bindings are used before falling back to the public `@rivetkit/rivetkit-wasm` import. +- Files changed: `rivetkit-typescript/packages/rivetkit/src/registry/config/index.ts`, `rivetkit-typescript/packages/rivetkit/src/registry/native.ts`, `rivetkit-typescript/packages/rivetkit/src/registry/wasm-runtime.ts`, `rivetkit-typescript/packages/rivetkit/tests/runtime-selection.test.ts`, `rivetkit-typescript/packages/rivetkit/tests/wasm-runtime.test.ts`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. +- Checks: `pnpm exec biome check src/registry/config/index.ts src/registry/native.ts src/registry/wasm-runtime.ts tests/runtime-selection.test.ts tests/wasm-runtime.test.ts` passed; `pnpm exec vitest run tests/runtime-selection.test.ts tests/wasm-runtime.test.ts` passed; `pnpm run check-types` passed. +- **Learnings for future iterations:** + - `RegistryConfigSchema` owns the typed wasm binding config. + - `loadWasmRuntime` accepts the full wasm config object so bindings and `initInput` stay paired. + - Keep platform-provided wasm bindings explicit in registry config instead of smuggling them through globals. +--- + +## 2026-05-01 20:09 PDT - US-006 +- Published the wasm package through one root public import path by keeping `@rivetkit/rivetkit-wasm` as the only package export and forwarding root declarations to the generated wasm-pack declarations. +- Fixed package contents so npm tarballs include the root JS/types, generated JS/types, and `.wasm` artifact despite wasm-pack's generated ignored `pkg/` directory. +- Added a `check:package` script to assert the published file list and a `prepack` build hook so clean package publishing regenerates wasm artifacts before packing. +- Files changed: `rivetkit-typescript/packages/rivetkit-wasm/.npmignore`, `rivetkit-typescript/packages/rivetkit-wasm/index.d.ts`, `rivetkit-typescript/packages/rivetkit-wasm/package.json`, `rivetkit-typescript/packages/rivetkit-wasm/scripts/check-package.mjs`, `rivetkit-typescript/CLAUDE.md`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. +- Checks: `pnpm --filter @rivetkit/rivetkit-wasm run check:package` passed; `pnpm --filter @rivetkit/rivetkit-wasm run check-types` passed; `pnpm --filter @rivetkit/rivetkit-wasm run check:wasm` passed with existing warnings in `rivetkit-core`; `pnpm --filter rivetkit run check-types` passed; `pnpm --filter rivetkit exec vitest run tests/wasm-runtime.test.ts tests/runtime-selection.test.ts` passed; public root import smoke via Node passed; `SKIP_WASM_BUILD=1 npm pack --dry-run` passed. +- **Learnings for future iterations:** + - `@rivetkit/rivetkit-wasm` should expose only the root export; do not add platform-specific or generated subpath exports unless a platform test proves the root path cannot work. + - wasm-pack writes `pkg/.gitignore` with `*`, so npm package contents need an explicit `.npmignore` override plus a tarball check. + - Root package declarations can forward to `./pkg/rivetkit_wasm.js` when the tarball includes the generated `.d.ts` files. +--- + +## 2026-05-01 20:15 PDT - US-007 +- Implemented wasm `CoreRegistry` serverless startup concurrency with a `BuildingServerless` state, waiters, structured invalid-state errors, and shutdown cleanup for runtimes built after cancellation. +- Added focused wasm runtime tests for concurrent first serverless requests, shutdown during serverless build, and structured wrong-mode errors. +- Added the generated `wasm.invalid_state` error artifact and a reusable wasm binding note in `rivetkit-typescript/AGENTS.md`. +- Files changed: `rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs`, `rivetkit-typescript/packages/rivetkit/tests/wasm-runtime.test.ts`, `rivetkit-typescript/CLAUDE.md`, `engine/artifacts/errors/wasm.invalid_state.json`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. +- Checks: `pnpm --filter @rivetkit/rivetkit-wasm run check:wasm` passed with existing `rivetkit-core` warnings; `pnpm --filter @rivetkit/rivetkit-wasm run check-types` passed; `pnpm --filter rivetkit run check-types` passed; `pnpm --filter rivetkit exec biome check tests/wasm-runtime.test.ts` passed; `pnpm --filter rivetkit exec vitest run tests/wasm-runtime.test.ts` passed. +- **Learnings for future iterations:** + - Use a waiter state rather than a temporary `Serving` state during wasm serverless runtime construction. + - Build failures and shutdown during wasm serverless startup must transition the registry to a terminal state and wake all waiters. + - New `RivetError` derives create `engine/artifacts/errors/*.json` files that should be committed with the source change. +--- + +## 2026-05-01 20:17 PDT - US-008 +- Restored wasm queue max-size parity by forwarding `WasmQueue.maxSize()` to the core queue config instead of returning `0`. +- Added adapter parity coverage proving NAPI and wasm both read queue max size through the shared runtime boundary. +- Made missing wasm runtime methods throw a structured `runtime.unsupported` `RivetError` with runtime and method metadata. +- Files changed: `rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs`, `rivetkit-typescript/packages/rivetkit/src/registry/wasm-runtime.ts`, `rivetkit-typescript/packages/rivetkit/tests/wasm-runtime.test.ts`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. +- Checks: `pnpm --filter @rivetkit/rivetkit-wasm run check:wasm` passed with existing `rivetkit-core` warnings; `pnpm --filter rivetkit exec biome check src/registry/wasm-runtime.ts tests/wasm-runtime.test.ts` passed; `pnpm --filter rivetkit run check-types` passed; `pnpm --filter rivetkit exec vitest run tests/wasm-runtime.test.ts` passed. +- **Learnings for future iterations:** + - `WasmQueue` should expose the same supported queue surface as NAPI by forwarding to `rivetkit-core::ActorContext`. + - Use focused adapter tests with fake actor contexts when parity behavior lives in the TypeScript runtime boundary. + - Missing wasm binding methods should fail as structured `runtime.unsupported` errors with the missing method name in metadata. +--- + +## 2026-05-01 20:20 PDT - US-009 +- Implemented fail-fast validation for explicit `RIVETKIT_DRIVER_TEST_RUNTIME=wasm` plus `RIVETKIT_DRIVER_TEST_SQLITE=local` driver matrix overrides. +- Added shared matrix tests covering the exact valid SQLite matrix cells and the explicit wasm/local configuration error. +- Marked US-009 passing in `prd.json`. +- Files changed: `rivetkit-typescript/packages/rivetkit/tests/driver/shared-matrix.ts`, `rivetkit-typescript/packages/rivetkit/tests/driver/shared-matrix.test.ts`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. +- Checks: `pnpm --filter rivetkit exec biome check tests/driver/shared-matrix.ts tests/driver/shared-matrix.test.ts` passed; `pnpm --filter rivetkit exec vitest run tests/driver/shared-matrix.test.ts` passed; `pnpm --filter rivetkit run check-types` passed. +- **Learnings for future iterations:** + - The normal SQLite driver matrix still filters unsupported wasm/local cells so native/local, native/remote, and wasm/remote coverage remains. + - Treat env matrix overrides as requested coverage. If they name an unsupported cell, throw instead of silently filtering it out. + - Keep driver matrix behavior covered in `tests/driver/shared-matrix.test.ts` because it is fast and does not need the shared engine. +--- + +## 2026-05-01 20:23 PDT - US-010 +- Added public `sqlite` registry config for selecting the runtime SQLite backend. +- Made explicit wasm/local SQLite fail during config parsing, while unset wasm SQLite defaults to remote and native keeps its previous default. +- Preserved the internal `test.sqliteBackend` path for existing driver coverage while routing runtime backend decisions through one helper. +- Files changed: `rivetkit-typescript/packages/rivetkit/src/registry/config/index.ts`, `rivetkit-typescript/packages/rivetkit/src/registry/native.ts`, `rivetkit-typescript/packages/rivetkit/tests/runtime-selection.test.ts`, `rivetkit-typescript/CLAUDE.md`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. +- Checks: `pnpm exec biome check src/registry/config/index.ts src/registry/native.ts tests/runtime-selection.test.ts` passed; `pnpm exec vitest run tests/runtime-selection.test.ts` passed; `pnpm run check-types` passed. +- **Learnings for future iterations:** + - Public setup config should use `sqlite: "local" | "remote"` for backend selection; the schema also normalizes object form to `{ backend }`. + - Keep `test.sqliteBackend` as a driver/test hook, but production runtime decisions should prefer public `sqlite.backend`. + - Explicit `runtime: "wasm"` is validated at config parse time; auto-selected wasm is guarded again during runtime normalization. +--- + +## 2026-05-01 20:26 PDT - US-011 +- Added `rivetkit-typescript/packages/rivetkit/tests/platforms/shared-registry.ts` with a raw-SQL SQLite counter actor exposing `increment` and `getCount`. +- Added a public-shape registry factory that requires explicit wasm bindings/init input, hardcodes `runtime: "wasm"`, and hardcodes remote SQLite with no local SQLite option. +- Files changed: `rivetkit-typescript/packages/rivetkit/tests/platforms/shared-registry.ts`, `rivetkit-typescript/CLAUDE.md`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. +- Checks: `pnpm exec biome check tests/platforms/shared-registry.ts` passed; `pnpm run check-types` passed; `pnpm exec tsx -e "import('./tests/platforms/shared-registry.ts').then(() => undefined)"` passed. +- **Learnings for future iterations:** + - Platform smoke tests should share the `sqliteCounter` actor from `tests/platforms/shared-registry.ts` instead of duplicating counter actor code per host. + - The shared platform registry intentionally omits `sqlite`, `runtime`, `test`, `use`, and `wasm` from caller options so tests cannot accidentally enable local SQLite or private test config. + - Use `ctx.sql` for this platform counter because it keeps the app import surface to public `rivetkit` plus explicit wasm bindings and avoids the Drizzle/database-provider subpaths. +--- + +## 2026-05-01 20:37 PDT - US-012 +- Added `rivetkit-typescript/packages/rivetkit/tests/platforms/shared-platform-harness.ts` with helpers for shared engine reuse, namespace creation, serverless runner config, SQLite counter clients, temp app dirs, logged child processes, health checks, and pinned `pnpm dlx` CLI launches. +- Added fast harness coverage in `tests/platforms/shared-platform-harness.test.ts`. +- Added `pnpm run test:platforms` and excluded `tests/platforms/**/*.test.ts` from default Vitest unless `RIVETKIT_INCLUDE_PLATFORM_TESTS=1` is set. +- Files changed: `rivetkit-typescript/packages/rivetkit/tests/platforms/shared-platform-harness.ts`, `rivetkit-typescript/packages/rivetkit/tests/platforms/shared-platform-harness.test.ts`, `rivetkit-typescript/packages/rivetkit/tests/platforms/shared-registry.ts`, `rivetkit-typescript/packages/rivetkit/package.json`, `rivetkit-typescript/packages/rivetkit/vitest.config.ts`, `rivetkit-typescript/CLAUDE.md`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. +- Checks: `pnpm exec biome check tests/platforms/shared-platform-harness.ts tests/platforms/shared-platform-harness.test.ts tests/platforms/shared-registry.ts package.json vitest.config.ts` passed; `pnpm run check-types` passed; `pnpm run test:platforms` passed; `pnpm exec tsx -e "import('./tests/platforms/shared-platform-harness.ts').then(() => undefined)"` passed; `pnpm exec vitest run tests/platforms --passWithNoTests` confirmed platform tests are excluded by default. A broader `pnpm exec vitest run --passWithNoTests` was stopped after several minutes in unrelated driver coverage. +- **Learnings for future iterations:** + - Use `createPlatformServerlessRunner(...)` to create a namespace and serverless runner config against the shared local engine. + - Use `createPlatformSqliteCounterClient(...)` with the returned runner when platform smoke tests need the shared counter registry. + - Launch platform CLIs through `spawnPinnedPnpmDlx(...)` so test code has to name a concrete package version. +--- + +## 2026-05-01 22:05 PDT - US-014 +- Added a real local Deno wasm platform smoke test in `rivetkit-typescript/packages/rivetkit/tests/platforms/deno.test.ts`. +- The generated Deno app imports only public `rivetkit` and `@rivetkit/rivetkit-wasm` exports, passes wasm bytes through public setup config, and exercises SQLite counter persistence, sleep/wake, and parallel actor IDs. +- Marked US-014 passing in `scripts/ralph/prd.json`. +- Files changed: `rivetkit-typescript/packages/rivetkit/tests/platforms/deno.test.ts`, `rivetkit-typescript/packages/rivetkit/tests/platforms/CLAUDE.md`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. +- Checks: `pnpm exec biome check tests/platforms/deno.test.ts` passed; `RIVETKIT_INCLUDE_PLATFORM_TESTS=1 pnpm exec vitest run tests/platforms/deno.test.ts` passed; `pnpm run check-types` passed; `pnpm run test:platforms` passed. +- **Learnings for future iterations:** + - Deno can resolve the local workspace packages through symlinked `node_modules` with `--node-modules-dir=manual`. + - The Deno app needs `--allow-sys` because `pino` reads the host name during public `rivetkit` import. + - Use the public wasm asset export with `import.meta.resolve("@rivetkit/rivetkit-wasm/rivetkit_wasm_bg.wasm")` and `Deno.readFile(...)` instead of importing generated `pkg` paths. +--- + +## 2026-05-01 23:11 PDT - US-015 +- Added a real local Supabase Functions wasm platform smoke test driven by pinned `pnpm dlx supabase@2.95.4 functions serve`. +- The generated function app imports only public `rivetkit` and `@rivetkit/rivetkit-wasm` exports, wires wasm bytes through public setup config, and exercises SQLite counter persistence, idle sleep/wake, and parallel actor IDs. +- Added Supabase-specific engine/network/package handling for Dockerized Edge Runtime and marked US-015 passing in `prd.json`. +- Files changed: `rivetkit-typescript/packages/rivetkit/tests/platforms/supabase-functions.test.ts`, `rivetkit-typescript/packages/rivetkit/tests/platforms/CLAUDE.md`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. +- Checks: `pnpm exec biome check tests/platforms/supabase-functions.test.ts tests/platforms/CLAUDE.md` passed; `RIVETKIT_INCLUDE_PLATFORM_TESTS=1 pnpm exec vitest run tests/platforms/supabase-functions.test.ts` passed; `pnpm run check-types` passed; `pnpm run test:platforms` passed. +- **Learnings for future iterations:** + - Supabase Edge Runtime runs in Docker, so local engine URLs must be reachable from containers; prefer the Docker bridge IP from `docker0` and fall back to `host.docker.internal`. + - Supabase Edge Runtime bare import resolution needs package metadata and real package trees next to the function entrypoint. + - Avoid serverless runner prewarm for Supabase Functions and use `per_worker`; prewarm/`oneshot` caused `/start` BOOT_ERRORs around long-lived serverless streams. +--- + +## 2026-05-01 23:21 PDT - US-016 +- Added Cloudflare Workers wasm runtime setup docs with public `rivetkit` and `@rivetkit/rivetkit-wasm` imports, explicit `setup({ runtime: "wasm", wasm: { bindings, initInput }, sqlite: "remote", use })`, and remote SQLite/runtime notes. +- Replaced the Supabase placeholder with Supabase Edge Functions wasm setup docs, including Deno wasm loading and serverless runner URL guidance. +- Added Cloudflare Workers and Supabase Functions to the Connect deploy metadata and quickstart cards, and fixed the KV range docs snippet that was blocking docs typechecking. +- Files changed: `website/src/content/docs/connect/cloudflare.mdx`, `website/src/content/docs/connect/supabase.mdx`, `website/src/content/docs/quickstart/index.mdx`, `website/src/content/docs/actors/quickstart/index.mdx`, `website/src/content/docs/actors/kv.mdx`, `frontend/packages/shared-data/src/deploy.ts`, `website/CLAUDE.md`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. +- Checks: `pnpm --filter rivet-website build` passed; `git diff --check` passed. `pnpm --filter rivet-website lint` is blocked because the website package has ESLint 9 but no `eslint.config.*` flat config. +- **Learnings for future iterations:** + - Connect docs navigation is driven by `frontend/packages/shared-data/src/deploy.ts`, so new Connect pages need a deploy option entry to appear in cards/sidebar. + - The website build is the useful docs gate because it typechecks TypeScript code blocks before building Astro pages. + - Avoid running Prettier blindly on MDX docs with nested code examples; it can rewrite code fences and example indentation in surprising ways. +--- + +## 2026-05-01 23:32 PDT - US-017 +- Removed Node `Buffer` construction from shared actor runtime glue in `registry/native.ts`; shared KV, HTTP, websocket, state, queue, and callback payloads now use `RuntimeBytes`/`Uint8Array` helpers. +- Kept NAPI `Buffer` conversion isolated to the NAPI adapter boundary and made callback error stack logging resilient when `Buffer` is unavailable. +- Added wasm runtime coverage that invokes shared actor callbacks after clearing `globalThis.Buffer`. +- Files changed: `rivetkit-typescript/packages/rivetkit/src/registry/native.ts`, `rivetkit-typescript/packages/rivetkit/src/common/utils.ts`, `rivetkit-typescript/packages/rivetkit/tests/wasm-runtime.test.ts`, `rivetkit-typescript/CLAUDE.md`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. +- Checks: `pnpm --filter rivetkit exec biome check src/registry/native.ts src/common/utils.ts tests/wasm-runtime.test.ts` passed; `pnpm --filter rivetkit exec vitest run tests/wasm-runtime.test.ts` passed; `pnpm --filter rivetkit run check-types` passed. +- **Learnings for future iterations:** + - Shared actor glue in `registry/native.ts` is used by both NAPI and wasm, so byte creation there should stay on `RuntimeBytes`/`Uint8Array` even when NAPI currently accepts Node `Buffer`. + - Tests can exercise wasm actor callback glue with a fake wasm context that implements `actorId()` and `runtimeState()`. + - Accessing `error.stack` can throw in Node source-map paths when `globalThis.Buffer` is unavailable, so stack logging should be best-effort. +--- + +## 2026-05-01 23:39 PDT - US-018 +- Tightened `RuntimeSqlBindParam` into exact discriminated union variants and limited `RuntimeSqlExecuteResult.route` to `read`, `write`, or `writeFallback`. +- Updated NAPI and wasm runtime adapters to normalize execute results before returning the shared runtime type, and tightened the local native database bind type so runtime forwarding remains type-safe. +- Added runtime SQL boundary tests for null, int, float, text, blob, and route normalization, and strengthened native database tests for exact bind shapes and unsupported route rejection. +- Files changed: `rivetkit-typescript/packages/rivetkit/src/registry/runtime.ts`, `rivetkit-typescript/packages/rivetkit/src/registry/napi-runtime.ts`, `rivetkit-typescript/packages/rivetkit/src/registry/wasm-runtime.ts`, `rivetkit-typescript/packages/rivetkit/src/registry/runtime.test.ts`, `rivetkit-typescript/packages/rivetkit/src/common/database/native-database.ts`, `rivetkit-typescript/packages/rivetkit/src/common/database/native-database.test.ts`, `rivetkit-typescript/CLAUDE.md`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. +- Checks: `pnpm --filter rivetkit exec biome check src/registry/runtime.ts src/registry/napi-runtime.ts src/registry/wasm-runtime.ts src/registry/runtime.test.ts src/common/database/native-database.ts src/common/database/native-database.test.ts` passed; `pnpm --filter rivetkit run check-types` passed; `pnpm --filter rivetkit exec vitest run src/registry/runtime.test.ts src/common/database/native-database.test.ts tests/wasm-host-smoke.test.ts` passed; `pnpm --filter rivetkit exec vitest run tests/wasm-runtime.test.ts` passed as part of an adapter run. `tests/napi-runtime-integration.test.ts` failed outside this story with `actor.validation_error: Invalid connection params`; an earlier retry was also blocked by an orphaned local engine holding the RocksDB lock. +- **Learnings for future iterations:** + - Runtime SQL bind params should be switched by `kind` at adapter edges so impossible extra value fields are compile-time errors. + - Generated NAPI SQL execute results expose route as a loose string, so normalize to the shared runtime route union before returning from `NapiCoreRuntime`. + - The NAPI integration test can leave an orphaned local `rivet-engine` holding `/home/nathan/.rivetkit/var/engine/db/LOCK` when startup fails; clean that process before rerunning engine-backed tests. +--- + +## 2026-05-01 23:48 PDT - US-019 +- Generated one shared docs-shaped SQLite counter registry source for platform apps via `buildPlatformSqliteCounterRegistrySource(...)`. +- Updated Cloudflare Workers, Deno, and Supabase Functions fixtures to write local `registry.ts` files that import public `rivetkit` and `@rivetkit/rivetkit-wasm` exports, then keep only platform bootstrapping in each app entrypoint. +- Marked US-019 passing in `prd.json` and preserved the reusable helper pattern in the platform AGENTS/CLAUDE notes. +- Files changed: `rivetkit-typescript/packages/rivetkit/tests/platforms/shared-platform-harness.ts`, `rivetkit-typescript/packages/rivetkit/tests/platforms/cloudflare-workers.test.ts`, `rivetkit-typescript/packages/rivetkit/tests/platforms/deno.test.ts`, `rivetkit-typescript/packages/rivetkit/tests/platforms/supabase-functions.test.ts`, `rivetkit-typescript/packages/rivetkit/tests/platforms/CLAUDE.md`, `rivetkit-typescript/packages/rivetkit/tests/platforms/AGENTS.md`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. +- Checks: `pnpm --filter rivetkit exec biome check tests/platforms/shared-platform-harness.ts tests/platforms/cloudflare-workers.test.ts tests/platforms/deno.test.ts tests/platforms/supabase-functions.test.ts` passed; `pnpm --filter rivetkit run check-types` passed; `pnpm --filter rivetkit run test:platforms` passed. +- **Learnings for future iterations:** + - Keep generated platform app registry code in a local app file so it can look like docs copy-paste code while still sharing one test utility source. + - Cloudflare uses the same registry source with a wasm module import; Deno and Supabase use the same source with `Deno.readFile(import.meta.resolve(...))`. + - The platform smoke suite can emit transient `actor_ready_timeout` logs during wake retries and still pass once the cold-start retry helper observes the new start request. +--- diff --git a/scripts/ralph/prd.json b/scripts/ralph/prd.json index 99894bb656..80896e1204 100644 --- a/scripts/ralph/prd.json +++ b/scripts/ralph/prd.json @@ -1,17 +1,17 @@ { - "project": "RivetKit Runtime Boundary Cleanup", - "branchName": "04-29-chore_rivetkit_wasm_support", - "description": "Clean up the RivetKit TypeScript runtime boundary so NAPI and WebAssembly use the same portable CoreRuntime contract, wasm initialization is explicit, invalid SQLite/runtime combinations fail clearly, and Cloudflare Workers, Supabase Functions, and Deno are covered by first-class platform smoke tests.", + "project": "RivetKit Wasm Support Review Fixes", + "branchName": "05-02-fix_rivetkit-wasm_fix_mem_leaks", + "description": "Address correctness, durability, parity, and build robustness issues identified in code review of PR #4860 (chore(rivetkit): wasm support). Each story targets one concrete defect or divergence between the wasm and NAPI runtimes so the wasm path matches the NAPI contract that the rest of rivetkit-core relies on.", "userStories": [ { "id": "US-001", - "title": "Extract shared engine test harness", - "description": "As a test maintainer, I need the driver suite and platform tests to share one engine startup mechanism so that platform smoke tests do not duplicate process and port management.", + "title": "Make WasmActorContext.requestSaveAndWait actually wait", + "description": "As an actor author running on wasm, I want `ctx.requestSaveAndWait({ immediate: true })` to resolve only after the save has completed so durability promises hold on Cloudflare Workers and Supabase Functions.", "acceptanceCriteria": [ - "Extract the engine start, health check, ref-count, and release logic from `rivetkit-typescript/packages/rivetkit/tests/driver/shared-harness.ts` into a shared test utility if it cannot be reused directly", - "Update driver tests to keep using the same engine behavior through the shared utility or existing exported helper", - "Expose a helper that platform tests can use to start and release a local `rivet-engine`", - "Do not introduce a second independent engine launcher for platform tests", + "`WasmActorContext::request_save_and_wait` in `rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs` calls `self.inner.request_save_and_wait(opts).await` instead of `self.inner.request_save(opts)`", + "Errors from core `request_save_and_wait` propagate to JS via `anyhow_to_js_error` so the bridged RivetError is preserved", + "NAPI and wasm both expose the same `requestSaveAndWait` semantics (resolves after save completes, rejects on save failure)", + "Add or extend a wasm host smoke test in `rivetkit-typescript/packages/rivetkit/tests/wasm-host-smoke.test.ts` that verifies `requestSaveAndWait` does not resolve before the save completes", "Typecheck passes", "Tests pass" ], @@ -21,13 +21,13 @@ }, { "id": "US-002", - "title": "Use runtime.kind for runtime normalization", - "description": "As a maintainer, I want runtime selection to depend on the `CoreRuntime` contract rather than concrete adapter classes so that duplicate modules and future adapters remain compatible.", + "title": "Cache bridged RivetErrorSchema to stop per-error memory leak", + "description": "As an operator running a long-lived wasm worker, I want bridged JS error decoding to stop leaking unbounded `String`s and `RivetErrorSchema` boxes so memory does not grow with bridged-error volume.", "acceptanceCriteria": [ - "`loadedRuntimeKind` switches on `runtime.kind`", - "No production runtime selection logic depends on `instanceof NapiCoreRuntime` or `instanceof WasmCoreRuntime`", - "Tests can use plain object `CoreRuntime` fakes with `kind: \"napi\"` or `kind: \"wasm\"`", - "Config resolution order remains setup config, then `RIVETKIT_RUNTIME`, then `auto`", + "`bridge_rivet_error_schema` in `rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs` deduplicates schema entries by `(group, code, default_message)` so each unique tuple is leaked at most once for the process lifetime", + "Use a process-global cache (for example `OnceCell>` or equivalent allowed concurrent map) instead of `Box::leak` on every call", + "`parse_bridge_rivet_error` continues to return an `anyhow::Error` carrying a `RivetTransportError` plus `BridgeRivetErrorContext`", + "Add a wasm crate test that decodes the same bridge error payload many times and asserts only one schema instance is interned", "Typecheck passes", "Tests pass" ], @@ -37,15 +37,14 @@ }, { "id": "US-003", - "title": "Define portable SQL runtime types", - "description": "As a runtime adapter author, I need SQL params and results to use shared plain TypeScript structs so that NAPI and wasm do not depend on NAPI-specific database types.", + "title": "Reject engine_binary_path on wasm runtimes", + "description": "As a wasm user, I want a clear error if I pass `engine_binary_path` so I do not get an obscure failure deep in core when wasm cannot spawn an engine binary anyway.", "acceptanceCriteria": [ - "`rivetkit-typescript/packages/rivetkit/src/registry/runtime.ts` no longer imports `JsNativeDatabaseLike`", - "Define explicit runtime SQL bind param, query result, execute result, and run result types using portable values and `Uint8Array` for blobs", - "NAPI and wasm SQL adapters both implement the same explicit SQL result types", - "Existing `wrapJsNativeDatabase` behavior remains unchanged for user-facing database APIs", - "Bigint, boolean, string, number, null, undefined, and `Uint8Array` SQL parameter normalization still works", - "User-facing SQL integer result behavior remains unchanged from the current TypeScript API", + "`From for ServeConfig` in `rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs` returns or surfaces a typed error when `engine_binary_path` is set", + "Or the wasm `serve` and `serverless_runtime` entrypoints validate `engine_binary_path.is_none()` up front and return a `RivetError` with group `wasm` and an actionable message", + "Error metadata includes the rejected field name so users can find it", + "Add a wasm crate test that calls `serve` with `engine_binary_path: Some(...)` and expects the typed configuration error", + "Indentation inside the `From for ServeConfig` struct literal is corrected to single tabs", "Typecheck passes", "Tests pass" ], @@ -55,13 +54,13 @@ }, { "id": "US-004", - "title": "Replace CoreRuntime Buffer fields with Uint8Array", - "description": "As an edge runtime user, I want the shared CoreRuntime byte boundary to use portable byte types so that wasm hosts do not need Node `Buffer` globals.", + "title": "Wire wasm registerTask to core register_task", + "description": "As an actor on wasm, I want `ctx.registerTask(promise)` to behave the same as on NAPI so sleep counters and shutdown semantics stay consistent between runtimes.", "acceptanceCriteria": [ - "`rivetkit-typescript/packages/rivetkit/src/registry/runtime.ts` no longer references `Buffer` in the `CoreRuntime` boundary", - "HTTP bodies, state deltas, KV keys and values, queue payloads, SQL blobs, websocket binary messages, connection bytes, and inspector bytes use `Uint8Array` or a portable alias", - "NAPI-only `Buffer` conversion remains inside `NapiCoreRuntime` where native bindings require it", - "Wasm runtime boundary normalization does not require `Buffer.from`, `Buffer.alloc`, or `Buffer.isBuffer`", + "`WasmActorContext::register_task` in `rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs` calls the dedicated `register_task` path on `rivetkit_core::ActorContext` instead of forwarding to `wait_until`", + "If core does not yet expose a wasm-friendly `register_task`, expose one in `rivetkit-rust/packages/rivetkit-core/src/actor/context.rs` behind the existing `wasm-runtime` feature flag", + "Sleep counter and shutdown drain semantics for `registerTask` match between NAPI and wasm", + "Add a wasm host smoke test that exercises `registerTask` and asserts the registered task is awaited during shutdown drain", "Typecheck passes", "Tests pass" ], @@ -71,14 +70,13 @@ }, { "id": "US-005", - "title": "Add explicit wasm bindings loader config", - "description": "As an edge runtime integrator, I want wasm bindings passed through configuration so that Cloudflare, Supabase, and Deno do not depend on hidden global mutation.", + "title": "Move bridge error status promotion into rivetkit-core", + "description": "As a maintainer, I want HTTP status promotion for known RivetError codes to live in core so NAPI and wasm return identical statusCodes for the same underlying error, per the single-source-of-truth rule in CLAUDE.md.", "acceptanceCriteria": [ - "Add a typed `wasm.bindings?: typeof import(\"@rivetkit/rivetkit-wasm\")` field to RivetKit TypeScript registry config", - "`loadWasmRuntime` uses configured `wasm.bindings` before falling back to importing `@rivetkit/rivetkit-wasm`", - "`wasm.initInput` continues to accept Worker-friendly and Deno-friendly wasm module, bytes, URL, or response inputs", - "`loadWasmRuntime` does not read `globalThis.__rivetkitWasmBindings`", - "Add tests proving configured bindings are used instead of hidden globals", + "Identify the canonical promotion list currently in `promoteKnownBridgeError` in `rivetkit-typescript/packages/rivetkit/src/registry/wasm-runtime.ts`", + "Move the `(group, code) -> statusCode` mapping into `rivetkit-rust/packages/rivetkit-core/src/error.rs` or the existing RivetError build path so core sets `statusCode` correctly on extract", + "Remove `promoteKnownBridgeError` from `wasm-runtime.ts` (and any `callWasm` wrapping that exists only to call it)", + "Add a unit test (Rust or TS) that decodes a bridged `auth.forbidden` and asserts statusCode 403 from both NAPI and wasm paths", "Typecheck passes", "Tests pass" ], @@ -88,16 +86,15 @@ }, { "id": "US-006", - "title": "Publish one public wasm package import path", - "description": "As an application developer, I want `@rivetkit/rivetkit-wasm` to expose a supported public import path so that platform apps do not import repo-relative generated files.", - "acceptanceCriteria": [ - "`@rivetkit/rivetkit-wasm` exposes one default public import path that can be passed as `wasm.bindings`", - "`package.json` exports and files include the JavaScript, declaration, and wasm artifacts needed by the public import path", - "Do not add `@rivetkit/rivetkit-wasm/cloudflare` or `@rivetkit/rivetkit-wasm/deno` exports unless platform tests prove the single export cannot work", - "If a specialized export becomes necessary, document the packaging failure that requires it", - "No platform app imports repo-relative `pkg`, `pkg-deno`, or `dist/tsup` paths", - "Typecheck passes", - "Tests pass" + "title": "Pin wasm-pack instead of fetching via npx -y", + "description": "As a release engineer, I want the wasm publish job to not depend on a live npm registry fetch on every build so a flaky network does not block publishes.", + "acceptanceCriteria": [ + "`rivetkit-typescript/packages/rivetkit-wasm/scripts/build.mjs` no longer relies on `npx -y wasm-pack`", + "Add `wasm-pack` as a pinned `devDependency` in `rivetkit-typescript/packages/rivetkit-wasm/package.json` or invoke a vendored binary", + "Build script resolves the pinned `wasm-pack` binary path locally and falls back with an explicit error message if missing, rather than silently re-fetching", + "`pnpm --filter @rivetkit/rivetkit-wasm build` succeeds offline once dependencies are installed", + "`pnpm --filter @rivetkit/rivetkit-wasm run check:package` still passes", + "Typecheck passes" ], "priority": 6, "passes": true, @@ -105,14 +102,13 @@ }, { "id": "US-007", - "title": "Make wasm serverless startup concurrency-safe", - "description": "As a serverless runtime maintainer, I want concurrent first requests to share wasm serverless startup so that edge hosts do not fail during cold-start races.", + "title": "Add wasm-runtime parity tests for save, registerTask, status promotion", + "description": "As a maintainer, I want a parity test that runs the same actor scenarios under NAPI and wasm so future regressions in either adapter are caught.", "acceptanceCriteria": [ - "Wasm registry implements a `BuildingServerless` equivalent to match the NAPI serverless state pattern", - "Concurrent first serverless requests share one build or wait for the build to finish instead of returning an already-serving or wrong-mode error", - "Shutdown during wasm serverless build leaves the registry in a terminal state and does not orphan a newly built runtime", - "NAPI and wasm return equivalent wrong-mode or shutdown errors for serve/serverless mode conflicts", - "Add focused tests for concurrent first serverless requests and shutdown during build using deterministic ordering where needed", + "Add a parity test file under `rivetkit-typescript/packages/rivetkit/tests/` that runs the same scenario through `NapiCoreRuntime` and `WasmCoreRuntime` (using the existing wasm host smoke harness)", + "Cover: durable save via `requestSaveAndWait`, `registerTask` shutdown drain, and HTTP statusCode promotion for at least `auth.forbidden`, `actor.action_not_found`, `actor.action_timed_out`", + "Each scenario asserts the same observable result on both runtimes", + "Tests skip gracefully if the wasm package artifact is absent rather than failing CI on environments without `wasm-pack`", "Typecheck passes", "Tests pass" ], @@ -122,12 +118,12 @@ }, { "id": "US-008", - "title": "Restore wasm queue API parity", - "description": "As a Rivet Actor developer, I want queue APIs to return the same values through NAPI and wasm so that runtime selection does not change behavior.", + "title": "Fix per-call schema leaks in napi_actor_events", + "description": "As an operator running a long-lived NAPI process, I want unknown bridged error decoding to stop allocating and leaking a fresh `RivetErrorSchema` per call so memory is bounded under high error volume.", "acceptanceCriteria": [ - "`WasmQueue.maxSize()` returns the real core queue max size instead of `0`", - "Add parity coverage for queue max size through both NAPI and wasm adapters", - "Unsupported wasm runtime methods fail with an explicit structured unsupported-runtime error", + "`action_not_found` in `rivetkit-typescript/packages/rivetkit-napi/src/napi_actor_events.rs` uses a `static`/`const` `RivetErrorSchema` instead of `Box::leak(Box::new(...))` since all fields are compile-time constants", + "`structured_timeout_schema` fallback path in the same file routes through the existing `BRIDGE_RIVET_ERROR_SCHEMAS` intern map (or an equivalent shared dedup map keyed by `(group, code)`) instead of leaking on every unknown timeout", + "Add a NAPI-side test that triggers `action_not_found` many times and asserts the schema pointer is reused (e.g., compare addresses)", "Typecheck passes", "Tests pass" ], @@ -137,13 +133,13 @@ }, { "id": "US-009", - "title": "Fail fast for explicit wasm local SQLite", - "description": "As a test runner user, I want impossible wasm/local SQLite selections to fail clearly so that requested coverage is not silently dropped.", + "title": "Stop wasm websocket_callback_regions Vec from growing unboundedly", + "description": "As an actor author running on wasm with frequent websocket callbacks, I want region tracking to release slots when callbacks end so memory does not grow with callback churn for the actor lifetime.", "acceptanceCriteria": [ - "Default driver matrix excludes `runtime=wasm` with `sqlite=local`", - "`RIVETKIT_DRIVER_TEST_RUNTIME=wasm` with `RIVETKIT_DRIVER_TEST_SQLITE=local` fails fast with a clear configuration error", - "Valid matrix cells remain native/local/all encodings, native/remote/all encodings, and wasm/remote/all encodings", - "`shared-matrix.test.ts` asserts the fail-fast behavior for explicit wasm/local selection", + "Replace `Rc>>>` in `WasmActorContext` (`rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs`) with a `HashMap` (or equivalent map) keyed by region_id, mirroring the NAPI `BTreeMap` pattern in `actor_context.rs`", + "`end_websocket_callback` removes the entry rather than leaving a `None` slot", + "Region IDs are still monotonically increasing and never reused while in flight", + "Add a wasm crate test that calls begin/end websocket callbacks many times and asserts the underlying map is empty afterward", "Typecheck passes", "Tests pass" ], @@ -153,185 +149,86 @@ }, { "id": "US-010", - "title": "Enforce wasm SQLite config invariants in setup", - "description": "As a RivetKit user, I want `setup()` to reject local SQLite for wasm so that the unsupported configuration fails before runtime work begins.", + "title": "Investigate napi runtime_state mem::forget bounded leak", + "description": "As a maintainer, I want a path to drop the napi `runtime_state` JsObject ref via an Env-bearing thread so the documented per-actor-wake leak goes away.", "acceptanceCriteria": [ - "`setup({ runtime: \"wasm\" })` defaults SQLite to remote when SQLite config is unset", - "`setup({ runtime: \"wasm\", sqlite: remote })` is allowed", - "`setup({ runtime: \"wasm\", sqlite: local })` fails with a clear configuration error", - "Native runtime keeps its current SQLite default and allows both local and remote SQLite", - "Add tests for wasm default remote SQLite, wasm remote allowed, wasm local rejected, and native behavior unchanged", + "Investigate `reset_runtime_state` and `Drop for ActorContextShared` in `rivetkit-typescript/packages/rivetkit-napi/src/actor_context.rs:720-749`", + "Document or implement an Env-bearing drop path (for example post a TSF call on the main thread that drops the napi `Ref` with an Env in scope)", + "If implementation is deferred, write up the constraints in `docs-internal/engine/napi-bridge.md` so the rationale survives the comment", + "If implemented: add a NAPI test that creates and destroys many actors and asserts the JsObject ref count returns to zero after process tear-down", "Typecheck passes", "Tests pass" ], "priority": 10, "passes": true, - "notes": "" + "notes": "Investigated and documented the Env-bearing cleanup path plus constraints in docs-internal/engine/napi-bridge.md. Implementation deferred until a NAPI integration test can verify TSF drain and reference counts." }, { "id": "US-011", - "title": "Add shared platform SQLite counter registry", - "description": "As a platform test maintainer, I need one tiny shared registry so that Cloudflare, Supabase, and Deno tests verify the same public wasm API without duplicating actor code.", + "title": "Drop message from wasm BRIDGE_RIVET_ERROR_SCHEMAS cache key", + "description": "As an operator running a long-lived wasm worker, I want bridged JS error decoding to intern at most one schema per `(group, code)` so callers whose error messages vary per call (timestamps, request IDs, dynamic context) cannot grow the schema map and `Box::leak` arena unboundedly. The current US-002 fix keys on `(group, code, message)`, which still leaks one schema per unique message and re-introduces the same class of leak the PR is trying to close. The NAPI-side `STRUCTURED_TIMEOUT_SCHEMAS` already keys only on `(&'static str, &'static str)` (`rivetkit-typescript/packages/rivetkit-napi/src/napi_actor_events.rs:96-98`); wasm must match.", "acceptanceCriteria": [ - "Create `rivetkit-typescript/packages/rivetkit/tests/platforms/shared-registry.ts`", - "Define a SQLite-backed counter actor with `increment` and `getCount` actions", - "The actor uses raw SQL rather than Drizzle", - "The registry factory uses public `setup({ runtime: \"wasm\", wasm: { bindings, initInput }, use })` shape", - "The registry uses remote SQLite and does not allow local SQLite", + "`BridgeRivetErrorSchemaKey` in `rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs:2596` becomes `(String, String)` (or equivalent two-tuple) keyed only on `(group, code)`", + "`bridge_rivet_error_schema` (`lib.rs:2599-2618`) inserts the first-seen `default_message` for a given `(group, code)` and reuses that schema for subsequent decodes regardless of payload message", + "The actual per-error `message` field continues to flow through `RivetTransportError.message: Some(payload.message)` so callers still see the live message", + "Update `parse_bridge_rivet_error_reuses_interned_schema` to assert that varying only the message for the same `(group, code)` reuses the schema (and therefore does NOT increment `BRIDGE_RIVET_ERROR_SCHEMAS.len()`)", + "Add a second test (or extend the existing one) that varying `(group, code)` does still allocate a new schema", + "`pnpm --filter @rivetkit/rivetkit-wasm run check:package` passes; `cargo test -p rivetkit-wasm` passes", "Typecheck passes", "Tests pass" ], "priority": 11, "passes": true, - "notes": "" + "notes": "Wasm bridged RivetError schemas now intern by `(group, code)` only while preserving each payload's live message on the transport error. Native `cargo test -p rivetkit-wasm` still hits the pre-existing host-target `wasm_bindgen_futures` compile issue; wasm-target cargo test compilation and package checks pass." }, { "id": "US-012", - "title": "Add shared platform test harness", - "description": "As a platform test maintainer, I need common process and client helpers so each platform smoke test can focus on its host runtime.", + "title": "Bound register_task shutdown drain against shutdown deadline", + "description": "As an operator, I want `ctx.registerTask(promise)` not to block actor shutdown indefinitely when the user-supplied JS promise never resolves. `WasmActorContext::register_task` (`rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs:1345-1356`) and the analogous NAPI path route through `ActorContext::register_task` -> `track_shutdown_task`, so a never-resolving promise hangs shutdown drain. This contradicts the new CLAUDE.md rule added in this PR (\"Spawned futures that capture JS callbacks or other heavy resources must have a guaranteed completion path (e.g. a `CancellationToken` whose clones are guaranteed to drop)\").", "acceptanceCriteria": [ - "Create `rivetkit-typescript/packages/rivetkit/tests/platforms/shared-platform-harness.ts`", - "The harness can create a namespace and serverless runner config against the shared test engine", - "The harness can create a RivetKit client for the shared SQLite counter registry", - "The harness provides helpers for temporary app directories, child process logging, health checks, and cleanup", - "Platform CLI commands are launched through pinned `pnpm dlx` versions where a CLI is needed", - "Platform tests are exposed through an explicit script such as `test:platforms` and are not included in the default test command", + "`ActorContext::register_task` in `rivetkit-rust/packages/rivetkit-core/src/actor/context.rs:552-587` races the user future against `ctx.shutdown_deadline_token().cancelled()` (or equivalent) so registered tasks unblock shutdown when the grace deadline elapses", + "Cancellation cause is logged at `tracing::warn!` with `actor_id` and a stable `reason` field so operators can find hanging registered tasks", + "Behavior is identical for the `wasm-runtime` and native `cfg(not(feature = \"wasm-runtime\"))` variants of `register_task`", + "Wasm `WasmActorContext::register_task` does not need a separate cancel path because core handles it", + "Add a Rust integration test in `rivetkit-rust/packages/rivetkit-core/tests/` that registers a never-completing future, triggers shutdown, and asserts shutdown completes within a bounded deadline rather than hanging", "Typecheck passes", "Tests pass" ], "priority": 12, "passes": true, - "notes": "" + "notes": "Core register_task now races registered futures against the shutdown deadline token for both native and wasm cfgs. A focused core test verifies a never-completing task drains after deadline cancellation and logs the stable reason." }, { "id": "US-013", - "title": "Add Cloudflare Workers wasm platform smoke test", - "description": "As a release owner, I want a real local Cloudflare Workers smoke test so that wasm runtime packaging works in workerd.", + "title": "Avoid panic on websocket_callback_regions ID overflow", + "description": "As an operator running a long-lived wasm actor with frequent websocket callbacks, I do not want my actor to crash after ~4B handshakes because `next_websocket_callback_region_id` overflows `u32`. The current code (`rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs:1351-1355`) uses `checked_add(1).expect(\"websocket callback region id overflow\")`, which crashes the actor process instead of degrading.", "acceptanceCriteria": [ - "Create `rivetkit-typescript/packages/rivetkit/tests/platforms/cloudflare-workers.test.ts`", - "The test runs real local workerd through pinned `pnpm dlx wrangler@... dev --local`", - "The test imports only public `rivetkit` and `@rivetkit/rivetkit-wasm` package exports from platform app code", - "The test performs multiple requests to the same SQLite counter actor and verifies persisted readback", - "The test verifies actor sleep and wake for the SQLite counter actor", - "The test runs multiple separate actor IDs in parallel on the same platform instance", - "The test does not cover raw HTTP, raw WebSocket, workflows, queues, or the full driver suite", + "Either bump `next_websocket_callback_region_id` to `Cell` (and update the `begin_websocket_callback` return type plus any wasm-bindgen surface that exposes it) so overflow becomes operationally unreachable", + "Or implement wraparound that skips IDs already present in `websocket_callback_regions` so reuse is safe while regions are in flight", + "Either approach must keep IDs strictly monotonic for IDs currently in the map so no two live regions ever share an ID", + "Add a wasm crate test that exercises the wraparound (or large-counter) path to confirm no panic and no ID collision with live regions", + "If u32 is preserved, the test seeds `next_websocket_callback_region_id` near `u32::MAX` and verifies wraparound", "Typecheck passes", "Tests pass" ], "priority": 13, "passes": true, - "notes": "" + "notes": "Wasm websocket callback region IDs now wrap across the u32 boundary, skip ID 0, and skip live region IDs instead of panicking. Added wasm-target regression coverage for wraparound with live regions." }, { "id": "US-014", - "title": "Add Deno wasm platform smoke test", - "description": "As a release owner, I want a plain Deno smoke test so that wasm runtime packaging works without the Supabase CLI wrapper.", + "title": "Document global cache delta in parse_bridge_rivet_error test", + "description": "As a future contributor adding tests under `rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs`, I want a comment on `parse_bridge_rivet_error_reuses_interned_schema` (`lib.rs:2715-2737`) explaining that its `BRIDGE_RIVET_ERROR_SCHEMAS.len()` delta assertions only hold because the test owns a unique `(group, code)` namespace (`wasm_schema_cache_test`/`same_payload`). Without the comment, a future test that shares the global cache namespace will silently flip this assertion under parallel `cargo test`.", "acceptanceCriteria": [ - "Create `rivetkit-typescript/packages/rivetkit/tests/platforms/deno.test.ts`", - "The test runs the platform app with real local Deno and does not use the Supabase CLI wrapper", - "The test imports only public `rivetkit` and `@rivetkit/rivetkit-wasm` package exports from platform app code", - "The test performs multiple requests to the same SQLite counter actor and verifies persisted readback", - "The test verifies actor sleep and wake for the SQLite counter actor", - "The test runs multiple separate actor IDs in parallel on the same platform instance", - "The test does not cover raw HTTP, raw WebSocket, workflows, queues, or the full driver suite", + "Add a one or two line comment immediately above `parse_bridge_rivet_error_reuses_interned_schema` (or above the `initial_count` capture) noting that the test relies on a dedicated `(group, code)` namespace so concurrent tests do not perturb the global `BRIDGE_RIVET_ERROR_SCHEMAS.len()` delta", + "Comment names the namespace strings (`wasm_schema_cache_test`, `same_payload`) so future contributors know what to avoid", + "If US-011 lands first and changes the cache key shape, update the comment to reflect the new key", "Typecheck passes", "Tests pass" ], "priority": 14, "passes": true, - "notes": "" - }, - { - "id": "US-015", - "title": "Add Supabase Functions wasm platform smoke test", - "description": "As a release owner, I want a real local Supabase Functions smoke test so that wasm runtime packaging works through `supabase functions serve`.", - "acceptanceCriteria": [ - "Create `rivetkit-typescript/packages/rivetkit/tests/platforms/supabase-functions.test.ts`", - "The test runs real local Supabase Functions through pinned `pnpm dlx supabase@... functions serve`", - "The test imports only public `rivetkit` and `@rivetkit/rivetkit-wasm` package exports from function app code", - "The test performs multiple requests to the same SQLite counter actor and verifies persisted readback", - "The test verifies actor sleep and wake for the SQLite counter actor", - "The test runs multiple separate actor IDs in parallel on the same platform instance", - "The test does not cover raw HTTP, raw WebSocket, workflows, queues, or the full driver suite", - "Typecheck passes", - "Tests pass" - ], - "priority": 15, - "passes": true, - "notes": "" - }, - { - "id": "US-016", - "title": "Document wasm runtime setup for Cloudflare and Supabase", - "description": "As an application developer, I want public docs for wasm runtime setup on Cloudflare Workers and Supabase Functions so that I can copy the same API used by the tests.", - "acceptanceCriteria": [ - "Update quickstart docs to point users at edge and serverless wasm setup", - "Add or update `website/src/content/docs/connect/cloudflare.mdx` for Cloudflare Workers", - "Replace the placeholder in `website/src/content/docs/connect/supabase.mdx` with Supabase Edge Functions setup", - "Update the sidebar source used by `website/src/sitemap/mod.ts` if a new Connect page is added", - "Docs show `setup({ runtime: \"wasm\", wasm: { bindings, initInput }, use })`", - "Docs explain that wasm cannot use local SQLite and defaults to remote SQLite when SQLite config is unset", - "Docs mention `runtime: \"wasm\"` and `RIVETKIT_RUNTIME=wasm`", - "Docs do not mention hidden globals, private generated paths, or lower-level registry builders", - "Quickstart and Connect pages link to each other where appropriate", - "Typecheck passes" - ], - "priority": 16, - "passes": true, - "notes": "" - }, - { - "id": "US-017", - "title": "Remove Buffer from shared actor runtime glue", - "description": "As an edge runtime maintainer, I want the shared TypeScript actor glue to use portable bytes so that wasm hosts do not rely on Node `Buffer` outside the NAPI adapter.", - "acceptanceCriteria": [ - "Audit `rivetkit-typescript/packages/rivetkit/src/registry/native.ts` for `Buffer` usage that is part of shared actor glue rather than NAPI-only adapter conversion", - "Replace shared actor glue byte construction and decoding with `Uint8Array`, `ArrayBuffer`, `TextEncoder`, `TextDecoder`, or portable helper functions", - "Keep required Node `Buffer` conversion inside `rivetkit-typescript/packages/rivetkit/src/registry/napi-runtime.ts` or clearly NAPI-only code paths", - "`WasmCoreRuntime` and wasm platform registry paths do not require `globalThis.Buffer`", - "Add or update tests that exercise wasm runtime behavior without installing a `Buffer` global", - "Typecheck passes", - "Tests pass" - ], - "priority": 17, - "passes": true, - "notes": "" - }, - { - "id": "US-018", - "title": "Tighten runtime SQL boundary types", - "description": "As a runtime adapter author, I want SQL boundary types to be exact discriminated unions so that NAPI and wasm cannot accidentally pass malformed SQL params or route strings.", - "acceptanceCriteria": [ - "Change `RuntimeSqlBindParam` to a discriminated union with exactly one value field per kind", - "Use `RuntimeBytes` or `Uint8Array` consistently for SQL blob params and results", - "Change `RuntimeSqlExecuteResult.route` to the exact union `\"read\" | \"write\" | \"writeFallback\"`", - "Update NAPI and wasm SQL adapters to satisfy the stricter runtime SQL types without casts that hide invalid shapes", - "Add type or unit coverage for null, int, float, text, blob, and route result normalization", - "User-facing SQL integer result behavior remains unchanged from the current TypeScript API", - "Typecheck passes", - "Tests pass" - ], - "priority": 18, - "passes": true, - "notes": "" - }, - { - "id": "US-019", - "title": "Make platform fixtures match public docs code", - "description": "As an application developer, I want platform smoke fixtures to use the same user-friendly code shown in docs so that tests validate the copy-paste Cloudflare, Supabase, and Deno setup.", - "acceptanceCriteria": [ - "Platform app code does not import helper names like `createPlatformSqliteCounterRegistry` from test-only modules", - "Each generated platform app includes a docs-shaped registry file that imports `actor`, `setup`, and `@rivetkit/rivetkit-wasm` directly through public package exports", - "Each generated platform app calls `setup({ runtime: \"wasm\", wasm: { bindings, initInput }, sqlite: \"remote\", use })` or the documented equivalent inline", - "Shared test utilities may generate or copy the docs-shaped registry source, but the app code itself must look like user documentation rather than a test harness API", - "Cloudflare Workers, Supabase Functions, and Deno platform tests all use the same docs-shaped SQLite counter actor source with only platform bootstrapping differences", - "No platform app uses hidden globals, lower-level registry builders, private generated wasm paths, or test-only registry wrappers", - "Typecheck passes", - "Tests pass" - ], - "priority": 19, - "passes": true, - "notes": "" + "notes": "Added a comment documenting the dedicated `(wasm_schema_cache_test, same_payload)` cache namespace used by the global schema-count delta assertion." } ] } diff --git a/scripts/ralph/progress.txt b/scripts/ralph/progress.txt index e2736160c3..d6202a75ba 100644 --- a/scripts/ralph/progress.txt +++ b/scripts/ralph/progress.txt @@ -1,266 +1,163 @@ # Ralph Progress Log - ## Codebase Patterns -- Current branch: `04-29-chore_rivetkit_wasm_support`. - The NAPI and wasm TypeScript adapters implement the shared `CoreRuntime` contract in `rivetkit-typescript/packages/rivetkit/src/registry/`. -- Keep raw `@rivetkit/rivetkit-napi` and `@rivetkit/rivetkit-wasm` imports inside runtime adapter modules or explicit edge entrypoints. +- Keep raw `@rivetkit/rivetkit-napi` and `@rivetkit/rivetkit-wasm` imports inside runtime adapter modules (`napi-runtime.ts`, `wasm-runtime.ts`); enforced by `tests/runtime-import-guard.test.ts`. - Wasm cannot use local SQLite. Valid SQLite runtime cells are native/local, native/remote, and wasm/remote. -- Edge smoke coverage should eventually validate public package exports, not only repo-relative generated wasm-pack output. -- Reuse `rivetkit-typescript/packages/rivetkit/tests/shared-engine.ts` for TypeScript tests that need a local `rivet-engine`; do not add separate engine launchers in driver or platform tests. -- Runtime normalization should use `CoreRuntime.kind`, not adapter `instanceof` checks. Map `kind: "napi"` to native and `kind: "wasm"` to wasm. -- `CoreRuntime` SQL methods use the portable `RuntimeSql*` structs from `src/registry/runtime.ts`; keep NAPI `Buffer` conversion inside `NapiCoreRuntime`. -- Keep runtime SQL bind params as exact discriminated unions and normalize adapter execute routes to `read`, `write`, or `writeFallback`. -- `CoreRuntime` byte payloads use `RuntimeBytes`/`Uint8Array`; keep Node `Buffer` conversion inside `NapiCoreRuntime` and out of `wasm-runtime.ts`. -- Shared actor glue in `src/registry/native.ts` should construct `RuntimeBytes`/`Uint8Array`; leave Node `Buffer` creation to `NapiCoreRuntime`. -- Pass wasm bindings through `setup({ wasm: { bindings, initInput } })`; do not rely on hidden `globalThis` wasm binding hooks. -- Use `pnpm --filter @rivetkit/rivetkit-wasm run check:package` after wasm package export/files changes; wasm-pack's generated `.gitignore` can otherwise hide required `pkg` artifacts from npm tarballs. -- Wasm `CoreRegistry` serverless startup uses a `BuildingServerless` waiter state; shutdown during build must wake waiters and drain any newly built runtime. -- Wasm bindings should forward supported parity APIs to `rivetkit-core`; do not leave placeholder returns for NAPI-supported APIs. -- Driver matrix env overrides that explicitly request `runtime=wasm` with `sqlite=local` should fail fast in `tests/driver/shared-matrix.ts`. -- Use public `setup({ sqlite: "local" | "remote" })` for runtime SQLite backend selection; wasm defaults unset SQLite to remote and rejects local during config parsing. -- Platform wasm smoke clients can use `tests/platforms/shared-registry.ts` for registry typing, while generated platform apps should use docs-shaped local registry source. -- Platform smoke tests should use `tests/platforms/shared-platform-harness.ts` for shared engine namespaces, serverless runner configs, clients, temp app dirs, health checks, child logs, and pinned `pnpm dlx` launches. -- Use `buildPlatformSqliteCounterRegistrySource(...)` to generate the shared docs-shaped platform SQLite counter registry source for Cloudflare, Deno, and Supabase apps. -- Platform tests that import public package exports must build `rivetkit` first because package exports point at `dist/tsup`. -- Raw `ctx.sql` platform fixtures still need a `db` provider so runtime SQLite is enabled. -- Cloudflare Workers platform fixtures need a fetch-upgrade `WebSocket` shim for wasm envoy connections. -- Do not duplicate engine-owned serverless start headers in platform runner config; Cloudflare may combine duplicate headers into comma-separated values. -- Avoid `sqlite_` table prefixes in platform SQLite fixtures because SQLite reserves them. -- Deno platform fixtures need `--allow-sys` because public `rivetkit` imports `pino`, which reads `os.hostname()`. -- Deno platform fixtures can pass wasm bytes from the public `@rivetkit/rivetkit-wasm/rivetkit_wasm_bg.wasm` export using `import.meta.resolve` plus `Deno.readFile`. -- Supabase Functions platform fixtures run inside Docker; advertise local engines through the Docker bridge IP when `docker0` exists and fall back to `host.docker.internal`. -- Supabase Functions Edge Runtime needs function-local package metadata and copied package trees for public bare package imports. -- Supabase Functions fixtures should use Edge Runtime `per_worker` policy and avoid serverless runner prewarm settings so long-lived `/start` streams coexist with metadata and wake requests. -- Connect docs cards and sidebar entries are generated from `frontend/packages/shared-data/src/deploy.ts`. -- Website docs code-block typechecking runs during `pnpm --filter rivet-website build`; `c.kv.listRange` and `c.kv.deleteRange` range bounds must be `Uint8Array`. - -Started: Fri May 01 2026 ---- - -## 2026-05-01 21:56 PDT - US-013 -- Added a real Cloudflare Workers wasm platform smoke test that launches pinned local `wrangler dev --local`. -- The generated Worker app imports only public `rivetkit` and `@rivetkit/rivetkit-wasm` exports, wires wasm through public `setup`, and exercises SQLite counter persistence, cold wake, and parallel actor IDs. -- Added the Cloudflare fetch-upgrade `WebSocket` shim, public wasm asset export, serverless header cleanup, and SQLite fixture adjustments needed for the workerd runtime. -- Files changed: `rivetkit-typescript/packages/rivetkit/tests/platforms/cloudflare-workers.test.ts`, `rivetkit-typescript/packages/rivetkit/tests/platforms/shared-platform-harness.ts`, `rivetkit-typescript/packages/rivetkit/tests/platforms/shared-registry.ts`, `rivetkit-typescript/packages/rivetkit/tests/platforms/CLAUDE.md`, `rivetkit-typescript/packages/rivetkit/tests/platforms/AGENTS.md`, `rivetkit-typescript/packages/rivetkit/package.json`, `rivetkit-typescript/packages/rivetkit-wasm/package.json`, `rivetkit-typescript/packages/rivetkit/tests/shared-engine.ts`, `rivetkit-rust/packages/rivetkit-core/src/serverless.rs`, `rivetkit-rust/packages/rivetkit-core/tests/serverless.rs`, `rivetkit-rust/packages/rivetkit-core/tests/context.rs`, `rivetkit-rust/packages/rivetkit-core/tests/sleep.rs`, `rivetkit-rust/packages/rivetkit-core/tests/task.rs`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. -- Checks: `pnpm --filter rivetkit run test:platforms` passed; `pnpm --filter rivetkit run check-types` passed; `pnpm --filter @rivetkit/rivetkit-wasm run check:package` passed; `pnpm --filter @rivetkit/rivetkit-wasm run check-types` passed; `cargo test -p rivetkit-core matches_combined_duplicate_endpoint_headers` passed. -- **Learnings for future iterations:** - - Cloudflare workerd does not provide a browser-compatible `new WebSocket("ws://...")` path, so the fixture must bridge wasm envoy connections through `fetch(..., { headers: { Upgrade: "websocket" } })`. - - Platform app code that uses `ctx.sql` must still declare `db` so the actor config enables runtime SQLite. - - The platform test command should build first because generated apps resolve public package exports to `dist/tsup`. - - Serverless start headers should be owned by the engine and harness, not duplicated in platform runner config. ---- - -## 2026-05-01 19:50 PDT - US-001 -- Extracted the shared local `rivet-engine` lifecycle from the driver harness into `rivetkit-typescript/packages/rivetkit/tests/shared-engine.ts`. -- Kept driver runtime setup on the existing `shared-harness.ts` API by delegating `getOrStartSharedEngine` and `releaseSharedEngine` to the shared utility. -- Added a reusable harness note to `rivetkit-typescript/AGENTS.md` and marked US-001 passing in `prd.json`. -- Files changed: `rivetkit-typescript/packages/rivetkit/tests/shared-engine.ts`, `rivetkit-typescript/packages/rivetkit/tests/driver/shared-harness.ts`, `rivetkit-typescript/AGENTS.md`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. -- Checks: targeted Biome passed for touched files; `pnpm run check-types` passed in `rivetkit-typescript/packages/rivetkit`; `pnpm exec vitest run tests/driver/shared-matrix.test.ts` passed. Full `pnpm run lint` is blocked by existing fixture lint failures outside this story. -- **Learnings for future iterations:** - - Use `tests/shared-engine.ts` for any TypeScript test that needs a shared local `rivet-engine`. - - `driver/shared-harness.ts` should stay focused on namespace, runner config, and driver runtime process setup. - - Package-wide lint currently reports many pre-existing issues under `tests/fixtures/driver-test-suite`. ---- - -## 2026-05-01 19:53 PDT - US-002 -- Updated runtime normalization to switch on the portable `CoreRuntime.kind` field instead of concrete NAPI/wasm adapter classes. -- Added runtime-selection coverage for plain object `CoreRuntime` fakes with `kind: "napi"` and `kind: "wasm"`. -- Kept config precedence behavior covered by the existing setup config, `RIVETKIT_RUNTIME`, and auto-selection tests. -- Files changed: `rivetkit-typescript/packages/rivetkit/src/registry/native.ts`, `rivetkit-typescript/packages/rivetkit/tests/runtime-selection.test.ts`, `rivetkit-typescript/CLAUDE.md` via the `AGENTS.md` symlink, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. -- Checks: `pnpm exec biome check src/registry/native.ts tests/runtime-selection.test.ts` passed; `pnpm exec vitest run tests/runtime-selection.test.ts` passed; `pnpm run check-types` passed. -- **Learnings for future iterations:** - - `CoreRuntime.kind` is the stable adapter boundary for runtime-specific behavior; avoid class identity checks so duplicate modules and future adapters still work. - - The public config still calls the NAPI runtime `native`, while the portable runtime contract uses `kind: "napi"`. - - `native.ts` may contain older lint-sensitive code, so touched-file Biome checks can surface nearby cleanup needs. ---- - -## 2026-05-01 19:57 PDT - US-003 -- Defined explicit portable SQL bind, query, execute, exec, run, and database types in `rivetkit-typescript/packages/rivetkit/src/registry/runtime.ts`. -- Updated NAPI and wasm SQL adapters to implement the shared runtime SQL shape, with NAPI converting `Uint8Array` blob params to `Buffer` only at the native binding call. -- Added bind normalization coverage for bigint, boolean, string, number, null, undefined, and `Uint8Array`. -- Files changed: `rivetkit-typescript/packages/rivetkit/src/registry/runtime.ts`, `rivetkit-typescript/packages/rivetkit/src/registry/napi-runtime.ts`, `rivetkit-typescript/packages/rivetkit/src/registry/wasm-runtime.ts`, `rivetkit-typescript/packages/rivetkit/src/common/database/native-database.test.ts`, `rivetkit-typescript/CLAUDE.md` via the `AGENTS.md` symlink, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. -- Checks: `pnpm exec biome check src/registry/runtime.ts src/registry/napi-runtime.ts src/registry/wasm-runtime.ts src/common/database/native-database.test.ts` passed; `pnpm run check-types` passed; `pnpm exec vitest run src/common/database/native-database.test.ts` passed; `pnpm exec vitest run tests/wasm-host-smoke.test.ts` passed. -- **Learnings for future iterations:** - - The shared runtime SQL boundary should stay independent of `JsNativeDatabaseLike`. - - NAPI generated bindings still use `Buffer` for SQL blobs, so adapt at the NAPI runtime edge instead of changing `CoreRuntime`. - - `wrapJsNativeDatabase` remains the user-facing SQL behavior boundary and is the right place to guard bind normalization. ---- - -## 2026-05-01 20:01 PDT - US-004 -- Replaced the shared `CoreRuntime` byte boundary with `RuntimeBytes`/`Uint8Array` for HTTP bodies, state deltas, KV keys and values, queue payloads, websocket bytes, connection bytes, and inspector bytes. -- Kept NAPI `Buffer` coercion inside `NapiCoreRuntime`, including serverless requests, HTTP requests, state deltas, KV batches, queue completions, schedules, connections, websockets, and SQL blob params. -- Removed Node `Buffer` normalization from `wasm-runtime.ts` so wasm byte handling stays portable. -- Files changed: `rivetkit-typescript/packages/rivetkit/src/registry/runtime.ts`, `rivetkit-typescript/packages/rivetkit/src/registry/napi-runtime.ts`, `rivetkit-typescript/packages/rivetkit/src/registry/wasm-runtime.ts`, `rivetkit-typescript/packages/rivetkit/src/registry/index.ts`, `rivetkit-typescript/CLAUDE.md`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. -- Checks: `pnpm exec biome check src/registry/runtime.ts src/registry/napi-runtime.ts src/registry/wasm-runtime.ts src/registry/index.ts` passed; `pnpm run check-types` passed; `pnpm exec vitest run tests/wasm-host-smoke.test.ts tests/runtime-selection.test.ts` passed. -- **Learnings for future iterations:** - - Use `RuntimeBytes` from `src/registry/runtime.ts` for shared runtime byte payloads. - - NAPI generated bindings still require `Buffer`, so convert at the `NapiCoreRuntime` call boundary rather than widening the shared contract. - - Wasm runtime code should avoid Node byte helpers such as `Buffer.from`, `Buffer.alloc`, and `Buffer.isBuffer`. ---- - -## 2026-05-01 20:04 PDT - US-005 -- Added typed `wasm.bindings` runtime config alongside `wasm.initInput`. -- Updated runtime loading so explicit and auto wasm selection pass the full wasm config object into `loadWasmRuntime`. -- Removed production `globalThis.__rivetkitWasmBindings` lookup; configured bindings are used before falling back to the public `@rivetkit/rivetkit-wasm` import. -- Files changed: `rivetkit-typescript/packages/rivetkit/src/registry/config/index.ts`, `rivetkit-typescript/packages/rivetkit/src/registry/native.ts`, `rivetkit-typescript/packages/rivetkit/src/registry/wasm-runtime.ts`, `rivetkit-typescript/packages/rivetkit/tests/runtime-selection.test.ts`, `rivetkit-typescript/packages/rivetkit/tests/wasm-runtime.test.ts`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. -- Checks: `pnpm exec biome check src/registry/config/index.ts src/registry/native.ts src/registry/wasm-runtime.ts tests/runtime-selection.test.ts tests/wasm-runtime.test.ts` passed; `pnpm exec vitest run tests/runtime-selection.test.ts tests/wasm-runtime.test.ts` passed; `pnpm run check-types` passed. -- **Learnings for future iterations:** - - `RegistryConfigSchema` owns the typed wasm binding config. - - `loadWasmRuntime` accepts the full wasm config object so bindings and `initInput` stay paired. - - Keep platform-provided wasm bindings explicit in registry config instead of smuggling them through globals. ---- - -## 2026-05-01 20:09 PDT - US-006 -- Published the wasm package through one root public import path by keeping `@rivetkit/rivetkit-wasm` as the only package export and forwarding root declarations to the generated wasm-pack declarations. -- Fixed package contents so npm tarballs include the root JS/types, generated JS/types, and `.wasm` artifact despite wasm-pack's generated ignored `pkg/` directory. -- Added a `check:package` script to assert the published file list and a `prepack` build hook so clean package publishing regenerates wasm artifacts before packing. -- Files changed: `rivetkit-typescript/packages/rivetkit-wasm/.npmignore`, `rivetkit-typescript/packages/rivetkit-wasm/index.d.ts`, `rivetkit-typescript/packages/rivetkit-wasm/package.json`, `rivetkit-typescript/packages/rivetkit-wasm/scripts/check-package.mjs`, `rivetkit-typescript/CLAUDE.md`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. -- Checks: `pnpm --filter @rivetkit/rivetkit-wasm run check:package` passed; `pnpm --filter @rivetkit/rivetkit-wasm run check-types` passed; `pnpm --filter @rivetkit/rivetkit-wasm run check:wasm` passed with existing warnings in `rivetkit-core`; `pnpm --filter rivetkit run check-types` passed; `pnpm --filter rivetkit exec vitest run tests/wasm-runtime.test.ts tests/runtime-selection.test.ts` passed; public root import smoke via Node passed; `SKIP_WASM_BUILD=1 npm pack --dry-run` passed. -- **Learnings for future iterations:** - - `@rivetkit/rivetkit-wasm` should expose only the root export; do not add platform-specific or generated subpath exports unless a platform test proves the root path cannot work. - - wasm-pack writes `pkg/.gitignore` with `*`, so npm package contents need an explicit `.npmignore` override plus a tarball check. - - Root package declarations can forward to `./pkg/rivetkit_wasm.js` when the tarball includes the generated `.d.ts` files. ---- - -## 2026-05-01 20:15 PDT - US-007 -- Implemented wasm `CoreRegistry` serverless startup concurrency with a `BuildingServerless` state, waiters, structured invalid-state errors, and shutdown cleanup for runtimes built after cancellation. -- Added focused wasm runtime tests for concurrent first serverless requests, shutdown during serverless build, and structured wrong-mode errors. -- Added the generated `wasm.invalid_state` error artifact and a reusable wasm binding note in `rivetkit-typescript/AGENTS.md`. -- Files changed: `rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs`, `rivetkit-typescript/packages/rivetkit/tests/wasm-runtime.test.ts`, `rivetkit-typescript/CLAUDE.md`, `engine/artifacts/errors/wasm.invalid_state.json`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. -- Checks: `pnpm --filter @rivetkit/rivetkit-wasm run check:wasm` passed with existing `rivetkit-core` warnings; `pnpm --filter @rivetkit/rivetkit-wasm run check-types` passed; `pnpm --filter rivetkit run check-types` passed; `pnpm --filter rivetkit exec biome check tests/wasm-runtime.test.ts` passed; `pnpm --filter rivetkit exec vitest run tests/wasm-runtime.test.ts` passed. -- **Learnings for future iterations:** - - Use a waiter state rather than a temporary `Serving` state during wasm serverless runtime construction. - - Build failures and shutdown during wasm serverless startup must transition the registry to a terminal state and wake all waiters. - - New `RivetError` derives create `engine/artifacts/errors/*.json` files that should be committed with the source change. ---- - -## 2026-05-01 20:17 PDT - US-008 -- Restored wasm queue max-size parity by forwarding `WasmQueue.maxSize()` to the core queue config instead of returning `0`. -- Added adapter parity coverage proving NAPI and wasm both read queue max size through the shared runtime boundary. -- Made missing wasm runtime methods throw a structured `runtime.unsupported` `RivetError` with runtime and method metadata. -- Files changed: `rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs`, `rivetkit-typescript/packages/rivetkit/src/registry/wasm-runtime.ts`, `rivetkit-typescript/packages/rivetkit/tests/wasm-runtime.test.ts`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. -- Checks: `pnpm --filter @rivetkit/rivetkit-wasm run check:wasm` passed with existing `rivetkit-core` warnings; `pnpm --filter rivetkit exec biome check src/registry/wasm-runtime.ts tests/wasm-runtime.test.ts` passed; `pnpm --filter rivetkit run check-types` passed; `pnpm --filter rivetkit exec vitest run tests/wasm-runtime.test.ts` passed. -- **Learnings for future iterations:** - - `WasmQueue` should expose the same supported queue surface as NAPI by forwarding to `rivetkit-core::ActorContext`. - - Use focused adapter tests with fake actor contexts when parity behavior lives in the TypeScript runtime boundary. - - Missing wasm binding methods should fail as structured `runtime.unsupported` errors with the missing method name in metadata. ---- - -## 2026-05-01 20:20 PDT - US-009 -- Implemented fail-fast validation for explicit `RIVETKIT_DRIVER_TEST_RUNTIME=wasm` plus `RIVETKIT_DRIVER_TEST_SQLITE=local` driver matrix overrides. -- Added shared matrix tests covering the exact valid SQLite matrix cells and the explicit wasm/local configuration error. -- Marked US-009 passing in `prd.json`. -- Files changed: `rivetkit-typescript/packages/rivetkit/tests/driver/shared-matrix.ts`, `rivetkit-typescript/packages/rivetkit/tests/driver/shared-matrix.test.ts`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. -- Checks: `pnpm --filter rivetkit exec biome check tests/driver/shared-matrix.ts tests/driver/shared-matrix.test.ts` passed; `pnpm --filter rivetkit exec vitest run tests/driver/shared-matrix.test.ts` passed; `pnpm --filter rivetkit run check-types` passed. -- **Learnings for future iterations:** - - The normal SQLite driver matrix still filters unsupported wasm/local cells so native/local, native/remote, and wasm/remote coverage remains. - - Treat env matrix overrides as requested coverage. If they name an unsupported cell, throw instead of silently filtering it out. - - Keep driver matrix behavior covered in `tests/driver/shared-matrix.test.ts` because it is fast and does not need the shared engine. ---- - -## 2026-05-01 20:23 PDT - US-010 -- Added public `sqlite` registry config for selecting the runtime SQLite backend. -- Made explicit wasm/local SQLite fail during config parsing, while unset wasm SQLite defaults to remote and native keeps its previous default. -- Preserved the internal `test.sqliteBackend` path for existing driver coverage while routing runtime backend decisions through one helper. -- Files changed: `rivetkit-typescript/packages/rivetkit/src/registry/config/index.ts`, `rivetkit-typescript/packages/rivetkit/src/registry/native.ts`, `rivetkit-typescript/packages/rivetkit/tests/runtime-selection.test.ts`, `rivetkit-typescript/CLAUDE.md`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. -- Checks: `pnpm exec biome check src/registry/config/index.ts src/registry/native.ts tests/runtime-selection.test.ts` passed; `pnpm exec vitest run tests/runtime-selection.test.ts` passed; `pnpm run check-types` passed. -- **Learnings for future iterations:** - - Public setup config should use `sqlite: "local" | "remote"` for backend selection; the schema also normalizes object form to `{ backend }`. - - Keep `test.sqliteBackend` as a driver/test hook, but production runtime decisions should prefer public `sqlite.backend`. - - Explicit `runtime: "wasm"` is validated at config parse time; auto-selected wasm is guarded again during runtime normalization. ---- - -## 2026-05-01 20:26 PDT - US-011 -- Added `rivetkit-typescript/packages/rivetkit/tests/platforms/shared-registry.ts` with a raw-SQL SQLite counter actor exposing `increment` and `getCount`. -- Added a public-shape registry factory that requires explicit wasm bindings/init input, hardcodes `runtime: "wasm"`, and hardcodes remote SQLite with no local SQLite option. -- Files changed: `rivetkit-typescript/packages/rivetkit/tests/platforms/shared-registry.ts`, `rivetkit-typescript/CLAUDE.md`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. -- Checks: `pnpm exec biome check tests/platforms/shared-registry.ts` passed; `pnpm run check-types` passed; `pnpm exec tsx -e "import('./tests/platforms/shared-registry.ts').then(() => undefined)"` passed. -- **Learnings for future iterations:** - - Platform smoke tests should share the `sqliteCounter` actor from `tests/platforms/shared-registry.ts` instead of duplicating counter actor code per host. - - The shared platform registry intentionally omits `sqlite`, `runtime`, `test`, `use`, and `wasm` from caller options so tests cannot accidentally enable local SQLite or private test config. - - Use `ctx.sql` for this platform counter because it keeps the app import surface to public `rivetkit` plus explicit wasm bindings and avoids the Drizzle/database-provider subpaths. ---- - -## 2026-05-01 20:37 PDT - US-012 -- Added `rivetkit-typescript/packages/rivetkit/tests/platforms/shared-platform-harness.ts` with helpers for shared engine reuse, namespace creation, serverless runner config, SQLite counter clients, temp app dirs, logged child processes, health checks, and pinned `pnpm dlx` CLI launches. -- Added fast harness coverage in `tests/platforms/shared-platform-harness.test.ts`. -- Added `pnpm run test:platforms` and excluded `tests/platforms/**/*.test.ts` from default Vitest unless `RIVETKIT_INCLUDE_PLATFORM_TESTS=1` is set. -- Files changed: `rivetkit-typescript/packages/rivetkit/tests/platforms/shared-platform-harness.ts`, `rivetkit-typescript/packages/rivetkit/tests/platforms/shared-platform-harness.test.ts`, `rivetkit-typescript/packages/rivetkit/tests/platforms/shared-registry.ts`, `rivetkit-typescript/packages/rivetkit/package.json`, `rivetkit-typescript/packages/rivetkit/vitest.config.ts`, `rivetkit-typescript/CLAUDE.md`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. -- Checks: `pnpm exec biome check tests/platforms/shared-platform-harness.ts tests/platforms/shared-platform-harness.test.ts tests/platforms/shared-registry.ts package.json vitest.config.ts` passed; `pnpm run check-types` passed; `pnpm run test:platforms` passed; `pnpm exec tsx -e "import('./tests/platforms/shared-platform-harness.ts').then(() => undefined)"` passed; `pnpm exec vitest run tests/platforms --passWithNoTests` confirmed platform tests are excluded by default. A broader `pnpm exec vitest run --passWithNoTests` was stopped after several minutes in unrelated driver coverage. -- **Learnings for future iterations:** - - Use `createPlatformServerlessRunner(...)` to create a namespace and serverless runner config against the shared local engine. - - Use `createPlatformSqliteCounterClient(...)` with the returned runner when platform smoke tests need the shared counter registry. - - Launch platform CLIs through `spawnPinnedPnpmDlx(...)` so test code has to name a concrete package version. ---- - -## 2026-05-01 22:05 PDT - US-014 -- Added a real local Deno wasm platform smoke test in `rivetkit-typescript/packages/rivetkit/tests/platforms/deno.test.ts`. -- The generated Deno app imports only public `rivetkit` and `@rivetkit/rivetkit-wasm` exports, passes wasm bytes through public setup config, and exercises SQLite counter persistence, sleep/wake, and parallel actor IDs. -- Marked US-014 passing in `scripts/ralph/prd.json`. -- Files changed: `rivetkit-typescript/packages/rivetkit/tests/platforms/deno.test.ts`, `rivetkit-typescript/packages/rivetkit/tests/platforms/CLAUDE.md`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. -- Checks: `pnpm exec biome check tests/platforms/deno.test.ts` passed; `RIVETKIT_INCLUDE_PLATFORM_TESTS=1 pnpm exec vitest run tests/platforms/deno.test.ts` passed; `pnpm run check-types` passed; `pnpm run test:platforms` passed. -- **Learnings for future iterations:** - - Deno can resolve the local workspace packages through symlinked `node_modules` with `--node-modules-dir=manual`. - - The Deno app needs `--allow-sys` because `pino` reads the host name during public `rivetkit` import. - - Use the public wasm asset export with `import.meta.resolve("@rivetkit/rivetkit-wasm/rivetkit_wasm_bg.wasm")` and `Deno.readFile(...)` instead of importing generated `pkg` paths. ---- - -## 2026-05-01 23:11 PDT - US-015 -- Added a real local Supabase Functions wasm platform smoke test driven by pinned `pnpm dlx supabase@2.95.4 functions serve`. -- The generated function app imports only public `rivetkit` and `@rivetkit/rivetkit-wasm` exports, wires wasm bytes through public setup config, and exercises SQLite counter persistence, idle sleep/wake, and parallel actor IDs. -- Added Supabase-specific engine/network/package handling for Dockerized Edge Runtime and marked US-015 passing in `prd.json`. -- Files changed: `rivetkit-typescript/packages/rivetkit/tests/platforms/supabase-functions.test.ts`, `rivetkit-typescript/packages/rivetkit/tests/platforms/CLAUDE.md`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. -- Checks: `pnpm exec biome check tests/platforms/supabase-functions.test.ts tests/platforms/CLAUDE.md` passed; `RIVETKIT_INCLUDE_PLATFORM_TESTS=1 pnpm exec vitest run tests/platforms/supabase-functions.test.ts` passed; `pnpm run check-types` passed; `pnpm run test:platforms` passed. -- **Learnings for future iterations:** - - Supabase Edge Runtime runs in Docker, so local engine URLs must be reachable from containers; prefer the Docker bridge IP from `docker0` and fall back to `host.docker.internal`. - - Supabase Edge Runtime bare import resolution needs package metadata and real package trees next to the function entrypoint. - - Avoid serverless runner prewarm for Supabase Functions and use `per_worker`; prewarm/`oneshot` caused `/start` BOOT_ERRORs around long-lived serverless streams. ---- - -## 2026-05-01 23:21 PDT - US-016 -- Added Cloudflare Workers wasm runtime setup docs with public `rivetkit` and `@rivetkit/rivetkit-wasm` imports, explicit `setup({ runtime: "wasm", wasm: { bindings, initInput }, sqlite: "remote", use })`, and remote SQLite/runtime notes. -- Replaced the Supabase placeholder with Supabase Edge Functions wasm setup docs, including Deno wasm loading and serverless runner URL guidance. -- Added Cloudflare Workers and Supabase Functions to the Connect deploy metadata and quickstart cards, and fixed the KV range docs snippet that was blocking docs typechecking. -- Files changed: `website/src/content/docs/connect/cloudflare.mdx`, `website/src/content/docs/connect/supabase.mdx`, `website/src/content/docs/quickstart/index.mdx`, `website/src/content/docs/actors/quickstart/index.mdx`, `website/src/content/docs/actors/kv.mdx`, `frontend/packages/shared-data/src/deploy.ts`, `website/CLAUDE.md`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. -- Checks: `pnpm --filter rivet-website build` passed; `git diff --check` passed. `pnpm --filter rivet-website lint` is blocked because the website package has ESLint 9 but no `eslint.config.*` flat config. -- **Learnings for future iterations:** - - Connect docs navigation is driven by `frontend/packages/shared-data/src/deploy.ts`, so new Connect pages need a deploy option entry to appear in cards/sidebar. - - The website build is the useful docs gate because it typechecks TypeScript code blocks before building Astro pages. - - Avoid running Prettier blindly on MDX docs with nested code examples; it can rewrite code fences and example indentation in surprising ways. ---- - -## 2026-05-01 23:32 PDT - US-017 -- Removed Node `Buffer` construction from shared actor runtime glue in `registry/native.ts`; shared KV, HTTP, websocket, state, queue, and callback payloads now use `RuntimeBytes`/`Uint8Array` helpers. -- Kept NAPI `Buffer` conversion isolated to the NAPI adapter boundary and made callback error stack logging resilient when `Buffer` is unavailable. -- Added wasm runtime coverage that invokes shared actor callbacks after clearing `globalThis.Buffer`. -- Files changed: `rivetkit-typescript/packages/rivetkit/src/registry/native.ts`, `rivetkit-typescript/packages/rivetkit/src/common/utils.ts`, `rivetkit-typescript/packages/rivetkit/tests/wasm-runtime.test.ts`, `rivetkit-typescript/CLAUDE.md`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. -- Checks: `pnpm --filter rivetkit exec biome check src/registry/native.ts src/common/utils.ts tests/wasm-runtime.test.ts` passed; `pnpm --filter rivetkit exec vitest run tests/wasm-runtime.test.ts` passed; `pnpm --filter rivetkit run check-types` passed. -- **Learnings for future iterations:** - - Shared actor glue in `registry/native.ts` is used by both NAPI and wasm, so byte creation there should stay on `RuntimeBytes`/`Uint8Array` even when NAPI currently accepts Node `Buffer`. - - Tests can exercise wasm actor callback glue with a fake wasm context that implements `actorId()` and `runtimeState()`. - - Accessing `error.stack` can throw in Node source-map paths when `globalThis.Buffer` is unavailable, so stack logging should be best-effort. ---- - -## 2026-05-01 23:39 PDT - US-018 -- Tightened `RuntimeSqlBindParam` into exact discriminated union variants and limited `RuntimeSqlExecuteResult.route` to `read`, `write`, or `writeFallback`. -- Updated NAPI and wasm runtime adapters to normalize execute results before returning the shared runtime type, and tightened the local native database bind type so runtime forwarding remains type-safe. -- Added runtime SQL boundary tests for null, int, float, text, blob, and route normalization, and strengthened native database tests for exact bind shapes and unsupported route rejection. -- Files changed: `rivetkit-typescript/packages/rivetkit/src/registry/runtime.ts`, `rivetkit-typescript/packages/rivetkit/src/registry/napi-runtime.ts`, `rivetkit-typescript/packages/rivetkit/src/registry/wasm-runtime.ts`, `rivetkit-typescript/packages/rivetkit/src/registry/runtime.test.ts`, `rivetkit-typescript/packages/rivetkit/src/common/database/native-database.ts`, `rivetkit-typescript/packages/rivetkit/src/common/database/native-database.test.ts`, `rivetkit-typescript/CLAUDE.md`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. -- Checks: `pnpm --filter rivetkit exec biome check src/registry/runtime.ts src/registry/napi-runtime.ts src/registry/wasm-runtime.ts src/registry/runtime.test.ts src/common/database/native-database.ts src/common/database/native-database.test.ts` passed; `pnpm --filter rivetkit run check-types` passed; `pnpm --filter rivetkit exec vitest run src/registry/runtime.test.ts src/common/database/native-database.test.ts tests/wasm-host-smoke.test.ts` passed; `pnpm --filter rivetkit exec vitest run tests/wasm-runtime.test.ts` passed as part of an adapter run. `tests/napi-runtime-integration.test.ts` failed outside this story with `actor.validation_error: Invalid connection params`; an earlier retry was also blocked by an orphaned local engine holding the RocksDB lock. -- **Learnings for future iterations:** - - Runtime SQL bind params should be switched by `kind` at adapter edges so impossible extra value fields are compile-time errors. - - Generated NAPI SQL execute results expose route as a loose string, so normalize to the shared runtime route union before returning from `NapiCoreRuntime`. - - The NAPI integration test can leave an orphaned local `rivet-engine` holding `/home/nathan/.rivetkit/var/engine/db/LOCK` when startup fails; clean that process before rerunning engine-backed tests. ---- - -## 2026-05-01 23:48 PDT - US-019 -- Generated one shared docs-shaped SQLite counter registry source for platform apps via `buildPlatformSqliteCounterRegistrySource(...)`. -- Updated Cloudflare Workers, Deno, and Supabase Functions fixtures to write local `registry.ts` files that import public `rivetkit` and `@rivetkit/rivetkit-wasm` exports, then keep only platform bootstrapping in each app entrypoint. -- Marked US-019 passing in `prd.json` and preserved the reusable helper pattern in the platform AGENTS/CLAUDE notes. -- Files changed: `rivetkit-typescript/packages/rivetkit/tests/platforms/shared-platform-harness.ts`, `rivetkit-typescript/packages/rivetkit/tests/platforms/cloudflare-workers.test.ts`, `rivetkit-typescript/packages/rivetkit/tests/platforms/deno.test.ts`, `rivetkit-typescript/packages/rivetkit/tests/platforms/supabase-functions.test.ts`, `rivetkit-typescript/packages/rivetkit/tests/platforms/CLAUDE.md`, `rivetkit-typescript/packages/rivetkit/tests/platforms/AGENTS.md`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. -- Checks: `pnpm --filter rivetkit exec biome check tests/platforms/shared-platform-harness.ts tests/platforms/cloudflare-workers.test.ts tests/platforms/deno.test.ts tests/platforms/supabase-functions.test.ts` passed; `pnpm --filter rivetkit run check-types` passed; `pnpm --filter rivetkit run test:platforms` passed. -- **Learnings for future iterations:** - - Keep generated platform app registry code in a local app file so it can look like docs copy-paste code while still sharing one test utility source. - - Cloudflare uses the same registry source with a wasm module import; Deno and Supabase use the same source with `Deno.readFile(import.meta.resolve(...))`. - - The platform smoke suite can emit transient `actor_ready_timeout` logs during wake retries and still pass once the cold-start retry helper observes the new start request. +- Cross-boundary error sanitization belongs in `rivetkit-core`. The TS bridge passes raw errors through; status code promotion should not live in `wasm-runtime.ts`. +- Use `rivetkit-typescript/packages/rivetkit/tests/shared-engine.ts` for any TS test that needs a local `rivet-engine`. +- The wasm crate uses `RuntimeSpawner::spawn` which maps to `tokio::task::spawn_local` under `wasm-runtime` and `tokio::spawn` otherwise. +- Bridged JS errors round-trip via `__RIVET_ERROR_JSON__:` prefix in the message string; decoded with `parse_bridge_rivet_error` and re-encoded with `anyhow_to_js_error`. +- Wasm `JsValue` callbacks are not Send/Sync; `WasmFunction` uses an unsafe Send/Sync impl because the wasm runtime is single-threaded. +- Wasm host smoke tests use fake `WasmBindings` in `rivetkit-typescript/packages/rivetkit/tests/wasm-host-smoke.test.ts` to exercise the TypeScript `WasmCoreRuntime` adapter without requiring a built wasm artifact. +- Wasm bindings that return `Result<_, JsValue>` should map core `anyhow::Error` values with `anyhow_to_js_error`. +- Wasm bridged RivetError schemas are process-interned with `scc::HashMap` by `(group, code)` only; live per-error messages stay on `RivetError.message`. +- Validate wasm-only `ServeConfig` constraints at wasm entrypoints before converting to core `ServeConfig` or starting registry side effects. +- Runtime-owned promises that must drain during shutdown use core `ActorContext::register_task(...)`, not public `wait_until(...)`, so NAPI and wasm can keep `registerTask` intent distinct. +- Public HTTP status promotion for bridged errors lives in `rivetkit-core::error::public_error_status_code`; TS native/wasm adapters should not duplicate promotion tables. +- `@rivetkit/rivetkit-wasm` builds must use the package-local pinned `wasm-pack` dependency; do not shell through `npx -y wasm-pack`. +- Runtime parity tests can instantiate `NapiCoreRuntime` and `WasmCoreRuntime` with fake binding classes, then drive shared actor glue through `buildNativeFactory(...)` without requiring generated NAPI or wasm artifacts. +- NAPI actor-event synthetic `RivetErrorSchema` values should be `static` when fields are compile-time constants, or process-interned by `(group, code)` with `scc::HashMap` when the default message is dynamic. +- Wasm websocket callback regions should be tracked in a map keyed by active region IDs and removed on end so callback churn does not retain empty slots. +- Wasm websocket callback region ID `0` is the untracked sentinel; `u32` allocation wraps by skipping live IDs instead of panicking. +- NAPI `Ref::unref` needs an `Env`; cleanup from worker or `Drop` paths should be routed through an Env-bearing TSF and must tolerate addon shutdown by falling back to a bounded leak. +- Core `ActorContext::register_task(...)` must race registered runtime promises against `shutdown_deadline_token()` so shutdown drain cannot hang forever. + +Started: Sat May 2 02:13:32 AM PDT 2026 +--- +## 2026-05-02 02:15:18 PDT - US-001 +- Implemented wasm `ActorContext.requestSaveAndWait` by awaiting `rivetkit_core::ActorContext::request_save_and_wait`. +- Extended the wasm host smoke harness so `saveState({ immediate: true })` cannot resolve until the host save gate completes. +- Files changed: `rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs`, `rivetkit-typescript/packages/rivetkit/tests/wasm-host-smoke.test.ts`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. +- Checks: `pnpm --filter rivetkit exec vitest run tests/wasm-host-smoke.test.ts`, `pnpm --filter @rivetkit/rivetkit-wasm run check:wasm`, `pnpm --filter rivetkit run check-types`, `pnpm --filter @rivetkit/rivetkit-wasm run check-types`, `pnpm --filter rivetkit exec biome check tests/wasm-host-smoke.test.ts`. +- Full `pnpm --filter rivetkit run lint` currently fails on pre-existing diagnostics in `fixtures/driver-test-suite/*`. +- **Learnings for future iterations:** + - `ctx.saveState(...)` routes through `WasmCoreRuntime.actorRequestSaveAndWait`, so host smoke tests can model durability waits by blocking fake `requestSaveAndWait`. + - The wasm crate check may emit existing `rivetkit-core` warnings while still passing. +--- +## 2026-05-02 02:24:00 PDT - US-002 +- Implemented process-global interning for wasm bridged `RivetErrorSchema` values keyed by `(group, code, default_message)`. +- Added a wasm crate unit test that decodes the same bridged payload repeatedly, checks schema pointer reuse, and verifies a different default message interns separately. +- Files changed: `rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs`, `rivetkit-typescript/packages/rivetkit-wasm/Cargo.toml`, `Cargo.lock`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. +- Checks: `cargo test -p rivetkit-wasm --target wasm32-unknown-unknown --no-run parse_bridge_rivet_error_reuses_interned_schema`, `pnpm --filter @rivetkit/rivetkit-wasm run check:wasm`, `pnpm --filter @rivetkit/rivetkit-wasm run check-types`. +- Native `cargo test -p rivetkit-wasm` currently fails before this crate compiles because `rivetkit-core`'s `wasm-runtime` feature references `wasm_bindgen_futures` on the host target. +- **Learnings for future iterations:** + - `rivetkit-wasm` has `autotests = false`, so private Rust leak guards are currently easiest as crate unit tests compiled for `wasm32-unknown-unknown`. + - The wasm-target test harness can be compiled with `cargo test --target wasm32-unknown-unknown --no-run` even when native host tests cannot run. +--- +## 2026-05-02 02:26:54 PDT - US-003 +- Implemented wasm `engine_binary_path` rejection with a typed `wasm.invalid_config` error before registry serve/serverless startup reaches core. +- Added wasm crate regression coverage for the typed metadata and the `serve` entrypoint fast-rejection path. +- Files changed: `rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs`, `engine/artifacts/errors/wasm.invalid_config.json`, `rivetkit-typescript/AGENTS.md`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. +- Checks: `cargo test -p rivetkit-wasm --target wasm32-unknown-unknown --no-run engine_binary_path_error_is_typed_with_field_metadata`, `cargo test -p rivetkit-wasm --target wasm32-unknown-unknown --no-run serve_rejects_engine_binary_path_before_core_setup`, `pnpm --filter @rivetkit/rivetkit-wasm run check:wasm`, `pnpm --filter @rivetkit/rivetkit-wasm run check-types`. +- **Learnings for future iterations:** + - Wasm runtime config errors should use typed RivetError values and cross the JS boundary through `anyhow_to_js_error`. + - Validate wasm-only serve constraints before setting inspector overrides or moving the registry out of `Registering`. +--- +## 2026-05-02 02:31:24 PDT - US-004 +- Implemented core `ActorContext::register_task(...)` for native and wasm cfgs, backed by the existing shutdown task counter and a distinct `registered_task` user-task metric. +- Updated wasm `ActorContext.registerTask` to call core `register_task` instead of forwarding to `waitUntil`. +- Extended the wasm host smoke harness so a registered task remains pending when shutdown starts and shutdown does not resolve until that task finishes. +- Files changed: `rivetkit-rust/packages/rivetkit-core/src/actor/context.rs`, `rivetkit-rust/packages/rivetkit-core/src/actor/task_types.rs`, `rivetkit-rust/packages/rivetkit-core/AGENTS.md`, `rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs`, `rivetkit-typescript/packages/rivetkit/tests/wasm-host-smoke.test.ts`, `rivetkit-typescript/AGENTS.md`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. +- Checks: `pnpm --filter rivetkit exec vitest run tests/wasm-host-smoke.test.ts`, `pnpm --filter @rivetkit/rivetkit-wasm run check:wasm`, `pnpm --filter @rivetkit/rivetkit-wasm run check-types`, `pnpm --filter rivetkit run check-types`, `pnpm --filter rivetkit exec biome check tests/wasm-host-smoke.test.ts`, `cargo check -p rivetkit-core`, `cargo test -p rivetkit-wasm --target wasm32-unknown-unknown --no-run`. +- **Learnings for future iterations:** + - `ActorContextHandleAdapter.internalKeepAwake(...)` routes to runtime `actorRegisterTask`, so wasm host smoke tests can exercise it by adding `registerTask(...)` and shutdown-drain behavior to the fake wasm context. + - Core `register_task(...)` should use shutdown task tracking directly while keeping a separate `UserTaskKind::RegisteredTask` label from public `wait_until(...)`. +--- +## 2026-05-02 02:37:23 PDT - US-005 +- Moved bridged RivetError HTTP status promotion into `rivetkit-core::error::public_error_status_code`. +- Updated NAPI and wasm bridge encoders to include promoted `public` and `statusCode` values from core, and removed the TS native/wasm adapter promotion tables. +- Removed the decoder-side TS fallback promotion so bridge payload status now comes from the encoded core payload. +- Added NAPI and wasm bridge tests for `auth.forbidden` producing statusCode 403. +- Files changed: `rivetkit-rust/packages/rivetkit-core/src/error.rs`, `rivetkit-rust/packages/rivetkit-core/AGENTS.md`, `rivetkit-typescript/packages/rivetkit-napi/src/lib.rs`, `rivetkit-typescript/packages/rivetkit-napi/tests/actor_factory.rs`, `rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs`, `rivetkit-typescript/packages/rivetkit/src/actor/errors.ts`, `rivetkit-typescript/packages/rivetkit/src/registry/native.ts`, `rivetkit-typescript/packages/rivetkit/src/registry/wasm-runtime.ts`, `rivetkit-typescript/AGENTS.md`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. +- Checks: `cargo check -p rivetkit-core`, `cargo check -p rivetkit-napi --tests`, `cargo test -p rivetkit-wasm --target wasm32-unknown-unknown --no-run wasm_bridge_payload_promotes_known_core_error_status`, `pnpm --filter rivetkit exec vitest run tests/rivet-error.test.ts`, `pnpm --filter rivetkit exec biome check src/actor/errors.ts src/registry/native.ts src/registry/wasm-runtime.ts`, `pnpm --filter rivetkit run check-types`, `pnpm --filter @rivetkit/rivetkit-wasm run check-types`, `pnpm --filter @rivetkit/rivetkit-wasm run check:wasm`. +- Direct `cargo test -p rivetkit-napi napi_bridge_payload_promotes_known_core_error_status` currently fails at the existing standalone N-API linker step with unresolved `napi_*` symbols; `cargo check -p rivetkit-napi --tests` compiles the added test. +- **Learnings for future iterations:** + - Test-only `#[derive(RivetError)]` structs can generate `engine/artifacts/errors/*.json`; use static `RivetErrorSchema` values in private bridge tests when no artifact should be committed. + - Keep bridge status promotion at the Rust encoding boundary so old/private/default 500 bridge contexts are normalized before TypeScript decodes them. +--- +## 2026-05-02 02:40:27 PDT - US-006 +- Added pinned `wasm-pack@0.14.0` as a dev dependency of `@rivetkit/rivetkit-wasm`. +- Updated the wasm build script to resolve and execute the local `wasm-pack` binary through Node instead of using `npx -y`. +- Files changed: `rivetkit-typescript/packages/rivetkit-wasm/package.json`, `rivetkit-typescript/packages/rivetkit-wasm/scripts/build.mjs`, `pnpm-lock.yaml`, `rivetkit-typescript/AGENTS.md`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. +- Checks: `pnpm --filter @rivetkit/rivetkit-wasm build`, `pnpm install --lockfile-only --frozen-lockfile`, `pnpm --filter @rivetkit/rivetkit-wasm run check:package`, `pnpm --filter @rivetkit/rivetkit-wasm run check-types`, `pnpm --filter @rivetkit/rivetkit-wasm run check:wasm`, `node --check rivetkit-typescript/packages/rivetkit-wasm/scripts/build.mjs`. +- Biome ignored the touched wasm package files by repository configuration, so no Biome lint signal was available for this package path. +- **Learnings for future iterations:** + - The `wasm-pack` npm package exposes a `wasm-pack` bin through `run.js`; resolve it from `wasm-pack/package.json` and execute it with `process.execPath`. + - Keep the wasm package build offline-friendly after dependencies are installed. Missing `wasm-pack` should fail with an explicit install/dependency error, not a registry fetch. +--- +## 2026-05-02 02:46:37 PDT - US-007 +- Added `runtime-parity.test.ts` to run the same save, registered-task drain, and promoted bridge-error status scenarios through `NapiCoreRuntime` and `WasmCoreRuntime`. +- Used fake NAPI/wasm binding classes plus `buildNativeFactory(...)` so the parity coverage does not require generated wasm package artifacts. +- Files changed: `rivetkit-typescript/packages/rivetkit/tests/runtime-parity.test.ts`, `rivetkit-typescript/AGENTS.md`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. +- Checks: `pnpm --filter rivetkit exec vitest run tests/runtime-parity.test.ts`, `pnpm --filter rivetkit exec biome check tests/runtime-parity.test.ts`, `pnpm --filter rivetkit run check-types`. +- **Learnings for future iterations:** + - `ActorContextHandleAdapter.saveState({ immediate: true })` is a useful shared path for parity tests because it exercises NAPI direct calls and wasm `callHandleAsync` while preserving bridged `RivetError` status fields. + - Fake runtime bindings should keep byte payloads as `Uint8Array`/`Buffer` at the adapter boundary and let `NapiCoreRuntime`/`WasmCoreRuntime` normalize them. +--- +## 2026-05-02 02:50:11 PDT - US-008 +- Replaced the per-call `action_not_found` schema leak with a compile-time static schema. +- Interned unknown structured timeout schemas by `(group, code)` so dynamic default messages are leaked at most once per unique timeout error. +- Added NAPI moved tests that compare schema pointers across repeated `action_not_found` and unknown structured timeout calls. +- Files changed: `rivetkit-typescript/packages/rivetkit-napi/src/napi_actor_events.rs`, `rivetkit-typescript/packages/rivetkit-napi/tests/napi_actor_events.rs`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`, `CLAUDE.md`. +- Checks: `cargo check -p rivetkit-napi --tests`, `cargo check -p rivetkit-napi`. +- Direct `cargo test -p rivetkit-napi action_not_found_reuses_static_schema --no-run` still fails at the existing standalone N-API linker step with unresolved `napi_*` symbols. +- **Learnings for future iterations:** + - The moved NAPI actor event tests have private-module access through the source-owned `#[path = "../tests/napi_actor_events.rs"]` shim, so pointer-level helpers can test private schema constructors directly. + - `cargo check -p rivetkit-napi --tests` is the practical compile gate for these Rust tests until the standalone N-API test linker setup is fixed. +--- +## 2026-05-02 02:53:12 PDT - US-009 +- Replaced wasm websocket callback region tracking with a `HashMap` plus a shared monotonic region ID counter. +- Updated `endWebsocketCallback` to remove completed regions instead of leaving `None` slots behind. +- Added a wasm crate regression test that churns callback begin/end calls and asserts the underlying map is empty afterward. +- Files changed: `rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs`, `rivetkit-typescript/CLAUDE.md`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. +- Checks: `cargo test -p rivetkit-wasm --target wasm32-unknown-unknown --no-run websocket_callback_regions_are_removed_after_end`, `pnpm --filter @rivetkit/rivetkit-wasm run check:wasm`, `pnpm --filter @rivetkit/rivetkit-wasm run check-types`. +- **Learnings for future iterations:** + - Wasm `ActorContext` clones must share both active websocket callback regions and the next region ID so interleaved callbacks remain uniquely tracked across cloned handles. + - `endWebsocketCallback` should drop the stored `WebSocketCallbackRegion` by removing the map entry; that releases the core guard and keeps memory bounded under repeated callbacks. +--- +## 2026-05-02 02:55:33 PDT - US-010 +- Investigated the `ActorContextShared::runtime_state` `mem::forget` fallback in NAPI. +- Documented the Env-bearing cleanup design using a cleanup `ThreadsafeFunction`, plus the shutdown edge cases that require preserving a bounded leak fallback. +- Files changed: `docs-internal/engine/napi-bridge.md`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. +- Checks: `jq empty scripts/ralph/prd.json`, `cargo check -p rivetkit-napi`. +- `cargo check -p rivetkit-napi` still emits existing warnings from `rivetkit-sqlite` and `rivetkit-core`. +- **Learnings for future iterations:** + - `napi::Ref::unref(env)` cannot be called from receive-loop worker reset paths or arbitrary `Drop` paths because both can lack a valid `Env`. + - A future implementation should wrap stale refs in a TSF payload that forgets the reference on failed delivery, then unrefs only inside the TSF callback while an `Env` is in scope. +--- +## 2026-05-02 03:21:47 PDT - US-011 +- Changed wasm bridged `RivetErrorSchema` interning to key by `(group, code)` only, reusing the first schema default message even when later payload messages vary. +- Extended the wasm crate cache test so same `(group, code)` with a different live message reuses the schema pointer and does not grow the cache, while a different code still allocates one new schema. +- Files changed: `rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs`, `rivetkit-typescript/CLAUDE.md`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. +- Checks: `cargo test -p rivetkit-wasm --target wasm32-unknown-unknown --no-run parse_bridge_rivet_error_reuses_interned_schema`, `pnpm --filter @rivetkit/rivetkit-wasm run check:package`, `pnpm --filter @rivetkit/rivetkit-wasm run check:wasm`, `pnpm --filter @rivetkit/rivetkit-wasm run check-types`. +- Plain `cargo test -p rivetkit-wasm` still fails on the pre-existing host-target `rivetkit-core` `wasm_bindgen_futures` dependency issue recorded in earlier progress. +- **Learnings for future iterations:** + - Use unique `(group, code)` namespaces when asserting `BRIDGE_RIVET_ERROR_SCHEMAS.len()` deltas because the intern map is process-global. + - Schema defaults are only the first-seen fallback for a `(group, code)`; callers should assert live bridged messages through `RivetTransportError.message()`. +--- +## 2026-05-02 03:30:40 PDT - US-012 +- Implemented bounded shutdown drain for core `ActorContext::register_task(...)` by racing registered futures against `shutdown_deadline_token()` in one shared native/wasm helper. +- Added a core sleep regression test that registers a never-completing future, cancels the shutdown deadline, verifies the shutdown counter drains, and checks the warning includes `actor_id` plus `reason = "shutdown_deadline_elapsed"`. +- Files changed: `rivetkit-rust/packages/rivetkit-core/src/actor/context.rs`, `rivetkit-rust/packages/rivetkit-core/tests/sleep.rs`, `rivetkit-rust/packages/rivetkit-core/AGENTS.md`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. +- Checks: `cargo test -p rivetkit-core register_task_exits_when_shutdown_deadline_cancels`, `cargo check -p rivetkit-core`, `scripts/cargo/check-rivetkit-core-wasm.sh`, `cargo test -p rivetkit-core actor_task_logs_lifecycle_dispatch_and_actor_event_flow`. +- Full `cargo test -p rivetkit-core` was stopped after it hung in the existing `save_tick_cancels_pending_inspector_deadline_and_broadcasts_overlay` test; that test also hung when run in isolation. +- **Learnings for future iterations:** + - Keep native and wasm `register_task(...)` behavior in a shared helper where possible so the two cfg-specific public signatures cannot diverge. + - The package-wide core test suite currently has an unrelated hanging inspector debounce test, so use focused tests plus `check-rivetkit-core-wasm.sh` for this shutdown path. +--- +## 2026-05-02 03:33:22 PDT - US-013 +- Implemented wraparound allocation for wasm websocket callback region IDs, skipping ID `0` and any IDs still live in the active region map instead of panicking at `u32::MAX`. +- Added a wasm-target regression test that seeds the allocator near `u32::MAX`, keeps a low ID live, and verifies wraparound does not collide. +- Files changed: `rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs`, `rivetkit-typescript/CLAUDE.md`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. +- Checks: `cargo test -p rivetkit-wasm --target wasm32-unknown-unknown --no-run websocket_callback_region_ids_wrap_without_collision`, `pnpm --filter @rivetkit/rivetkit-wasm run check:wasm`, `pnpm --filter @rivetkit/rivetkit-wasm run check-types`, `pnpm --filter @rivetkit/rivetkit-wasm run check:package`. +- **Learnings for future iterations:** + - Wasm websocket callback region allocation preserves the existing `u32` JS surface and uses `0` as the untracked sentinel for the impossible exhausted-ID case. + - Tests that assert wraparound should keep an existing low region ID live so the allocator proves it skips active IDs after wrapping. +--- +## 2026-05-02 03:34:47 PDT - US-014 +- Documented that `parse_bridge_rivet_error_reuses_interned_schema` owns the `(wasm_schema_cache_test, same_payload)` cache key so global schema-count delta assertions remain stable under concurrent tests. +- Files changed: `rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`. +- Checks: `cargo test -p rivetkit-wasm --target wasm32-unknown-unknown --no-run parse_bridge_rivet_error_reuses_interned_schema`, `pnpm --filter @rivetkit/rivetkit-wasm run check:package`, `pnpm --filter @rivetkit/rivetkit-wasm run check-types`, `pnpm --filter @rivetkit/rivetkit-wasm run check:wasm`. +- **Learnings for future iterations:** + - Existing Codebase Patterns already cover unique `(group, code)` namespaces for global wasm schema-cache length assertions. ---