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:
Generated
+21
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"))
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -36,6 +36,7 @@ mod api;
|
||||
mod invocation_persistence;
|
||||
mod kv_backend;
|
||||
mod persistence;
|
||||
mod web;
|
||||
mod wit_registry;
|
||||
|
||||
use std::{
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user