bundle UI files into the binary

This is optional but now enabled for release builds.

Why?

* It shrinks the release docker images a bit, as the binary
  includes only the gzipped version of files and uncompressed into RAM
  at startup (which should be fast).

* It's a step toward #160.
This commit is contained in:
Scott Lamb 2023-08-04 12:52:05 -05:00
parent 02ac1a5570
commit faba358925
13 changed files with 534 additions and 61 deletions

View File

@ -36,6 +36,9 @@ jobs:
# The retry here is to work around "Unable to connect to azure.archive.ubuntu.com" errors.
# https://github.com/actions/runner-images/issues/6894
run: sudo apt-get --option=APT::Acquire::Retries=3 update && sudo apt-get --option=APT::Acquire::Retries=3 install libncurses-dev libsqlite3-dev pkgconf
- uses: actions/setup-node@v2
with:
node-version: 18
- name: Install Rust
uses: actions-rs/toolchain@v1
with:
@ -43,8 +46,13 @@ jobs:
toolchain: ${{ matrix.rust }}
override: true
components: ${{ matrix.extra_components }}
- run: cd ui && npm ci
- run: cd ui && npm run build
- name: Test
run: cd server && cargo test ${{ matrix.extra_args }} --all
run: |
cd server
cargo test ${{ matrix.extra_args }} --all
cargo test --features=bundled-ui ${{ matrix.extra_args }} --all
continue-on-error: ${{ matrix.rust == 'nightly' }}
- name: Check formatting
if: matrix.rust == 'stable'

View File

