Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,23 @@ jobs:
--latest ${{ needs.context.outputs.latest }} \
--name container-runner

# The Inspector UI lives in frontend/dist, outside the rivetkit-core
# crate, so it must be built and staged into the crate before packaging.
# Otherwise the published crate embeds the empty fallback and serves
# inspector.ui_asset_not_found. Runs for every trigger so branch previews
# gate the crate the same way release does.
- name: Build Inspector UI bundle
env:
SKIP_NAPI_BUILD: "1"
SKIP_WASM_BUILD: "1"
run: npx turbo build:inspector-ui -F @rivetkit/engine-frontend

- name: Stage Inspector UI into rivetkit-core
run: node rivetkit-rust/packages/rivetkit-core/scripts/stage-inspector-bundle.mjs

- name: Verify Inspector UI ships in the crate
run: node rivetkit-rust/packages/rivetkit-core/scripts/verify-inspector-bundle.mjs

- name: Dry-run Rust crate publish
if: needs.context.outputs.trigger != 'release'
# Branch previews publish npm/R2/Docker artifacts only. Unlike npm,
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions engine/sdks/rust/envoy-client/src/callbacks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ impl ActorStopHandle {
}
}

/// Test-only constructor that discards the completion signal. Not for production use.
#[doc(hidden)]
pub fn detached() -> Self {
let (tx, _rx) = oneshot::channel();
Self::new(tx)
}

pub fn complete(self) -> bool {
self.finish(Ok(()))
}
Expand Down
4 changes: 4 additions & 0 deletions rivetkit-rust/packages/rivetkit-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,7 @@ tracing-subscriber.workspace = true
[[test]]
name = "integration"
path = "tests/integration.rs"

[[test]]
name = "inspector_bundle"
path = "tests/inspector_bundle.rs"
81 changes: 49 additions & 32 deletions rivetkit-rust/packages/rivetkit-core/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,25 @@ use std::env;
use std::fs;
use std::path::Path;

use anyhow::Result;
use anyhow::{Context, Result};
use fs_extra::dir;

