feat(console): embed the management UI in release binaries

- build locked Web dependencies automatically for release profiles
- keep debug Cargo builds independent from the Vite development server
- embed generated Host, Page, manifest, and service worker assets
- serve exact control-plane assets with MIME and cache headers
- reject non-canonical and unknown paths without an SPA fallback
- cover embedded document boundaries and path validation
This commit is contained in:
Maofeng
2026-07-30 09:22:04 +08:00
parent 70ee5e33da
commit f6ad94e948
7 changed files with 239 additions and 0 deletions
Generated
+21
View File
@@ -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",
+2
View File
@@ -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"
+2
View File
@@ -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
+104
View File
@@ -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}"))
}
+1
View File
@@ -99,6 +99,7 @@ pub fn app(console: Arc<Console>, allowed_origins: Vec<HeaderValue>) -> 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.
+1
View File
@@ -36,6 +36,7 @@ mod api;
mod invocation_persistence;
mod kv_backend;
mod persistence;
mod web;
mod wit_registry;
use std::{
+108
View File
@@ -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<String>) -> 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"));
}
}