diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 0ec1841ede..e28c13555e 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -157,6 +157,44 @@ jobs: upload_prefix: engine platform: windows-x64 release_only: true + # Rivet Cloud CLI: 4 platforms for preview, 5 for release. The publish + # job also places the matching rivet-engine artifact next to the CLI + # binary so `rivet dev` works from the npm package. + - name: cli (linux-x64-musl) + build_target: cli + docker: docker/build/linux-x64-musl.Dockerfile + artifact: rivet-x86_64-unknown-linux-musl + upload_prefix: cli + platform: linux-x64-musl + release_only: false + - name: cli (linux-arm64-musl) + build_target: cli + docker: docker/build/linux-arm64-musl.Dockerfile + artifact: rivet-aarch64-unknown-linux-musl + upload_prefix: cli + platform: linux-arm64-musl + release_only: false + - name: cli (darwin-x64) + build_target: cli + docker: docker/build/darwin-x64.Dockerfile + artifact: rivet-x86_64-apple-darwin + upload_prefix: cli + platform: darwin-x64 + release_only: false + - name: cli (darwin-arm64) + build_target: cli + docker: docker/build/darwin-arm64.Dockerfile + artifact: rivet-aarch64-apple-darwin + upload_prefix: cli + platform: darwin-arm64 + release_only: false + - name: cli (windows-x64) + build_target: cli + docker: docker/build/windows-x64.Dockerfile + artifact: rivet-x86_64-pc-windows-gnu.exe + upload_prefix: cli + platform: windows-x64 + release_only: true runs-on: depot-ubuntu-24.04-8 permissions: contents: read @@ -363,6 +401,12 @@ jobs: path: engine-artifacts pattern: engine-* merge-multiple: true + - name: Download CLI artifacts + uses: actions/download-artifact@v4 + with: + path: cli-artifacts + pattern: cli-* + merge-multiple: true - name: Place native binaries in platform packages run: | NATIVE_DIR=rivetkit-typescript/packages/rivetkit-napi @@ -408,6 +452,72 @@ jobs: fi done + - name: Place CLI binaries in CLI platform packages + run: | + CLI_DIR=rivetkit-typescript/packages/cli/npm + declare -A TRIPLE_TO_PLATFORM=( + [rivet-x86_64-unknown-linux-musl]=linux-x64-musl + [rivet-aarch64-unknown-linux-musl]=linux-arm64-musl + [rivet-x86_64-apple-darwin]=darwin-x64 + [rivet-aarch64-apple-darwin]=darwin-arm64 + [rivet-x86_64-pc-windows-gnu.exe]=win32-x64 + ) + for f in cli-artifacts/rivet-*; do + [ -e "$f" ] || continue + filename=$(basename "$f") + platform="${TRIPLE_TO_PLATFORM[$filename]:-}" + if [ -z "$platform" ]; then + echo "Skipping CLI artifact not mapped to a platform package: $filename" + continue + fi + dest="${CLI_DIR}/${platform}" + if [ ! -d "$dest" ]; then + echo "Missing CLI platform dir: $dest" >&2 + exit 1 + fi + if [ "$platform" = "win32-x64" ]; then + cp "$f" "$dest/rivet.exe" + echo "Placed $filename -> npm/${platform}/rivet.exe" + else + cp "$f" "$dest/rivet" + chmod +x "$dest/rivet" + echo "Placed $filename -> npm/${platform}/rivet" + fi + done + + - name: Place bundled engine in CLI platform packages + run: | + CLI_DIR=rivetkit-typescript/packages/cli/npm + declare -A ENGINE_TO_PLATFORM=( + [rivet-engine-x86_64-unknown-linux-musl]=linux-x64-musl + [rivet-engine-aarch64-unknown-linux-musl]=linux-arm64-musl + [rivet-engine-x86_64-apple-darwin]=darwin-x64 + [rivet-engine-aarch64-apple-darwin]=darwin-arm64 + [rivet-engine-x86_64-pc-windows-gnu.exe]=win32-x64 + ) + for f in engine-artifacts/rivet-engine-*; do + [ -e "$f" ] || continue + filename=$(basename "$f") + platform="${ENGINE_TO_PLATFORM[$filename]:-}" + if [ -z "$platform" ]; then + echo "Skipping engine artifact not mapped to a CLI platform package: $filename" + continue + fi + dest="${CLI_DIR}/${platform}" + if [ ! -d "$dest" ]; then + echo "Missing CLI platform dir: $dest" >&2 + exit 1 + fi + if [ "$platform" = "win32-x64" ]; then + cp "$f" "$dest/rivet-engine.exe" + echo "Placed $filename -> npm/${platform}/rivet-engine.exe" + else + cp "$f" "$dest/rivet-engine" + chmod +x "$dest/rivet-engine" + echo "Placed $filename -> npm/${platform}/rivet-engine" + fi + done + - name: Bump package versions for build run: | pnpm --filter=publish exec tsx src/ci/bin.ts bump-versions \ @@ -418,7 +528,9 @@ jobs: - name: Build TypeScript packages env: SKIP_WASM_BUILD: "1" - run: pnpm build -F rivetkit -F '@rivetkit/*' -F '!@rivetkit/shared-data' -F '!@rivetkit/engine-frontend' -F '!@rivetkit/mcp-hub' -F '!@rivetkit/rivetkit-napi' -F '!@rivetkit/rivetkit-wasm' + run: | + pnpm build -F rivetkit + pnpm build -F rivetkit -F '@rivetkit/*' -F '!@rivetkit/shared-data' -F '!@rivetkit/engine-frontend' -F '!@rivetkit/mcp-hub' -F '!@rivetkit/rivetkit-napi' -F '!@rivetkit/rivetkit-wasm' # ---- shared publish (runs for all triggers) ---- - name: Finalize package versions for publish diff --git a/Cargo.lock b/Cargo.lock index 84e63204dd..ca428a9a1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5213,6 +5213,24 @@ dependencies = [ "rivet-util", ] +[[package]] +name = "rivet-cli" +version = "2.3.0-rc.12" +dependencies = [ + "anyhow", + "clap", + "dirs", + "reqwest 0.12.22", + "rivetkit-engine-process", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", + "tracing-subscriber", + "url", +] + [[package]] name = "rivet-config" version = "2.3.0-rc.12" @@ -6086,6 +6104,7 @@ dependencies = [ "rivet-metrics", "rivetkit-actor-persist", "rivetkit-client-protocol", + "rivetkit-engine-process", "rivetkit-inspector-protocol", "rivetkit-shared-types", "scc", @@ -6108,6 +6127,22 @@ dependencies = [ "web-time", ] +[[package]] +name = "rivetkit-engine-process" +version = "2.3.0-rc.12" +dependencies = [ + "anyhow", + "reqwest 0.12.22", + "rivet-error", + "serde", + "serde_json", + "sha2", + "tempfile", + "tokio", + "tracing", + "url", +] + [[package]] name = "rivetkit-inspector-protocol" version = "2.3.0-rc.12" diff --git a/Cargo.toml b/Cargo.toml index a4d2c4d9df..5575ba6d63 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "engine/packages/cache", "engine/packages/cache-purge", "engine/packages/cache-result", + "engine/packages/cli", "engine/packages/config", "engine/packages/config-schema-gen", "engine/packages/datacenter", @@ -67,6 +68,7 @@ members = [ "engine/sdks/rust/ups-protocol", "rivetkit-rust/packages/actor-persist", "rivetkit-rust/packages/client", + "rivetkit-rust/packages/engine-process", "rivetkit-rust/packages/rivetkit", "rivetkit-rust/packages/rivetkit-core", "rivetkit-rust/packages/shared-types", @@ -619,6 +621,10 @@ members = [ path = "rivetkit-rust/packages/rivetkit-core" version = "=2.3.0-rc.12" + [workspace.dependencies.rivetkit-engine-process] + path = "rivetkit-rust/packages/engine-process" + version = "=2.3.0-rc.12" + [workspace.dependencies.rivetkit-shared-types] path = "rivetkit-rust/packages/shared-types" version = "=2.3.0-rc.12" diff --git a/docker/build/darwin-arm64.Dockerfile b/docker/build/darwin-arm64.Dockerfile index a7a4858547..71c0ec23f7 100644 --- a/docker/build/darwin-arm64.Dockerfile +++ b/docker/build/darwin-arm64.Dockerfile @@ -70,6 +70,9 @@ RUN --mount=type=cache,id=cargo-registry-darwin-arm64,target=/usr/local/cargo/re if [ "$BUILD_TARGET" = "engine" ]; then \ cargo build -p rivet-engine --bin rivet-engine $CARGO_FLAG --target aarch64-apple-darwin && \ cp target/aarch64-apple-darwin/$PROFILE_DIR/rivet-engine /artifacts/rivet-engine-aarch64-apple-darwin; \ + elif [ "$BUILD_TARGET" = "cli" ]; then \ + cargo build -p rivet-cli --bin rivet $CARGO_FLAG --target aarch64-apple-darwin && \ + cp target/aarch64-apple-darwin/$PROFILE_DIR/rivet /artifacts/rivet-aarch64-apple-darwin; \ elif [ "$BUILD_TARGET" = "rivetkit-napi" ]; then \ cd rivetkit-typescript/packages/rivetkit-napi && \ NAPI_RS_CROSS_COMPILE=1 napi build --platform $CARGO_FLAG --target aarch64-apple-darwin && \ diff --git a/docker/build/darwin-x64.Dockerfile b/docker/build/darwin-x64.Dockerfile index cc797599c2..2d2433b434 100644 --- a/docker/build/darwin-x64.Dockerfile +++ b/docker/build/darwin-x64.Dockerfile @@ -70,6 +70,9 @@ RUN --mount=type=cache,id=cargo-registry-darwin-x64,target=/usr/local/cargo/regi if [ "$BUILD_TARGET" = "engine" ]; then \ cargo build -p rivet-engine --bin rivet-engine $CARGO_FLAG --target x86_64-apple-darwin && \ cp target/x86_64-apple-darwin/$PROFILE_DIR/rivet-engine /artifacts/rivet-engine-x86_64-apple-darwin; \ + elif [ "$BUILD_TARGET" = "cli" ]; then \ + cargo build -p rivet-cli --bin rivet $CARGO_FLAG --target x86_64-apple-darwin && \ + cp target/x86_64-apple-darwin/$PROFILE_DIR/rivet /artifacts/rivet-x86_64-apple-darwin; \ elif [ "$BUILD_TARGET" = "rivetkit-napi" ]; then \ cd rivetkit-typescript/packages/rivetkit-napi && \ NAPI_RS_CROSS_COMPILE=1 napi build --platform $CARGO_FLAG --target x86_64-apple-darwin && \ diff --git a/docker/build/linux-arm64-musl.Dockerfile b/docker/build/linux-arm64-musl.Dockerfile index eee8640a0a..eabcf6ee2b 100644 --- a/docker/build/linux-arm64-musl.Dockerfile +++ b/docker/build/linux-arm64-musl.Dockerfile @@ -63,7 +63,13 @@ RUN --mount=type=cache,id=cargo-registry-linux-arm64-musl,target=/usr/local/carg if [ "$BUILD_TARGET" = "engine" ]; then \ RUSTFLAGS="--cfg tokio_unstable -C target-feature=+crt-static -C link-arg=-static-libgcc" \ cargo build -p rivet-engine --bin rivet-engine $CARGO_FLAG --target aarch64-unknown-linux-musl && \ + /opt/aarch64-linux-musl-cross/bin/aarch64-linux-musl-strip target/aarch64-unknown-linux-musl/$PROFILE_DIR/rivet-engine && \ cp target/aarch64-unknown-linux-musl/$PROFILE_DIR/rivet-engine /artifacts/rivet-engine-aarch64-unknown-linux-musl; \ + elif [ "$BUILD_TARGET" = "cli" ]; then \ + RUSTFLAGS="-C target-feature=+crt-static -C link-arg=-static-libgcc" \ + cargo build -p rivet-cli --bin rivet $CARGO_FLAG --target aarch64-unknown-linux-musl && \ + /opt/aarch64-linux-musl-cross/bin/aarch64-linux-musl-strip target/aarch64-unknown-linux-musl/$PROFILE_DIR/rivet && \ + cp target/aarch64-unknown-linux-musl/$PROFILE_DIR/rivet /artifacts/rivet-aarch64-unknown-linux-musl; \ elif [ "$BUILD_TARGET" = "rivetkit-napi" ]; then \ cd rivetkit-typescript/packages/rivetkit-napi && \ RUSTFLAGS="--cfg tokio_unstable -C target-feature=-crt-static" \ diff --git a/docker/build/linux-x64-musl.Dockerfile b/docker/build/linux-x64-musl.Dockerfile index 01e7644a54..19bcb67aea 100644 --- a/docker/build/linux-x64-musl.Dockerfile +++ b/docker/build/linux-x64-musl.Dockerfile @@ -62,7 +62,13 @@ RUN --mount=type=cache,id=cargo-registry-linux-x64-musl,target=/usr/local/cargo/ if [ "$BUILD_TARGET" = "engine" ]; then \ RUSTFLAGS="--cfg tokio_unstable -C target-feature=+crt-static -C link-arg=-static-libgcc" \ cargo build -p rivet-engine --bin rivet-engine $CARGO_FLAG --target x86_64-unknown-linux-musl && \ + /opt/x86_64-unknown-linux-musl/bin/x86_64-unknown-linux-musl-strip target/x86_64-unknown-linux-musl/$PROFILE_DIR/rivet-engine && \ cp target/x86_64-unknown-linux-musl/$PROFILE_DIR/rivet-engine /artifacts/rivet-engine-x86_64-unknown-linux-musl; \ + elif [ "$BUILD_TARGET" = "cli" ]; then \ + RUSTFLAGS="-C target-feature=+crt-static -C link-arg=-static-libgcc" \ + cargo build -p rivet-cli --bin rivet $CARGO_FLAG --target x86_64-unknown-linux-musl && \ + /opt/x86_64-unknown-linux-musl/bin/x86_64-unknown-linux-musl-strip target/x86_64-unknown-linux-musl/$PROFILE_DIR/rivet && \ + cp target/x86_64-unknown-linux-musl/$PROFILE_DIR/rivet /artifacts/rivet-x86_64-unknown-linux-musl; \ elif [ "$BUILD_TARGET" = "rivetkit-napi" ]; then \ cd rivetkit-typescript/packages/rivetkit-napi && \ RUSTFLAGS="--cfg tokio_unstable -C target-feature=-crt-static" \ diff --git a/docker/build/windows-x64.Dockerfile b/docker/build/windows-x64.Dockerfile index bba87b0947..d55ecfe59e 100644 --- a/docker/build/windows-x64.Dockerfile +++ b/docker/build/windows-x64.Dockerfile @@ -70,6 +70,9 @@ RUN --mount=type=cache,id=cargo-registry-windows-x64,target=/usr/local/cargo/reg if [ "$BUILD_TARGET" = "engine" ]; then \ cargo build -p rivet-engine --bin rivet-engine $CARGO_FLAG --target x86_64-pc-windows-gnu && \ cp target/x86_64-pc-windows-gnu/$PROFILE_DIR/rivet-engine.exe /artifacts/rivet-engine-x86_64-pc-windows-gnu.exe; \ + elif [ "$BUILD_TARGET" = "cli" ]; then \ + cargo build -p rivet-cli --bin rivet $CARGO_FLAG --target x86_64-pc-windows-gnu && \ + cp target/x86_64-pc-windows-gnu/$PROFILE_DIR/rivet.exe /artifacts/rivet-x86_64-pc-windows-gnu.exe; \ elif [ "$BUILD_TARGET" = "rivetkit-napi" ]; then \ cd rivetkit-typescript/packages/rivetkit-napi && \ napi build --platform $CARGO_FLAG --target x86_64-pc-windows-gnu && \ diff --git a/engine/packages/cli/Cargo.toml b/engine/packages/cli/Cargo.toml new file mode 100644 index 0000000000..f943148db4 --- /dev/null +++ b/engine/packages/cli/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "rivet-cli" +publish = false +version.workspace = true +authors.workspace = true +license.workspace = true +edition.workspace = true + +[[bin]] +name = "rivet" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +clap.workspace = true +dirs.workspace = true +reqwest.workspace = true +rivetkit-engine-process.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +url.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/engine/packages/cli/src/cloud.rs b/engine/packages/cli/src/cloud.rs new file mode 100644 index 0000000000..6282aa76d6 --- /dev/null +++ b/engine/packages/cli/src/cloud.rs @@ -0,0 +1,282 @@ +use std::time::Duration; + +use anyhow::{Context, Result, bail}; +use reqwest::{Method, StatusCode}; +use serde::{Deserialize, de::DeserializeOwned}; +use serde_json::{Value, json}; +use tokio::time::sleep; +use url::Url; + +use crate::{POOL_NAME, util::encode}; + +#[derive(Deserialize)] +pub struct TokenInspectResponse { + pub project: String, + pub organization: String, +} + +#[derive(Deserialize)] +pub struct NamespaceResponse { + pub namespace: Namespace, +} + +#[derive(Deserialize)] +struct NamespacesResponse { + namespaces: Vec, + pagination: Option, +} + +#[derive(Deserialize)] +struct Pagination { + cursor: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Namespace { + pub name: String, + pub display_name: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ManagedPoolResponse { + managed_pool: Option, +} + +#[derive(Deserialize)] +struct ManagedPool { + status: Option, + error: Option, +} + +#[derive(Deserialize)] +struct ManagedPoolError { + message: Option, +} + +pub struct CloudClient { + http: reqwest::Client, + base: Url, + token: String, +} + +impl CloudClient { + pub fn new(base: &str, token: String) -> Result { + Ok(Self { + http: reqwest::Client::new(), + base: Url::parse(base).context("invalid Cloud API endpoint")?, + token, + }) + } + + pub async fn request( + &self, + method: Method, + path: &str, + body: Option, + ) -> Result> { + let url = self.base.join(path.trim_start_matches('/'))?; + let mut request = self + .http + .request(method, url) + .bearer_auth(&self.token) + .header("Content-Type", "application/json"); + if let Some(body) = body { + request = request.json(&body); + } + let response = request.send().await.context("Cloud API request failed")?; + if response.status() == StatusCode::NOT_FOUND { + return Ok(None); + } + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + if !status.is_success() { + bail!("Cloud API error {status}: {text}"); + } + if text.trim().is_empty() { + return Ok(None); + } + Ok(Some(serde_json::from_str(&text).with_context(|| { + format!("Cloud API returned invalid JSON for {path}") + })?)) + } + + pub async fn request_ok( + &self, + method: Method, + path: &str, + body: Option, + ) -> Result> { + let url = self.base.join(path.trim_start_matches('/'))?; + let mut request = self + .http + .request(method, url) + .bearer_auth(&self.token) + .header("Content-Type", "application/json"); + if let Some(body) = body { + request = request.json(&body); + } + let response = request.send().await.context("Cloud API request failed")?; + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + if !status.is_success() { + bail!("Cloud API error {status}: {text}"); + } + if text.trim().is_empty() { + return Ok(None); + } + Ok(Some(serde_json::from_str(&text).with_context(|| { + format!("Cloud API returned invalid JSON for {path}") + })?)) + } +} + +pub async fn ensure_namespace( + cloud: &CloudClient, + project: &str, + org: &str, + namespace: &str, +) -> Result { + let path = format!( + "/projects/{}/namespaces/{}?org={}", + encode(project), + encode(namespace), + encode(org) + ); + if let Some(response) = cloud + .request::(Method::GET, &path, None) + .await? + { + return Ok(response.namespace); + } + + let list_path = format!( + "/projects/{}/namespaces?org={}&limit=100", + encode(project), + encode(org) + ); + if let Some(response) = cloud + .request::(Method::GET, &list_path, None) + .await? + { + let _next_cursor = response.pagination.and_then(|p| p.cursor); + if let Some(found) = response.namespaces.into_iter().find(|ns| { + ns.name == namespace + || ns + .display_name + .as_ref() + .is_some_and(|display| display.eq_ignore_ascii_case(namespace)) + }) { + return Ok(found); + } + } + + tracing::info!(%namespace, "creating namespace"); + let create_path = format!( + "/projects/{}/namespaces?org={}", + encode(project), + encode(org) + ); + let response: NamespaceResponse = cloud + .request( + Method::POST, + &create_path, + Some(json!({ "displayName": namespace })), + ) + .await? + .context("namespace create returned no body")?; + Ok(response.namespace) +} + +pub async fn create_or_update_pool( + cloud: &CloudClient, + project: &str, + org: &str, + namespace: &str, + body: Value, +) -> Result<()> { + let path = format!( + "/projects/{}/namespaces/{}/managed-pools/{}?org={}", + encode(project), + encode(namespace), + POOL_NAME, + encode(org) + ); + let _: Option = cloud.request_ok(Method::PUT, &path, Some(body)).await?; + Ok(()) +} + +async fn get_pool( + cloud: &CloudClient, + project: &str, + org: &str, + namespace: &str, +) -> Result> { + let path = format!( + "/projects/{}/namespaces/{}/managed-pools/{}?org={}", + encode(project), + encode(namespace), + POOL_NAME, + encode(org) + ); + Ok(cloud + .request::(Method::GET, &path, None) + .await? + .and_then(|r| r.managed_pool)) +} + +pub async fn wait_for_pool( + cloud: &CloudClient, + project: &str, + org: &str, + namespace: &str, + throw_on_error: bool, +) -> Result<()> { + for _ in 0..180 { + let pool = get_pool(cloud, project, org, namespace) + .await? + .context("managed pool disappeared while polling")?; + let status = pool.status.unwrap_or_else(|| "unknown".to_string()); + tracing::info!(%status, "pool status"); + match status.as_str() { + "ready" => return Ok(()), + "error" if throw_on_error => { + bail!( + "managed pool entered error state: {}", + pool.error + .and_then(|e| e.message) + .unwrap_or_else(|| "unknown error".to_string()) + ); + } + "error" => return Ok(()), + _ => sleep(Duration::from_secs(2)).await, + } + } + bail!("timed out waiting for managed pool to become ready") +} + +pub fn registry_endpoint(cloud_api: &str) -> Result { + derive_endpoint(cloud_api, "registry") +} + +pub fn dashboard_endpoint(cloud_api: &str) -> Result { + derive_endpoint(cloud_api, "dashboard") +} + +fn derive_endpoint(input: &str, subdomain: &str) -> Result { + let mut url = Url::parse(input)?; + let host = url.host_str().context("endpoint missing host")?; + let next_host = if let Some(rest) = host.strip_prefix("cloud-api.") { + format!("{subdomain}.{rest}") + } else if let Some(rest) = host.strip_prefix("api.") { + format!("{subdomain}.{rest}") + } else { + format!("{subdomain}.{host}") + }; + url.set_host(Some(&next_host))?; + url.set_path(""); + url.set_query(None); + url.set_fragment(None); + Ok(url.as_str().trim_end_matches('/').to_string()) +} diff --git a/engine/packages/cli/src/commands/deploy.rs b/engine/packages/cli/src/commands/deploy.rs new file mode 100644 index 0000000000..615b0e15f0 --- /dev/null +++ b/engine/packages/cli/src/commands/deploy.rs @@ -0,0 +1,139 @@ +use std::path::PathBuf; + +use anyhow::{Context, Result, bail}; +use clap::Parser; +use reqwest::Method; +use serde_json::json; + +use crate::{ + DEFAULT_CLOUD_API, DEFAULT_NAMESPACE, + cloud::{ + CloudClient, TokenInspectResponse, create_or_update_pool, dashboard_endpoint, + ensure_namespace, registry_endpoint, wait_for_pool, + }, + credentials::{resolve_token, write_credentials}, + util::{default_image_tag, docker_build, docker_login, encode, parse_env_vars, run_command}, +}; + +#[derive(Parser)] +pub struct Opts { + /// Rivet Cloud API token. Also writes ~/.rivet/credentials for later commands. + #[arg(long)] + token: Option, + /// Cloud namespace to deploy to. + #[arg(long, default_value = DEFAULT_NAMESPACE)] + namespace: String, + /// Override project from /tokens/api/inspect. + #[arg(long)] + project: Option, + /// Override organization from /tokens/api/inspect. + #[arg(long)] + org: Option, + /// Dockerfile to build. + #[arg(long, default_value = "Dockerfile")] + dockerfile: PathBuf, + /// Docker build context. + #[arg(long, default_value = ".")] + build_context: PathBuf, + /// Environment override, repeatable as KEY=VAL. + #[arg(long = "env")] + env_vars: Vec, + /// Skip prompts. + #[arg(long)] + yes: bool, + /// Cloud API endpoint. + #[arg(long, default_value = DEFAULT_CLOUD_API)] + cloud_api: String, + /// Image repository name in Rivet's registry. Defaults to the project slug. + #[arg(long)] + image: Option, + /// Image tag. Defaults to the current git short SHA, or a timestamp outside git. + #[arg(long)] + tag: Option, +} + +impl Opts { + pub async fn execute(self) -> Result<()> { + let token = resolve_token(self.token.as_deref())?; + if let Some(token) = &self.token { + write_credentials(token)?; + } + + if !self.dockerfile.exists() { + bail!("Dockerfile not found: {}", self.dockerfile.display()); + } + + let cloud = CloudClient::new(&self.cloud_api, token.clone())?; + tracing::info!("inspecting Rivet Cloud token"); + let inspect: TokenInspectResponse = cloud + .request(Method::GET, "/tokens/api/inspect", None) + .await? + .context("token inspect returned no body")?; + let project = self.project.unwrap_or(inspect.project); + let organization = self.org.unwrap_or(inspect.organization); + let namespace = ensure_namespace(&cloud, &project, &organization, &self.namespace).await?; + + let registry = registry_endpoint(&self.cloud_api)?; + let dashboard = dashboard_endpoint(&self.cloud_api)?; + let image_name = self.image.unwrap_or_else(|| project.clone()); + let tag = self.tag.unwrap_or_else(default_image_tag); + let image_ref = format!("{registry}/{image_name}:{tag}"); + let dashboard_url = format!( + "{dashboard}/orgs/{}/projects/{}/ns/{}?skipOnboarding=1", + encode(&organization), + encode(&project), + encode(&namespace.name) + ); + + if !self.yes { + tracing::info!( + context = %self.build_context.display(), + %project, + namespace = %namespace.name, + image = %image_ref, + "deploying" + ); + } + + tracing::info!("enabling managed pool"); + create_or_update_pool( + &cloud, + &project, + &organization, + &namespace.name, + json!({ "displayName": "Default" }), + ) + .await?; + wait_for_pool(&cloud, &project, &organization, &namespace.name, false).await?; + + tracing::info!("logging in to Rivet registry"); + docker_login(®istry, &token)?; + + tracing::info!("building Docker image"); + docker_build(&self.build_context, &self.dockerfile, &image_ref)?; + + tracing::info!("pushing Docker image"); + run_command("docker", &["push", &image_ref], None)?; + + tracing::info!("upserting managed pool"); + let mut pool_body = json!({ + "displayName": "Default", + "maxConcurrentActors": 1000, + "image": { + "repository": image_name, + "tag": tag, + }, + }); + let env_map = parse_env_vars(&self.env_vars)?; + if !env_map.is_empty() { + pool_body["environment"] = serde_json::to_value(env_map)?; + } + create_or_update_pool(&cloud, &project, &organization, &namespace.name, pool_body).await?; + wait_for_pool(&cloud, &project, &organization, &namespace.name, true).await?; + + // The dashboard URL is the command's result; print it to stdout so it + // can be captured by scripts. + println!("{dashboard_url}"); + Ok(()) + } +} diff --git a/engine/packages/cli/src/commands/dev.rs b/engine/packages/cli/src/commands/dev.rs new file mode 100644 index 0000000000..e727cf1c4c --- /dev/null +++ b/engine/packages/cli/src/commands/dev.rs @@ -0,0 +1,531 @@ +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, bail}; +use clap::{Parser, ValueEnum}; +use reqwest::Client; +use rivetkit_engine_process::EngineProcessManager; +use serde_json::json; +use tokio::process::{Child, Command}; + +use crate::{ + DEFAULT_ENGINE_ENDPOINT, LOCAL_NAMESPACE, POOL_NAME, SUPABASE_FN_DEFAULT, + engine_runner::engine_config, util::encode, +}; + +const HANDLER_METADATA_TIMEOUT: Duration = Duration::from_secs(30); +const HANDLER_METADATA_RETRY: Duration = Duration::from_millis(200); +const HANDLER_METADATA_REQUEST_TIMEOUT: Duration = Duration::from_secs(3); + +#[derive(Parser)] +pub struct Opts { + /// Serverless platform preset. Omit to run a custom dev server you point at + /// with --port or --url. + #[arg(long, value_enum)] + provider: Option, + /// Handler port. Required in the default (no provider) mode unless --url is + /// set. Overrides the provider's default port. + #[arg(long)] + port: Option, + /// Supabase function name when --provider=supabase. + #[arg(long, default_value = SUPABASE_FN_DEFAULT)] + fn_name: String, + /// Explicit full handler URL. Overrides port and path construction. + #[arg(long)] + url: Option, + /// Path to a rivet-engine binary. Defaults to RIVET_ENGINE_BINARY_PATH, a + /// binary next to this CLI, a local build, or an auto-downloaded release. + #[arg(long)] + engine_binary: Option, + /// Dev server command to spawn. Everything after `--`. + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + command: Vec, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] +enum Provider { + /// Generic serverless handler. The CLI assigns a free port and passes it as + /// the PORT environment variable. + Serverless, + Cloudflare, + Supabase, + /// Run only the engine, do not spawn a handler. + None, +} + +impl Opts { + pub async fn execute(self) -> Result<()> { + let mut config = engine_config(self.engine_binary.clone()); + config.public_url = resolve_engine_public_url(&self)?; + if self.provider == Some(Provider::Supabase) { + config.bind_host = Some("0.0.0.0".to_string()); + } + + // Engine-only mode: start (or reuse) the engine and wait. + if matches!(self.provider, Some(Provider::None)) { + let _engine = EngineProcessManager::start_or_reuse(config).await?; + tracing::info!( + engine = DEFAULT_ENGINE_ENDPOINT, + "engine ready (no handler); press Ctrl-C to stop" + ); + tokio::signal::ctrl_c().await.context("listen for ctrl-c")?; + return Ok(()); + } + + let plan = HandlerPlan::resolve(&self)?; + + // Start (or reuse) the engine. The engine is intentionally orphaned, so + // it survives this process and a later `rivet dev` reattaches to it. + let _engine = EngineProcessManager::start_or_reuse(config).await?; + + let mut child = plan.spawn()?; + + tokio::select! { + result = wait_for_handler_metadata(&plan.handler_url) => { + if let Err(err) = result { + let _ = child.kill().await; + return Err(err); + } + } + status = child.wait() => { + let status = status.context("wait for dev server")?; + bail!("dev server exited before the Rivet handler became ready: {status}"); + } + } + + if let Err(err) = + register_runner_config(DEFAULT_ENGINE_ENDPOINT, POOL_NAME, &plan.handler_url).await + { + let _ = child.kill().await; + return Err(err); + } + + tracing::info!( + engine = DEFAULT_ENGINE_ENDPOINT, + handler = %plan.handler_url, + "rivet dev ready; press Ctrl-C to stop" + ); + + tokio::select! { + status = child.wait() => { + let status = status.context("wait for dev server")?; + if !status.success() { + bail!("dev server exited with {status}"); + } + } + _ = tokio::signal::ctrl_c() => { + tracing::info!( + "stopping dev server (engine keeps running; use `rivet engine` to manage it)" + ); + let _ = child.kill().await; + } + } + + Ok(()) + } +} + +/// Resolved spawn plan for the dev server: where it listens, the command to +/// run, and any environment the CLI injects. +#[derive(Debug)] +struct HandlerPlan { + handler_url: String, + program: String, + args: Vec, + env: Vec<(String, String)>, +} + +impl HandlerPlan { + fn resolve(opts: &Opts) -> Result { + let provider = opts.provider; + let port = resolve_port(provider, opts.port, opts.url.is_some())?; + let handler_url = match &opts.url { + Some(url) => url.clone(), + None => build_handler_url(provider, &opts.fn_name, port), + }; + + let (program, args, env) = match provider { + Some(Provider::Cloudflare) => { + let mut args = vec![ + "wrangler".to_string(), + "dev".to_string(), + "--port".to_string(), + port.to_string(), + ]; + args.extend(opts.command.iter().cloned()); + ("npx".to_string(), args, Vec::new()) + } + Some(Provider::Supabase) => { + let mut args = vec![ + "supabase".to_string(), + "functions".to_string(), + "serve".to_string(), + opts.fn_name.clone(), + "--no-verify-jwt".to_string(), + ]; + args.extend(opts.command.iter().cloned()); + ("npx".to_string(), args, Vec::new()) + } + Some(Provider::Serverless) => { + let (program, args) = split_command(&opts.command)?; + // Serverless handlers learn their port from the PORT env var. + (program, args, vec![("PORT".to_string(), port.to_string())]) + } + // Default (no provider): spawn the user's command verbatim. + None => { + let (program, args) = split_command(&opts.command)?; + (program, args, Vec::new()) + } + Some(Provider::None) => unreachable!("engine-only mode handled before resolve"), + }; + + Ok(Self { + handler_url, + program, + args, + env, + }) + } + + fn spawn(&self) -> Result { + let mut command = Command::new(&self.program); + command.args(&self.args); + for (key, value) in &self.env { + command.env(key, value); + } + command + .spawn() + .with_context(|| format!("spawn dev server `{}`", self.program)) + } +} + +/// Resolves the handler port for the given provider. Returns an error in the +/// default mode when neither a port nor an explicit URL is provided. +fn resolve_port(provider: Option, port: Option, has_url: bool) -> Result { + match provider { + Some(Provider::Cloudflare) => Ok(port.unwrap_or(8787)), + Some(Provider::Supabase) => Ok(port.unwrap_or(54321)), + Some(Provider::Serverless) => match port { + Some(port) => Ok(port), + None => pick_free_port(), + }, + // Default mode: the port is not managed by the CLI, so it must be + // provided so the runner can be registered. `0` is a sentinel that + // callers only reach when --url is set (and the port is unused). + None if has_url => Ok(port.unwrap_or(0)), + None => port.context("provide --port (or --url) for the default dev server mode"), + Some(Provider::None) => unreachable!("engine-only mode handled before resolve"), + } +} + +fn build_handler_url(provider: Option, fn_name: &str, port: u16) -> String { + match provider { + Some(Provider::Supabase) => { + format!("http://127.0.0.1:{port}/functions/v1/{fn_name}/api/rivet") + } + _ => format!("http://127.0.0.1:{port}/api/rivet"), + } +} + +fn split_command(command: &[String]) -> Result<(String, Vec)> { + let Some((program, args)) = command.split_first() else { + bail!( + "provide a dev server command after `--` (for example `rivet dev -- npm run dev`), \ + or use `--provider none` to run only the engine" + ); + }; + Ok((program.clone(), args.to_vec())) +} + +fn resolve_engine_public_url(opts: &Opts) -> Result> { + if opts.provider != Some(Provider::Supabase) { + return Ok(None); + } + + if let Some(endpoint) = read_env_value("RIVET_ENDPOINT") { + return Ok(Some(endpoint)); + } + + if let Some(env_file) = supabase_env_file(&opts.command) { + if let Some(endpoint) = read_dotenv_value(&env_file, "RIVET_ENDPOINT") { + return Ok(Some(endpoint)); + } + } + + if let Some(endpoint) = read_dotenv_value(Path::new(".env.local"), "RIVET_ENDPOINT") { + return Ok(Some(endpoint)); + } + + Ok(None) +} + +fn supabase_env_file(command: &[String]) -> Option { + command + .windows(2) + .find_map(|window| (window[0] == "--env-file").then(|| PathBuf::from(&window[1]))) +} + +fn read_env_value(key: &str) -> Option { + std::env::var(key) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn read_dotenv_value(path: &Path, key: &str) -> Option { + let contents = std::fs::read_to_string(path).ok()?; + contents.lines().find_map(|line| { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + return None; + } + let (name, value) = line.split_once('=')?; + if name.trim() != key { + return None; + } + Some(strip_env_quotes(value.trim()).to_string()) + }) +} + +fn strip_env_quotes(value: &str) -> &str { + if value.len() >= 2 + && ((value.starts_with('"') && value.ends_with('"')) + || (value.starts_with('\'') && value.ends_with('\''))) + { + &value[1..value.len() - 1] + } else { + value + } +} + +/// Allocates a free TCP port for the serverless handler. There is a small +/// window between picking the port and the handler binding it, which is +/// acceptable for local development. +fn pick_free_port() -> Result { + let listener = std::net::TcpListener::bind("127.0.0.1:0") + .context("allocate a free port for the serverless handler")?; + Ok(listener.local_addr().context("read allocated port")?.port()) +} + +async fn register_runner_config(endpoint: &str, runner: &str, handler_url: &str) -> Result<()> { + let url = format!( + "{}/runner-configs/{}?namespace={}", + endpoint.trim_end_matches('/'), + encode(runner), + LOCAL_NAMESPACE + ); + let body = json!({ + "datacenters": { + "default": { + "serverless": { + "url": handler_url, + "headers": {}, + "request_lifespan": 3600, + "slots_per_runner": 1, + "min_runners": 0, + "max_runners": 100000, + "runners_margin": 0, + "metadata_poll_interval": 1000 + } + } + } + }); + let response = Client::new() + .put(url) + .header("Content-Type", "application/json") + .bearer_auth("dev") + .json(&body) + .send() + .await + .context("register local runner config")?; + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + bail!("runner config update failed: {status}: {text}"); + } + Ok(()) +} + +async fn wait_for_handler_metadata(handler_url: &str) -> Result<()> { + let metadata_url = format!("{}/metadata", handler_url.trim_end_matches('/')); + let client = Client::new(); + let deadline = Instant::now() + HANDLER_METADATA_TIMEOUT; + let mut last_error: Option = None; + + loop { + if Instant::now() >= deadline { + bail!( + "Rivet handler metadata did not become ready at {metadata_url} within {}s (last error: {})", + HANDLER_METADATA_TIMEOUT.as_secs(), + last_error.as_deref().unwrap_or("no request attempted") + ); + } + + match client + .get(&metadata_url) + .timeout(HANDLER_METADATA_REQUEST_TIMEOUT) + .send() + .await + { + Ok(response) if response.status().is_success() => return Ok(()), + Ok(response) => { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + last_error = Some(format!("HTTP {status}: {body}")); + } + Err(err) => { + last_error = Some(err.to_string()); + } + } + + tokio::time::sleep(HANDLER_METADATA_RETRY).await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn opts(provider: Option) -> Opts { + Opts { + provider, + port: None, + fn_name: SUPABASE_FN_DEFAULT.to_string(), + url: None, + engine_binary: None, + command: Vec::new(), + } + } + + #[test] + fn cloudflare_provider_uses_default_port_and_wrangler_command() { + let plan = HandlerPlan::resolve(&opts(Some(Provider::Cloudflare))).unwrap(); + + assert_eq!(plan.handler_url, "http://127.0.0.1:8787/api/rivet"); + assert_eq!(plan.program, "npx"); + assert_eq!(plan.args, ["wrangler", "dev", "--port", "8787"]); + assert!(plan.env.is_empty()); + } + + #[test] + fn cloudflare_provider_allows_custom_port_and_appended_args() { + let mut opts = opts(Some(Provider::Cloudflare)); + opts.port = Some(8788); + opts.command = vec!["--local-protocol".into(), "http".into()]; + + let plan = HandlerPlan::resolve(&opts).unwrap(); + + assert_eq!(plan.handler_url, "http://127.0.0.1:8788/api/rivet"); + assert_eq!( + plan.args, + [ + "wrangler", + "dev", + "--port", + "8788", + "--local-protocol", + "http" + ] + ); + } + + #[test] + fn supabase_provider_uses_default_port_function_and_no_verify_jwt() { + let plan = HandlerPlan::resolve(&opts(Some(Provider::Supabase))).unwrap(); + + assert_eq!( + plan.handler_url, + "http://127.0.0.1:54321/functions/v1/rivet/api/rivet" + ); + assert_eq!(plan.program, "npx"); + assert_eq!( + plan.args, + ["supabase", "functions", "serve", "rivet", "--no-verify-jwt"] + ); + assert!(plan.env.is_empty()); + } + + #[test] + fn supabase_provider_allows_custom_function_port_and_appended_args() { + let mut opts = opts(Some(Provider::Supabase)); + opts.port = Some(4000); + opts.fn_name = "actors".into(); + opts.command = vec!["--env-file".into(), ".env.local".into()]; + + let plan = HandlerPlan::resolve(&opts).unwrap(); + + assert_eq!( + plan.handler_url, + "http://127.0.0.1:4000/functions/v1/actors/api/rivet" + ); + assert_eq!( + plan.args, + [ + "supabase", + "functions", + "serve", + "actors", + "--no-verify-jwt", + "--env-file", + ".env.local" + ] + ); + } + + #[test] + fn serverless_provider_injects_port_env_for_command() { + let mut opts = opts(Some(Provider::Serverless)); + opts.port = Some(3001); + opts.command = vec!["node".into(), "handler.js".into()]; + + let plan = HandlerPlan::resolve(&opts).unwrap(); + + assert_eq!(plan.handler_url, "http://127.0.0.1:3001/api/rivet"); + assert_eq!(plan.program, "node"); + assert_eq!(plan.args, ["handler.js"]); + assert_eq!(plan.env, [("PORT".to_string(), "3001".to_string())]); + } + + #[test] + fn default_mode_requires_port_or_url() { + let mut opts = opts(None); + opts.command = vec!["npm".into(), "run".into(), "dev".into()]; + + let error = HandlerPlan::resolve(&opts).unwrap_err().to_string(); + + assert!(error.contains("provide --port")); + } + + #[test] + fn explicit_url_overrides_handler_url() { + let mut opts = opts(None); + opts.url = Some("http://127.0.0.1:9000/custom".into()); + opts.command = vec!["npm".into(), "run".into(), "dev".into()]; + + let plan = HandlerPlan::resolve(&opts).unwrap(); + + assert_eq!(plan.handler_url, "http://127.0.0.1:9000/custom"); + assert_eq!(plan.program, "npm"); + assert_eq!(plan.args, ["run", "dev"]); + } + + #[test] + fn supabase_public_engine_url_reads_passed_env_file() { + let temp = tempfile::tempdir().unwrap(); + let env_path = temp.path().join(".env.local"); + std::fs::write( + &env_path, + "RIVET_ENDPOINT=\"http://host.docker.internal:6420\"\n", + ) + .unwrap(); + let mut opts = opts(Some(Provider::Supabase)); + opts.command = vec!["--env-file".into(), env_path.to_string_lossy().into_owned()]; + + let public_url = resolve_engine_public_url(&opts).unwrap(); + + assert_eq!( + public_url, + Some("http://host.docker.internal:6420".to_string()) + ); + } +} diff --git a/engine/packages/cli/src/commands/engine.rs b/engine/packages/cli/src/commands/engine.rs new file mode 100644 index 0000000000..8dd389b09e --- /dev/null +++ b/engine/packages/cli/src/commands/engine.rs @@ -0,0 +1,46 @@ +use std::{path::PathBuf, process::Stdio}; + +use anyhow::{Context, Result, bail}; +use clap::Parser; +use rivetkit_engine_process::{engine_env, resolve_engine_binary_path}; +use tokio::process::Command; + +use crate::engine_runner::engine_config; + +#[derive(Parser)] +pub struct Opts { + /// Path to a rivet-engine binary. Defaults to RIVET_ENGINE_BINARY_PATH, a + /// binary next to this CLI, a local build, or an auto-downloaded release. + #[arg(long)] + engine_binary: Option, + /// Arguments forwarded verbatim to the rivet-engine binary. + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, +} + +impl Opts { + pub async fn execute(self) -> Result<()> { + let config = engine_config(self.engine_binary); + let binary = resolve_engine_binary_path(&config).await?; + let env = engine_env(&config)?; + + let mut command = Command::new(&binary); + command.args(&self.args); + for (key, value) in &env { + command.env(key, value); + } + command + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + + let status = command + .status() + .await + .with_context(|| format!("run {}", binary.display()))?; + if !status.success() { + bail!("rivet-engine exited with {status}"); + } + Ok(()) + } +} diff --git a/engine/packages/cli/src/commands/mod.rs b/engine/packages/cli/src/commands/mod.rs new file mode 100644 index 0000000000..5321f220ba --- /dev/null +++ b/engine/packages/cli/src/commands/mod.rs @@ -0,0 +1,4 @@ +pub mod deploy; +pub mod dev; +pub mod engine; +pub mod setup_ci; diff --git a/engine/packages/cli/src/commands/setup_ci.rs b/engine/packages/cli/src/commands/setup_ci.rs new file mode 100644 index 0000000000..5658eb57f2 --- /dev/null +++ b/engine/packages/cli/src/commands/setup_ci.rs @@ -0,0 +1,33 @@ +use std::{fs, path::PathBuf}; + +use anyhow::{Result, bail}; +use clap::Parser; + +use crate::templates::{RIVET_DEPLOY_WORKFLOW_PATH, rivet_deploy_workflow}; + +#[derive(Parser)] +pub struct Opts { + /// Overwrite the workflow file if it already exists. + #[arg(long)] + force: bool, +} + +impl Opts { + pub async fn execute(self) -> Result<()> { + let path = PathBuf::from(RIVET_DEPLOY_WORKFLOW_PATH); + if path.exists() && !self.force { + bail!( + "{} already exists; pass --force to overwrite", + path.display() + ); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&path, rivet_deploy_workflow())?; + tracing::info!(path = %path.display(), "wrote GitHub Actions deploy workflow"); + tracing::info!("add your Rivet Cloud token as a repository secret to enable CI:"); + tracing::info!(" gh secret set RIVET_CLOUD_TOKEN"); + Ok(()) + } +} diff --git a/engine/packages/cli/src/credentials.rs b/engine/packages/cli/src/credentials.rs new file mode 100644 index 0000000000..8b5fcc512b --- /dev/null +++ b/engine/packages/cli/src/credentials.rs @@ -0,0 +1,78 @@ +use std::{env, fs, path::PathBuf}; + +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +pub struct Credentials { + pub rivet_cloud_token: String, +} + +/// Resolves the Rivet Cloud token from, in order: the flag, the +/// `RIVET_CLOUD_TOKEN` env var, then `~/.rivet/credentials`. +pub fn resolve_token(flag: Option<&str>) -> Result { + if let Some(token) = flag { + return Ok(token.to_string()); + } + if let Ok(token) = env::var("RIVET_CLOUD_TOKEN") { + if !token.trim().is_empty() { + return Ok(token); + } + } + let path = credentials_path()?; + if path.exists() { + let credentials: Credentials = serde_json::from_str(&fs::read_to_string(&path)?)?; + if !credentials.rivet_cloud_token.trim().is_empty() { + return Ok(credentials.rivet_cloud_token); + } + } + bail!("missing Rivet Cloud token; pass --token or set RIVET_CLOUD_TOKEN") +} + +pub fn write_credentials(token: &str) -> Result<()> { + let path = credentials_path()?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let contents = serde_json::to_string_pretty(&Credentials { + rivet_cloud_token: token.to_string(), + })?; + write_secret_file(&path, contents.as_bytes())?; + Ok(()) +} + +#[cfg(unix)] +fn write_secret_file(path: &std::path::Path, contents: &[u8]) -> Result<()> { + use std::{ + fs::OpenOptions, + io::Write, + os::unix::fs::{OpenOptionsExt, PermissionsExt}, + }; + + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .mode(0o600) + .open(path) + .with_context(|| format!("open {}", path.display()))?; + file.write_all(contents)?; + file.sync_all()?; + let mut perms = file.metadata()?.permissions(); + perms.set_mode(0o600); + fs::set_permissions(path, perms)?; + Ok(()) +} + +#[cfg(not(unix))] +fn write_secret_file(path: &std::path::Path, contents: &[u8]) -> Result<()> { + fs::write(path, contents)?; + Ok(()) +} + +fn credentials_path() -> Result { + Ok(dirs::home_dir() + .context("could not resolve home directory")? + .join(".rivet") + .join("credentials")) +} diff --git a/engine/packages/cli/src/engine_runner.rs b/engine/packages/cli/src/engine_runner.rs new file mode 100644 index 0000000000..508f05d5e3 --- /dev/null +++ b/engine/packages/cli/src/engine_runner.rs @@ -0,0 +1,50 @@ +use std::{ + env, + path::{Path, PathBuf}, +}; + +use rivetkit_engine_process::EngineResolverConfig; + +use crate::DEFAULT_ENGINE_ENDPOINT; + +/// Builds the engine resolver config shared by `rivet dev` and `rivet engine`. +/// +/// Resolution order (handled by the engine-process crate): the explicit +/// `--engine-binary` path, then `RIVET_ENGINE_BINARY_PATH`, then a binary +/// bundled next to this CLI, then a local build, then an auto-downloaded +/// release. +pub fn engine_config(engine_binary: Option) -> EngineResolverConfig { + let explicit = engine_binary.or_else(|| { + let bundled = bundled_engine_binary(); + bundled.exists().then_some(bundled) + }); + + EngineResolverConfig::from_parts( + DEFAULT_ENGINE_ENDPOINT, + explicit, + None, + None, + engine_auto_download(), + ) +} + +/// Whether the CLI may download a release engine binary when none is found +/// locally. Enabled by default for the CLI; set `RIVETKIT_ENGINE_AUTO_DOWNLOAD` +/// to `0` or `false` to require a local binary. +fn engine_auto_download() -> bool { + match env::var("RIVETKIT_ENGINE_AUTO_DOWNLOAD") { + Ok(value) => !matches!(value.trim(), "0" | "false" | ""), + Err(_) => true, + } +} + +/// Path to a rivet-engine binary distributed next to this CLI binary. +fn bundled_engine_binary() -> PathBuf { + let exe = env::current_exe().unwrap_or_else(|_| PathBuf::from("rivet")); + let name = if cfg!(windows) { + "rivet-engine.exe" + } else { + "rivet-engine" + }; + exe.parent().unwrap_or_else(|| Path::new(".")).join(name) +} diff --git a/engine/packages/cli/src/main.rs b/engine/packages/cli/src/main.rs new file mode 100644 index 0000000000..0dd3c344f8 --- /dev/null +++ b/engine/packages/cli/src/main.rs @@ -0,0 +1,60 @@ +use anyhow::Result; +use clap::{Parser, Subcommand}; + +mod cloud; +mod commands; +mod credentials; +mod engine_runner; +mod templates; +mod util; + +pub(crate) const DEFAULT_CLOUD_API: &str = "https://cloud-api.rivet.dev"; +pub(crate) const DEFAULT_ENGINE_ENDPOINT: &str = "http://127.0.0.1:6420"; +pub(crate) const DEFAULT_NAMESPACE: &str = "production"; +pub(crate) const LOCAL_NAMESPACE: &str = "default"; +pub(crate) const POOL_NAME: &str = "default"; +pub(crate) const SUPABASE_FN_DEFAULT: &str = "rivet"; + +#[derive(Parser)] +#[command(name = "rivet", version, about = "Rivet CLI")] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Run a local Rivet engine and the dev server for your handler. + Dev(commands::dev::Opts), + /// Run the bundled rivet-engine binary directly (proxies all arguments). + Engine(commands::engine::Opts), + /// Build and deploy the current project to Rivet Cloud. + Deploy(commands::deploy::Opts), + /// Install the GitHub Actions workflow that deploys to Rivet Cloud. + SetupCi(commands::setup_ci::Opts), +} + +#[tokio::main] +async fn main() -> Result<()> { + init_tracing(); + + let cli = Cli::parse(); + match cli.command { + Commands::Dev(opts) => opts.execute().await, + Commands::Engine(opts) => opts.execute().await, + Commands::Deploy(opts) => opts.execute().await, + Commands::SetupCi(opts) => opts.execute().await, + } +} + +fn init_tracing() { + use tracing_subscriber::{EnvFilter, fmt}; + + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + fmt() + .with_env_filter(filter) + .with_writer(std::io::stderr) + .without_time() + .with_target(false) + .init(); +} diff --git a/engine/packages/cli/src/templates.rs b/engine/packages/cli/src/templates.rs new file mode 100644 index 0000000000..aafeb869f8 --- /dev/null +++ b/engine/packages/cli/src/templates.rs @@ -0,0 +1,33 @@ +/// Path of the GitHub Actions workflow installed by `rivet setup-ci`. +pub const RIVET_DEPLOY_WORKFLOW_PATH: &str = ".github/workflows/rivet-deploy.yml"; + +/// GitHub Actions workflow that deploys to Rivet Cloud on push and pull +/// request. Kept in sync with the dashboard cloud onboarding flow +/// (`frontend/src/app/getting-started.tsx`). +pub fn rivet_deploy_workflow() -> &'static str { + r#"name: Rivet Deploy + +on: + pull_request: + types: [opened, synchronize, reopened, closed] + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: rivet-deploy-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + rivet-deploy: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + - uses: rivet-dev/deploy-action@v1.1.2 + with: + rivet-token: ${{ secrets.RIVET_CLOUD_TOKEN }} +"# +} diff --git a/engine/packages/cli/src/util.rs b/engine/packages/cli/src/util.rs new file mode 100644 index 0000000000..cc029339d5 --- /dev/null +++ b/engine/packages/cli/src/util.rs @@ -0,0 +1,109 @@ +use std::{ + collections::BTreeMap, + io::Write, + path::Path, + process::{Command as StdCommand, Stdio}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use anyhow::{Context, Result, bail}; + +/// URL-encodes a path or query segment. +pub fn encode(value: &str) -> String { + url::form_urlencoded::byte_serialize(value.as_bytes()).collect() +} + +/// Parses repeated `KEY=VAL` arguments into a map. +pub fn parse_env_vars(vars: &[String]) -> Result> { + let mut map = BTreeMap::new(); + for var in vars { + let Some((key, value)) = var.split_once('=') else { + bail!("--env must be KEY=VAL, got {var}"); + }; + if key.is_empty() { + bail!("--env key cannot be empty"); + } + map.insert(key.to_string(), value.to_string()); + } + Ok(map) +} + +/// Default image tag: the current git short SHA, or a unix timestamp outside a +/// git repo. +pub fn default_image_tag() -> String { + if let Ok(output) = StdCommand::new("git") + .args(["rev-parse", "--short=7", "HEAD"]) + .stderr(Stdio::null()) + .output() + { + if output.status.success() { + let tag = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !tag.is_empty() { + return tag; + } + } + } + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .to_string() +} + +pub fn docker_login(registry: &str, token: &str) -> Result<()> { + let mut child = StdCommand::new("docker") + .args(["login", registry, "--username", "rivet", "--password-stdin"]) + .stdin(Stdio::piped()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .context("docker login")?; + child + .stdin + .as_mut() + .context("docker login stdin unavailable")? + .write_all(token.as_bytes())?; + let status = child.wait()?; + if !status.success() { + bail!("docker login failed with {status}"); + } + Ok(()) +} + +pub fn docker_build(context: &Path, dockerfile: &Path, image_ref: &str) -> Result<()> { + let context_str = context.to_string_lossy(); + let dockerfile_str = dockerfile.to_string_lossy(); + run_command( + "docker", + &[ + "buildx", + "build", + "--platform", + "linux/amd64", + "--load", + &context_str, + "-f", + &dockerfile_str, + "-t", + image_ref, + ], + None, + ) +} + +pub fn run_command(program: &str, args: &[&str], cwd: Option<&Path>) -> Result<()> { + tracing::info!(command = %format!("{} {}", program, args.join(" ")), "running command"); + let mut command = StdCommand::new(program); + command + .args(args) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + if let Some(cwd) = cwd { + command.current_dir(cwd); + } + let status = command.status().with_context(|| format!("run {program}"))?; + if !status.success() { + bail!("{program} failed with {status}"); + } + Ok(()) +} diff --git a/engine/packages/pegboard-outbound/src/lib.rs b/engine/packages/pegboard-outbound/src/lib.rs index b38617d4bd..5d3f3a325d 100644 --- a/engine/packages/pegboard-outbound/src/lib.rs +++ b/engine/packages/pegboard-outbound/src/lib.rs @@ -238,23 +238,27 @@ async fn handle(ctx: &StandaloneCtx, packet: protocol::ToOutbound) -> Result<()> let protocol_version = pool.protocol_version.unwrap_or(PROTOCOL_VERSION); let res = async { - let payload = versioned::ToEnvoy::wrap_latest(protocol::ToEnvoy::ToEnvoyCommands(vec![ - protocol::CommandWrapper { - checkpoint, - inner: protocol::Command::CommandStartActor(protocol::CommandStartActor { - config: actor_config, - hibernating_requests: hibernating_requests - .into_iter() - .map(|x| protocol::HibernatingRequest { - gateway_id: x.gateway_id, - request_id: x.request_id, - }) - .collect(), - preloaded_kv, - }), - }, - ])) - .serialize_with_embedded_version(protocol_version)?; + let payload_body = + versioned::ToEnvoy::wrap_latest(protocol::ToEnvoy::ToEnvoyCommands(vec![ + protocol::CommandWrapper { + checkpoint, + inner: protocol::Command::CommandStartActor(protocol::CommandStartActor { + config: actor_config, + hibernating_requests: hibernating_requests + .into_iter() + .map(|x| protocol::HibernatingRequest { + gateway_id: x.gateway_id, + request_id: x.request_id, + }) + .collect(), + preloaded_kv, + }), + }, + ])) + .serialize(protocol_version)?; + let mut payload = Vec::with_capacity(2 + payload_body.len()); + payload.extend_from_slice(&protocol_version.to_le_bytes()); + payload.extend_from_slice(&payload_body); // Send ack to actor wf before starting an outbound req ctx.signal(pegboard::workflows::actor2::Allocated { generation }) diff --git a/engine/packages/pegboard/src/ops/runner_config/upsert.rs b/engine/packages/pegboard/src/ops/runner_config/upsert.rs index 186c1a4ce5..e5d2c3c4b3 100644 --- a/engine/packages/pegboard/src/ops/runner_config/upsert.rs +++ b/engine/packages/pegboard/src/ops/runner_config/upsert.rs @@ -204,6 +204,28 @@ pub async fn pegboard_runner_config_upsert(ctx: &OperationCtx, input: &Input) -> .custom_instrument(tracing::info_span!("runner_config_upsert_tx")) .await?; + if endpoint_config_changed { + crate::utils::purge_runner_config_caches(ctx.cache(), input.namespace_id, &input.name) + .await?; + + // Update runner metadata before notifying the pool workflow so newer + // RivetKit serverless handlers are treated as envoy-backed immediately. + if let Some((url, headers)) = serverless_config { + tracing::debug!("endpoint config changed, refreshing metadata"); + if let Err(err) = ctx + .op(crate::ops::runner_config::refresh_metadata::Input { + namespace_id: input.namespace_id, + runner_name: input.name.clone(), + url, + headers, + }) + .await + { + tracing::warn!(?err, runner_name=?input.name, "failed to refresh runner config metadata"); + } + } + } + if pool_created { ctx.workflow(crate::workflows::runner_pool::Input { namespace_id: input.namespace_id, @@ -240,28 +262,5 @@ pub async fn pegboard_runner_config_upsert(ctx: &OperationCtx, input: &Input) -> } } - if endpoint_config_changed { - crate::utils::purge_runner_config_caches(ctx.cache(), input.namespace_id, &input.name) - .await?; - - // Update runner metadata - // - // This allows us to populate the actor names immediately upon configuring a serverless runner - if let Some((url, headers)) = serverless_config { - tracing::debug!("endpoint config changed, refreshing metadata"); - if let Err(err) = ctx - .op(crate::ops::runner_config::refresh_metadata::Input { - namespace_id: input.namespace_id, - runner_name: input.name.clone(), - url, - headers, - }) - .await - { - tracing::warn!(?err, runner_name=?input.name, "failed to refresh runner config metadata"); - } - } - } - Ok(endpoint_config_changed) } diff --git a/examples/hello-world-cloudflare-workers/.gitignore b/examples/hello-world-cloudflare-workers/.gitignore new file mode 100644 index 0000000000..ad69be8c88 --- /dev/null +++ b/examples/hello-world-cloudflare-workers/.gitignore @@ -0,0 +1,4 @@ +.actorcore +node_modules +.wrangler +dist diff --git a/examples/hello-world-cloudflare-workers/README.md b/examples/hello-world-cloudflare-workers/README.md new file mode 100644 index 0000000000..851a7bced6 --- /dev/null +++ b/examples/hello-world-cloudflare-workers/README.md @@ -0,0 +1,29 @@ +# Hello World - Cloudflare Workers + +A minimal Rivet Actor counter running on Cloudflare Workers with the WebAssembly runtime. + +## Getting Started + +```sh +git clone https://github.com/rivet-dev/rivet.git +cd rivet/examples/hello-world-cloudflare-workers +npm install +npm run dev +``` + +`rivet dev` runs a local Rivet engine and spawns `wrangler dev` for you. + +## Implementation + +The Worker creates the registry with `runtime: "wasm"` and serves the Rivet handler. `RIVET_ENDPOINT` is the only required variable, set in [`wrangler.toml`](https://github.com/rivet-dev/rivet/tree/main/examples/hello-world-cloudflare-workers/wrangler.toml). + +- Worker entry ([`src/index.ts`](https://github.com/rivet-dev/rivet/tree/main/examples/hello-world-cloudflare-workers/src/index.ts)): Counter actor and the `fetch` handler. +- WebSocket shim ([`src/cloudflare-websocket.ts`](https://github.com/rivet-dev/rivet/tree/main/examples/hello-world-cloudflare-workers/src/cloudflare-websocket.ts)): Provides the `WebSocket` constructor the envoy connection needs on Workers. + +## Resources + +Read more about [actions](/docs/actors/actions) and [state](/docs/actors/state), or follow the [Cloudflare Workers Quickstart](/docs/actors/quickstart/cloudflare) and [deploy guide](/docs/deploy/cloudflare). + +## License + +MIT diff --git a/examples/hello-world-cloudflare-workers/package.json b/examples/hello-world-cloudflare-workers/package.json new file mode 100644 index 0000000000..c33d7fb8b5 --- /dev/null +++ b/examples/hello-world-cloudflare-workers/package.json @@ -0,0 +1,27 @@ +{ + "name": "hello-world-cloudflare-workers", + "version": "2.0.21", + "private": true, + "type": "module", + "scripts": { + "dev": "npx @rivetkit/cli dev --provider cloudflare", + "check-types": "tsc --noEmit", + "deploy": "wrangler deploy" + }, + "devDependencies": { + "typescript": "^5.7.3", + "wrangler": "^4.87.0" + }, + "dependencies": { + "@rivetkit/rivetkit-wasm": "*", + "rivetkit": "*" + }, + "stableVersion": "0.8.0", + "template": { + "technologies": ["rivet", "cloudflare", "typescript"], + "tags": ["serverless"], + "noFrontend": true, + "skipVercel": true + }, + "license": "MIT" +} diff --git a/examples/hello-world-cloudflare-workers/src/cloudflare-websocket.ts b/examples/hello-world-cloudflare-workers/src/cloudflare-websocket.ts new file mode 100644 index 0000000000..1a52edba21 --- /dev/null +++ b/examples/hello-world-cloudflare-workers/src/cloudflare-websocket.ts @@ -0,0 +1,90 @@ +// Cloudflare Workers do not expose the `new WebSocket()` constructor that the +// Rivet envoy client uses to reach the engine. This shim implements that +// constructor on top of the fetch-based WebSocket upgrade that Workers support. + +type CloudflareWebSocket = WebSocket & { accept(): void }; + +class FetchWebSocket { + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSING = 2; + static readonly CLOSED = 3; + + binaryType: BinaryType = "arraybuffer"; + onopen: ((event: Event) => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + onclose: ((event: CloseEvent) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + readyState = FetchWebSocket.CONNECTING; + #socket: CloudflareWebSocket | undefined; + #pending: Array = []; + + constructor(url: string, protocols?: string | string[]) { + void this.#connect(url, protocols); + } + + async #connect(url: string, protocols?: string | string[]) { + try { + const protocolList = Array.isArray(protocols) + ? protocols + : protocols + ? [protocols] + : []; + const headers = new Headers({ Upgrade: "websocket" }); + if (protocolList.length > 0) { + headers.set("Sec-WebSocket-Protocol", protocolList.join(", ")); + } + const response = await fetch( + url.replace(/^ws:/, "http:").replace(/^wss:/, "https:"), + { headers }, + ); + const socket = ( + response as unknown as { webSocket: CloudflareWebSocket | null } + ).webSocket; + if (!socket) { + throw new Error( + `websocket upgrade failed with status ${response.status}`, + ); + } + + socket.accept(); + socket.binaryType = this.binaryType; + this.#socket = socket; + this.readyState = FetchWebSocket.OPEN; + socket.addEventListener("message", (event) => { + this.onmessage?.(event); + }); + socket.addEventListener("close", (event) => { + this.readyState = FetchWebSocket.CLOSED; + this.onclose?.(event); + }); + socket.addEventListener("error", () => { + this.onerror?.(new Event("error")); + }); + this.onopen?.(new Event("open")); + for (const data of this.#pending.splice(0)) { + socket.send(data); + } + } catch { + this.readyState = FetchWebSocket.CLOSED; + this.onerror?.(new Event("error")); + this.onclose?.(new CloseEvent("close", { code: 1006 })); + } + } + + send(data: string | ArrayBuffer | ArrayBufferView) { + if (this.readyState === FetchWebSocket.CONNECTING) { + this.#pending.push(data); + return; + } + this.#socket?.send(data); + } + + close(code?: number, reason?: string) { + this.readyState = FetchWebSocket.CLOSING; + this.#socket?.close(code, reason); + } +} + +(globalThis as unknown as { WebSocket: typeof WebSocket }).WebSocket = + FetchWebSocket as unknown as typeof WebSocket; diff --git a/examples/hello-world-cloudflare-workers/src/index.ts b/examples/hello-world-cloudflare-workers/src/index.ts new file mode 100644 index 0000000000..9ae7c67dfd --- /dev/null +++ b/examples/hello-world-cloudflare-workers/src/index.ts @@ -0,0 +1,38 @@ +import * as wasmBindings from "@rivetkit/rivetkit-wasm"; +import wasmModule from "@rivetkit/rivetkit-wasm/rivetkit_wasm_bg.wasm"; +import { actor, setup } from "rivetkit"; +import "./cloudflare-websocket"; + +const counter = actor({ + state: { count: 0 }, + actions: { + increment: (c, amount = 1) => { + c.state.count += amount; + return c.state.count; + }, + getCount: (c) => c.state.count, + }, +}); + +interface Env { + RIVET_ENDPOINT: string; +} + +let registry: { handler(request: Request): Promise } | undefined; + +function getRegistry(env: Env) { + registry ??= setup({ + runtime: "wasm", + wasm: { bindings: wasmBindings, initInput: wasmModule }, + use: { counter }, + endpoint: env.RIVET_ENDPOINT, + }); + + return registry; +} + +export default { + async fetch(request: Request, env: Env): Promise { + return await getRegistry(env).handler(request); + }, +}; diff --git a/examples/hello-world-cloudflare-workers/src/wasm.d.ts b/examples/hello-world-cloudflare-workers/src/wasm.d.ts new file mode 100644 index 0000000000..7f14d1a5a5 --- /dev/null +++ b/examples/hello-world-cloudflare-workers/src/wasm.d.ts @@ -0,0 +1,4 @@ +declare module "*.wasm" { + const wasmModule: WebAssembly.Module; + export default wasmModule; +} diff --git a/examples/hello-world-cloudflare-workers/tsconfig.json b/examples/hello-world-cloudflare-workers/tsconfig.json new file mode 100644 index 0000000000..74817ce07f --- /dev/null +++ b/examples/hello-world-cloudflare-workers/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["esnext", "dom", "dom.iterable"], + "module": "esnext", + "moduleResolution": "bundler", + "types": [], + "noEmit": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["src/**/*"] +} diff --git a/examples/hello-world-cloudflare-workers/turbo.json b/examples/hello-world-cloudflare-workers/turbo.json new file mode 100644 index 0000000000..8d06db6c15 --- /dev/null +++ b/examples/hello-world-cloudflare-workers/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "dependsOn": ["rivetkit#build"] + } + } +} diff --git a/examples/hello-world-cloudflare-workers/wrangler.toml b/examples/hello-world-cloudflare-workers/wrangler.toml new file mode 100644 index 0000000000..88d3cf4362 --- /dev/null +++ b/examples/hello-world-cloudflare-workers/wrangler.toml @@ -0,0 +1,7 @@ +name = "hello-world-cloudflare-workers" +main = "src/index.ts" +compatibility_date = "2025-04-01" +compatibility_flags = ["nodejs_compat"] + +[vars] +RIVET_ENDPOINT = "http://localhost:6420" diff --git a/examples/hello-world-supabase-functions/.gitignore b/examples/hello-world-supabase-functions/.gitignore new file mode 100644 index 0000000000..fd1591ff01 --- /dev/null +++ b/examples/hello-world-supabase-functions/.gitignore @@ -0,0 +1,4 @@ +.actorcore +node_modules +supabase/.temp +supabase/.branches diff --git a/examples/hello-world-supabase-functions/README.md b/examples/hello-world-supabase-functions/README.md new file mode 100644 index 0000000000..c1903f94f3 --- /dev/null +++ b/examples/hello-world-supabase-functions/README.md @@ -0,0 +1,33 @@ +# Hello World - Supabase Functions + +A minimal Rivet Actor counter running on Supabase Edge Functions with the WebAssembly runtime. + +## Getting Started + +```sh +git clone https://github.com/rivet-dev/rivet.git +cd rivet/examples/hello-world-supabase-functions +npm install +npm run dev +``` + +`rivet dev` runs a local Rivet engine and spawns `supabase functions serve` for you. + +## Prerequisites + +- [Supabase CLI](https://supabase.com/docs/guides/cli) +- Docker, for Supabase's local Edge Runtime + +## Implementation + +The function loads the wasm bytes with Deno, creates the registry with `runtime: "wasm"`, and serves the Rivet handler. `RIVET_ENDPOINT` is the only required variable. + +See [`supabase/functions/rivet/index.ts`](https://github.com/rivet-dev/rivet/tree/main/examples/hello-world-supabase-functions/supabase/functions/rivet/index.ts). + +## Resources + +Read more about [actions](/docs/actors/actions) and [state](/docs/actors/state), or follow the [Supabase Functions Quickstart](/docs/actors/quickstart/supabase) and [deploy guide](/docs/deploy/supabase). + +## License + +MIT diff --git a/examples/hello-world-supabase-functions/package.json b/examples/hello-world-supabase-functions/package.json new file mode 100644 index 0000000000..27f7831076 --- /dev/null +++ b/examples/hello-world-supabase-functions/package.json @@ -0,0 +1,26 @@ +{ + "name": "hello-world-supabase-functions", + "version": "2.0.21", + "private": true, + "type": "module", + "scripts": { + "dev": "npx @rivetkit/cli dev --provider supabase", + "check-types": "tsc --noEmit", + "deploy": "npx supabase functions deploy rivet" + }, + "devDependencies": { + "typescript": "^5.7.3" + }, + "dependencies": { + "@rivetkit/rivetkit-wasm": "*", + "rivetkit": "*" + }, + "stableVersion": "0.8.0", + "template": { + "technologies": ["rivet", "supabase", "typescript"], + "tags": ["serverless"], + "noFrontend": true, + "skipVercel": true + }, + "license": "MIT" +} diff --git a/examples/hello-world-supabase-functions/supabase/config.toml b/examples/hello-world-supabase-functions/supabase/config.toml new file mode 100644 index 0000000000..ac1794f435 --- /dev/null +++ b/examples/hello-world-supabase-functions/supabase/config.toml @@ -0,0 +1,4 @@ +project_id = "hello-world-rivet" + +[edge_runtime] +enabled = true diff --git a/examples/hello-world-supabase-functions/supabase/functions/rivet/deno.d.ts b/examples/hello-world-supabase-functions/supabase/functions/rivet/deno.d.ts new file mode 100644 index 0000000000..336df806ad --- /dev/null +++ b/examples/hello-world-supabase-functions/supabase/functions/rivet/deno.d.ts @@ -0,0 +1,5 @@ +declare const Deno: { + readFile(path: string | URL): Promise; + env: { get(key: string): string | undefined }; + serve(handler: (request: Request) => Response | Promise): void; +}; diff --git a/examples/hello-world-supabase-functions/supabase/functions/rivet/index.ts b/examples/hello-world-supabase-functions/supabase/functions/rivet/index.ts new file mode 100644 index 0000000000..d43bb36c28 --- /dev/null +++ b/examples/hello-world-supabase-functions/supabase/functions/rivet/index.ts @@ -0,0 +1,29 @@ +import * as wasmBindings from "@rivetkit/rivetkit-wasm"; +import { actor, setup } from "rivetkit"; + +const resolveModule = ( + import.meta as unknown as { resolve(specifier: string): string } +).resolve; +const wasmModule = await Deno.readFile( + new URL(resolveModule("@rivetkit/rivetkit-wasm/rivetkit_wasm_bg.wasm")), +); + +const counter = actor({ + state: { count: 0 }, + actions: { + increment: (c, amount = 1) => { + c.state.count += amount; + return c.state.count; + }, + getCount: (c) => c.state.count, + }, +}); + +const registry = setup({ + runtime: "wasm", + wasm: { bindings: wasmBindings, initInput: wasmModule }, + use: { counter }, + endpoint: Deno.env.get("RIVET_ENDPOINT"), +}); + +Deno.serve((request) => registry.handler(request)); diff --git a/examples/hello-world-supabase-functions/tsconfig.json b/examples/hello-world-supabase-functions/tsconfig.json new file mode 100644 index 0000000000..74a2f5749b --- /dev/null +++ b/examples/hello-world-supabase-functions/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["esnext", "dom"], + "module": "esnext", + "moduleResolution": "bundler", + "types": [], + "noEmit": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["supabase/functions/**/*"] +} diff --git a/examples/hello-world-supabase-functions/turbo.json b/examples/hello-world-supabase-functions/turbo.json new file mode 100644 index 0000000000..8d06db6c15 --- /dev/null +++ b/examples/hello-world-supabase-functions/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "dependsOn": ["rivetkit#build"] + } + } +} diff --git a/frontend/packages/icons/CLAUDE.md b/frontend/packages/icons/CLAUDE.md index eac30018b4..192e568115 100644 --- a/frontend/packages/icons/CLAUDE.md +++ b/frontend/packages/icons/CLAUDE.md @@ -4,6 +4,14 @@ Icons come from Font Awesome Pro packages and a custom Font Awesome kit (`@awesome.me/kit-63db24046b`). +## Requesting a new icon + +Custom kit icons (for example a company brand) cannot be uploaded by an agent. When you need one: + +1. If you can find the relevant icon (for example a company logo), look up its SVG and download a single-color (monocolor, `fill="currentColor"`) version to a temp directory ready for the user to upload. Do not commit it to the repo. +2. Ask the user to upload it to the custom Font Awesome kit at https://fontawesome.com/kits/63db24046b/customicons. +3. Once the user confirms it is uploaded, repull the icons with the steps below, then consume the generated icon from `@rivet-gg/icons` instead of inlining an SVG path. + ## Adding new custom kit icons When a new icon has been uploaded to the custom Font Awesome kit: diff --git a/frontend/packages/icons/dist/icons/faSupabase.js b/frontend/packages/icons/dist/icons/faSupabase.js new file mode 100644 index 0000000000..7b900ab5c8 --- /dev/null +++ b/frontend/packages/icons/dist/icons/faSupabase.js @@ -0,0 +1,15 @@ +// src/node_modules/@awesome.me/kit-63db24046b/icons/modules/kit/custom.mjs +var faSupabase = { + prefix: "fak", + iconName: "supabase", + icon: [ + 512, + 512, + [], + "e01c", + "M253.9 22.1c-.3-21-26.9-30.1-40-13.6L16.3 257.1c-23.3 29.4-2.4 72.6 35.1 72.6l204.4 0 2.4 160.2c.3 21 26.9 30 40 13.6L495.7 254.9c23.3-29.3 2.4-72.6-35.1-72.6l-205.7 0z" + ] +}; +export { + faSupabase +}; diff --git a/frontend/packages/icons/dist/index.js b/frontend/packages/icons/dist/index.js index 22f26b7a06..94f0eed9b6 100644 --- a/frontend/packages/icons/dist/index.js +++ b/frontend/packages/icons/dist/index.js @@ -4928,6 +4928,7 @@ export { faRender } from "./icons/faRender.js"; export { faRivet } from "./icons/faRivet.js"; export { faSelect } from "./icons/faSelect.js"; export { faSqlite } from "./icons/faSqlite.js"; +export { faSupabase } from "./icons/faSupabase.js"; export { faTs } from "./icons/faTs.js"; export { faVercel } from "./icons/faVercel.js"; export { faVscode } from "./icons/faVscode.js"; diff --git a/frontend/packages/icons/manifest.json b/frontend/packages/icons/manifest.json index 4dce48b067..a7df7de785 100644 --- a/frontend/packages/icons/manifest.json +++ b/frontend/packages/icons/manifest.json @@ -24487,6 +24487,12 @@ "faSqlite" ] }, + { + "icon": "faSupabase", + "aliases": [ + "faSupabase" + ] + }, { "icon": "faTs", "aliases": [ diff --git a/frontend/packages/icons/scripts/shared-utils.js b/frontend/packages/icons/scripts/shared-utils.js index 9bad3e34eb..8810d61221 100644 --- a/frontend/packages/icons/scripts/shared-utils.js +++ b/frontend/packages/icons/scripts/shared-utils.js @@ -37,7 +37,7 @@ const PATHS = { const FA_PACKAGES_CONFIG = { // Custom kit with Rivet-specific icons - "@awesome.me/kit-63db24046b": "1.0.43", + "@awesome.me/kit-63db24046b": "1.0.44", // Pro packages (regular and solid styles) "@fortawesome/pro-regular-svg-icons": "6.6.0", "@fortawesome/pro-solid-svg-icons": "6.6.0", diff --git a/frontend/packages/icons/src/index.gen.js b/frontend/packages/icons/src/index.gen.js index 1085ac37a2..3cfcd4f885 100644 --- a/frontend/packages/icons/src/index.gen.js +++ b/frontend/packages/icons/src/index.gen.js @@ -4921,4 +4921,4 @@ export { definition as faWreathLaurel } from "@fortawesome/pro-solid-svg-icons/f export { definition as faWrenchSimple } from "@fortawesome/pro-solid-svg-icons/faWrenchSimple"; export { definition as faXmarkLarge } from "@fortawesome/pro-solid-svg-icons/faXmarkLarge"; export const faHono = {"prefix":"fakd","iconName":"hono","icon":[512,512,[],"e012",["M62.5 299.3c-2.4 24.7-.1 48.8 7 72.5 27.4 72.7 79.3 116.8 155.5 132.4 63.8 9.1 120.2-7 169.1-48.3 55.9-54.1 70.2-117.7 42.8-190.8-17.2-41-38-80-62.4-116.8-33-48.9-68.3-96.2-105.7-141.9-1-.8-2.2-1.2-3.5-1-42.5 52.7-79.1 109.4-109.7 170.1-4-3.5-7.9-7.2-11.6-11.1-8-11-16.4-21.8-25.2-32.2-10.8 13.4-19.5 28.2-26.2 44.3-16.1 39.4-26.1 80.4-30.2 122.8zm65.4 22.1c-1.9-16.2-.2-32 5-47.3 7.5-19 16.5-37.1 27.2-54.4 10.1-14.8 20.1-29.5 30.2-44.3 22.9-29.4 45.5-58.9 68-88.6 36.9 42.8 70.3 88.5 100.2 136.9 9.4 16 17.1 32.8 23.2 50.3 12.7 49.8-.9 90.9-40.8 123.3-38.5 27.1-80.8 35.2-126.8 24.2-49.6-15.4-78.3-48.8-86.1-100.2z","M258.3 86.9c36.9 42.8 70.3 88.5 100.2 136.9 9.4 16 17.1 32.8 23.2 50.3 12.7 49.8-.9 90.9-40.8 123.3-38.5 27.1-80.8 35.2-126.8 24.2-49.6-15.4-78.3-48.8-86.1-100.2-1.9-16.2-.2-32 5-47.3 7.5-19 16.5-37.1 27.2-54.4 10.1-14.8 20.1-29.5 30.2-44.3 22.9-29.4 45.5-58.9 68-88.6z"]]}; -export { faActors, faActorsBorderless, faCursor, faFreestyle, faGb, faGoogleCloud, faHetzner, faHetznerH, faLinear, faLogs, faNetlify, faNextjs, faProject, faRailway, faRegex, faRender, faRivet, faSelect, faSqlite, faTs, faVercel, faVscode, faWord, faWorkflow, faWorkflowBorderless } from "@awesome.me/kit-63db24046b/icons/kit/custom"; +export { faActors, faActorsBorderless, faCursor, faFreestyle, faGb, faGoogleCloud, faHetzner, faHetznerH, faLinear, faLogs, faNetlify, faNextjs, faProject, faRailway, faRegex, faRender, faRivet, faSelect, faSqlite, faSupabase, faTs, faVercel, faVscode, faWord, faWorkflow, faWorkflowBorderless } from "@awesome.me/kit-63db24046b/icons/kit/custom"; diff --git a/frontend/packages/icons/src/index.gen.ts b/frontend/packages/icons/src/index.gen.ts index 421c95317a..a11bc8edc5 100644 --- a/frontend/packages/icons/src/index.gen.ts +++ b/frontend/packages/icons/src/index.gen.ts @@ -4922,4 +4922,4 @@ export { definition as faWreathLaurel } from "@fortawesome/pro-solid-svg-icons/f export { definition as faWrenchSimple } from "@fortawesome/pro-solid-svg-icons/faWrenchSimple"; export { definition as faXmarkLarge } from "@fortawesome/pro-solid-svg-icons/faXmarkLarge"; export const faHono = {"prefix":"fakd","iconName":"hono","icon":[512,512,[],"e012",["M62.5 299.3c-2.4 24.7-.1 48.8 7 72.5 27.4 72.7 79.3 116.8 155.5 132.4 63.8 9.1 120.2-7 169.1-48.3 55.9-54.1 70.2-117.7 42.8-190.8-17.2-41-38-80-62.4-116.8-33-48.9-68.3-96.2-105.7-141.9-1-.8-2.2-1.2-3.5-1-42.5 52.7-79.1 109.4-109.7 170.1-4-3.5-7.9-7.2-11.6-11.1-8-11-16.4-21.8-25.2-32.2-10.8 13.4-19.5 28.2-26.2 44.3-16.1 39.4-26.1 80.4-30.2 122.8zm65.4 22.1c-1.9-16.2-.2-32 5-47.3 7.5-19 16.5-37.1 27.2-54.4 10.1-14.8 20.1-29.5 30.2-44.3 22.9-29.4 45.5-58.9 68-88.6 36.9 42.8 70.3 88.5 100.2 136.9 9.4 16 17.1 32.8 23.2 50.3 12.7 49.8-.9 90.9-40.8 123.3-38.5 27.1-80.8 35.2-126.8 24.2-49.6-15.4-78.3-48.8-86.1-100.2z","M258.3 86.9c36.9 42.8 70.3 88.5 100.2 136.9 9.4 16 17.1 32.8 23.2 50.3 12.7 49.8-.9 90.9-40.8 123.3-38.5 27.1-80.8 35.2-126.8 24.2-49.6-15.4-78.3-48.8-86.1-100.2-1.9-16.2-.2-32 5-47.3 7.5-19 16.5-37.1 27.2-54.4 10.1-14.8 20.1-29.5 30.2-44.3 22.9-29.4 45.5-58.9 68-88.6z"]]}; -export { faActors, faActorsBorderless, faCursor, faFreestyle, faGb, faGoogleCloud, faHetzner, faHetznerH, faLinear, faLogs, faNetlify, faNextjs, faProject, faRailway, faRegex, faRender, faRivet, faSelect, faSqlite, faTs, faVercel, faVscode, faWord, faWorkflow, faWorkflowBorderless } from "@awesome.me/kit-63db24046b/icons/kit/custom"; +export { faActors, faActorsBorderless, faCursor, faFreestyle, faGb, faGoogleCloud, faHetzner, faHetznerH, faLinear, faLogs, faNetlify, faNextjs, faProject, faRailway, faRegex, faRender, faRivet, faSelect, faSqlite, faSupabase, faTs, faVercel, faVscode, faWord, faWorkflow, faWorkflowBorderless } from "@awesome.me/kit-63db24046b/icons/kit/custom"; diff --git a/frontend/packages/shared-data/src/deploy.ts b/frontend/packages/shared-data/src/deploy.ts index afd9adfc74..33f6f20129 100644 --- a/frontend/packages/shared-data/src/deploy.ts +++ b/frontend/packages/shared-data/src/deploy.ts @@ -8,24 +8,10 @@ import { faRivet, faRocket, faServer, + faSupabase, faVercel, } from "@rivet-gg/icons"; -// Supabase's official monotone logo. Font Awesome has no Supabase brand icon, -// so this is a Font Awesome compatible icon definition built from the real -// brand SVG (https://simpleicons.org/?q=supabase). Renders in currentColor. -export const faSupabase = { - prefix: "fak", - iconName: "supabase", - icon: [ - 24, - 24, - [], - "", - "M11.9 1.036c-.015-.986-1.26-1.41-1.874-.637L.764 12.05C-.33 13.427.65 15.455 2.409 15.455h9.579l.113 7.51c.014.985 1.259 1.408 1.873.636l9.262-11.653c1.093-1.375.113-3.403-1.645-3.403h-9.642z", - ], -} as any; - export interface DeployOption { displayName: string; name: string; diff --git a/frontend/src/app/getting-started.tsx b/frontend/src/app/getting-started.tsx index 0428b149b1..aa3e10c1b4 100644 --- a/frontend/src/app/getting-started.tsx +++ b/frontend/src/app/getting-started.tsx @@ -912,6 +912,9 @@ function BackendSetupRivet() { dataProvider.createApiTokenQueryOptions({ name: "Onboarding" }), ); + const deployCmd = cloudToken + ? `npx @rivetkit/cli deploy --token ${cloudToken}` + : "npx @rivetkit/cli deploy --token "; const ghSecretCmd = cloudToken ? `gh secret set RIVET_CLOUD_TOKEN --body "${cloudToken}"` : "gh secret set RIVET_CLOUD_TOKEN"; @@ -937,27 +940,24 @@ function BackendSetupRivet() {
-

