Skip to main content

rivetkit_core/inspector_bundle/
mod.rs

1//! Inspector UI bundle serving for the shared inspector paths:
2//!
3//!   GET /inspector/ui/             -> index.html
4//!   GET /inspector/ui/<rel>        -> assets/... or other static files
5//!   GET /inspector/tab.css         -> shared --rivet-* token stylesheet
6//!
7//! The bundle is embedded into the binary at build time via `include_dir!`
8//! (staged from `frontend/dist/inspector-ui` and `frontend/dist/inspector-tab`
9//! by `build.rs`) and served entirely from memory. This is the only serving
10//! path: there is no filesystem-root mode and no CDN fallback, so every runner
11//! (native or wasm) serves the same embedded bytes.
12//!
13//! Per-actor public paths (`/inspector/custom-tabs/*`) are NOT served here;
14//! they depend on the actor's config and live in the inspector handler itself.
15
16use std::collections::HashMap;
17
18use ::http::StatusCode;
19use include_dir::{Dir, include_dir};
20use rivet_envoy_client::config::HttpResponse;
21use serde_json::json;
22
23/// Inspector-UI frontend bundle. Staged into `$OUT_DIR/inspector-ui` by
24/// `build.rs` and embedded at compile time, so the bytes ship inside the
25/// binary on every target including wasm.
26static INSPECTOR_UI_DIR: Dir<'_> = include_dir!("$OUT_DIR/inspector-ui");
27
28/// Shared stylesheet served to custom inspector tabs at `/inspector/tab.css`.
29/// Authored by `frontend/scripts/generate-inspector-tab-css.mjs`, which mirrors
30/// the dashboard's design tokens so tabs that `<link>` to it look native.
31static INSPECTOR_TAB_DIR: Dir<'_> = include_dir!("$OUT_DIR/inspector-tab");
32
33/// Output filename written by `frontend/scripts/generate-inspector-tab-css.mjs`.
34/// Renaming the generator output without updating this produces a silent 404.
35const TAB_STYLESHEET_FILE: &str = "styles.css";
36
37/// Inline HTML shown inside the custom-tab iframe on wasm runtimes, which
38/// cannot read `inspector.tabs[].source` files from disk. The tab still
39/// appears in the dashboard tab strip because tab config keeps flowing from
40/// core via the inspector WS `Init` message; only the custom-tab content
41/// iframe degrades.
42const WASM_CUSTOM_TAB_UNAVAILABLE_HTML: &str = include_str!("wasm-custom-tab-unavailable.html");
43
44// =============================================================================
45// Public dispatch
46// =============================================================================
47
48/// Whether a path is one of the shared "public" inspector routes that must
49/// never require the per-actor bearer token. Tab-config and custom-tabs are
50/// per-actor and must be checked separately by the inspector handler since
51/// this module doesn't carry their bytes.
52pub fn is_public_inspector_bundle_path(method: &str, pathname: &str) -> bool {
53	if method != "GET" {
54		return false;
55	}
56	pathname == "/inspector/tab.css"
57		|| pathname == "/inspector/ui/"
58		|| pathname == "/inspector/ui"
59		|| pathname.starts_with("/inspector/ui/")
60}
61
62/// Try to serve a request from the embedded inspector-UI bundle.
63///
64/// Returns `Some(HttpResponse)` for any of the shared public paths (a 200 with
65/// the embedded bytes, or a 404 if the asset isn't bundled). Returns `None` for
66/// paths that aren't part of the shared bundle so the caller can fall through to
67/// per-actor / authenticated handling.
68pub fn serve_inspector_bundle(method: &str, pathname: &str) -> Option<HttpResponse> {
69	if method != "GET" {
70		return None;
71	}
72	if pathname == "/inspector/tab.css" {
73		return Some(serve_tab_stylesheet());
74	}
75	let rel = map_ui_pathname_to_rel(pathname)?;
76	if is_unsafe_rel(&rel) {
77		return Some(not_found_response());
78	}
79	Some(serve_ui_asset(&rel))
80}
81
82/// Wasm runtimes cannot read `inspector.tabs[].source` files from disk, so
83/// `GET /inspector/custom-tabs/*` short-circuits to this styled HTML page
84/// inside the iframe.
85pub fn serve_wasm_custom_tab_unavailable() -> HttpResponse {
86	HttpResponse {
87		status: StatusCode::OK.as_u16(),
88		headers: shared_response_headers("text/html; charset=utf-8"),
89		body: Some(WASM_CUSTOM_TAB_UNAVAILABLE_HTML.as_bytes().to_vec()),
90		body_stream: None,
91	}
92}
93
94// =============================================================================
95// Embedded serve
96// =============================================================================
97
98fn serve_ui_asset(rel: &str) -> HttpResponse {
99	// Single-entry SPA: `/inspector/ui/` serves index.html, every other path
100	// under that prefix is an asset relative to the bundle root.
101	let stripped = rel.trim_start_matches('/');
102	let candidate = if stripped.is_empty() || stripped.ends_with('/') {
103		format!("{stripped}index.html")
104	} else {
105		stripped.to_owned()
106	};
107	match INSPECTOR_UI_DIR.get_file(&candidate) {
108		Some(file) => ok_response(mime_of(&candidate), file.contents().to_vec()),
109		None => not_found_response(),
110	}
111}
112
113fn serve_tab_stylesheet() -> HttpResponse {
114	match INSPECTOR_TAB_DIR.get_file(TAB_STYLESHEET_FILE) {
115		Some(file) => ok_response("text/css; charset=utf-8", file.contents().to_vec()),
116		None => not_found_response(),
117	}
118}
119
120// =============================================================================
121// Helpers
122// =============================================================================
123
124fn map_ui_pathname_to_rel(pathname: &str) -> Option<String> {
125	if pathname == "/inspector/ui/" || pathname == "/inspector/ui" {
126		return Some("index.html".to_owned());
127	}
128	pathname
129		.strip_prefix("/inspector/ui/")
130		.map(|rest| rest.to_owned())
131}
132
133fn is_unsafe_rel(rel: &str) -> bool {
134	if rel.is_empty() {
135		return true;
136	}
137	if rel.starts_with('/') {
138		return true;
139	}
140	for seg in rel.split('/') {
141		if seg == ".." || seg == "." {
142			return true;
143		}
144	}
145	false
146}
147
148fn mime_of(rel: &str) -> &'static str {
149	let Some(dot) = rel.rfind('.') else {
150		return "application/octet-stream";
151	};
152	let ext = rel[dot + 1..].to_ascii_lowercase();
153	match ext.as_str() {
154		"html" | "htm" => "text/html; charset=utf-8",
155		"js" | "mjs" | "cjs" => "application/javascript; charset=utf-8",
156		"css" => "text/css; charset=utf-8",
157		"json" | "map" => "application/json; charset=utf-8",
158		"svg" => "image/svg+xml",
159		"png" => "image/png",
160		"jpg" | "jpeg" => "image/jpeg",
161		"gif" => "image/gif",
162		"webp" => "image/webp",
163		"ico" => "image/x-icon",
164		"woff" => "font-woff",
165		"woff2" => "font-woff2",
166		"ttf" => "font-ttf",
167		"otf" => "font-otf",
168		"txt" => "text/plain; charset=utf-8",
169		"wasm" => "application/wasm",
170		_ => "application/octet-stream",
171	}
172}
173
174fn shared_response_headers(content_type: &str) -> HashMap<String, String> {
175	let mut headers = HashMap::new();
176	headers.insert("Content-Type".to_owned(), content_type.to_owned());
177	headers.insert("Cache-Control".to_owned(), "no-cache".to_owned());
178	headers.insert("Referrer-Policy".to_owned(), "no-referrer".to_owned());
179	headers
180}
181
182fn ok_response(content_type: &str, body: Vec<u8>) -> HttpResponse {
183	HttpResponse {
184		status: StatusCode::OK.as_u16(),
185		headers: shared_response_headers(content_type),
186		body: Some(body),
187		body_stream: None,
188	}
189}
190
191fn not_found_response() -> HttpResponse {
192	let body = json!({
193		"group": "inspector",
194		"code": "ui_asset_not_found",
195		"message": "Inspector UI asset was not found in the embedded bundle",
196		"metadata": serde_json::Value::Null,
197	});
198	let bytes = serde_json::to_vec(&body).unwrap_or_else(|_| b"{}".to_vec());
199	HttpResponse {
200		status: StatusCode::NOT_FOUND.as_u16(),
201		headers: shared_response_headers("application/json"),
202		body: Some(bytes),
203		body_stream: None,
204	}
205}
206
207// =============================================================================
208// Tests
209// =============================================================================
210
211#[cfg(test)]
212mod tests {
213	use super::*;
214
215	#[test]
216	fn map_ui_pathname_to_rel_maps_known_paths() {
217		assert_eq!(
218			map_ui_pathname_to_rel("/inspector/ui").as_deref(),
219			Some("index.html"),
220		);
221		assert_eq!(
222			map_ui_pathname_to_rel("/inspector/ui/").as_deref(),
223			Some("index.html"),
224		);
225		assert_eq!(
226			map_ui_pathname_to_rel("/inspector/ui/assets/app.js").as_deref(),
227			Some("assets/app.js"),
228		);
229		assert!(map_ui_pathname_to_rel("/inspector/state").is_none());
230	}
231
232	#[test]
233	fn is_unsafe_rel_rejects_traversal_and_absolute() {
234		assert!(is_unsafe_rel(""));
235		assert!(is_unsafe_rel("/etc/passwd"));
236		assert!(is_unsafe_rel("../etc/passwd"));
237		assert!(is_unsafe_rel("assets/../../etc"));
238		assert!(is_unsafe_rel("./assets"));
239		assert!(!is_unsafe_rel("assets/app.js"));
240		assert!(!is_unsafe_rel("index.html"));
241	}
242
243	#[test]
244	fn is_public_inspector_bundle_path_matches() {
245		assert!(is_public_inspector_bundle_path("GET", "/inspector/ui/"));
246		assert!(is_public_inspector_bundle_path("GET", "/inspector/ui"));
247		assert!(is_public_inspector_bundle_path(
248			"GET",
249			"/inspector/ui/assets/app.js"
250		));
251		assert!(is_public_inspector_bundle_path("GET", "/inspector/tab.css"));
252		assert!(!is_public_inspector_bundle_path("POST", "/inspector/ui/"));
253		assert!(!is_public_inspector_bundle_path("GET", "/inspector/state"));
254	}
255
256	#[test]
257	fn non_get_and_unrelated_paths_are_passthrough() {
258		assert!(serve_inspector_bundle("POST", "/inspector/ui/").is_none());
259		assert!(serve_inspector_bundle("GET", "/inspector/state").is_none());
260	}
261
262	#[test]
263	fn wasm_custom_tab_unavailable_is_html() {
264		let resp = serve_wasm_custom_tab_unavailable();
265		assert_eq!(resp.status, StatusCode::OK.as_u16());
266		assert_eq!(
267			resp.headers.get("Content-Type").map(String::as_str),
268			Some("text/html; charset=utf-8"),
269		);
270		let body = resp.body.expect("body present");
271		assert!(body.starts_with(b"<!doctype html>"));
272	}
273}