@ -16,7 +16,8 @@ even on minor releases, e.g. `0.7.5` -> `0.7.6`.
* fix [#289](https://github.com/scottlamb/moonfire-nvr/issues/289): crash on
pressing the `Add` button in the sample file directory dialog
* log to `stderr` again, fixing a regression with the `tracing` change in 0.7.6.
* experimental (off by default) support for bundling UI files into the
executable.
## 0.7.6 (2023-07-08)

View File

@ -48,6 +48,7 @@ FROM --platform=$BUILDPLATFORM dev AS build-server
LABEL maintainer="slamb@slamb.org"
ARG INVALIDATE_CACHE_BUILD_SERVER=
COPY docker/build-server.bash /
COPY --from=build-ui /var/lib/moonfire-nvr/src/ui/build /var/lib/moonfire-nvr/src/ui/build
RUN --mount=type=cache,id=target,target=/var/lib/moonfire-nvr/target,sharing=locked,mode=777 \
--mount=type=cache,id=cargo,target=/cargo-cache,sharing=locked,mode=777 \
--mount=type=bind,source=server,target=/var/lib/moonfire-nvr/src/server,readonly \
@ -66,7 +67,6 @@ COPY --from=dev /docker-build-debug/dev/ /docker-build-debug/dev/
COPY --from=build-server /docker-build-debug/build-server/ /docker-build-debug/build-server/
COPY --from=build-server /usr/local/bin/moonfire-nvr /usr/local/bin/moonfire-nvr
COPY --from=build-ui /docker-build-debug/build-ui /docker-build-debug/build-ui
COPY --from=build-ui /var/lib/moonfire-nvr/src/ui/build /usr/local/lib/moonfire-nvr/ui
# The install instructions say to use --user in the docker run commandline.
# Specify a non-root user just in case someone forgets.

View File

@ -27,8 +27,8 @@ ln -s /cargo-cache/{git,registry} ~/.cargo
build_profile=release-lto
cd src/server
time cargo test
time cargo build --profile=$build_profile
time cargo test --features=bundled-ui
time cargo build --features=bundled-ui --profile=$build_profile
find /cargo-cache -ls > /docker-build-debug/build-server/cargo-cache-after
find ~/target -ls > /docker-build-debug/build-server/target-after
sudo install -m 755 \

30
server/Cargo.lock generated
View File

@ -1101,6 +1101,7 @@ dependencies = [
"bytes",
"chrono",
"cursive",
"flate2",
"fnv",
"futures",
"h264-reader",
@ -1143,6 +1144,7 @@ dependencies = [
"ulid",
"url",
"uuid",
"walkdir",
]
[[package]]
@ -1736,6 +1738,15 @@ dependencies = [
"cipher",
]
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "scrypt"
version = "0.10.0"
@ -2372,6 +2383,16 @@ version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
[[package]]
name = "walkdir"
version = "2.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36df944cda56c7d8d8b7496af378e6b16de9284591917d307c9b4d313c44e698"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "want"
version = "0.3.0"
@ -2497,6 +2518,15 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178"
dependencies = [
"winapi",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"

View File

@ -9,14 +9,15 @@ rust-version = "1.70"
publish = false
[features]
# The nightly feature is used within moonfire-nvr itself to gate the
# benchmarks. Also pass it along to crates that can benefit from it.
nightly = ["db/nightly"]
# The bundled feature includes bundled (aka statically linked) versions of
# native libraries where possible.
bundled = ["rusqlite/bundled"]
# The bundled feature aims to make a single executable file that is deployable,
# including statically linked libraries and embedded UI files.
bundled = ["rusqlite/bundled", "bundled-ui"]
bundled-ui = []
[workspace]
members = ["base", "db"]
@ -71,6 +72,12 @@ tracing-log = "0.1.3"
ulid = "1.0.0"
url = "2.1.1"
uuid = { version = "1.1.2", features = ["serde", "std", "v4"] }
flate2 = "1.0.26"
[build-dependencies]
blake3 = "1.0.0"
fnv = "1.0"
walkdir = "2.3.3"
[dev-dependencies]
mp4 = { git = "https://github.com/scottlamb/mp4-rust", branch = "moonfire" }

149
server/build.rs Normal file
View File

@ -0,0 +1,149 @@
// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2023 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
//! Build script to bundle UI files if `bundled-ui` Cargo feature is selected.
use std::fmt::Write;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
const UI_DIR: &str = "../ui/build";
fn ensure_link(original: &Path, link: &Path) {
match std::fs::read_link(link) {
Ok(dst) if dst == original => return,
Err(e) if e.kind() != std::io::ErrorKind::NotFound => {
panic!("couldn't create link {link:?} to original path {original:?}: {e}")
}
_ => {}
}
std::os::unix::fs::symlink(original, link).expect("symlink creation should succeed");
}
struct File {
/// Path with `ui_files/` prefix and the encoding suffix; suitable for
/// passing to `include_bytes!` in the expanded code.
///
/// E.g. `ui_files/index.html.gz`.
include_path: String,
encoding: FileEncoding,
etag: blake3::Hash,
}
#[derive(Copy, Clone)]
enum FileEncoding {
Uncompressed,
Gzipped,
}
impl FileEncoding {
fn to_str(self) -> &'static str {
match self {
Self::Uncompressed => "FileEncoding::Uncompressed",
Self::Gzipped => "FileEncoding::Gzipped",
}
}
}
/// Map of "bare path" to the best representation.
///
/// A "bare path" has no prefix for the root and no suffix for encoding, e.g.
/// `favicons/blah.ico` rather than `../../ui/build/favicons/blah.ico.gz`.
///
/// The best representation is gzipped if available, uncompressed otherwise.
type FileMap = fnv::FnvHashMap<String, File>;
fn stringify_files(files: &FileMap) -> Result<String, std::fmt::Error> {
let mut buf = String::new();
write!(buf, "const FILES: [BuildFile; {}] = [\n", files.len())?;
for (bare_path, file) in files {
let include_path = &file.include_path;
let etag = file.etag.to_hex();
let encoding = file.encoding.to_str();
write!(buf, " BuildFile {{ bare_path: {bare_path:?}, data: include_bytes!({include_path:?}), etag: {etag:?}, encoding: {encoding} }},\n")?;
}
write!(buf, "];\n")?;
Ok(buf)
}
fn main() -> ExitCode {
// Explicitly declare dependencies, so this doesn't re-run if other source files change.
println!("cargo:rerun-if-changed=build.rs");
// Nothing to do if the feature is off. cargo will re-run if features change.
if !cfg!(feature = "bundled-ui") {
return ExitCode::SUCCESS;
}
// If the feature is on, also re-run if the actual UI files change.
println!("cargo:rerun-if-changed={UI_DIR}");
let out_dir: PathBuf = std::env::var_os("OUT_DIR")
.expect("cargo should set OUT_DIR")
.into();
let abs_ui_dir = std::fs::canonicalize(UI_DIR)
.expect("ui dir should be accessible. Did you run `npm run build` first?");
let mut files = FileMap::default();
for entry in walkdir::WalkDir::new(&abs_ui_dir) {
let entry = match entry {
Ok(e) => e,
Err(e) => {
eprintln!(
"walkdir failed. Did you run `npm run build` first?\n\n\
caused by:\n{e}"
);
return ExitCode::FAILURE;
}
};
if !entry.file_type().is_file() {
continue;
}
let path = entry
.path()
.strip_prefix(&abs_ui_dir)
.expect("walkdir should return root-prefixed entries");
let path = path.to_str().expect("ui file paths should be valid UTF-8");
let (bare_path, encoding);
match path.strip_suffix(".gz") {
Some(p) => {
bare_path = p;
encoding = FileEncoding::Gzipped;
}
None => {
bare_path = path;
encoding = FileEncoding::Uncompressed;
if files.get(bare_path).is_some() {
continue; // don't replace with suboptimal encoding.
}
}
}
let contents = std::fs::read(entry.path()).expect("ui files should be readable");
let etag = blake3::hash(&contents);
let include_path = format!("ui_files/{path}");
files.insert(
bare_path.to_owned(),
File {
include_path,
encoding,
etag,
},
);
}
let files = stringify_files(&files).expect("write to String should succeed");
let mut out_rs_path = std::path::PathBuf::new();
out_rs_path.push(&out_dir);
out_rs_path.push("ui_files.rs");
std::fs::write(&out_rs_path, files).expect("writing ui_files.rs should succeed");
let mut out_link_path = std::path::PathBuf::new();
out_link_path.push(&out_dir);
out_link_path.push("ui_files");
ensure_link(&abs_ui_dir, &out_link_path);
return ExitCode::SUCCESS;
}

202
server/src/bundled_ui.rs Normal file
View File

@ -0,0 +1,202 @@
// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2023 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception.
//! UI bundled (compiled/linked) into the executable for single-file deployment.
use fnv::FnvHashMap;
use http::{header, HeaderMap, HeaderValue};
use std::io::Read;
use std::sync::OnceLock;
use crate::body::{BoxedError, Chunk};
pub struct Ui(FnvHashMap<&'static str, FileSet>);
/// A file as passed in from `build.rs`.
struct BuildFile {
/// Path without any prefix (even `/`) for the root or any encoding suffix (`.gz`).
bare_path: &'static str,
data: &'static [u8],
etag: &'static str,
encoding: FileEncoding,
}
#[derive(Copy, Clone)]
enum FileEncoding {
Uncompressed,
Gzipped,
}
// `build.rs` fills in: `static FILES: [BuildFile; _] = [ ... ];`
include!(concat!(env!("OUT_DIR"), "/ui_files.rs"));
/// A file, ready to serve.
struct File {
data: &'static [u8],
etag: &'static str,
}
struct FileSet {
uncompressed: File,
gzipped: Option<File>,
}
impl Ui {
pub fn get() -> &'static Self {
UI.get_or_init(Self::init)
}
#[tracing::instrument]
fn init() -> Self {
Ui(FILES
.iter()
.map(|f| {
let set = if matches!(f.encoding, FileEncoding::Gzipped) {
let mut uncompressed = Vec::new();
let mut d = flate2::read::GzDecoder::new(f.data);
d.read_to_end(&mut uncompressed)
.expect("bundled gzip files should be valid");
// TODO: use String::leak in rust 1.72+.
let etag = format!("{}.ungzipped", f.etag);
let etag = etag.into_bytes().leak();
let etag =
std::str::from_utf8(etag).expect("just-formatted str is valid utf-8");
FileSet {
uncompressed: File {
data: uncompressed.leak(),
etag,
},
gzipped: Some(File {
data: f.data,
etag: f.etag,
}),
}
} else {
FileSet {
uncompressed: File {
data: f.data,
etag: f.etag,
},
gzipped: None,
}
};
(f.bare_path, set)
})
.collect())
}
pub fn lookup(
&'static self,
path: &str,
hdrs: &HeaderMap<HeaderValue>,
cache_control: &'static str,
content_type: &'static str,
) -> Option<Entity> {
let Some(set) = self.0.get(path) else {
return None;
};
let auto_gzip;
if let Some(ref gzipped) = set.gzipped {
auto_gzip = true;
if http_serve::should_gzip(hdrs) {
return Some(Entity {
file: &gzipped,
auto_gzip,
is_gzipped: true,
cache_control,
content_type,
});
}
} else {
auto_gzip = false
};
Some(Entity {
file: &set.uncompressed,
auto_gzip,
is_gzipped: false,
cache_control,
content_type,
})
}
}
static UI: OnceLock<Ui> = OnceLock::new();
#[derive(Copy, Clone)]
pub struct Entity {
file: &'static File,
auto_gzip: bool,
is_gzipped: bool,
cache_control: &'static str,
content_type: &'static str,
}
impl http_serve::Entity for Entity {
type Data = Chunk;
type Error = BoxedError;
fn len(&self) -> u64 {
self.file
.data
.len()
.try_into()
.expect("usize should be convertible to u64")
}
fn get_range(
&self,
range: std::ops::Range<u64>,
) -> Box<dyn futures::Stream<Item = Result<Self::Data, Self::Error>> + Send + Sync> {
let file = self.file;
Box::new(futures::stream::once(async move {
let r = usize::try_from(range.start)?..usize::try_from(range.end)?;
let Some(data) = file.data.get(r) else {
let len = file.data.len();
return Err(format!("static file range {range:?} invalid (len {len:?})").into());
};
Ok(data.into())
}))
}
fn add_headers(&self, hdrs: &mut http::HeaderMap) {
if self.auto_gzip {
hdrs.insert(header::VARY, HeaderValue::from_static("accept-encoding"));
}
if self.is_gzipped {
hdrs.insert(header::CONTENT_ENCODING, HeaderValue::from_static("gzip"));
}
hdrs.insert(
header::CACHE_CONTROL,
HeaderValue::from_static(self.cache_control),
);
hdrs.insert(
header::CONTENT_TYPE,
HeaderValue::from_static(self.content_type),
);
}
fn etag(&self) -> Option<http::HeaderValue> {
Some(http::HeaderValue::from_static(self.file.etag))
}
fn last_modified(&self) -> Option<std::time::SystemTime> {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn index_html_uncompressed() {
let ui = Ui::get();
let e = ui
.lookup("index.html", &HeaderMap::new(), "public", "text/html")
.unwrap();
assert!(e.file.data.starts_with(b"<!doctype html"));
}
}

View File

@ -14,9 +14,6 @@ use crate::json::Permissions;
fn default_db_dir() -> PathBuf {
crate::DEFAULT_DB_DIR.into()
}
fn default_ui_dir() -> PathBuf {
"/usr/local/lib/moonfire-nvr/ui".into()
}
/// Top-level configuration file object.
#[derive(Debug, Deserialize)]
@ -32,8 +29,9 @@ pub struct ConfigFile {
pub db_dir: PathBuf,
/// Directory holding user interface files (`.html`, `.js`, etc).
#[serde(default = "default_ui_dir")]
pub ui_dir: PathBuf,
#[cfg_attr(not(feature = "bundled-ui"), serde(default))]
#[cfg_attr(feature = "bundled-ui", serde(default))]
pub ui_dir: UiDir,
/// The number of worker threads used by the asynchronous runtime.
///
@ -42,6 +40,34 @@ pub struct ConfigFile {
pub worker_threads: Option<usize>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", untagged)]
pub enum UiDir {
FromFilesystem(PathBuf),
Bundled(BundledUi),
}
impl Default for UiDir {
#[cfg(feature = "bundled-ui")]
fn default() -> Self {
UiDir::Bundled(BundledUi { bundled: true })
}
#[cfg(not(feature = "bundled-ui"))]
fn default() -> Self {
UiDir::FromFilesystem("/usr/local/lib/moonfire-nvr/ui".into())
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
#[serde(rename_all = "camelCase")]
pub struct BundledUi {
/// Just a marker to select this variant.
#[allow(unused)]
bundled: bool,
}
/// Per-bind configuration.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]

View File

@ -24,7 +24,7 @@ use tracing::{info, warn};
use self::config::ConfigFile;
mod config;
pub mod config;
/// Runs the server, saving recordings and allowing web access.
#[derive(Bpaf, Debug)]

View File

@ -20,6 +20,9 @@ mod stream;
mod streamer;
mod web;
#[cfg(feature = "bundled-ui")]
mod bundled_ui;
const DEFAULT_DB_DIR: &str = "/var/lib/moonfire-nvr/db";
/// Moonfire NVR: security camera network video recorder.

View File

@ -17,6 +17,7 @@ use self::path::Path;
use crate::body::Body;
use crate::json;
use crate::mp4;
use crate::web::static_file::Ui;
use base::err;
use base::Error;
use base::ResultExt;
@ -28,7 +29,6 @@ use db::{auth, recording};
use fnv::FnvHashMap;
use http::header::{self, HeaderValue};
use http::{status::StatusCode, Request, Response};
use http_serve::dir::FsDir;
use hyper::body::Bytes;
use std::net::IpAddr;
use std::sync::Arc;
@ -162,7 +162,7 @@ fn require_csrf_if_session(caller: &Caller, csrf: Option<&str>) -> Result<(), ba
pub struct Config<'a> {
pub db: Arc<db::Database>,
pub ui_dir: Option<&'a std::path::Path>,
pub ui_dir: Option<&'a crate::cmds::run::config::UiDir>,
pub trust_forward_hdrs: bool,
pub time_zone_name: String,
pub allow_unauthenticated_permissions: Option<db::Permissions>,
@ -171,7 +171,7 @@ pub struct Config<'a> {
pub struct Service {
db: Arc<db::Database>,
ui_dir: Option<Arc<FsDir>>,
ui: Ui,
dirs_by_stream_id: Arc<FnvHashMap<i32, Arc<SampleFileDir>>>,
time_zone_name: String,
allow_unauthenticated_permissions: Option<db::Permissions>,
@ -195,19 +195,7 @@ enum CacheControl {
impl Service {
pub fn new(config: Config) -> Result<Self, Error> {
let mut ui_dir = None;
if let Some(d) = config.ui_dir {
match FsDir::builder().for_path(d) {
Err(e) => {
warn!(
"Unable to load ui dir {}; will serve no static files: {}",
d.display(),
e
);
}
Ok(d) => ui_dir = Some(d),
};
}
let ui_dir = config.ui_dir.map(Ui::from).unwrap_or(Ui::None);
let dirs_by_stream_id = {
let l = config.db.lock();
let mut d =
@ -225,7 +213,7 @@ impl Service {
Ok(Service {
db: config.db,
dirs_by_stream_id,
ui_dir,
ui: ui_dir,
allow_unauthenticated_permissions: config.allow_unauthenticated_permissions,
trust_forward_hdrs: config.trust_forward_hdrs,
time_zone_name: config.time_zone_name,

View File

@ -4,45 +4,104 @@
//! Static file serving.
use base::{bail, err, Error, ErrorKind, ResultExt};
use std::sync::Arc;
use base::{bail, err, ErrorKind, ResultExt};
use http::{header, HeaderValue, Request};
use http_serve::dir::FsDir;
use tracing::warn;
use crate::cmds::run::config::UiDir;
use super::{ResponseResult, Service};
pub enum Ui {
None,
FromFilesystem(Arc<FsDir>),
#[cfg(feature = "bundled-ui")]
Bundled(&'static crate::bundled_ui::Ui),
}
impl Ui {
pub fn from(cfg: &UiDir) -> Self {
match cfg {
UiDir::FromFilesystem(d) => match FsDir::builder().for_path(d) {
Err(err) => {
warn!(
%err,
"unable to load ui dir {}; will serve no static files",
d.display(),
);
Self::None
}
Ok(d) => Self::FromFilesystem(d),
},
#[cfg(feature = "bundled-ui")]
UiDir::Bundled(_) => Self::Bundled(crate::bundled_ui::Ui::get()),
#[cfg(not(feature = "bundled-ui"))]
UiDir::Bundled(_) => {
warn!("server compiled without bundled ui; will serve not static files");
Self::None
}
}
}
async fn serve(
&self,
path: &str,
req: &Request<hyper::Body>,
cache_control: &'static str,
content_type: &'static str,
) -> ResponseResult {
match self {
Ui::None => bail!(
NotFound,
msg("ui not configured or missing; no static files available")
),
Ui::FromFilesystem(d) => {
let node = d.clone().get(path, req.headers()).await.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
err!(NotFound, msg("static file not found"))
} else {
err!(Internal, source(e))
}
})?;
let mut hdrs = http::HeaderMap::new();
node.add_encoding_headers(&mut hdrs);
hdrs.insert(
header::CACHE_CONTROL,
HeaderValue::from_static(cache_control),
);
hdrs.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
let e = node.into_file_entity(hdrs).err_kind(ErrorKind::Internal)?;
Ok(http_serve::serve(e, &req))
}
#[cfg(feature = "bundled-ui")]
Ui::Bundled(ui) => {
let Some(e) = ui.lookup(path, req.headers(), cache_control, content_type) else {
bail!(NotFound, msg("static file not found"));
};
Ok(http_serve::serve(e, &req))
}
}
}
}
impl Service {
/// Serves a static file if possible.
pub(super) async fn static_file(&self, req: Request<hyper::Body>) -> ResponseResult {
let Some(dir) = self.ui_dir.clone() else {
bail!(NotFound, msg("ui dir not configured or missing; no static files available"))
};
let Some(static_req) = StaticFileRequest::parse(req.uri().path()) else {
bail!(NotFound, msg("static file not found"));
};
let f = dir.get(static_req.path, req.headers());
let node = f.await.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
err!(NotFound, msg("no such static file"))
} else {
Error::wrap(ErrorKind::Internal, e)
}
})?;
let mut hdrs = http::HeaderMap::new();
node.add_encoding_headers(&mut hdrs);
hdrs.insert(
header::CACHE_CONTROL,
HeaderValue::from_static(if static_req.immutable {
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#Caching_static_assets
"public, max-age=604800, immutable"
} else {
"public"
}),
);
hdrs.insert(
header::CONTENT_TYPE,
HeaderValue::from_static(static_req.mime),
);
let e = node.into_file_entity(hdrs).err_kind(ErrorKind::Internal)?;
Ok(http_serve::serve(e, &req))
let cache_control = if static_req.immutable {
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#Caching_static_assets
"public, max-age=604800, immutable"
} else {
"public"
};
self.ui
.serve(static_req.path, &req, cache_control, static_req.mime)
.await
}
}