Add GitHub secret

+

Deploy to Rivet Compute

- Add your Rivet token as a repository secret named{" "} - - RIVET_CLOUD_TOKEN - - . + Run the deploy command from your project root. The token + is saved locally for future deploys.

{[ ghSecretCmd} + code={() => deployCmd} className="m-0" > , ]} @@ -967,14 +967,31 @@ function BackendSetupRivet() {
-

Add GitHub Action

+

Optionally add CI

- Create{" "} + Add your token as a repository secret, then create{" "} .github/workflows/rivet-deploy.yml {" "} - to automatically deploy on every push and pull request. + to deploy on every push and pull request.

+ + {[ + ghSecretCmd} + className="m-0" + > + + , + ]} + {[
-

Deploy to Rivet Compute

+

Monitor deployment

- Push your changes to trigger the{" "} - Rivet Deploy workflow. The status check - below will update automatically once your backend is - deployed. + The status check below updates automatically once your + backend is deployed.

@@ -1300,7 +1315,7 @@ function FrontendSetup() { label="Deploy your backend" sublabel={ waitingForFirstImage - ? "Push your changes to trigger the Rivet Deploy workflow." + ? "Run npx @rivetkit/cli deploy from your project." : "Deployment detected." } /> diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c072a8db0..cf32a847b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -998,6 +998,22 @@ importers: specifier: ^3.1.1 version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.10)(less@4.4.1)(lightningcss@1.32.0)(msw@2.14.4(@types/node@22.19.10)(typescript@5.9.3))(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0) + examples/hello-world-cloudflare-workers: + dependencies: + '@rivetkit/rivetkit-wasm': + specifier: workspace:* + version: link:../../rivetkit-typescript/packages/rivetkit-wasm + rivetkit: + specifier: workspace:* + version: link:../../rivetkit-typescript/packages/rivetkit + devDependencies: + typescript: + specifier: ^5.7.3 + version: 5.9.3 + wrangler: + specifier: ^4.87.0 + version: 4.100.0 + examples/hello-world-effect: dependencies: '@effect/platform-node': @@ -1087,6 +1103,19 @@ importers: specifier: ^5.0.0 version: 5.4.21(@types/node@22.19.15)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0) + examples/hello-world-supabase-functions: + dependencies: + '@rivetkit/rivetkit-wasm': + specifier: workspace:* + version: link:../../rivetkit-typescript/packages/rivetkit-wasm + rivetkit: + specifier: workspace:* + version: link:../../rivetkit-typescript/packages/rivetkit + devDependencies: + typescript: + specifier: ^5.7.3 + version: 5.9.3 + examples/hono: dependencies: hono: @@ -2839,6 +2868,8 @@ importers: specifier: ^5.7.3 version: 5.9.3 + rivetkit-typescript/packages/cli: {} + rivetkit-typescript/packages/devtools: dependencies: '@floating-ui/react': @@ -4733,6 +4764,49 @@ packages: '@chevrotain/utils@11.0.3': resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==} + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + + '@cloudflare/unenv-preset@2.16.1': + resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/workerd-darwin-64@1.20260611.1': + resolution: {integrity: sha512-iJICldmi4sBGgi7IrQles8cStOGXM/Tmv95C4OODVs6VIbMsJPqThUM5h3uYVQNULuJ8I/aVvnJ3Eh/wZCKwuA==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20260611.1': + resolution: {integrity: sha512-yBbVXvbZyltR3I7NJdC4C4ItkItjZSiabcA/3HzEWOUQjLVKFqRh4so6ToHr70VCYh8VGeR8EDZL23igLhXqFQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20260611.1': + resolution: {integrity: sha512-PfNjpxOlaIgZFYuhD7+neEEewCN2Ud993wEEN0fmbtSOax1AK53LGqmXUDvFhnbkHxJLFAxYCSNISW8QbzaAIg==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260611.1': + resolution: {integrity: sha512-GEp4XbuIKjlF8pakqXcUDJfKiJosD/Q7S83J0d+r+z9XIlYGfF3ntm08e2aiF5TFTwp3fnG4yMoPUAKNhNJpvQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20260611.1': + resolution: {integrity: sha512-S6JkS0kEbcCKs19RGqEPhjCRbP8GBkQwqYLp2fhBJtD/KTlwqLzOJ9E6PQ7gQKgWHtxy1NBG3oXarlNFRNU/dw==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + '@cloudflare/workers-types@4.20251014.0': resolution: {integrity: sha512-tEW98J/kOa0TdylIUOrLKRdwkUw0rvvYVlo+Ce0mqRH3c8kSoxLzUH9gfCvwLe0M89z1RkzFovSKAW2Nwtyn3w==} @@ -6987,6 +7061,15 @@ packages: engines: {node: '>=18'} hasBin: true + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@posthog/core@1.5.3': resolution: {integrity: sha512-1cHCMR2uS/rAdBIFlBPJ4rPYaw1O42VkFy/LwQLtoy2hMQb2DdhCoSHfgA66R9TvcOybZsSANlbuihmGEZUKVQ==} @@ -8342,6 +8425,10 @@ packages: resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + '@sindresorhus/merge-streams@2.3.0': resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==} engines: {node: '>=18'} @@ -8556,6 +8643,9 @@ packages: resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==} engines: {node: '>=18.0.0'} + '@speed-highlight/core@1.2.17': + resolution: {integrity: sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==} + '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} @@ -10200,6 +10290,9 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + bn.js@4.12.3: resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==} @@ -11553,6 +11646,9 @@ packages: error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + error-stack-parser@2.1.4: resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} @@ -13102,6 +13198,10 @@ packages: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + koa-compose@4.1.0: resolution: {integrity: sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==} @@ -13889,6 +13989,11 @@ packages: resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==} hasBin: true + miniflare@4.20260611.0: + resolution: {integrity: sha512-i+JwEo8vN96naz1WL3ntFgFyRluBDYL408zwhHKvR2jefJ464KsZ/gCmJAQ5k+oaWeb5Ug+s7yne5AyiAEswjg==} + engines: {node: '>=22.0.0'} + hasBin: true + minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} @@ -16032,6 +16137,10 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -16509,10 +16618,17 @@ packages: resolution: {integrity: sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==} engines: {node: '>=20.18.1'} + undici@7.24.8: + resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==} + engines: {node: '>=20.18.1'} + undici@8.3.0: resolution: {integrity: sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==} engines: {node: '>=22.19.0'} + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + unicode-canonical-property-names-ecmascript@2.0.1: resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} engines: {node: '>=4'} @@ -17285,6 +17401,21 @@ packages: wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + workerd@1.20260611.1: + resolution: {integrity: sha512-CS/640T7pIJ2HYX6x2DwKFGbcSckAWN3tgcdq+ptB6SaqjWUhlzIgA/YhPuwIU+/NnMnGpqOFX/hC18Oyge63w==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.100.0: + resolution: {integrity: sha512-dSQO7DO+mD6XDzkVWIWBoGLO3yw+lacWSc/KhFvd7pgfpth+kX98qb5SGRHZN8ACCDhhfwzDLXwB6qHsIHhfBg==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^4.20260611.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -17474,6 +17605,12 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + z-schema@5.0.5: resolution: {integrity: sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==} engines: {node: '>=8.0.0'} @@ -19030,6 +19167,29 @@ snapshots: '@chevrotain/utils@11.0.3': {} + '@cloudflare/kv-asset-handler@0.5.0': {} + + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260611.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260611.1 + + '@cloudflare/workerd-darwin-64@1.20260611.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260611.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20260611.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20260611.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20260611.1': + optional: true + '@cloudflare/workers-types@4.20251014.0': optional: true @@ -21667,6 +21827,18 @@ snapshots: playwright: 1.57.0 optional: true + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + '@posthog/core@1.5.3': dependencies: cross-spawn: 7.0.6 @@ -23371,6 +23543,8 @@ snapshots: '@sindresorhus/is@4.6.0': {} + '@sindresorhus/is@7.2.0': {} + '@sindresorhus/merge-streams@2.3.0': {} '@sindresorhus/merge-streams@4.0.0': {} @@ -23693,6 +23867,8 @@ snapshots: dependencies: tslib: 2.8.1 + '@speed-highlight/core@1.2.17': {} + '@standard-schema/spec@1.0.0': {} '@standard-schema/spec@1.1.0': {} @@ -25822,6 +25998,8 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + blake3-wasm@2.1.5: {} + bn.js@4.12.3: {} bn.js@5.2.3: {} @@ -27104,6 +27282,8 @@ snapshots: dependencies: is-arrayish: 0.2.1 + error-stack-parser-es@1.0.5: {} + error-stack-parser@2.1.4: dependencies: stackframe: 1.3.4 @@ -29053,6 +29233,8 @@ snapshots: kleur@3.0.3: {} + kleur@4.1.5: {} + koa-compose@4.1.0: {} koa-convert@2.0.0: @@ -30312,6 +30494,18 @@ snapshots: mini-svg-data-uri@1.4.4: {} + miniflare@4.20260611.0: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.34.5 + undici: 7.24.8 + workerd: 1.20260611.1 + ws: 8.20.1 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + minimalistic-assert@1.0.1: {} minimalistic-crypto-utils@1.0.1: {} @@ -32827,6 +33021,8 @@ snapshots: tinyglobby: 0.2.15 ts-interface-checker: 0.1.13 + supports-color@10.2.2: {} + supports-color@5.5.0: dependencies: has-flag: 3.0.0 @@ -33457,8 +33653,14 @@ snapshots: undici@7.24.7: {} + undici@7.24.8: {} + undici@8.3.0: {} + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + unicode-canonical-property-names-ecmascript@2.0.1: {} unicode-emoji-modifier-base@1.0.0: {} @@ -34603,6 +34805,30 @@ snapshots: wordwrap@1.0.0: {} + workerd@1.20260611.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260611.1 + '@cloudflare/workerd-darwin-arm64': 1.20260611.1 + '@cloudflare/workerd-linux-64': 1.20260611.1 + '@cloudflare/workerd-linux-arm64': 1.20260611.1 + '@cloudflare/workerd-windows-64': 1.20260611.1 + + wrangler@4.100.0: + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260611.1) + blake3-wasm: 2.1.5 + esbuild: 0.27.3 + miniflare: 4.20260611.0 + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260611.1 + optionalDependencies: + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -34743,6 +34969,19 @@ snapshots: yoctocolors@2.1.2: {} + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.17 + cookie: 1.1.1 + youch-core: 0.3.3 + z-schema@5.0.5: dependencies: lodash.get: 4.4.2 diff --git a/rivetkit-rust/packages/engine-process/Cargo.toml b/rivetkit-rust/packages/engine-process/Cargo.toml new file mode 100644 index 0000000000..55f9a72f46 --- /dev/null +++ b/rivetkit-rust/packages/engine-process/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "rivetkit-engine-process" +version.workspace = true +authors.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +edition.workspace = true +workspace = "../../../" +description = "Resolves, spawns, and reuses the rivet-engine subprocess for local hosts and the CLI" + +[dependencies] +anyhow.workspace = true +reqwest.workspace = true +rivet-error.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +tokio.workspace = true +tracing.workspace = true +url.workspace = true + +[dev-dependencies] +tempfile.workspace = true +tokio = { workspace = true, features = ["test-util"] } diff --git a/rivetkit-rust/packages/engine-process/src/error.rs b/rivetkit-rust/packages/engine-process/src/error.rs new file mode 100644 index 0000000000..956b2232ce --- /dev/null +++ b/rivetkit-rust/packages/engine-process/src/error.rs @@ -0,0 +1,62 @@ +use rivet_error::RivetError; +use serde::{Deserialize, Serialize}; + +#[derive(RivetError, Debug, Clone, Deserialize, Serialize)] +#[error("engine")] +pub enum EngineProcessError { + #[error( + "binary_not_found", + "Engine binary was not found.", + "Engine binary was not found at '{path}'." + )] + BinaryNotFound { path: String }, + + #[error( + "binary_unavailable", + "Engine binary is unavailable.", + "No usable engine binary was found for version '{version}'. Build `rivet-engine`, set `RIVET_ENGINE_BINARY_PATH`, or enable `RIVETKIT_ENGINE_AUTO_DOWNLOAD=1`." + )] + BinaryUnavailable { version: String }, + + #[error( + "download_failed", + "Engine binary download failed.", + "Engine binary download failed for '{url}': {reason}" + )] + DownloadFailed { url: String, reason: String }, + + #[error( + "checksum_mismatch", + "Engine binary checksum mismatch.", + "Engine binary checksum mismatch for '{artifact}': expected {expected}, received {received}." + )] + ChecksumMismatch { + artifact: String, + expected: String, + received: String, + }, + + #[error( + "invalid_endpoint", + "Engine endpoint is invalid.", + "Engine endpoint '{endpoint}' is invalid: {reason}" + )] + InvalidEndpoint { endpoint: String, reason: String }, + + #[error("missing_pid", "Engine process is missing a pid.")] + MissingPid, + + #[error( + "health_check_failed", + "Engine health check failed.", + "Engine health check failed after {attempts} attempts: {reason}" + )] + HealthCheckFailed { attempts: u32, reason: String }, + + #[error( + "port_occupied", + "Engine port is occupied by a different runtime.", + "Cannot start engine: endpoint '{endpoint}' is already serving runtime '{runtime}'. Stop that process and retry." + )] + PortOccupied { endpoint: String, runtime: String }, +} diff --git a/rivetkit-rust/packages/engine-process/src/lib.rs b/rivetkit-rust/packages/engine-process/src/lib.rs new file mode 100644 index 0000000000..a9bc2b0c28 --- /dev/null +++ b/rivetkit-rust/packages/engine-process/src/lib.rs @@ -0,0 +1,996 @@ +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use reqwest::{Client, Url}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use tokio::process::{Child, Command}; +use tokio::task::JoinHandle; + +mod error; + +pub use error::EngineProcessError; + +const ENGINE_RUNTIME: &str = "engine"; +const RIVETKIT_RUNTIME: &str = "rivetkit"; +const ENGINE_VERSION_ENV: &str = "RIVETKIT_ENGINE_VERSION"; +const RELEASES_ENDPOINT_ENV: &str = "RIVETKIT_ENGINE_RELEASES_ENDPOINT"; +const RELEASES_ENDPOINT: &str = "https://releases.rivet.dev"; +const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(60); + +#[derive(Debug, Deserialize)] +struct EngineHealthResponse { + status: Option, + runtime: Option, + version: Option, +} + +#[derive(Clone, Debug)] +pub struct EngineResolverConfig { + pub endpoint: String, + pub explicit_binary_path: Option, + pub bind_host: Option, + pub bind_port: Option, + pub public_url: Option, + pub auto_download: bool, + pub version: String, + pub releases_endpoint: String, +} + +impl EngineResolverConfig { + pub fn from_parts( + endpoint: &str, + explicit_binary_path: Option, + bind_host: Option, + bind_port: Option, + auto_download: bool, + ) -> Self { + Self { + endpoint: endpoint.to_owned(), + explicit_binary_path, + bind_host, + bind_port, + public_url: None, + auto_download, + version: std::env::var(ENGINE_VERSION_ENV) + .unwrap_or_else(|_| env!("CARGO_PKG_VERSION").to_owned()), + releases_endpoint: std::env::var(RELEASES_ENDPOINT_ENV) + .unwrap_or_else(|_| RELEASES_ENDPOINT.to_owned()), + } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum ResolvedEngine { + Existing, + Binary(PathBuf), +} + +/// Manages the rivet-engine subprocess. +/// +/// The engine is intentionally orphaned: dropping the manager (or having the +/// host process exit) must NOT terminate the engine. This lets a dev-server +/// restart of the rivetkit host reattach to the same long-lived engine and +/// keep all in-flight actor state. To honor that contract: +/// +/// - `Command::kill_on_drop` is left at its default (false) so the tokio +/// `Child` does not send SIGKILL on drop. +/// - Stdout and stderr are routed to log files at spawn time so the engine's +/// write fds remain valid after the host's pipes close. +/// - On startup we probe the configured endpoint and reuse a healthy engine +/// instead of spawning a duplicate. +/// +/// When we spawn the engine, `watcher` holds a tokio task that owns the +/// `Child` and awaits `child.wait()` so we get a log line if the engine dies +/// while rivetkit is still running. On Drop we abort the watcher; aborting +/// drops the `Child` without killing it (kill_on_drop=false), so the engine +/// stays running and gets reparented to init when rivetkit exits. +/// +/// `watcher` is `None` when we attached to an already-running engine. +#[derive(Debug)] +pub struct EngineProcessManager { + watcher: Option>, +} + +impl EngineProcessManager { + pub async fn start_or_reuse(config: EngineResolverConfig) -> Result { + let resolved = resolve_engine_binary(&config).await?; + Self::start_resolved(resolved, &config).await + } + + async fn start_resolved( + resolved: ResolvedEngine, + config: &EngineResolverConfig, + ) -> Result { + let endpoint = &config.endpoint; + if matches!(resolved, ResolvedEngine::Existing) { + tracing::info!( + endpoint = %endpoint, + "reusing already-running engine process" + ); + return Ok(Self { watcher: None }); + } + + let ResolvedEngine::Binary(binary_path) = resolved else { + unreachable!("existing engine handled above"); + }; + if let Some(health) = probe_existing_engine(endpoint).await? { + tracing::info!( + endpoint = %endpoint, + status = ?health.status, + runtime = ?health.runtime, + version = ?health.version, + "reusing already-running engine process" + ); + return Ok(Self { watcher: None }); + } + + if !binary_path.exists() { + return Err(EngineProcessError::BinaryNotFound { + path: binary_path.display().to_string(), + } + .build()); + } + + let env = engine_env(config)?; + let config_path = write_engine_config(config)?; + let db_path = engine_db_path()?; + let logs_dir = storage_root()? + .join("var") + .join("logs") + .join("rivet-engine"); + ensure_dir(&db_path).context("create engine db directory")?; + ensure_dir(&logs_dir).context("create engine logs directory")?; + + let timestamp = log_timestamp(); + let stdout_log_path = logs_dir.join(format!("engine-{timestamp}-stdout.log")); + let stderr_log_path = logs_dir.join(format!("engine-{timestamp}-stderr.log")); + let stdout_file = open_log_file(&stdout_log_path) + .with_context(|| format!("open engine stdout log `{}`", stdout_log_path.display()))?; + let stderr_file = open_log_file(&stderr_log_path) + .with_context(|| format!("open engine stderr log `{}`", stderr_log_path.display()))?; + + let mut command = Command::new(&binary_path); + command.arg("start"); + if let Some(config_path) = &config_path { + command.arg("--config").arg(config_path); + } + for (key, value) in &env { + command.env(key, value); + } + command + .stdin(Stdio::null()) + .stdout(Stdio::from(stdout_file)) + .stderr(Stdio::from(stderr_file)); + + // Put the engine in its own process group so terminal signals + // (Ctrl+C, Ctrl+Z, SIGHUP on terminal close) targeting our foreground + // process group do not reach the engine. Combined with no-kill-on-drop + // and file-fd stdio, this gives the engine a real "intentional orphan" + // lifetime that survives the host being killed for any reason. + #[cfg(unix)] + command.process_group(0); + + let mut child = command + .spawn() + .with_context(|| format!("spawn engine binary `{}`", binary_path.display()))?; + let pid = child + .id() + .ok_or_else(|| EngineProcessError::MissingPid.build())?; + + tracing::info!( + pid, + path = %binary_path.display(), + endpoint = %endpoint, + db_path = %db_path.display(), + "spawned engine process (intentionally orphaned, will outlive this process)" + ); + tracing::info!( + stdout_log = %stdout_log_path.display(), + stderr_log = %stderr_log_path.display(), + "engine stdout/stderr piped to log files" + ); + + let health_url = engine_health_url(endpoint); + let health = match wait_for_engine_health(&health_url).await { + Ok(health) => health, + Err(error) => { + let error = match child.try_wait() { + Ok(Some(status)) => error.context(format!( + "engine process exited before becoming healthy with status {status}" + )), + Ok(None) => error, + Err(wait_error) => error.context(format!( + "failed to inspect engine process status: {wait_error:#}" + )), + }; + if let Err(cleanup_error) = terminate_failed_spawn(&mut child).await { + tracing::warn!( + ?cleanup_error, + "failed to terminate engine process that never became healthy" + ); + } + return Err(error); + } + }; + + tracing::info!( + pid, + status = ?health.status, + runtime = ?health.runtime, + version = ?health.version, + "engine process is healthy" + ); + + Ok(Self { + watcher: Some(spawn_engine_watcher(child, pid)), + }) + } +} + +/// Path to the rivet-engine database directory under the shared storage root. +pub fn engine_db_path() -> Result { + Ok(storage_root()?.join("var").join("engine").join("db")) +} + +/// Computes the environment variables that configure a rivet-engine process +/// for the given endpoint. +/// +/// Shared by the spawn path and by callers that exec the engine binary +/// directly (for example the CLI `engine` proxy) so both operate on the same +/// database, guard, api-peer, and metrics ports. +pub fn engine_env(config: &EngineResolverConfig) -> Result> { + let endpoint = &config.endpoint; + let endpoint_url = + Url::parse(endpoint).with_context(|| format!("parse engine endpoint `{endpoint}`"))?; + let guard_host = endpoint_url + .host_str() + .ok_or_else(|| invalid_endpoint(endpoint, "missing host"))? + .to_owned(); + let guard_host = config.bind_host.clone().unwrap_or(guard_host); + let guard_port = endpoint_url + .port_or_known_default() + .ok_or_else(|| invalid_endpoint(endpoint, "missing port"))?; + let guard_port = config.bind_port.unwrap_or(guard_port); + let api_peer_port = guard_port + .checked_add(1) + .ok_or_else(|| invalid_endpoint(endpoint, "port is too large"))?; + let metrics_port = guard_port + .checked_add(10) + .ok_or_else(|| invalid_endpoint(endpoint, "port is too large"))?; + + let db_path = engine_db_path()?; + + Ok(vec![ + ("RIVET__GUARD__HOST".to_owned(), guard_host.clone()), + ("RIVET__GUARD__PORT".to_owned(), guard_port.to_string()), + ("RIVET__API_PEER__HOST".to_owned(), guard_host.clone()), + ( + "RIVET__API_PEER__PORT".to_owned(), + api_peer_port.to_string(), + ), + ("RIVET__METRICS__HOST".to_owned(), guard_host), + ("RIVET__METRICS__PORT".to_owned(), metrics_port.to_string()), + ( + "RIVET__FILE_SYSTEM__PATH".to_owned(), + db_path.to_string_lossy().into_owned(), + ), + ]) +} + +fn write_engine_config(config: &EngineResolverConfig) -> Result> { + let Some(public_url) = &config.public_url else { + return Ok(None); + }; + + let public_url = Url::parse(public_url) + .with_context(|| format!("parse engine public URL `{public_url}`"))?; + let peer_url = peer_url_for_public_url(&public_url)?; + let dir = storage_root()?.join("var").join("engine"); + ensure_dir(&dir)?; + let path = dir.join("config.json"); + let config = serde_json::json!({ + "topology": { + "datacenter_label": 1, + "datacenters": { + "default": { + "datacenter_label": 1, + "is_leader": true, + "public_url": public_url.as_str(), + "peer_url": peer_url, + } + } + } + }); + std::fs::write(&path, serde_json::to_string_pretty(&config)?) + .with_context(|| format!("write engine config `{}`", path.display()))?; + Ok(Some(path)) +} + +fn peer_url_for_public_url(public_url: &Url) -> Result { + let mut peer_url = public_url.clone(); + let port = public_url + .port_or_known_default() + .ok_or_else(|| invalid_endpoint(public_url.as_str(), "missing port"))? + .checked_add(1) + .ok_or_else(|| invalid_endpoint(public_url.as_str(), "port is too large"))?; + if peer_url.set_port(Some(port)).is_err() { + return Err(invalid_endpoint( + public_url.as_str(), + "could not derive peer URL port", + )); + } + peer_url.set_path(""); + peer_url.set_query(None); + peer_url.set_fragment(None); + Ok(peer_url.to_string().trim_end_matches('/').to_string()) +} + +pub async fn resolve_engine_binary(config: &EngineResolverConfig) -> Result { + if let Some(path) = config.explicit_binary_path.as_ref() { + return verify_binary_path(path); + } + + if let Some(path) = std::env::var_os("RIVET_ENGINE_BINARY_PATH").map(PathBuf::from) { + return verify_binary_path(&path); + } + + if probe_existing_engine(&config.endpoint).await?.is_some() { + return Ok(ResolvedEngine::Existing); + } + + let local_roots = local_engine_search_roots(); + let cached = cached_engine_path(&config.version)?; + resolve_engine_binary_after_probe(config, false, &local_roots, cached).await +} + +/// Resolves the engine binary path without probing for an already-running +/// engine. +/// +/// Used by callers that exec the binary directly (for example the CLI `engine` +/// proxy), where a running engine on the endpoint should not short-circuit +/// resolution to `Existing` and leave the caller without a path to run. +pub async fn resolve_engine_binary_path(config: &EngineResolverConfig) -> Result { + if let Some(path) = config.explicit_binary_path.as_ref() { + verify_binary_path(path)?; + return Ok(path.clone()); + } + + if let Some(path) = std::env::var_os("RIVET_ENGINE_BINARY_PATH").map(PathBuf::from) { + verify_binary_path(&path)?; + return Ok(path); + } + + let local_roots = local_engine_search_roots(); + let cached = cached_engine_path(&config.version)?; + match resolve_engine_binary_after_probe(config, false, &local_roots, cached).await? { + ResolvedEngine::Binary(path) => Ok(path), + ResolvedEngine::Existing => { + unreachable!("no-probe resolution never returns Existing") + } + } +} + +async fn resolve_engine_binary_after_probe( + config: &EngineResolverConfig, + existing_engine: bool, + local_roots: &[PathBuf], + cached: PathBuf, +) -> Result { + if existing_engine { + return Ok(ResolvedEngine::Existing); + } + + if let Some(path) = find_local_engine_binary_in_roots(local_roots) { + return Ok(ResolvedEngine::Binary(path)); + } + + if cached.exists() { + return Ok(ResolvedEngine::Binary(cached)); + } + + if !config.auto_download { + return Err(EngineProcessError::BinaryUnavailable { + version: config.version.clone(), + } + .build()); + } + + download_engine_binary(config, &cached).await?; + Ok(ResolvedEngine::Binary(cached)) +} + +fn verify_binary_path(path: &Path) -> Result { + if !path.exists() { + return Err(EngineProcessError::BinaryNotFound { + path: path.display().to_string(), + } + .build()); + } + Ok(ResolvedEngine::Binary(path.to_path_buf())) +} + +fn local_engine_search_roots() -> Vec { + Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .map(Path::to_path_buf) + .collect() +} + +fn find_local_engine_binary_in_roots(roots: &[PathBuf]) -> Option { + for root in roots { + for profile in ["debug", "release"] { + let candidate = root + .join("target") + .join(profile) + .join(exe_name("rivet-engine")); + if candidate.exists() { + return Some(candidate); + } + } + } + None +} + +fn cached_engine_path(version: &str) -> Result { + Ok(storage_root()? + .join("engine") + .join(version) + .join(engine_artifact_name())) +} + +async fn download_engine_binary(config: &EngineResolverConfig, destination: &Path) -> Result<()> { + let artifact = engine_artifact_name(); + let base = config.releases_endpoint.trim_end_matches('/'); + let artifact_url = format!("{base}/rivet/{}/engine/{artifact}", config.version); + let manifest_url = format!("{base}/rivet/{}/engine/SHA256SUMS", config.version); + let client = Client::builder() + .timeout(DOWNLOAD_TIMEOUT) + .build() + .context("build reqwest client for engine download")?; + + let manifest = fetch_text(&client, &manifest_url).await?; + let expected = checksum_for_artifact(&manifest, &artifact).ok_or_else(|| { + EngineProcessError::DownloadFailed { + url: manifest_url.clone(), + reason: format!("manifest does not contain `{artifact}`"), + } + .build() + })?; + + let bytes = fetch_bytes(&client, &artifact_url).await?; + let received = sha256_hex(&bytes); + if !received.eq_ignore_ascii_case(&expected) { + return Err(EngineProcessError::ChecksumMismatch { + artifact, + expected, + received, + } + .build()); + } + + let parent = destination + .parent() + .context("engine cache destination has no parent")?; + ensure_dir(parent)?; + std::fs::write(destination, bytes) + .with_context(|| format!("write engine binary `{}`", destination.display()))?; + make_executable(destination)?; + Ok(()) +} + +async fn fetch_text(client: &Client, url: &str) -> Result { + let response = client.get(url).send().await.map_err(|error| { + EngineProcessError::DownloadFailed { + url: url.to_owned(), + reason: error.to_string(), + } + .build() + })?; + if !response.status().is_success() { + let status = response.status(); + return Err(EngineProcessError::DownloadFailed { + url: url.to_owned(), + reason: format!("unexpected status {status}"), + } + .build()); + } + response.text().await.map_err(|error| { + EngineProcessError::DownloadFailed { + url: url.to_owned(), + reason: error.to_string(), + } + .build() + }) +} + +async fn fetch_bytes(client: &Client, url: &str) -> Result> { + let response = client.get(url).send().await.map_err(|error| { + EngineProcessError::DownloadFailed { + url: url.to_owned(), + reason: error.to_string(), + } + .build() + })?; + if !response.status().is_success() { + let status = response.status(); + return Err(EngineProcessError::DownloadFailed { + url: url.to_owned(), + reason: format!("unexpected status {status}"), + } + .build()); + } + response + .bytes() + .await + .map(|bytes| bytes.to_vec()) + .map_err(|error| { + EngineProcessError::DownloadFailed { + url: url.to_owned(), + reason: error.to_string(), + } + .build() + }) +} + +fn checksum_for_artifact(manifest: &str, artifact: &str) -> Option { + manifest.lines().find_map(|line| { + let mut parts = line.split_whitespace(); + let checksum = parts.next()?; + let name = parts.next()?.trim_start_matches('*'); + (checksum.len() == 64 && name == artifact).then(|| checksum.to_owned()) + }) +} + +fn sha256_hex(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let mut out = String::with_capacity(digest.len() * 2); + for byte in digest { + use std::fmt::Write; + let _ = write!(&mut out, "{byte:02x}"); + } + out +} + +fn engine_artifact_name() -> String { + let arch = match std::env::consts::ARCH { + "x86_64" => "x86_64", + "aarch64" => "aarch64", + other => other, + }; + let target = match std::env::consts::OS { + "linux" => format!("{arch}-unknown-linux-musl"), + "macos" => format!("{arch}-apple-darwin"), + "windows" => format!("{arch}-pc-windows-gnu.exe"), + other => format!("{arch}-{other}"), + }; + format!("rivet-engine-{target}") +} + +fn exe_name(base: &str) -> String { + if cfg!(windows) { + format!("{base}.exe") + } else { + base.to_owned() + } +} + +fn make_executable(path: &Path) -> Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = std::fs::metadata(path) + .with_context(|| format!("read metadata for `{}`", path.display()))? + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(path, permissions) + .with_context(|| format!("mark `{}` executable", path.display()))?; + } + #[cfg(not(unix))] + { + let _ = path; + } + Ok(()) +} + +impl Drop for EngineProcessManager { + fn drop(&mut self) { + if let Some(handle) = self.watcher.take() { + // Aborting drops the `Child` owned by the task. With + // `kill_on_drop=false`, dropping the `Child` does NOT signal the + // engine, so the engine survives and gets reparented to init. + // We give up our crash-detection log line here, but if we are + // being dropped the rivetkit host is shutting down anyway. + handle.abort(); + tracing::debug!( + "aborted engine watcher; engine continues running (intentional orphan)" + ); + } + } +} + +/// Spawns a background task that owns the `Child` and awaits `wait()` so we +/// log a clear message if the engine dies while rivetkit is still up. Taking +/// the `Child` into the task also reaps it via `waitpid` on exit, so a +/// crashed engine never lingers as a zombie in our process table. +fn spawn_engine_watcher(mut child: Child, pid: u32) -> JoinHandle<()> { + tokio::spawn(async move { + match child.wait().await { + Ok(status) if status.success() => { + tracing::warn!( + pid, + ?status, + "engine process exited cleanly while rivetkit was still running; \ + rivetkit expected the engine to outlive it" + ); + } + Ok(status) => { + tracing::error!( + pid, + ?status, + "engine process crashed while rivetkit was still running" + ); + } + Err(error) => { + tracing::error!( + pid, + ?error, + "failed to wait on engine process; cannot detect crashes" + ); + } + } + }) +} + +/// Probes the configured endpoint for an already-running, healthy engine. +/// +/// Returns `Ok(Some(health))` if the endpoint is serving a `runtime: "engine"` +/// health response that we can reattach to. Returns `Ok(None)` if the port is +/// free. Returns `Err(...)` if the port is occupied by a non-engine process +/// (for example a stale rivetkit) which would conflict with a fresh spawn. +async fn probe_existing_engine(endpoint: &str) -> Result> { + let health_url = engine_health_url(endpoint); + let client = Client::builder() + .build() + .context("build reqwest client for engine probe")?; + + let response = match client + .get(&health_url) + .timeout(Duration::from_secs(1)) + .send() + .await + { + Ok(response) => response, + Err(_) => return Ok(None), + }; + + if !response.status().is_success() { + return Ok(None); + } + + let health = response + .json::() + .await + .context("decode existing engine health response")?; + + match health.runtime.as_deref() { + Some(ENGINE_RUNTIME) => Ok(Some(health)), + Some(RIVETKIT_RUNTIME) => Err(EngineProcessError::PortOccupied { + endpoint: endpoint.to_owned(), + runtime: RIVETKIT_RUNTIME.to_owned(), + } + .build()), + Some(other) => Err(EngineProcessError::PortOccupied { + endpoint: endpoint.to_owned(), + runtime: other.to_owned(), + } + .build()), + None => Err(EngineProcessError::PortOccupied { + endpoint: endpoint.to_owned(), + runtime: "unknown".to_owned(), + } + .build()), + } +} + +fn engine_health_url(endpoint: &str) -> String { + format!("{}/health", endpoint.trim_end_matches('/')) +} + +fn storage_root() -> Result { + if let Ok(path) = std::env::var("RIVETKIT_STORAGE_PATH") { + return Ok(PathBuf::from(path).join(".rivetkit")); + } + let home = std::env::var("HOME") + .map(PathBuf::from) + .or_else(|_| std::env::current_dir()) + .context("locate home directory for engine storage path")?; + Ok(home.join(".rivetkit")) +} + +fn ensure_dir(path: &Path) -> Result<()> { + std::fs::create_dir_all(path).with_context(|| format!("create directory `{}`", path.display())) +} + +fn open_log_file(path: &Path) -> Result { + std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .with_context(|| format!("open log file `{}`", path.display())) +} + +fn log_timestamp() -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + format!("{}", now.as_secs()) +} + +async fn wait_for_engine_health(health_url: &str) -> Result { + const HEALTH_MAX_WAIT: Duration = Duration::from_secs(10); + const HEALTH_REQUEST_TIMEOUT: Duration = Duration::from_secs(1); + const HEALTH_INITIAL_BACKOFF: Duration = Duration::from_millis(100); + const HEALTH_MAX_BACKOFF: Duration = Duration::from_secs(1); + + let client = Client::builder() + .build() + .context("build reqwest client for engine health check")?; + let deadline = Instant::now() + HEALTH_MAX_WAIT; + let mut attempt = 0u32; + let mut backoff = HEALTH_INITIAL_BACKOFF; + + loop { + attempt += 1; + + let last_error = match client + .get(health_url) + .timeout(HEALTH_REQUEST_TIMEOUT) + .send() + .await + { + Ok(response) if response.status().is_success() => { + let health = response + .json::() + .await + .context("decode engine health response")?; + return Ok(health); + } + Ok(response) => format!("unexpected status {}", response.status()), + Err(error) => error.to_string(), + }; + + if Instant::now() >= deadline { + return Err(EngineProcessError::HealthCheckFailed { + attempts: attempt, + reason: last_error, + } + .build()); + } + + tokio::time::sleep(backoff).await; + backoff = std::cmp::min(backoff * 2, HEALTH_MAX_BACKOFF); + } +} + +/// Cleanup path for a spawn that never reached `healthy`. We *do* kill here +/// because the half-started engine has no useful state to preserve and +/// leaving it running would conflict with a retry. This is the only place +/// allowed to terminate the engine. +async fn terminate_failed_spawn(child: &mut Child) -> Result<()> { + const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); + + if child + .try_wait() + .context("check engine process status")? + .is_some() + { + return Ok(()); + } + + child + .start_kill() + .context("kill half-started engine process")?; + match tokio::time::timeout(SHUTDOWN_TIMEOUT, child.wait()).await { + Ok(result) => { + let status = result.context("wait for half-started engine to exit")?; + tracing::info!(?status, "half-started engine process exited"); + Ok(()) + } + Err(_) => { + tracing::warn!("half-started engine process did not exit within timeout"); + Ok(()) + } + } +} + +fn invalid_endpoint(endpoint: &str, reason: &str) -> anyhow::Error { + EngineProcessError::InvalidEndpoint { + endpoint: endpoint.to_owned(), + reason: reason.to_owned(), + } + .build() +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + use super::*; + + fn test_config(releases_endpoint: String, auto_download: bool) -> EngineResolverConfig { + EngineResolverConfig { + endpoint: "http://127.0.0.1:1".to_owned(), + explicit_binary_path: None, + bind_host: None, + bind_port: None, + public_url: None, + auto_download, + version: "test-version".to_owned(), + releases_endpoint, + } + } + + #[tokio::test] + async fn resolver_prefers_existing_engine_before_filesystem_paths() { + let temp = tempfile::tempdir().expect("create temp dir"); + let local = temp + .path() + .join("target") + .join("debug") + .join(exe_name("rivet-engine")); + std::fs::create_dir_all(local.parent().expect("local parent")).expect("create local dir"); + std::fs::write(&local, b"local").expect("write local binary"); + let cached = temp.path().join("cache").join(exe_name("rivet-engine")); + + let resolved = resolve_engine_binary_after_probe( + &test_config("http://127.0.0.1:1".to_owned(), false), + true, + &[temp.path().to_path_buf()], + cached, + ) + .await + .expect("resolve engine"); + + assert_eq!(resolved, ResolvedEngine::Existing); + } + + #[tokio::test] + async fn resolver_prefers_local_binary_before_cached_binary() { + let temp = tempfile::tempdir().expect("create temp dir"); + let local = temp + .path() + .join("target") + .join("debug") + .join(exe_name("rivet-engine")); + std::fs::create_dir_all(local.parent().expect("local parent")).expect("create local dir"); + std::fs::write(&local, b"local").expect("write local binary"); + let cached = temp.path().join("cache").join(exe_name("rivet-engine")); + std::fs::create_dir_all(cached.parent().expect("cached parent")).expect("create cache dir"); + std::fs::write(&cached, b"cached").expect("write cached binary"); + + let resolved = resolve_engine_binary_after_probe( + &test_config("http://127.0.0.1:1".to_owned(), false), + false, + &[temp.path().to_path_buf()], + cached, + ) + .await + .expect("resolve engine"); + + assert_eq!(resolved, ResolvedEngine::Binary(local)); + } + + #[tokio::test] + async fn resolver_reuses_cached_binary_without_download() { + let temp = tempfile::tempdir().expect("create temp dir"); + let cached = temp.path().join("cache").join(exe_name("rivet-engine")); + std::fs::create_dir_all(cached.parent().expect("cached parent")).expect("create cache dir"); + std::fs::write(&cached, b"cached").expect("write cached binary"); + + let resolved = resolve_engine_binary_after_probe( + &test_config("http://127.0.0.1:1".to_owned(), false), + false, + &[], + cached.clone(), + ) + .await + .expect("resolve engine"); + + assert_eq!(resolved, ResolvedEngine::Binary(cached)); + } + + #[tokio::test] + async fn resolver_reports_actionable_error_without_binary_or_download() { + let temp = tempfile::tempdir().expect("create temp dir"); + let cached = temp.path().join("cache").join(exe_name("rivet-engine")); + + let error = resolve_engine_binary_after_probe( + &test_config("http://127.0.0.1:1".to_owned(), false), + false, + &[], + cached, + ) + .await + .expect_err("missing binary should fail"); + let message = error.to_string(); + + assert!(message.contains("No usable engine binary was found")); + assert!(message.contains("Build `rivet-engine`")); + assert!(message.contains("RIVET_ENGINE_BINARY_PATH")); + } + + #[tokio::test] + async fn resolver_download_checks_manifest_checksum() { + let temp = tempfile::tempdir().expect("create temp dir"); + let cached = temp.path().join("cache").join(exe_name("rivet-engine")); + let artifact = engine_artifact_name(); + let expected = sha256_hex(b"different bytes"); + let manifest = format!("{expected} {artifact}\n"); + let releases_endpoint = spawn_download_server(HashMap::from([ + ( + format!("/rivet/test-version/engine/SHA256SUMS"), + manifest.into_bytes(), + ), + ( + format!("/rivet/test-version/engine/{artifact}"), + b"actual bytes".to_vec(), + ), + ])) + .await; + + let error = resolve_engine_binary_after_probe( + &test_config(releases_endpoint, true), + false, + &[], + cached, + ) + .await + .expect_err("checksum mismatch should fail"); + + assert!( + error + .to_string() + .contains("Engine binary checksum mismatch") + ); + } + + async fn spawn_download_server(routes: HashMap>) -> String { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind download server"); + let addr = listener.local_addr().expect("download server address"); + tokio::spawn(async move { + for _ in 0..routes.len() { + let (mut socket, _) = listener.accept().await.expect("accept download request"); + let mut buffer = [0_u8; 2048]; + let n = socket + .read(&mut buffer) + .await + .expect("read download request"); + let request = String::from_utf8_lossy(&buffer[..n]); + let path = request + .split_whitespace() + .nth(1) + .expect("request path") + .to_owned(); + let body = routes.get(&path).expect("route body"); + let header = format!( + "HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n", + body.len() + ); + socket + .write_all(header.as_bytes()) + .await + .expect("write response header"); + socket.write_all(body).await.expect("write response body"); + } + }); + format!("http://{addr}") + } +} diff --git a/rivetkit-rust/packages/rivetkit-core/Cargo.toml b/rivetkit-rust/packages/rivetkit-core/Cargo.toml index b11d713ea3..53e3e5a52c 100644 --- a/rivetkit-rust/packages/rivetkit-core/Cargo.toml +++ b/rivetkit-rust/packages/rivetkit-core/Cargo.toml @@ -15,6 +15,7 @@ default = ["native-runtime"] native-runtime = [ "dep:nix", "dep:reqwest", + "dep:rivetkit-engine-process", "rivet-envoy-client/native-transport", ] wasm-runtime = ["rivet-envoy-client/wasm-transport"] @@ -44,6 +45,7 @@ rivet-metrics.workspace = true rivetkit-shared-types.workspace = true rivetkit-actor-persist.workspace = true rivetkit-client-protocol.workspace = true +rivetkit-engine-process = { workspace = true, optional = true } rivetkit-inspector-protocol.workspace = true depot-client-types.workspace = true depot-client = { workspace = true, optional = true } diff --git a/rivetkit-rust/packages/rivetkit-core/src/engine_process.rs b/rivetkit-rust/packages/rivetkit-core/src/engine_process.rs index 305f43d6e0..345b8e04dc 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/engine_process.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/engine_process.rs @@ -1,884 +1,9 @@ -use std::path::{Path, PathBuf}; -use std::process::Stdio; -use std::time::{Duration, Instant}; - -use anyhow::{Context, Result}; -use reqwest::{Client, Url}; -use serde::Deserialize; -use sha2::{Digest, Sha256}; -use tokio::process::{Child, Command}; -use tokio::task::JoinHandle; - -use crate::error::EngineProcessError; - -const ENGINE_RUNTIME: &str = "engine"; -const RIVETKIT_RUNTIME: &str = "rivetkit"; -const ENGINE_VERSION_ENV: &str = "RIVETKIT_ENGINE_VERSION"; -const RELEASES_ENDPOINT_ENV: &str = "RIVETKIT_ENGINE_RELEASES_ENDPOINT"; -const RELEASES_ENDPOINT: &str = "https://releases.rivet.dev"; -const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(60); - -#[derive(Debug, Deserialize)] -struct EngineHealthResponse { - status: Option, - runtime: Option, - version: Option, -} - -#[derive(Clone, Debug)] -pub(crate) struct EngineResolverConfig { - pub endpoint: String, - pub explicit_binary_path: Option, - pub bind_host: Option, - pub bind_port: Option, - pub auto_download: bool, - pub version: String, - pub releases_endpoint: String, -} - -impl EngineResolverConfig { - pub(crate) fn from_parts( - endpoint: &str, - explicit_binary_path: Option, - bind_host: Option, - bind_port: Option, - auto_download: bool, - ) -> Self { - Self { - endpoint: endpoint.to_owned(), - explicit_binary_path, - bind_host, - bind_port, - auto_download, - version: std::env::var(ENGINE_VERSION_ENV) - .unwrap_or_else(|_| env!("CARGO_PKG_VERSION").to_owned()), - releases_endpoint: std::env::var(RELEASES_ENDPOINT_ENV) - .unwrap_or_else(|_| RELEASES_ENDPOINT.to_owned()), - } - } -} - -#[derive(Debug, PartialEq, Eq)] -enum ResolvedEngine { - Existing, - Binary(PathBuf), -} - -/// Manages the rivet-engine subprocess. -/// -/// The engine is intentionally orphaned: dropping the manager (or having the -/// host process exit) must NOT terminate the engine. This lets a dev-server -/// restart of the rivetkit host reattach to the same long-lived engine and -/// keep all in-flight actor state. To honor that contract: -/// -/// - `Command::kill_on_drop` is left at its default (false) so the tokio -/// `Child` does not send SIGKILL on drop. -/// - Stdout and stderr are routed to log files at spawn time so the engine's -/// write fds remain valid after the host's pipes close. -/// - On startup we probe the configured endpoint and reuse a healthy engine -/// instead of spawning a duplicate. -/// -/// When we spawn the engine, `watcher` holds a tokio task that owns the -/// `Child` and awaits `child.wait()` so we get a log line if the engine dies -/// while rivetkit is still running. On Drop we abort the watcher; aborting -/// drops the `Child` without killing it (kill_on_drop=false), so the engine -/// stays running and gets reparented to init when rivetkit exits. -/// -/// `watcher` is `None` when we attached to an already-running engine. -#[derive(Debug)] -pub(crate) struct EngineProcessManager { - watcher: Option>, -} - -impl EngineProcessManager { - pub(crate) async fn start_or_reuse(config: EngineResolverConfig) -> Result { - let resolved = resolve_engine_binary(&config).await?; - Self::start_resolved(resolved, &config).await - } - - async fn start_resolved( - resolved: ResolvedEngine, - config: &EngineResolverConfig, - ) -> Result { - let endpoint = &config.endpoint; - if matches!(resolved, ResolvedEngine::Existing) { - tracing::info!( - endpoint = %endpoint, - "reusing already-running engine process" - ); - return Ok(Self { watcher: None }); - } - - let ResolvedEngine::Binary(binary_path) = resolved else { - unreachable!("existing engine handled above"); - }; - if let Some(health) = probe_existing_engine(endpoint).await? { - tracing::info!( - endpoint = %endpoint, - status = ?health.status, - runtime = ?health.runtime, - version = ?health.version, - "reusing already-running engine process" - ); - return Ok(Self { watcher: None }); - } - - if !binary_path.exists() { - return Err(EngineProcessError::BinaryNotFound { - path: binary_path.display().to_string(), - } - .build()); - } - - let endpoint_url = - Url::parse(endpoint).with_context(|| format!("parse engine endpoint `{endpoint}`"))?; - let guard_host = endpoint_url - .host_str() - .ok_or_else(|| invalid_endpoint(endpoint, "missing host"))? - .to_owned(); - let guard_host = config.bind_host.clone().unwrap_or(guard_host); - let guard_port = endpoint_url - .port_or_known_default() - .ok_or_else(|| invalid_endpoint(endpoint, "missing port"))?; - let guard_port = config.bind_port.unwrap_or(guard_port); - let api_peer_port = guard_port - .checked_add(1) - .ok_or_else(|| invalid_endpoint(endpoint, "port is too large"))?; - let metrics_port = guard_port - .checked_add(10) - .ok_or_else(|| invalid_endpoint(endpoint, "port is too large"))?; - - let storage_root = storage_root()?; - let var_dir = storage_root.join("var"); - let db_path = var_dir.join("engine").join("db"); - let logs_dir = var_dir.join("logs").join("rivet-engine"); - ensure_dir(&db_path).context("create engine db directory")?; - ensure_dir(&logs_dir).context("create engine logs directory")?; - - let timestamp = log_timestamp(); - let stdout_log_path = logs_dir.join(format!("engine-{timestamp}-stdout.log")); - let stderr_log_path = logs_dir.join(format!("engine-{timestamp}-stderr.log")); - let stdout_file = open_log_file(&stdout_log_path) - .with_context(|| format!("open engine stdout log `{}`", stdout_log_path.display()))?; - let stderr_file = open_log_file(&stderr_log_path) - .with_context(|| format!("open engine stderr log `{}`", stderr_log_path.display()))?; - - let mut command = Command::new(&binary_path); - command - .arg("start") - .env("RIVET__GUARD__HOST", &guard_host) - .env("RIVET__GUARD__PORT", guard_port.to_string()) - .env("RIVET__API_PEER__HOST", &guard_host) - .env("RIVET__API_PEER__PORT", api_peer_port.to_string()) - .env("RIVET__METRICS__HOST", &guard_host) - .env("RIVET__METRICS__PORT", metrics_port.to_string()) - .env("RIVET__FILE_SYSTEM__PATH", &db_path) - .stdin(Stdio::null()) - .stdout(Stdio::from(stdout_file)) - .stderr(Stdio::from(stderr_file)); - - // Put the engine in its own process group so terminal signals - // (Ctrl+C, Ctrl+Z, SIGHUP on terminal close) targeting our foreground - // process group do not reach the engine. Combined with no-kill-on-drop - // and file-fd stdio, this gives the engine a real "intentional orphan" - // lifetime that survives the host being killed for any reason. - #[cfg(unix)] - command.process_group(0); - - let mut child = command - .spawn() - .with_context(|| format!("spawn engine binary `{}`", binary_path.display()))?; - let pid = child - .id() - .ok_or_else(|| EngineProcessError::MissingPid.build())?; - - tracing::info!( - pid, - path = %binary_path.display(), - endpoint = %endpoint, - db_path = %db_path.display(), - "spawned engine process (intentionally orphaned, will outlive this process)" - ); - tracing::info!( - stdout_log = %stdout_log_path.display(), - stderr_log = %stderr_log_path.display(), - "engine stdout/stderr piped to log files" - ); - - let health_url = engine_health_url(endpoint); - let health = match wait_for_engine_health(&health_url).await { - Ok(health) => health, - Err(error) => { - let error = match child.try_wait() { - Ok(Some(status)) => error.context(format!( - "engine process exited before becoming healthy with status {status}" - )), - Ok(None) => error, - Err(wait_error) => error.context(format!( - "failed to inspect engine process status: {wait_error:#}" - )), - }; - if let Err(cleanup_error) = terminate_failed_spawn(&mut child).await { - tracing::warn!( - ?cleanup_error, - "failed to terminate engine process that never became healthy" - ); - } - return Err(error); - } - }; - - tracing::info!( - pid, - status = ?health.status, - runtime = ?health.runtime, - version = ?health.version, - "engine process is healthy" - ); - - Ok(Self { - watcher: Some(spawn_engine_watcher(child, pid)), - }) - } -} - -async fn resolve_engine_binary(config: &EngineResolverConfig) -> Result { - if let Some(path) = config.explicit_binary_path.as_ref() { - return verify_binary_path(path); - } - - if let Some(path) = std::env::var_os("RIVET_ENGINE_BINARY_PATH").map(PathBuf::from) { - return verify_binary_path(&path); - } - - if probe_existing_engine(&config.endpoint).await?.is_some() { - return Ok(ResolvedEngine::Existing); - } - - let local_roots = local_engine_search_roots(); - let cached = cached_engine_path(&config.version)?; - resolve_engine_binary_after_probe(config, false, &local_roots, cached).await -} - -async fn resolve_engine_binary_after_probe( - config: &EngineResolverConfig, - existing_engine: bool, - local_roots: &[PathBuf], - cached: PathBuf, -) -> Result { - if existing_engine { - return Ok(ResolvedEngine::Existing); - } - - if let Some(path) = find_local_engine_binary_in_roots(local_roots) { - return Ok(ResolvedEngine::Binary(path)); - } - - if cached.exists() { - return Ok(ResolvedEngine::Binary(cached)); - } - - if !config.auto_download { - return Err(EngineProcessError::BinaryUnavailable { - version: config.version.clone(), - } - .build()); - } - - download_engine_binary(config, &cached).await?; - Ok(ResolvedEngine::Binary(cached)) -} - -fn verify_binary_path(path: &Path) -> Result { - if !path.exists() { - return Err(EngineProcessError::BinaryNotFound { - path: path.display().to_string(), - } - .build()); - } - Ok(ResolvedEngine::Binary(path.to_path_buf())) -} - -fn local_engine_search_roots() -> Vec { - Path::new(env!("CARGO_MANIFEST_DIR")) - .ancestors() - .map(Path::to_path_buf) - .collect() -} - -fn find_local_engine_binary_in_roots(roots: &[PathBuf]) -> Option { - for root in roots { - for profile in ["debug", "release"] { - let candidate = root - .join("target") - .join(profile) - .join(exe_name("rivet-engine")); - if candidate.exists() { - return Some(candidate); - } - } - } - None -} - -fn cached_engine_path(version: &str) -> Result { - Ok(storage_root()? - .join("engine") - .join(version) - .join(engine_artifact_name())) -} - -async fn download_engine_binary(config: &EngineResolverConfig, destination: &Path) -> Result<()> { - let artifact = engine_artifact_name(); - let base = config.releases_endpoint.trim_end_matches('/'); - let artifact_url = format!("{base}/rivet/{}/engine/{artifact}", config.version); - let manifest_url = format!("{base}/rivet/{}/engine/SHA256SUMS", config.version); - let client = Client::builder() - .timeout(DOWNLOAD_TIMEOUT) - .build() - .context("build reqwest client for engine download")?; - - let manifest = fetch_text(&client, &manifest_url).await?; - let expected = checksum_for_artifact(&manifest, &artifact).ok_or_else(|| { - EngineProcessError::DownloadFailed { - url: manifest_url.clone(), - reason: format!("manifest does not contain `{artifact}`"), - } - .build() - })?; - - let bytes = fetch_bytes(&client, &artifact_url).await?; - let received = sha256_hex(&bytes); - if !received.eq_ignore_ascii_case(&expected) { - return Err(EngineProcessError::ChecksumMismatch { - artifact, - expected, - received, - } - .build()); - } - - let parent = destination - .parent() - .context("engine cache destination has no parent")?; - ensure_dir(parent)?; - std::fs::write(destination, bytes) - .with_context(|| format!("write engine binary `{}`", destination.display()))?; - make_executable(destination)?; - Ok(()) -} - -async fn fetch_text(client: &Client, url: &str) -> Result { - let response = client.get(url).send().await.map_err(|error| { - EngineProcessError::DownloadFailed { - url: url.to_owned(), - reason: error.to_string(), - } - .build() - })?; - if !response.status().is_success() { - let status = response.status(); - return Err(EngineProcessError::DownloadFailed { - url: url.to_owned(), - reason: format!("unexpected status {status}"), - } - .build()); - } - response.text().await.map_err(|error| { - EngineProcessError::DownloadFailed { - url: url.to_owned(), - reason: error.to_string(), - } - .build() - }) -} - -async fn fetch_bytes(client: &Client, url: &str) -> Result> { - let response = client.get(url).send().await.map_err(|error| { - EngineProcessError::DownloadFailed { - url: url.to_owned(), - reason: error.to_string(), - } - .build() - })?; - if !response.status().is_success() { - let status = response.status(); - return Err(EngineProcessError::DownloadFailed { - url: url.to_owned(), - reason: format!("unexpected status {status}"), - } - .build()); - } - response - .bytes() - .await - .map(|bytes| bytes.to_vec()) - .map_err(|error| { - EngineProcessError::DownloadFailed { - url: url.to_owned(), - reason: error.to_string(), - } - .build() - }) -} - -fn checksum_for_artifact(manifest: &str, artifact: &str) -> Option { - manifest.lines().find_map(|line| { - let mut parts = line.split_whitespace(); - let checksum = parts.next()?; - let name = parts.next()?.trim_start_matches('*'); - (checksum.len() == 64 && name == artifact).then(|| checksum.to_owned()) - }) -} - -fn sha256_hex(bytes: &[u8]) -> String { - let digest = Sha256::digest(bytes); - let mut out = String::with_capacity(digest.len() * 2); - for byte in digest { - use std::fmt::Write; - let _ = write!(&mut out, "{byte:02x}"); - } - out -} - -fn engine_artifact_name() -> String { - let arch = match std::env::consts::ARCH { - "x86_64" => "x86_64", - "aarch64" => "aarch64", - other => other, - }; - let target = match std::env::consts::OS { - "linux" => format!("{arch}-unknown-linux-musl"), - "macos" => format!("{arch}-apple-darwin"), - "windows" => format!("{arch}-pc-windows-gnu.exe"), - other => format!("{arch}-{other}"), - }; - if target.ends_with(".exe") { - format!("rivet-engine-{target}") - } else { - format!("rivet-engine-{target}") - } -} - -fn exe_name(base: &str) -> String { - if cfg!(windows) { - format!("{base}.exe") - } else { - base.to_owned() - } -} - -fn make_executable(path: &Path) -> Result<()> { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut permissions = std::fs::metadata(path) - .with_context(|| format!("read metadata for `{}`", path.display()))? - .permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(path, permissions) - .with_context(|| format!("mark `{}` executable", path.display()))?; - } - #[cfg(not(unix))] - { - let _ = path; - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - use tokio::net::TcpListener; - - use super::*; - - fn test_config(releases_endpoint: String, auto_download: bool) -> EngineResolverConfig { - EngineResolverConfig { - endpoint: "http://127.0.0.1:1".to_owned(), - explicit_binary_path: None, - auto_download, - version: "test-version".to_owned(), - releases_endpoint, - } - } - - #[tokio::test] - async fn resolver_prefers_existing_engine_before_filesystem_paths() { - let temp = tempfile::tempdir().expect("create temp dir"); - let local = temp - .path() - .join("target") - .join("debug") - .join(exe_name("rivet-engine")); - std::fs::create_dir_all(local.parent().expect("local parent")).expect("create local dir"); - std::fs::write(&local, b"local").expect("write local binary"); - let cached = temp.path().join("cache").join(exe_name("rivet-engine")); - - let resolved = resolve_engine_binary_after_probe( - &test_config("http://127.0.0.1:1".to_owned(), false), - true, - &[temp.path().to_path_buf()], - cached, - ) - .await - .expect("resolve engine"); - - assert_eq!(resolved, ResolvedEngine::Existing); - } - - #[tokio::test] - async fn resolver_prefers_local_binary_before_cached_binary() { - let temp = tempfile::tempdir().expect("create temp dir"); - let local = temp - .path() - .join("target") - .join("debug") - .join(exe_name("rivet-engine")); - std::fs::create_dir_all(local.parent().expect("local parent")).expect("create local dir"); - std::fs::write(&local, b"local").expect("write local binary"); - let cached = temp.path().join("cache").join(exe_name("rivet-engine")); - std::fs::create_dir_all(cached.parent().expect("cached parent")).expect("create cache dir"); - std::fs::write(&cached, b"cached").expect("write cached binary"); - - let resolved = resolve_engine_binary_after_probe( - &test_config("http://127.0.0.1:1".to_owned(), false), - false, - &[temp.path().to_path_buf()], - cached, - ) - .await - .expect("resolve engine"); - - assert_eq!(resolved, ResolvedEngine::Binary(local)); - } - - #[tokio::test] - async fn resolver_reuses_cached_binary_without_download() { - let temp = tempfile::tempdir().expect("create temp dir"); - let cached = temp.path().join("cache").join(exe_name("rivet-engine")); - std::fs::create_dir_all(cached.parent().expect("cached parent")).expect("create cache dir"); - std::fs::write(&cached, b"cached").expect("write cached binary"); - - let resolved = resolve_engine_binary_after_probe( - &test_config("http://127.0.0.1:1".to_owned(), false), - false, - &[], - cached.clone(), - ) - .await - .expect("resolve engine"); - - assert_eq!(resolved, ResolvedEngine::Binary(cached)); - } - - #[tokio::test] - async fn resolver_reports_actionable_error_without_binary_or_download() { - let temp = tempfile::tempdir().expect("create temp dir"); - let cached = temp.path().join("cache").join(exe_name("rivet-engine")); - - let error = resolve_engine_binary_after_probe( - &test_config("http://127.0.0.1:1".to_owned(), false), - false, - &[], - cached, - ) - .await - .expect_err("missing binary should fail"); - let message = error.to_string(); - - assert!(message.contains("No usable engine binary was found")); - assert!(message.contains("Build `rivet-engine`")); - assert!(message.contains("RIVET_ENGINE_BINARY_PATH")); - } - - #[tokio::test] - async fn resolver_download_checks_manifest_checksum() { - let temp = tempfile::tempdir().expect("create temp dir"); - let cached = temp.path().join("cache").join(exe_name("rivet-engine")); - let artifact = engine_artifact_name(); - let expected = sha256_hex(b"different bytes"); - let manifest = format!("{expected} {artifact}\n"); - let releases_endpoint = spawn_download_server(HashMap::from([ - ( - format!("/rivet/test-version/engine/SHA256SUMS"), - manifest.into_bytes(), - ), - ( - format!("/rivet/test-version/engine/{artifact}"), - b"actual bytes".to_vec(), - ), - ])) - .await; - - let error = resolve_engine_binary_after_probe( - &test_config(releases_endpoint, true), - false, - &[], - cached, - ) - .await - .expect_err("checksum mismatch should fail"); - - assert!( - error - .to_string() - .contains("Engine binary checksum mismatch") - ); - } - - async fn spawn_download_server(routes: HashMap>) -> String { - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("bind download server"); - let addr = listener.local_addr().expect("download server address"); - tokio::spawn(async move { - for _ in 0..routes.len() { - let (mut socket, _) = listener.accept().await.expect("accept download request"); - let mut buffer = [0_u8; 2048]; - let n = socket - .read(&mut buffer) - .await - .expect("read download request"); - let request = String::from_utf8_lossy(&buffer[..n]); - let path = request - .split_whitespace() - .nth(1) - .expect("request path") - .to_owned(); - let body = routes.get(&path).expect("route body"); - let header = format!( - "HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n", - body.len() - ); - socket - .write_all(header.as_bytes()) - .await - .expect("write response header"); - socket.write_all(body).await.expect("write response body"); - } - }); - format!("http://{addr}") - } -} - -impl Drop for EngineProcessManager { - fn drop(&mut self) { - if let Some(handle) = self.watcher.take() { - // Aborting drops the `Child` owned by the task. With - // `kill_on_drop=false`, dropping the `Child` does NOT signal the - // engine, so the engine survives and gets reparented to init. - // We give up our crash-detection log line here, but if we are - // being dropped the rivetkit host is shutting down anyway. - handle.abort(); - tracing::debug!( - "aborted engine watcher; engine continues running (intentional orphan)" - ); - } - } -} - -/// Spawns a background task that owns the `Child` and awaits `wait()` so we -/// log a clear message if the engine dies while rivetkit is still up. Taking -/// the `Child` into the task also reaps it via `waitpid` on exit, so a -/// crashed engine never lingers as a zombie in our process table. -fn spawn_engine_watcher(mut child: Child, pid: u32) -> JoinHandle<()> { - tokio::spawn(async move { - match child.wait().await { - Ok(status) if status.success() => { - tracing::warn!( - pid, - ?status, - "engine process exited cleanly while rivetkit was still running; \ - rivetkit expected the engine to outlive it" - ); - } - Ok(status) => { - tracing::error!( - pid, - ?status, - "engine process crashed while rivetkit was still running" - ); - } - Err(error) => { - tracing::error!( - pid, - ?error, - "failed to wait on engine process; cannot detect crashes" - ); - } - } - }) -} - -/// Probes the configured endpoint for an already-running, healthy engine. -/// -/// Returns `Ok(Some(health))` if the endpoint is serving a `runtime: "engine"` -/// health response that we can reattach to. Returns `Ok(None)` if the port is -/// free. Returns `Err(...)` if the port is occupied by a non-engine process -/// (for example a stale rivetkit) which would conflict with a fresh spawn. -async fn probe_existing_engine(endpoint: &str) -> Result> { - let health_url = engine_health_url(endpoint); - let client = Client::builder() - .build() - .context("build reqwest client for engine probe")?; - - let response = match client - .get(&health_url) - .timeout(Duration::from_secs(1)) - .send() - .await - { - Ok(response) => response, - Err(_) => return Ok(None), - }; - - if !response.status().is_success() { - return Ok(None); - } - - let health = response - .json::() - .await - .context("decode existing engine health response")?; - - match health.runtime.as_deref() { - Some(ENGINE_RUNTIME) => Ok(Some(health)), - Some(RIVETKIT_RUNTIME) => Err(EngineProcessError::PortOccupied { - endpoint: endpoint.to_owned(), - runtime: RIVETKIT_RUNTIME.to_owned(), - } - .build()), - Some(other) => Err(EngineProcessError::PortOccupied { - endpoint: endpoint.to_owned(), - runtime: other.to_owned(), - } - .build()), - None => Err(EngineProcessError::PortOccupied { - endpoint: endpoint.to_owned(), - runtime: "unknown".to_owned(), - } - .build()), - } -} - -fn engine_health_url(endpoint: &str) -> String { - format!("{}/health", endpoint.trim_end_matches('/')) -} - -fn storage_root() -> Result { - if let Ok(path) = std::env::var("RIVETKIT_STORAGE_PATH") { - return Ok(PathBuf::from(path).join(".rivetkit")); - } - let home = std::env::var("HOME") - .map(PathBuf::from) - .or_else(|_| std::env::current_dir()) - .context("locate home directory for engine storage path")?; - Ok(home.join(".rivetkit")) -} - -fn ensure_dir(path: &Path) -> Result<()> { - std::fs::create_dir_all(path).with_context(|| format!("create directory `{}`", path.display())) -} - -fn open_log_file(path: &Path) -> Result { - std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(path) - .with_context(|| format!("open log file `{}`", path.display())) -} - -fn log_timestamp() -> String { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default(); - format!("{}", now.as_secs()) -} - -async fn wait_for_engine_health(health_url: &str) -> Result { - const HEALTH_MAX_WAIT: Duration = Duration::from_secs(10); - const HEALTH_REQUEST_TIMEOUT: Duration = Duration::from_secs(1); - const HEALTH_INITIAL_BACKOFF: Duration = Duration::from_millis(100); - const HEALTH_MAX_BACKOFF: Duration = Duration::from_secs(1); - - let client = Client::builder() - .build() - .context("build reqwest client for engine health check")?; - let deadline = Instant::now() + HEALTH_MAX_WAIT; - let mut attempt = 0u32; - let mut backoff = HEALTH_INITIAL_BACKOFF; - - loop { - attempt += 1; - - let last_error = match client - .get(health_url) - .timeout(HEALTH_REQUEST_TIMEOUT) - .send() - .await - { - Ok(response) if response.status().is_success() => { - let health = response - .json::() - .await - .context("decode engine health response")?; - return Ok(health); - } - Ok(response) => format!("unexpected status {}", response.status()), - Err(error) => error.to_string(), - }; - - if Instant::now() >= deadline { - return Err(EngineProcessError::HealthCheckFailed { - attempts: attempt, - reason: last_error, - } - .build()); - } - - tokio::time::sleep(backoff).await; - backoff = std::cmp::min(backoff * 2, HEALTH_MAX_BACKOFF); - } -} - -/// Cleanup path for a spawn that never reached `healthy`. We *do* kill here -/// because the half-started engine has no useful state to preserve and -/// leaving it running would conflict with a retry. This is the only place -/// allowed to terminate the engine. -async fn terminate_failed_spawn(child: &mut Child) -> Result<()> { - const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); - - if child - .try_wait() - .context("check engine process status")? - .is_some() - { - return Ok(()); - } - - child - .start_kill() - .context("kill half-started engine process")?; - match tokio::time::timeout(SHUTDOWN_TIMEOUT, child.wait()).await { - Ok(result) => { - let status = result.context("wait for half-started engine to exit")?; - tracing::info!(?status, "half-started engine process exited"); - Ok(()) - } - Err(_) => { - tracing::warn!("half-started engine process did not exit within timeout"); - Ok(()) - } - } -} - -fn invalid_endpoint(endpoint: &str, reason: &str) -> anyhow::Error { - EngineProcessError::InvalidEndpoint { - endpoint: endpoint.to_owned(), - reason: reason.to_owned(), - } - .build() -} +//! The rivet-engine subprocess manager lives in the standalone +//! `rivetkit-engine-process` crate so the CLI and other hosts can reuse the +//! same resolution, spawn, reuse, and orphan-lifetime logic. This module +//! re-exports it for existing in-crate callers. + +pub use rivetkit_engine_process::{ + EngineProcessError, EngineProcessManager, EngineResolverConfig, ResolvedEngine, engine_db_path, + engine_env, resolve_engine_binary, resolve_engine_binary_path, +}; diff --git a/rivetkit-rust/packages/rivetkit-core/src/error.rs b/rivetkit-rust/packages/rivetkit-core/src/error.rs index 4241551135..83c9a9f3bb 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/error.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/error.rs @@ -227,63 +227,3 @@ pub(crate) enum SqliteRuntimeError { )] RemoteFenceMismatch { reason: String }, } - -#[derive(RivetError, Debug, Clone, Deserialize, Serialize)] -#[error("engine")] -pub(crate) enum EngineProcessError { - #[error( - "binary_not_found", - "Engine binary was not found.", - "Engine binary was not found at '{path}'." - )] - BinaryNotFound { path: String }, - - #[error( - "binary_unavailable", - "Engine binary is unavailable.", - "No usable engine binary was found for version '{version}'. Build `rivet-engine`, set `RIVET_ENGINE_BINARY_PATH`, or enable `RIVETKIT_ENGINE_AUTO_DOWNLOAD=1`." - )] - BinaryUnavailable { version: String }, - - #[error( - "download_failed", - "Engine binary download failed.", - "Engine binary download failed for '{url}': {reason}" - )] - DownloadFailed { url: String, reason: String }, - - #[error( - "checksum_mismatch", - "Engine binary checksum mismatch.", - "Engine binary checksum mismatch for '{artifact}': expected {expected}, received {received}." - )] - ChecksumMismatch { - artifact: String, - expected: String, - received: String, - }, - - #[error( - "invalid_endpoint", - "Engine endpoint is invalid.", - "Engine endpoint '{endpoint}' is invalid: {reason}" - )] - InvalidEndpoint { endpoint: String, reason: String }, - - #[error("missing_pid", "Engine process is missing a pid.")] - MissingPid, - - #[error( - "health_check_failed", - "Engine health check failed.", - "Engine health check failed after {attempts} attempts: {reason}" - )] - HealthCheckFailed { attempts: u32, reason: String }, - - #[error( - "port_occupied", - "Engine port is occupied by a different runtime.", - "Cannot start engine: endpoint '{endpoint}' is already serving runtime '{runtime}'. Stop that process and retry." - )] - PortOccupied { endpoint: String, runtime: String }, -} diff --git a/rivetkit-typescript/packages/cli/README.md b/rivetkit-typescript/packages/cli/README.md new file mode 100644 index 0000000000..899353f3d0 --- /dev/null +++ b/rivetkit-typescript/packages/cli/README.md @@ -0,0 +1,11 @@ +# @rivetkit/cli + +Rivet Cloud CLI distributed over npm. + +```sh +npx @rivetkit/cli dev +npx @rivetkit/cli dev --provider cloudflare +npx @rivetkit/cli dev --provider supabase -- --env-file .env.local +npx @rivetkit/cli deploy --token cloud_api_xxxxx +npx @rivetkit/cli setup-ci +``` diff --git a/rivetkit-typescript/packages/cli/index.d.ts b/rivetkit-typescript/packages/cli/index.d.ts new file mode 100644 index 0000000000..5d3a196c9b --- /dev/null +++ b/rivetkit-typescript/packages/cli/index.d.ts @@ -0,0 +1,5 @@ +/** Returns the absolute path to the rivet CLI binary for the current host. */ +export function getCliPath(): string; + +/** Returns the expected platform-specific npm package for the current host, or null if unsupported. */ +export function getPlatformPackageName(): string | null; diff --git a/rivetkit-typescript/packages/cli/index.js b/rivetkit-typescript/packages/cli/index.js new file mode 100644 index 0000000000..d15b09e257 --- /dev/null +++ b/rivetkit-typescript/packages/cli/index.js @@ -0,0 +1,81 @@ +#!/usr/bin/env node +const { existsSync } = require("node:fs"); +const { spawnSync } = require("node:child_process"); +const { dirname, join } = require("node:path"); + +function getPlatformPackageName() { + const { platform, arch } = process; + switch (platform) { + case "linux": + if (arch === "x64") return "@rivetkit/cli-linux-x64-musl"; + if (arch === "arm64") return "@rivetkit/cli-linux-arm64-musl"; + break; + case "darwin": + if (arch === "x64") return "@rivetkit/cli-darwin-x64"; + if (arch === "arm64") return "@rivetkit/cli-darwin-arm64"; + break; + case "win32": + if (arch === "x64") return "@rivetkit/cli-win32-x64"; + break; + } + return null; +} + +const BINARY_NAME = process.platform === "win32" ? "rivet.exe" : "rivet"; + +function getCliPath() { + if (process.env.RIVET_CLI_BINARY) { + if (!existsSync(process.env.RIVET_CLI_BINARY)) { + throw new Error( + `RIVET_CLI_BINARY is set to ${process.env.RIVET_CLI_BINARY} but the file does not exist`, + ); + } + return process.env.RIVET_CLI_BINARY; + } + + const localBinary = join(__dirname, BINARY_NAME); + if (existsSync(localBinary)) return localBinary; + + const platformPkg = getPlatformPackageName(); + if (!platformPkg) { + throw new Error( + `@rivetkit/cli: unsupported platform ${process.platform}/${process.arch}`, + ); + } + + let pkgJsonPath; + try { + pkgJsonPath = require.resolve(`${platformPkg}/package.json`); + } catch { + if (process.platform === "win32" && process.arch === "x64") { + const version = require("./package.json").version; + if ( + typeof version === "string" && + version.startsWith("0.0.0-") + ) { + throw new Error( + "@rivetkit/cli: Windows x64 binaries are only published for release versions.\n" + + `The current package version (${version}) is a preview build, so @rivetkit/cli-win32-x64 was intentionally not published.\n` + + "Use a release build or set RIVET_CLI_BINARY to a local rivet.exe binary.", + ); + } + } + throw new Error( + `@rivetkit/cli: platform package ${platformPkg} is not installed.\n` + + "Optional dependencies may have been skipped. Try npm install --include=optional @rivetkit/cli.", + ); + } + return join(dirname(pkgJsonPath), BINARY_NAME); +} + +if (require.main === module) { + const result = spawnSync(getCliPath(), process.argv.slice(2), { + stdio: "inherit", + env: process.env, + }); + if (result.error) throw result.error; + process.exit(result.status ?? 1); +} + +module.exports.getCliPath = getCliPath; +module.exports.getPlatformPackageName = getPlatformPackageName; diff --git a/rivetkit-typescript/packages/cli/npm/darwin-arm64/package.json b/rivetkit-typescript/packages/cli/npm/darwin-arm64/package.json new file mode 100644 index 0000000000..e0e852266d --- /dev/null +++ b/rivetkit-typescript/packages/cli/npm/darwin-arm64/package.json @@ -0,0 +1,19 @@ +{ + "name": "@rivetkit/cli-darwin-arm64", + "version": "2.3.0-rc.12", + "description": "Rivet CLI for macOS arm64 (Apple Silicon)", + "license": "LicenseRef-RivetEnterprise", + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], + "files": [ + "rivet", + "rivet-engine" + ], + "engines": { + "node": ">= 20.0.0" + } +} diff --git a/rivetkit-typescript/packages/cli/npm/darwin-x64/package.json b/rivetkit-typescript/packages/cli/npm/darwin-x64/package.json new file mode 100644 index 0000000000..cdef458ead --- /dev/null +++ b/rivetkit-typescript/packages/cli/npm/darwin-x64/package.json @@ -0,0 +1,19 @@ +{ + "name": "@rivetkit/cli-darwin-x64", + "version": "2.3.0-rc.12", + "description": "Rivet CLI for macOS x64", + "license": "LicenseRef-RivetEnterprise", + "os": [ + "darwin" + ], + "cpu": [ + "x64" + ], + "files": [ + "rivet", + "rivet-engine" + ], + "engines": { + "node": ">= 20.0.0" + } +} diff --git a/rivetkit-typescript/packages/cli/npm/linux-arm64-musl/package.json b/rivetkit-typescript/packages/cli/npm/linux-arm64-musl/package.json new file mode 100644 index 0000000000..320d926ac6 --- /dev/null +++ b/rivetkit-typescript/packages/cli/npm/linux-arm64-musl/package.json @@ -0,0 +1,19 @@ +{ + "name": "@rivetkit/cli-linux-arm64-musl", + "version": "2.3.0-rc.12", + "description": "Rivet CLI for Linux arm64 musl (static)", + "license": "LicenseRef-RivetEnterprise", + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ], + "files": [ + "rivet", + "rivet-engine" + ], + "engines": { + "node": ">= 20.0.0" + } +} diff --git a/rivetkit-typescript/packages/cli/npm/linux-x64-musl/package.json b/rivetkit-typescript/packages/cli/npm/linux-x64-musl/package.json new file mode 100644 index 0000000000..e048b17ffd --- /dev/null +++ b/rivetkit-typescript/packages/cli/npm/linux-x64-musl/package.json @@ -0,0 +1,19 @@ +{ + "name": "@rivetkit/cli-linux-x64-musl", + "version": "2.3.0-rc.12", + "description": "Rivet CLI for Linux x64 musl (static)", + "license": "LicenseRef-RivetEnterprise", + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "files": [ + "rivet", + "rivet-engine" + ], + "engines": { + "node": ">= 20.0.0" + } +} diff --git a/rivetkit-typescript/packages/cli/npm/win32-x64/package.json b/rivetkit-typescript/packages/cli/npm/win32-x64/package.json new file mode 100644 index 0000000000..20bc0208e8 --- /dev/null +++ b/rivetkit-typescript/packages/cli/npm/win32-x64/package.json @@ -0,0 +1,19 @@ +{ + "name": "@rivetkit/cli-win32-x64", + "version": "2.3.0-rc.12", + "description": "Rivet CLI for Windows x64", + "license": "LicenseRef-RivetEnterprise", + "os": [ + "win32" + ], + "cpu": [ + "x64" + ], + "files": [ + "rivet.exe", + "rivet-engine.exe" + ], + "engines": { + "node": ">= 20.0.0" + } +} diff --git a/rivetkit-typescript/packages/cli/package.json b/rivetkit-typescript/packages/cli/package.json new file mode 100644 index 0000000000..981fabd86e --- /dev/null +++ b/rivetkit-typescript/packages/cli/package.json @@ -0,0 +1,20 @@ +{ + "name": "@rivetkit/cli", + "version": "2.3.0-rc.12", + "description": "Rivet Cloud CLI", + "license": "LicenseRef-RivetEnterprise", + "main": "index.js", + "types": "index.d.ts", + "bin": { + "rivet": "index.js" + }, + "engines": { + "node": ">= 20.0.0" + }, + "files": [ + "index.js", + "index.d.ts", + "package.json", + "README.md" + ] +} diff --git a/rivetkit-typescript/packages/engine-cli/index.js b/rivetkit-typescript/packages/engine-cli/index.js index 8c31656537..7c1b567b5d 100644 --- a/rivetkit-typescript/packages/engine-cli/index.js +++ b/rivetkit-typescript/packages/engine-cli/index.js @@ -83,7 +83,7 @@ function getEnginePath() { const version = require("./package.json").version; if ( typeof version === "string" && - (version.includes("-pr.") || version.includes("-main.")) + version.startsWith("0.0.0-") ) { throw new Error( "@rivetkit/engine-cli: Windows x64 binaries are only published for release versions.\n" + diff --git a/scripts/publish/src/ci/bin.ts b/scripts/publish/src/ci/bin.ts index cab832800d..9a46b8514d 100644 --- a/scripts/publish/src/ci/bin.ts +++ b/scripts/publish/src/ci/bin.ts @@ -31,7 +31,7 @@ import { tagAndPush, } from "../lib/git.js"; import { scoped } from "../lib/logger.js"; -import { publishAll } from "../lib/npm.js"; +import { publishAll, repairBranchPreviewLatestTags } from "../lib/npm.js"; import { copyPrefix, uploadDir, @@ -207,10 +207,10 @@ program .option("--release-mode", "Fail if every package is already published") .action(async (opts) => { const repoRoot = findRepoRoot(); + const ctx = await resolveContext(); let tag: string = opts.tag; let releaseMode: boolean | undefined = opts.releaseMode; if (!tag || releaseMode === undefined) { - const ctx = await resolveContext(); tag = tag ?? ctx.npmTag; if (opts.releaseMode === undefined) { releaseMode = ctx.trigger === "release"; @@ -218,11 +218,19 @@ program } await publishAll(repoRoot, { tag, + version: ctx.version, includeReleaseOnlyPackages: releaseMode, parallel: Number(opts.parallel), retries: Number(opts.retries), releaseMode, }); + if (!releaseMode) { + await repairBranchPreviewLatestTags(repoRoot, { + tag, + version: ctx.version, + includeReleaseOnlyPackages: releaseMode, + }); + } }); // --------------------------------------------------------------------------- @@ -458,7 +466,7 @@ program "", `All packages published as \`${version}\` with tag \`${tag}\`.`, "", - "Engine binary is shipped via `@rivetkit/engine-cli` on linux-x64-musl, linux-arm64-musl, darwin-x64, and darwin-arm64. Windows users should use the release installer or set `RIVET_ENGINE_BINARY`.", + "Engine binary is shipped via `@rivetkit/engine-cli` on linux-x64-musl, linux-arm64-musl, darwin-x64, and darwin-arm64. `@rivetkit/cli` ships the `rivet` binary plus bundled engine on the same platforms. Windows users should use release versions or set `RIVET_ENGINE_BINARY` / `RIVET_CLI_BINARY`.", "", "Docker images:", "```sh", diff --git a/scripts/publish/src/lib/npm.ts b/scripts/publish/src/lib/npm.ts index 91a6934866..3215ad65a9 100644 --- a/scripts/publish/src/lib/npm.ts +++ b/scripts/publish/src/lib/npm.ts @@ -10,6 +10,7 @@ import { scoped } from "./logger.js"; import { assertDiscoverySanity, discoverPackages, + META_PACKAGES, type Package, } from "./packages.js"; @@ -18,6 +19,8 @@ const log = scoped("npm"); export interface PublishAllOptions { /** npm dist-tag (e.g. pr-123, main, latest, rc, next). */ tag: string; + /** Version being published. Used to repair preview latest tags. */ + version?: string; /** Max simultaneous publishes. */ parallel?: number; /** Max retries per package. */ @@ -58,6 +61,11 @@ export interface PublishSummary { elapsedSeconds: number; } +interface PublishBatchResult { + results: PublishResult[]; + elapsedMs: number; +} + const ALREADY_PUBLISHED_PATTERNS = [ "cannot publish over the previously published versions", "cannot publish over previously published version", @@ -121,6 +129,59 @@ function runNpmPublish( }); } +function runNpmDistTagAdd( + pkg: Package, + version: string, + tag: string, +): Promise<{ code: number; output: string }> { + return new Promise((resolvePromise) => { + const child = spawn("npm", ["dist-tag", "add", `${pkg.name}@${version}`, tag], { + cwd: pkg.dir, + stdio: ["ignore", "pipe", "pipe"], + env: process.env, + }); + const chunks: Buffer[] = []; + child.stdout.on("data", (c) => chunks.push(c)); + child.stderr.on("data", (c) => chunks.push(c)); + child.on("close", (code) => { + resolvePromise({ + code: code ?? 1, + output: Buffer.concat(chunks).toString("utf8"), + }); + }); + }); +} + +function npmViewLatestTag(pkg: Package): Promise { + return new Promise((resolvePromise, reject) => { + const child = spawn("npm", ["view", pkg.name, "dist-tags.latest", "--json"], { + cwd: pkg.dir, + stdio: ["ignore", "pipe", "pipe"], + env: process.env, + }); + const chunks: Buffer[] = []; + child.stdout.on("data", (c) => chunks.push(c)); + child.stderr.on("data", (c) => chunks.push(c)); + child.on("close", (code) => { + const output = Buffer.concat(chunks).toString("utf8").trim(); + if (code !== 0) { + reject(new Error(`npm view ${pkg.name} latest failed: ${output}`)); + return; + } + if (!output || output === "null") { + resolvePromise(undefined); + return; + } + try { + const parsed = JSON.parse(output); + resolvePromise(typeof parsed === "string" ? parsed : undefined); + } catch { + resolvePromise(output); + } + }); + }); +} + async function publishOne( pkg: Package, opts: Required< @@ -154,6 +215,38 @@ async function publishOne( return { pkg, status: "failed", attempts: opts.retries + 1 }; } +export async function repairBranchPreviewLatestTags( + repoRoot: string, + opts: Required> & + Pick, +): Promise { + const previewPrefix = `0.0.0-${opts.tag}.`; + const packages = discoverPackages(repoRoot, { + includeReleaseOnly: opts.includeReleaseOnlyPackages, + }); + + for (const pkg of packages) { + const latest = await npmViewLatestTag(pkg); + if ( + latest === undefined || + latest === opts.version || + !latest.startsWith(previewPrefix) + ) { + continue; + } + + log.info( + `repairing ${pkg.name} latest tag: ${latest} -> ${opts.version}`, + ); + const result = await runNpmDistTagAdd(pkg, opts.version, "latest"); + if (result.code !== 0) { + throw new Error( + `npm dist-tag add ${pkg.name}@${opts.version} latest failed: ${extractError(result.output)}`, + ); + } + } +} + function printResult(r: PublishResult): void { const name = r.pkg.name.padEnd(48); const symbol = @@ -171,6 +264,42 @@ function printResult(r: PublishResult): void { log.info(` ${symbol} ${name}${suffix}`); } +async function publishBatch( + packages: Package[], + opts: Required< + Pick + >, +): Promise { + const queue = [...packages]; + const results: PublishResult[] = []; + const startedAt = Date.now(); + + async function worker(): Promise { + while (true) { + const pkg = queue.shift(); + if (!pkg) return; + const result = await publishOne(pkg, { + tag: opts.tag, + retries: opts.retries, + initialBackoffMs: opts.initialBackoffMs, + }); + printResult(result); + results.push(result); + } + } + + const workers: Promise[] = []; + for (let i = 0; i < Math.min(opts.parallel, packages.length); i++) { + workers.push(worker()); + } + await Promise.all(workers); + + return { + results, + elapsedMs: Date.now() - startedAt, + }; +} + export async function publishAll( repoRoot: string, opts: PublishAllOptions, @@ -189,27 +318,42 @@ export async function publishAll( `publishing ${packages.length} packages | tag=${tag} | parallel=${parallel} | retries=${retries}`, ); - const queue = [...packages]; - const results: PublishResult[] = []; - const startedAt = Date.now(); + const metaNames = new Set(META_PACKAGES.map((p) => p.meta)); + const platformPackages = packages.filter((p) => + META_PACKAGES.some(({ platformPrefix }) => + p.name.startsWith(platformPrefix), + ), + ); + const metaPackages = packages.filter((p) => metaNames.has(p.name)); + const otherPackages = packages.filter( + (p) => + !metaNames.has(p.name) && + !META_PACKAGES.some(({ platformPrefix }) => + p.name.startsWith(platformPrefix), + ), + ); - async function worker(): Promise { - while (true) { - const pkg = queue.shift(); - if (!pkg) return; - const result = await publishOne(pkg, { tag, retries, initialBackoffMs }); - printResult(result); - results.push(result); + const batchOpts = { tag, parallel, retries, initialBackoffMs }; + let elapsedMs = 0; + const results: PublishResult[] = []; + for (const [label, batch] of [ + ["platform packages", platformPackages], + ["meta packages", metaPackages], + ["regular packages", otherPackages], + ] as const) { + if (batch.length === 0) continue; + log.info(`publishing ${label} (${batch.length})`); + const batchResult = await publishBatch(batch, batchOpts); + elapsedMs += batchResult.elapsedMs; + results.push(...batchResult.results); + const failed = batchResult.results.filter((r) => r.status === "failed"); + if (failed.length > 0) { + log.error(`${label} failed; not publishing later batches`); + break; } } - const workers: Promise[] = []; - for (let i = 0; i < Math.min(parallel, packages.length); i++) { - workers.push(worker()); - } - await Promise.all(workers); - - const elapsed = (Date.now() - startedAt) / 1000; + const elapsed = elapsedMs / 1000; const counts = { success: results.filter((r) => r.status === "success").length, retried: results.filter((r) => r.status === "retried-success").length, diff --git a/scripts/publish/src/lib/packages.ts b/scripts/publish/src/lib/packages.ts index d9ca7675fe..58d007cfaf 100644 --- a/scripts/publish/src/lib/packages.ts +++ b/scripts/publish/src/lib/packages.ts @@ -72,10 +72,16 @@ export const META_PACKAGES: readonly MetaPackageSpec[] = [ meta: "@rivetkit/engine-cli", platformPrefix: "@rivetkit/engine-cli-", }, + { + meta: "@rivetkit/cli", + platformPrefix: "@rivetkit/cli-", + }, ]; export const RELEASE_ONLY_PACKAGES = new Set([ + "@rivetkit/rivetkit-napi-win32-x64-msvc", "@rivetkit/engine-cli-win32-x64", + "@rivetkit/cli-win32-x64", ]); function isPublishable(pkg: { name?: string; private?: boolean }): boolean { @@ -126,9 +132,11 @@ export function discoverPackages( // resolves at install time. // - rivetkit-napi: the N-API addon (.node files) // - engine-cli: the rivet-engine binary + // - cli: the rivet CLI binary plus bundled rivet-engine for (const metaRelDir of [ "rivetkit-typescript/packages/rivetkit-napi/npm", "rivetkit-typescript/packages/engine-cli/npm", + "rivetkit-typescript/packages/cli/npm", ]) { const npmDir = join(repoRoot, metaRelDir); if (!existsSync(npmDir)) continue; @@ -205,6 +213,7 @@ export function assertDiscoverySanity(packages: Package[]): void { "@rivetkit/react", "@rivetkit/rivetkit-napi", "@rivetkit/engine-cli", + "@rivetkit/cli", ]; const missing = required.filter((r) => !byName.has(r)); if (missing.length > 0) { diff --git a/website/src/components/docs/docsLandings.ts b/website/src/components/docs/docsLandings.ts index 3027bcc69a..85ad47a93a 100644 --- a/website/src/components/docs/docsLandings.ts +++ b/website/src/components/docs/docsLandings.ts @@ -8,8 +8,9 @@ import { faReact, faRust, faScaleBalanced, + faSupabase, } from "@rivet-gg/icons"; -import { deployOptions, faSupabase } from "@rivetkit/shared-data"; +import { deployOptions } from "@rivetkit/shared-data"; import type { DocsLandingData } from "./DocsLanding"; const actors: DocsLandingData = { @@ -24,7 +25,7 @@ const actors: DocsLandingData = { { title: "Node.js & Bun", href: "/docs/actors/quickstart/backend", icon: faNodeJs, description: "Set up actors with Node.js, Bun, and web frameworks." }, { title: "React", href: "/docs/actors/quickstart/react", icon: faReact, description: "Build realtime React applications backed by actors." }, { title: "Next.js", href: "/docs/actors/quickstart/next-js", icon: faNextjs, description: "Server-rendered Next.js experiences backed by actors." }, - { title: "Rust", href: "/docs/actors/quickstart/rust", icon: faRust, badge: "Beta", description: "Native Rust with the typed rivetkit crate." }, + { title: "Rust", href: "/docs/actors/quickstart/rust", icon: faRust, badge: "Beta", description: "Build a Rivet Actor in Rust." }, { title: "Effect.ts", href: "/docs/actors/quickstart/effect", icon: faLayerGroup, badge: "Beta", description: "The Effect SDK with typed Schema actions." }, { title: "Cloudflare Workers", href: "/docs/actors/quickstart/cloudflare", icon: faCloudflare, description: "Run RivetKit on Cloudflare Workers." }, { title: "Supabase Functions", href: "/docs/actors/quickstart/supabase", icon: faSupabase, description: "Run RivetKit on Supabase Edge Functions." }, diff --git a/website/src/content/docs/actors/quickstart/cloudflare.mdx b/website/src/content/docs/actors/quickstart/cloudflare.mdx new file mode 100644 index 0000000000..3ba9d0081d --- /dev/null +++ b/website/src/content/docs/actors/quickstart/cloudflare.mdx @@ -0,0 +1,107 @@ +--- +title: "Cloudflare Workers Quickstart" +description: "Set up a Rivet project locally targeting Cloudflare Workers." +skill: true +--- + +Set up a Rivet project locally that runs on Cloudflare Workers. Use the public `@rivetkit/rivetkit-wasm` package and pass the bindings through `setup({ wasm })`. + +## Steps + + + + +- [Node.js](https://nodejs.org/) + +The CLI runs the local Rivet engine as a bundled native binary, so no Docker is required. A Cloudflare account is only needed to deploy. + + + + +```sh +npm install rivetkit @rivetkit/rivetkit-wasm +npm install --save-dev wrangler +``` + + + + +Set local Rivet connection values as Worker variables. `rivet dev` registers the matching serverless runner for you. + +```toml wrangler.toml +name = "rivetkit-cloudflare" +main = "src/index.ts" +compatibility_date = "2025-04-01" +compatibility_flags = ["nodejs_compat"] + +[vars] +RIVET_ENDPOINT = "http://localhost:6420" +``` + + + + +```ts src/index.ts @nocheck +import { actor, setup } from "rivetkit"; +import * as wasmBindings from "@rivetkit/rivetkit-wasm"; +import wasmModule from "@rivetkit/rivetkit-wasm/rivetkit_wasm_bg.wasm"; + +interface Env { + RIVET_ENDPOINT: string; +} + +const counter = actor({ + state: { count: 0 }, + actions: { + increment: (c, amount = 1) => { + c.state.count += amount; + return c.state.count; + }, + }, +}); + +let registry: { handler(request: Request): Promise } | undefined; + +function getRegistry(env: Env) { + registry ??= setup({ + runtime: "wasm", + wasm: { + bindings: wasmBindings, + initInput: wasmModule, + }, + use: { counter }, + endpoint: env.RIVET_ENDPOINT, + }); + + return registry; +} + +export default { + async fetch(request: Request, env: Env): Promise { + return await getRegistry(env).handler(request); + }, +}; +``` + + + + +Start Rivet. The CLI runs the local engine and spawns `wrangler dev` for you: + +```sh +npx @rivetkit/cli dev --provider cloudflare +``` + + + + +Ready to ship? See [Deploying to Cloudflare Workers](/docs/deploy/cloudflare). + + + + +## Related + +- [Quickstart](/docs/actors/quickstart) +- [Deploying to Cloudflare Workers](/docs/deploy/cloudflare) +- [SQLite](/docs/actors/sqlite) diff --git a/website/src/content/docs/actors/quickstart/supabase.mdx b/website/src/content/docs/actors/quickstart/supabase.mdx new file mode 100644 index 0000000000..114b82530f --- /dev/null +++ b/website/src/content/docs/actors/quickstart/supabase.mdx @@ -0,0 +1,92 @@ +--- +title: "Supabase Functions Quickstart" +description: "Set up a Rivet project locally targeting Supabase Edge Functions." +skill: true +--- + +Set up a Rivet project locally that runs on Supabase Edge Functions. Use the public `@rivetkit/rivetkit-wasm` package and load the wasm file with Deno. + +## Steps + + + + +- [Node.js](https://nodejs.org/) +- [Supabase CLI](https://supabase.com/docs/guides/cli) +- Docker, for Supabase's local Edge Runtime + +The CLI runs the local Rivet engine as a bundled native binary, so Docker is only needed for Supabase itself. A Supabase project is only needed to deploy. + + + + +```sh +npx supabase functions new rivet +``` + +Add the packages used by the function: + +```sh +npm install rivetkit @rivetkit/rivetkit-wasm +``` + + + + +Supabase Functions run under Deno, so load the wasm bytes from the package export and pass them to `setup({ wasm })`. + +```ts supabase/functions/rivet/index.ts @nocheck +import { actor, setup } from "rivetkit"; +import * as wasmBindings from "@rivetkit/rivetkit-wasm"; + +const wasmModule = await Deno.readFile( + new URL(import.meta.resolve("@rivetkit/rivetkit-wasm/rivetkit_wasm_bg.wasm")), +); + +const counter = actor({ + state: { count: 0 }, + actions: { + increment: (c, amount = 1) => { + c.state.count += amount; + return c.state.count; + }, + }, +}); + +const registry = setup({ + runtime: "wasm", + wasm: { + bindings: wasmBindings, + initInput: wasmModule, + }, + use: { counter }, + endpoint: Deno.env.get("RIVET_ENDPOINT"), +}); + +Deno.serve(async (request) => { + return await registry.handler(request); +}); +``` + + + + +Start Rivet. The CLI runs the local engine, spawns `supabase functions serve` for you, and populates the connection values: + +```sh +npx @rivetkit/cli dev --provider supabase +``` + + + + +Ready to ship? See [Deploying to Supabase Functions](/docs/deploy/supabase). + + + + +## Related + +- [Quickstart](/docs/actors/quickstart) +- [Deploying to Supabase Functions](/docs/deploy/supabase) +- [SQLite](/docs/actors/sqlite) diff --git a/website/src/content/docs/deploy/cli.mdx b/website/src/content/docs/deploy/cli.mdx new file mode 100644 index 0000000000..d3216da940 --- /dev/null +++ b/website/src/content/docs/deploy/cli.mdx @@ -0,0 +1,154 @@ +--- +title: "CLI" +description: "The rivet CLI runs a local engine and dev server, proxies the engine, deploys to Rivet Cloud, and scaffolds CI." +skill: true +--- + +The `rivet` CLI is distributed as `@rivetkit/cli`. Run it with your package runner: + +```bash +npx @rivetkit/cli +``` + +| Command | Description | +| --- | --- | +| `rivet dev` | Run a local Rivet engine and the dev server for your handler. | +| `rivet engine` | Run the bundled `rivet-engine` binary directly (proxies all arguments). | +| `rivet deploy` | Build and deploy the current project to Rivet Cloud. | +| `rivet setup-ci` | Install the GitHub Actions workflow that deploys to Rivet Cloud. | + +## `rivet dev` + +Starts a local Rivet engine, optionally spawns your dev server, registers the serverless runner that points at it, and supervises everything under one Ctrl-C. + +```bash +rivet dev [--provider ] [--port N] [--fn-name NAME] [--url URL] [-- ...] +``` + +The local engine is started once and kept running across `rivet dev` restarts (it is intentionally orphaned). Pressing Ctrl-C stops the dev server but leaves the engine running. Use [`rivet engine`](#rivet-engine) to manage it. + +### Providers + +`--provider` selects how the dev server is launched and how its port is determined. Omit it to run a custom dev server you point at yourself. + +| `--provider` | Spawns | Port | Handler URL | +| --- | --- | --- | --- | +| _(omitted)_ | your `-- ` | from `--port` (required unless `--url`) | `http://127.0.0.1:{port}/api/rivet` | +| `serverless` | your `-- ` | auto-assigned free port, passed as the `PORT` environment variable | `http://127.0.0.1:{port}/api/rivet` | +| `cloudflare` | `npx wrangler dev --port {port}` | `8787` | `http://127.0.0.1:{port}/api/rivet` | +| `supabase` | `npx supabase functions serve {fn-name} --no-verify-jwt` | `54321` | `http://127.0.0.1:{port}/functions/v1/{fn-name}/api/rivet` | +| `none` | nothing | — | — (engine only) | + +For `cloudflare` and `supabase`, anything after `--` is appended to the preset command. For the default and `serverless` modes, everything after `--` is the command to run. + +### Options + +| Flag | Default | Description | +| --- | --- | --- | +| `--provider` | _unset_ | Serverless platform preset. See above. | +| `--port` | per-provider | Handler port. Required in the default mode unless `--url` is set. | +| `--fn-name` | `rivet` | Supabase function name (used in the spawned command and URL path). | +| `--url` | _derived_ | Explicit full handler URL. Overrides port and path construction. | +| `--engine-binary` | _resolved_ | Path to a `rivet-engine` binary. See [Engine binary resolution](#engine-binary-resolution). | + +### Examples + +```bash +# Run your own dev server and tell the CLI where it listens +rivet dev --port 3000 -- npm run dev + +# Generic serverless handler; the CLI picks a free port and sets PORT +rivet dev --provider serverless -- node handler.js + +# Cloudflare Workers, zero config +rivet dev --provider cloudflare + +# Supabase Functions with extra arguments appended +rivet dev --provider supabase -- --inspect + +# Run only the engine +rivet dev --provider none +``` + +## `rivet engine` + +Runs the bundled `rivet-engine` binary directly. Every argument after `engine` is forwarded verbatim, configured to use the same local database and ports as `rivet dev`. + +```bash +rivet engine ... +``` + +```bash +# Wipe local engine state +rivet engine nuke + +# Inspect workflows +rivet engine wf list + +# Query UniversalDB directly +rivet engine udb -q 'ls 0/1/2' +``` + +## `rivet deploy` + +Builds your project's Docker image, pushes it to Rivet's built-in registry, and upserts the managed pool. Prints the deployment dashboard URL to stdout when the pool is ready. See [Deploying to Rivet Compute](/docs/deploy/rivet-compute) for the full guide. + +```bash +rivet deploy --token cloud_api_xxxxx +``` + +The `--token` flag saves the token to `~/.rivet/credentials`, so later deploys can omit it: + +```bash +rivet deploy +``` + +| Flag | Default | Description | +| --- | --- | --- | +| `--token` | _from env / credentials_ | Rivet Cloud API token. Also written to `~/.rivet/credentials`. | +| `--namespace` | `production` | Cloud namespace to deploy to. | +| `--project` | _from token_ | Override the project resolved from the token. | +| `--org` | _from token_ | Override the organization resolved from the token. | +| `--dockerfile` | `Dockerfile` | Dockerfile to build. | +| `--build-context` | `.` | Docker build context. | +| `--env KEY=VAL` | _none_ | Environment override, repeatable. | +| `--image` | _project slug_ | Image repository name in Rivet's registry. | +| `--tag` | _git short SHA_ | Image tag. Falls back to a timestamp outside a git repo. | + +The token resolves from, in order: `--token`, the `RIVET_CLOUD_TOKEN` environment variable, then `~/.rivet/credentials`. + +## `rivet setup-ci` + +Installs `.github/workflows/rivet-deploy.yml`, the GitHub Actions workflow that deploys to Rivet Cloud on every push and pull request. + +```bash +rivet setup-ci +``` + +Then add your token as a repository secret: + +```bash +gh secret set RIVET_CLOUD_TOKEN +``` + +| Flag | Default | Description | +| --- | --- | --- | +| `--force` | `false` | Overwrite the workflow file if it already exists. | + +## Engine binary resolution + +`rivet dev` and `rivet engine` resolve the `rivet-engine` binary in this order: + +1. The `--engine-binary` flag. +2. The `RIVET_ENGINE_BINARY_PATH` environment variable. +3. A binary bundled next to the CLI. +4. A local build under `target/{debug,release}`. +5. An auto-downloaded release. + +Auto-download is enabled by default. Set `RIVETKIT_ENGINE_AUTO_DOWNLOAD=0` to require a local binary instead. + +## Related + +- [Deploying to Rivet Compute](/docs/deploy/rivet-compute) +- [Cloudflare Workers Quickstart](/docs/actors/quickstart/cloudflare) +- [Supabase Functions Quickstart](/docs/actors/quickstart/supabase) diff --git a/website/src/content/docs/deploy/cloudflare.mdx b/website/src/content/docs/deploy/cloudflare.mdx index 39d0f3d2b8..9bee9cbdb9 100644 --- a/website/src/content/docs/deploy/cloudflare.mdx +++ b/website/src/content/docs/deploy/cloudflare.mdx @@ -22,7 +22,7 @@ Follow the [Cloudflare Workers Quickstart](/docs/actors/quickstart/cloudflare) t -Set your Rivet connection values as Worker variables. The pool name must match the serverless runner configured in Rivet. +Set your Rivet endpoint as a Worker variable. Include your namespace and token in the URL. ```toml wrangler.toml name = "rivetkit-cloudflare" @@ -31,11 +31,7 @@ compatibility_date = "2025-04-01" compatibility_flags = ["nodejs_compat"] [vars] -RIVET_ENDPOINT = "https://api.rivet.dev" -RIVET_NAMESPACE = "your-namespace" -RIVET_POOL = "cloudflare-workers" -RIVET_TOKEN = "sk_..." -RIVET_PUBLIC_ENDPOINT = "https://your-namespace:pk_...@api.rivet.dev" +RIVET_ENDPOINT = "https://your-namespace:sk_...@api.rivet.dev" ``` @@ -53,14 +49,6 @@ After deploy, set the Worker URL with the `/api/rivet` path as the serverless ru -## Runtime Notes - -- Use `runtime: "wasm"` in `setup(...)` for Workers. You can also set `RIVETKIT_RUNTIME=wasm` in environments where the registry config does not set `runtime`. -- Pass `wasm: { bindings, initInput }` explicitly from `@rivetkit/rivetkit-wasm`. -- Use remote SQLite on Workers. Leaving SQLite unset with `runtime: "wasm"` selects remote SQLite automatically. -- Keep `RIVET_PUBLIC_ENDPOINT` pointed at the client-facing Rivet endpoint. Register the Worker URL separately as the serverless runner URL. -- Local Workers runtimes must support outbound WebSockets for the Rivet envoy connection. - ## Related - [Cloudflare Workers Quickstart](/docs/actors/quickstart/cloudflare) diff --git a/website/src/content/docs/deploy/rivet-compute.mdx b/website/src/content/docs/deploy/rivet-compute.mdx new file mode 100644 index 0000000000..ade043f0c5 --- /dev/null +++ b/website/src/content/docs/deploy/rivet-compute.mdx @@ -0,0 +1,130 @@ +--- +title: "Deploying to Rivet Compute" +description: "Run your backend on Rivet Compute." +skill: true +--- + + +Rivet Compute is currently in beta. + + + +Using an AI coding agent? Open **Connect** on the [Rivet dashboard](https://dashboard.rivet.dev), select **Rivet Cloud**, and paste the one-shot prompt into your agent and have it connect with Rivet Compute for you. + + +## Steps + + + + +- Your RivetKit app + - If you don't have one, see the [Quickstart](/docs/actors/quickstart) page or our [Examples](https://github.com/rivet-dev/rivet/tree/main/examples) +- A [Rivet Cloud](https://dashboard.rivet.dev) account and project +- Docker running locally + + + + +Add a `Dockerfile` to your project root that builds and runs your RivetKit server: + +```dockerfile @nocheck +FROM node:24-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm ci --omit=dev +COPY . . +CMD ["node", "src/server.js"] +``` + + + + +1. Open the [Rivet dashboard](https://dashboard.rivet.dev) and navigate to your project +2. Click **Connect** and select **Rivet Cloud** +3. Copy the **`RIVET_CLOUD_TOKEN`** value shown + + + + +Run the deploy command from your project root. The token is saved to `~/.rivet/credentials`, so later deploys can omit it. + +```bash +npx @rivetkit/cli deploy --token cloud_api_xxxxx +``` + +The CLI resolves your project from the token, builds and pushes your Docker image to Rivet's built-in registry, upserts the managed pool, and prints the deployment URL on stdout when the pool is ready. + + + + +After local deploys work, install the GitHub Actions workflow that deploys on every push and pull request: + +```bash +npx @rivetkit/cli setup-ci +``` + +This writes `.github/workflows/rivet-deploy.yml`. Add your token as a repository secret to enable it: + +```bash +gh secret set RIVET_CLOUD_TOKEN +``` + +The workflow creates production and pull-request namespaces, posts preview links, and cleans up PR namespaces when pull requests close. See the [CLI reference](/docs/deploy/cli) for all commands. + + + + +The dashboard shows live status as Rivet Compute provisions your backend: + +| Status | Description | +| --- | --- | +| Provisioning | Allocating compute resources | +| Initializing | Starting the runtime environment | +| Allocating | Assigning the runner to your pool | +| Deploying | Pulling and launching your container | +| Binding | Connecting the runner to the network | +| Ready | Deployment complete | + +Once the status reaches **Ready**, your backend is live and actors are available for connections. + + + +If you are an agent monitoring the deployment via API rather than the dashboard, poll the managed-pool endpoint on the Cloud API. + +The `RIVET_CLOUD_TOKEN` secret is a `cloud_api_*` management token scoped to the Cloud API at `cloud-api.rivet.dev`. Use it for `Authorization: Bearer ...` against the Cloud API. Do not confuse it with a `pk_*` publishable key, which is scoped to the Rivet Engine API at `api.rivet.dev` and will 401 against this endpoint. + +Substitute `$CLOUD_API_URL` (typically `https://cloud-api.rivet.dev`), `$PROJECT`, `$ORG`, `$CLOUD_NAMESPACE`, and `$CLOUD_TOKEN`. + +Poll every 5 seconds until `status` is `ready`. Stop and investigate if `status` is `error`. + +```bash +curl -s "$CLOUD_API_URL/projects/$PROJECT/namespaces/$CLOUD_NAMESPACE/managed-pools/default?org=$ORG" -H "Authorization: Bearer $CLOUD_TOKEN" +``` + + + + + + +## Troubleshooting + + + + +If the status stays in **Provisioning** for more than a few minutes, verify that: + +- The `RIVET_CLOUD_TOKEN` secret is correctly set in your GitHub repository +- The GitHub Actions workflow completed without errors — check the run logs + + + + +If the status shows **Error**, check that your container starts successfully and does not exit immediately. Common causes: + +- The server file is not calling `registry.startRunner()` +- A runtime crash on startup — test the image locally with `docker run` +- The Dockerfile is not listening on the `PORT` environmental variable + + + + diff --git a/website/src/content/docs/deploy/supabase.mdx b/website/src/content/docs/deploy/supabase.mdx index 797f12dd2c..b158622e8f 100644 --- a/website/src/content/docs/deploy/supabase.mdx +++ b/website/src/content/docs/deploy/supabase.mdx @@ -22,15 +22,10 @@ Follow the [Supabase Functions Quickstart](/docs/actors/quickstart/supabase) to -Set the Rivet connection values as Supabase secrets. The pool name must match the serverless runner configured in Rivet. +Set your Rivet endpoint as a Supabase secret. Include your namespace and token in the URL. ```sh -npx supabase secrets set \ - RIVET_ENDPOINT=https://api.rivet.dev \ - RIVET_PUBLIC_ENDPOINT=https://your-namespace:pk_...@api.rivet.dev \ - RIVET_NAMESPACE=your-namespace \ - RIVET_POOL=supabase-functions \ - RIVET_TOKEN=sk_... +npx supabase secrets set RIVET_ENDPOINT=https://your-namespace:sk_...@api.rivet.dev ``` @@ -48,14 +43,6 @@ After deploy, set the function URL with the `/api/rivet` path as the serverless -## Runtime Notes - -- Use `runtime: "wasm"` in `setup(...)` for Supabase Functions. You can also set `RIVETKIT_RUNTIME=wasm` in environments where the registry config does not set `runtime`. -- Pass `wasm: { bindings, initInput }` explicitly from `@rivetkit/rivetkit-wasm`. -- Use remote SQLite on Supabase Functions. Leaving SQLite unset with `runtime: "wasm"` selects remote SQLite automatically. -- Keep `RIVET_PUBLIC_ENDPOINT` pointed at the client-facing Rivet endpoint. Register the function URL separately as the serverless runner URL. -- Supabase Functions run in Deno, so load the wasm module with Deno-friendly bytes, URL, response, or module input. - ## Related - [Supabase Functions Quickstart](/docs/actors/quickstart/supabase) diff --git a/website/src/content/docs/quickstart/index.mdx b/website/src/content/docs/quickstart/index.mdx index 289cd6daf5..d3cc03abf7 100644 --- a/website/src/content/docs/quickstart/index.mdx +++ b/website/src/content/docs/quickstart/index.mdx @@ -10,8 +10,8 @@ import { faReact, faNextjs, faRust, + faSupabase, } from "@rivet-gg/icons"; -import { faSupabase } from "@rivetkit/shared-data"; - Build a Rivet Actor in Rust with the typed `rivetkit` crate + Build a Rivet Actor in Rust