diff --git a/Cargo.lock b/Cargo.lock index b8c956e..83b794a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1844,6 +1844,25 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "index_vec" version = "0.1.4" @@ -4709,6 +4728,8 @@ version = "0.1.0" dependencies = [ "axum", "base64 0.23.0", + "include_dir", + "mime_guess", "semver", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index c911c12..15556b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,8 @@ license = "Apache-2.0" [workspace.dependencies] axum = { version = "0.8.9", features = ["multipart"] } base64 = "0.23.0" +include_dir = "0.7.4" +mime_guess = "2.0.5" reqwest = { version = "0.13.4", default-features = false, features = ["blocking", "json", "multipart", "rustls"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" diff --git a/crates/wasmeld-console/Cargo.toml b/crates/wasmeld-console/Cargo.toml index 688c938..3074ac9 100644 --- a/crates/wasmeld-console/Cargo.toml +++ b/crates/wasmeld-console/Cargo.toml @@ -8,6 +8,8 @@ license.workspace = true [dependencies] axum.workspace = true base64.workspace = true +include_dir.workspace = true +mime_guess.workspace = true serde.workspace = true serde_json.workspace = true semver.workspace = true diff --git a/crates/wasmeld-console/build.rs b/crates/wasmeld-console/build.rs new file mode 100644 index 0000000..4263d60 --- /dev/null +++ b/crates/wasmeld-console/build.rs @@ -0,0 +1,104 @@ +//! Builds the Solid management UI for embedded Release binaries. +//! +//! Debug builds intentionally return before invoking npm: during development +//! Vite serves `web/` independently and proxies its API client to the Rust +//! listener. Release builds use the lockfile, write only into Cargo `OUT_DIR`, +//! and expose a cfg consumed by `src/web.rs`. The source tree therefore never +//! needs a generated `dist/` for `cargo build --release`. +//! +//! Set `WASMELD_BUILD_WEB=1` to exercise the embedded path in another Cargo +//! profile, or `NPM=/absolute/path/to/npm` when npm is not on `PATH`. + +use std::{ + env, fs, + path::PathBuf, + process::{Command, ExitStatus}, +}; + +const WEB_INPUTS: &[&str] = &[ + "web/src", + "web/public", + "web/index.html", + "web/page.html", + "web/package.json", + "web/package-lock.json", + "web/tsconfig.json", + "web/vite.config.ts", +]; + +fn main() { + println!("cargo:rustc-check-cfg=cfg(wasmeld_embedded_web)"); + println!("cargo:rerun-if-env-changed=WASMELD_BUILD_WEB"); + println!("cargo:rerun-if-env-changed=NPM"); + for input in WEB_INPUTS { + println!("cargo:rerun-if-changed={input}"); + } + + let force = env::var("WASMELD_BUILD_WEB").is_ok_and(|value| value == "1"); + if env::var("PROFILE").as_deref() != Ok("release") && !force { + return; + } + + let crate_dir = PathBuf::from(required_env("CARGO_MANIFEST_DIR")); + let web_dir = crate_dir.join("web"); + let output_dir = PathBuf::from(required_env("OUT_DIR")).join("web"); + let npm = env::var_os("NPM") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(if cfg!(windows) { "npm.cmd" } else { "npm" })); + + if output_dir.exists() { + fs::remove_dir_all(&output_dir).unwrap_or_else(|error| { + panic!( + "failed to clear embedded Web output {}: {error}", + output_dir.display() + ) + }); + } + + // `npm ci` is deliberate here: a release must not silently rewrite the + // dependency graph represented by package-lock.json. + run( + Command::new(&npm).current_dir(&web_dir).args([ + "ci", + "--ignore-scripts", + "--no-audit", + "--no-fund", + ]), + "install locked Web dependencies", + ); + run( + Command::new(&npm) + .current_dir(&web_dir) + .env("WASMELD_WEB_OUT_DIR", &output_dir) + .args(["run", "build"]), + "build embedded Web application", + ); + + for required in ["index.html", "page.html", "sw.js", "manifest.webmanifest"] { + let path = output_dir.join(required); + assert!( + path.is_file(), + "Web build did not produce required asset {}", + path.display() + ); + } + println!("cargo:rustc-cfg=wasmeld_embedded_web"); +} + +fn run(command: &mut Command, action: &str) { + let status = command + .status() + .unwrap_or_else(|error| panic!("failed to {action}: {error}")); + assert_success(status, action); +} + +fn assert_success(status: ExitStatus, action: &str) { + assert!( + status.success(), + "failed to {action}: process exited with {status}" + ); +} + +fn required_env(name: &str) -> String { + env::var(name).unwrap_or_else(|_| panic!("Cargo did not provide {name}")) +} diff --git a/crates/wasmeld-console/src/api.rs b/crates/wasmeld-console/src/api.rs index 049d4f0..ec7a83b 100644 --- a/crates/wasmeld-console/src/api.rs +++ b/crates/wasmeld-console/src/api.rs @@ -99,6 +99,7 @@ pub fn app(console: Arc, allowed_origins: Vec) -> Router { .on_response(DefaultOnResponse::new().level(Level::INFO)), ) .with_state(console) + .merge(web::router()) } /// Builds the public data-plane API around the same resident Runtime. diff --git a/crates/wasmeld-console/src/lib.rs b/crates/wasmeld-console/src/lib.rs index ca3513d..4e8719f 100644 --- a/crates/wasmeld-console/src/lib.rs +++ b/crates/wasmeld-console/src/lib.rs @@ -36,6 +36,7 @@ mod api; mod invocation_persistence; mod kv_backend; mod persistence; +mod web; mod wit_registry; use std::{ diff --git a/crates/wasmeld-console/src/web.rs b/crates/wasmeld-console/src/web.rs new file mode 100644 index 0000000..e196cf3 --- /dev/null +++ b/crates/wasmeld-console/src/web.rs @@ -0,0 +1,108 @@ +//! Embedded Solid management UI served by the control-plane listener. +//! +//! The router is empty in normal Debug builds because Vite owns Web assets +//! during development. Release builds embed Cargo `OUT_DIR/web`; requests only +//! resolve exact files, so an unknown URL cannot accidentally receive the Host +//! document as an SPA fallback. + +use axum::Router; + +#[cfg(wasmeld_embedded_web)] +use axum::{ + body::{Body, Bytes}, + extract::Path, + http::{StatusCode, header}, + response::{IntoResponse, Response}, + routing::get, +}; +#[cfg(wasmeld_embedded_web)] +use include_dir::{Dir, include_dir}; + +#[cfg(wasmeld_embedded_web)] +static WEB_ASSETS: Dir<'static> = include_dir!("$OUT_DIR/web"); + +pub(crate) fn router() -> Router { + #[cfg(wasmeld_embedded_web)] + { + Router::new() + .route("/", get(index)) + .route("/{*path}", get(asset)) + } + + #[cfg(not(wasmeld_embedded_web))] + { + Router::new() + } +} + +#[cfg(wasmeld_embedded_web)] +async fn index() -> Response { + response("index.html") +} + +#[cfg(wasmeld_embedded_web)] +async fn asset(Path(path): Path) -> Response { + response(&path) +} + +#[cfg(wasmeld_embedded_web)] +fn response(path: &str) -> Response { + // The wildcard route is public input. Reject non-canonical paths before + // looking inside the compile-time directory, even though `include_dir` + // itself cannot access the host filesystem at runtime. + if !valid_asset_path(path) { + return StatusCode::NOT_FOUND.into_response(); + } + let Some(file) = WEB_ASSETS.get_file(path) else { + return StatusCode::NOT_FOUND.into_response(); + }; + let content_type = mime_guess::from_path(path) + .first_or_octet_stream() + .essence_str() + .to_owned(); + let cache_control = if path.starts_with("assets/") { + "public, max-age=31536000, immutable" + } else { + "no-cache" + }; + let mut builder = Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, content_type) + .header(header::CACHE_CONTROL, cache_control) + .header("x-content-type-options", "nosniff"); + if path == "sw.js" { + builder = builder.header("service-worker-allowed", "/"); + } + builder + .body(Body::from(Bytes::from_static(file.contents()))) + .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()) +} + +#[cfg(wasmeld_embedded_web)] +fn valid_asset_path(path: &str) -> bool { + !path.is_empty() + && !path.starts_with('/') + && !path.contains('\\') + && path + .split('/') + .all(|segment| !matches!(segment, "" | "." | "..")) +} + +#[cfg(all(test, wasmeld_embedded_web))] +mod tests { + use super::*; + + #[test] + fn embeds_both_document_boundaries_and_pwa_assets() { + for path in ["index.html", "page.html", "manifest.webmanifest", "sw.js"] { + assert!(WEB_ASSETS.get_file(path).is_some(), "missing {path}"); + } + } + + #[test] + fn rejects_non_canonical_asset_paths() { + assert!(!valid_asset_path("../index.html")); + assert!(!valid_asset_path("assets//app.js")); + assert!(!valid_asset_path(r"assets\app.js")); + } +}