// Stages frontend/dist/inspector-ui/ into $OUT_DIR/inspector-ui/ and
// frontend/dist/inspector-tab/ into $OUT_DIR/inspector-tab/ so the inspector
// bundle module can embed both via include_dir!. Falls back to an empty
// directory when the frontend has not been built yet, so a missing bundle
// degrades to 404 at runtime instead of a compile error.
// Stages the inspector-ui and inspector-tab bundles into `$OUT_DIR` so the
// inspector bundle module can embed both via include_dir!.
//
// Source resolution order per bundle:
// 1. In-crate `inspector-dist/<name>/`, staged by
// `scripts/stage-inspector-bundle.mjs` before `cargo package` / publish.
// This is the only copy that ships to crates.io, because `frontend/dist`
// lives outside the crate and is therefore absent from the `.crate`
// archive.
// 2. Monorepo `../../../frontend/dist/<name>/`, produced by the frontend
// build. Used for in-workspace dev builds where staging has not run.
// 3. An empty placeholder, so a not-yet-built frontend degrades to a runtime
// 404 (`inspector.ui_asset_not_found`) instead of a compile error.
//
// A bundle only counts as present when its marker file exists, so an empty
// staging placeholder correctly falls through to the frontend build.
fn main() -> Result<()> {
let manifest_dir = env::var("CARGO_MANIFEST_DIR")?;
let out_dir = env::var("OUT_DIR")?;
Expand All @@ -18,43 +29,49 @@ fn main() -> Result<()> {
// generic source changes, so the script itself must opt in explicitly.
println!("cargo:rerun-if-changed=build.rs");

stage_dir(
&manifest_dir,
&out_dir,
"../../../frontend/dist/inspector-ui",
"inspector-ui",
)?;
stage_dir(
&manifest_dir,
&out_dir,
"../../../frontend/dist/inspector-tab",
"inspector-tab",
)?;
stage_bundle(&manifest_dir, &out_dir, "inspector-ui", "index.html")?;
stage_bundle(&manifest_dir, &out_dir, "inspector-tab", "styles.css")?;

Ok(())
}

fn stage_dir(manifest_dir: &str, out_dir: &str, source_rel: &str, staged_name: &str) -> Result<()> {
let source = Path::new(manifest_dir).join(source_rel);
let staged = Path::new(out_dir).join(staged_name);
fn stage_bundle(manifest_dir: &str, out_dir: &str, name: &str, marker: &str) -> Result<()> {
let manifest = Path::new(manifest_dir);
let in_crate = manifest.join("inspector-dist").join(name);
let monorepo = manifest.join("../../../frontend/dist").join(name);

println!("cargo:rerun-if-changed={}", source.display());
// Rerun when either candidate bundle changes so a rebuild picks up staged
// or freshly built assets.
println!("cargo:rerun-if-changed={}", in_crate.display());
println!("cargo:rerun-if-changed={}", monorepo.display());

let source = if in_crate.join(marker).is_file() {
Some(in_crate)
} else if monorepo.join(marker).is_file() {
Some(monorepo)
} else {
None
};

let staged = Path::new(out_dir).join(name);
if staged.exists() {
fs::remove_dir_all(&staged)?;
}
fs::create_dir_all(&staged)?;

if source.exists() && source.is_dir() {
let mut opts = dir::CopyOptions::new();
opts.content_only = true;
opts.overwrite = true;
dir::copy(&source, &staged, &opts)
.unwrap_or_else(|e| panic!("failed to copy {source_rel} into OUT_DIR: {e}"));
} else {
// Placeholder so include_dir! has something to embed even when the
// frontend has not been built yet.
fs::write(staged.join(".empty"), b"")?;
match source {
Some(source) => {
let mut opts = dir::CopyOptions::new();
opts.content_only = true;
opts.overwrite = true;
dir::copy(&source, &staged, &opts)
.with_context(|| format!("failed to copy {} into OUT_DIR", source.display()))?;
}
None => {
// Placeholder so include_dir! has something to embed even when
// neither the staged crate bundle nor the frontend build exists yet.
fs::write(staged.join(".empty"), b"")?;
}
}

Ok(())
Expand Down
22 changes: 22 additions & 0 deletions rivetkit-rust/packages/rivetkit-core/inspector-dist/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# inspector-dist

Publish staging area for the Inspector UI bundle.

`build.rs` embeds two directories into the crate via `include_dir!`:

- `inspector-dist/inspector-ui/` — the built Inspector UI SPA (served at
`/inspector/ui/`).
- `inspector-dist/inspector-tab/` — the shared custom-tab stylesheet (served at
`/inspector/tab.css`).

These assets are generated by the frontend build (`frontend/dist/...`), which
lives outside this crate and therefore never ships to crates.io. To make the
Inspector UI a real input to the published crate, `scripts/stage-inspector-bundle.mjs`
copies the built assets into this directory before `cargo package` / publish,
and `scripts/verify-inspector-bundle.mjs` asserts they landed in the `.crate`
archive and are served (not `inspector.ui_asset_not_found`).

The generated `inspector-ui/` and `inspector-tab/` contents are intentionally
**not** committed: they are staged fresh during publish. For in-workspace dev
builds `build.rs` falls back to `../../../frontend/dist/...`, so nothing needs
to be staged locally. This directory only needs to exist.
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
#!/usr/bin/env node
/**
* Stage the built Inspector UI bundle into the rivetkit-core crate so it ships
* to crates.io.
*
* `frontend/dist/inspector-{ui,tab}` lives outside the crate and is absent from
* the `.crate` archive, so a published crate would embed the empty fallback and
* serve `inspector.ui_asset_not_found`. This copies the built assets into the
* in-crate `inspector-dist/` directory, which `build.rs` embeds via
* `include_dir!`.
*
* Run the frontend build first:
* pnpm turbo build:inspector-ui -F @rivetkit/engine-frontend
* then:
* node scripts/stage-inspector-bundle.mjs
*
* Source maps are stripped: they are debug-only, bloat the crate, and the crate
* has no need to serve `*.map` requests.
*/
import { cpSync, existsSync, mkdirSync, rmSync, statSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const __dirname = dirname(fileURLToPath(import.meta.url));
const crateDir = dirname(__dirname);
const repoRoot = resolve(crateDir, "../../..");
const frontendDist = join(repoRoot, "frontend", "dist");
const stageRoot = join(crateDir, "inspector-dist");

// Each bundle plus the marker file that proves it was actually built.
const BUNDLES = [
{ name: "inspector-ui", marker: "index.html" },
{ name: "inspector-tab", marker: "styles.css" },
];

function fail(message) {
console.error(`stage-inspector-bundle: ${message}`);
process.exit(1);
}

let strippedMaps = 0;
let strippedBytes = 0;

for (const { name, marker } of BUNDLES) {
const source = join(frontendDist, name);
const markerPath = join(source, marker);
if (!existsSync(markerPath)) {
fail(
`missing ${name}/${marker} at ${markerPath}. Build it first:\n` +
" pnpm turbo build:inspector-ui -F @rivetkit/engine-frontend",
);
}

const dest = join(stageRoot, name);
rmSync(dest, { recursive: true, force: true });
mkdirSync(dest, { recursive: true });

cpSync(source, dest, {
recursive: true,
filter: (src) => {
if (src.endsWith(".map")) {
try {
strippedBytes += statSync(src).size;
} catch {}
strippedMaps += 1;
return false;
}
return true;
},
});

console.log(`staged ${name} -> ${dest}`);
}

if (strippedMaps > 0) {
console.log(
`stripped ${strippedMaps} source map file(s) (${Math.round(strippedBytes / 1024)} KiB) from the published crate bundle`,
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#!/usr/bin/env node
/**
* Release check: prove the Inspector UI actually ships in the rivetkit-core
* `.crate` and is served, rather than degrading to
* `inspector.ui_asset_not_found`.
*
* Two independent assertions:
* 1. Archive inclusion: assert the Inspector UI index.html and the tab
* stylesheet are in the exact file list cargo would pack (`cargo package
* --list`). This catches the root-cause bug (asset missing from the
* published artifact) that the empty-bundle fallback otherwise hides.
*
* `--list` is used instead of building the `.crate`: full packaging strips
* path deps and resolves the exact-pinned sibling crates against
* crates.io, which are not published yet at this point in the ordered
* publish run. `--list` uses the workspace path deps, so it never touches
* the registry while reporting the same file set.
* 2. Runtime serving: build the crate and assert `GET /inspector/ui/` returns
* index.html, not the `ui_asset_not_found` JSON error, via the
* `inspector_bundle` integration test (gated on
* RIVETKIT_ASSERT_INSPECTOR_BUNDLE so ordinary `cargo test` runs without a
* built frontend still pass).
*
* Run `node scripts/stage-inspector-bundle.mjs` first.
*/
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync, statSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const __dirname = dirname(fileURLToPath(import.meta.url));
const crateDir = dirname(__dirname);
const repoRoot = resolve(crateDir, "../../..");

function fail(message) {
console.error(`verify-inspector-bundle: ${message}`);
process.exit(1);
}

function assertHtmlIndex(path, label) {
if (!existsSync(path)) {
fail(
`${label} missing (${path}). The published crate would serve inspector.ui_asset_not_found.`,
);
}
const html = readFileSync(path, "utf-8").toLowerCase();
if (!html.includes("<!doctype html") && !html.includes("<html")) {
fail(`${label} is not HTML (${path}).`);
}
if (statSync(path).size < 200) {
fail(`${label} is suspiciously small (${path}); looks like a placeholder.`);
}
}

// --- Pre-check: staged in-crate assets exist -------------------------------

const stagedIndex = join(crateDir, "inspector-dist", "inspector-ui", "index.html");
const stagedTabCss = join(crateDir, "inspector-dist", "inspector-tab", "styles.css");
assertHtmlIndex(stagedIndex, "staged inspector-ui/index.html");
if (!existsSync(stagedTabCss)) {
fail(`staged inspector-tab/styles.css missing (${stagedTabCss}).`);
}

// --- 1. Archive inclusion --------------------------------------------------

console.log("listing packaged files for rivetkit-core...");
const listed = execFileSync(
"cargo",
["package", "-p", "rivetkit-core", "--allow-dirty", "--no-verify", "--list"],
{ cwd: repoRoot, encoding: "utf-8" },
);
const files = new Set(listed.split("\n").map((l) => l.trim()));

for (const required of [
"inspector-dist/inspector-ui/index.html",
"inspector-dist/inspector-tab/styles.css",
]) {
if (!files.has(required)) {
fail(
`${required} is not in the rivetkit-core package file list. ` +
"The published crate would serve inspector.ui_asset_not_found.",
);
}
}
console.log("ok: rivetkit-core packages inspector-ui/index.html and inspector-tab/styles.css");

// --- 2. Runtime serving ----------------------------------------------------

console.log("asserting GET /inspector/ui/ serves index.html...");
execFileSync(
"cargo",
["test", "-p", "rivetkit-core", "--test", "inspector_bundle"],
{
cwd: repoRoot,
stdio: "inherit",
env: { ...process.env, RIVETKIT_ASSERT_INSPECTOR_BUNDLE: "1" },
},
);

console.log("verify-inspector-bundle: ok");
2 changes: 1 addition & 1 deletion rivetkit-rust/packages/rivetkit-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ pub use actor::task_types::ShutdownKind;
pub use actor::work_registry::{ActorWorkKind, ActorWorkPolicy};
pub use error::ActorLifecycle;
pub use inspector::{Inspector, InspectorSnapshot};
pub use registry::{CoreRegistry, EngineSpawnMode, ServeConfig};
pub use registry::{CoreRegistry, EngineSpawnMode, RuntimeMode, ServeConfig};
pub use rivet_envoy_client::config::{
HTTP_BODY_MAX_CHUNK_SIZE, HTTP_BODY_STREAM_CHANNEL_CAPACITY, HttpRequestBodyStream,
ResponseChunk,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,17 @@ impl EnvoyCallbacks for RegistryCallbacks {
&self,
_handle: EnvoyHandle,
actor_id: String,
_generation: u32,
generation: u32,
reason: protocol::StopActorReason,
stop_handle: ActorStopHandle,
) -> EnvoyBoxFuture<anyhow::Result<()>> {
let dispatcher = self.dispatcher.clone();
Box::pin(async move {
RuntimeSpawner::spawn(
async move {
if let Err(error) = dispatcher.stop_actor(&actor_id, reason, stop_handle).await
if let Err(error) = dispatcher
.stop_actor(&actor_id, generation, reason, stop_handle)
.await
{
tracing::error!(
?error,
Expand Down
Loading
Loading