mod handlers;
mod state;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use axum::http::{header::HeaderName, HeaderValue};
use clap::Args;
use tower_http::cors::CorsLayer;
use tower_http::services::ServeDir;
use tower_http::set_header::SetResponseHeaderLayer;
use tower_http::trace::TraceLayer;
use crate::source::{SourceSpec, TileSource};
use state::{AppState, StyleReload, StyleSnapshot};
#[derive(Args, Debug)]
pub struct ServeCmd {
#[arg(long, conflicts_with = "mvt", env = "EZU_PMTILES_URL")]
pmtiles: Option<String>,
#[arg(long, conflicts_with = "pmtiles", env = "EZU_MVT_URL")]
mvt: Option<String>,
#[arg(value_name = "STYLE")]
style_arg: Option<String>,
#[arg(
long = "style",
default_value = "crates/ezu/examples/styles/watercolor.json",
env = "EZU_STYLE"
)]
style_flag: String,
#[arg(long, env = "EZU_ASSETS")]
assets_dir: Option<PathBuf>,
#[arg(long, default_value = "127.0.0.1:8080", env = "EZU_BIND")]
bind: SocketAddr,
#[arg(long, default_value_t = 4)]
overzoom_levels: u8,
}
pub async fn run(args: ServeCmd) -> Result<(), Box<dyn std::error::Error>> {
let cli_source = match (&args.pmtiles, &args.mvt) {
(Some(p), None) => Some((SourceSpec::PmTiles(p.clone()), "--pmtiles flag")),
(None, Some(u)) => Some((SourceSpec::Mvt(u.clone()), "--mvt flag")),
(None, None) => None,
_ => return Err("--pmtiles and --mvt are mutually exclusive".into()),
};
let style_src = args
.style_arg
.as_deref()
.unwrap_or(args.style_flag.as_str());
tracing::info!("loading style from {style_src}");
let assets_dir = args.assets_dir.clone().unwrap_or_else(|| {
if is_url(style_src) {
PathBuf::from(".")
} else {
Path::new(style_src)
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."))
}
});
let style_text = crate::fetch_text(style_src).await?;
let snapshot = StyleSnapshot::build(style_text, 1, &assets_dir).await?;
tracing::info!(
"loaded style {} ({} nodes, tile={}, pad={}, {} brushes, {} images, {} dem source(s))",
snapshot.doc.name,
snapshot.doc.nodes.len(),
snapshot.doc.tile_size,
snapshot.doc.pad,
snapshot.assets.bank.len(),
snapshot.assets.images.len(),
snapshot.dem_sources.len(),
);
let pick = crate::feature_source_from_doc(&snapshot.doc);
let (source, source_name) = match (pick, cli_source) {
(Some(p), Some((spec, origin))) => {
tracing::info!(
"opening tile source ({origin}, bound as `{}`): {spec:?}",
p.name
);
(Some(TileSource::open(&spec).await?), Some(p.name))
}
(Some(p), None) => {
tracing::info!("opening tile source ({}): {:?}", p.origin, p.spec);
(Some(TileSource::open(&p.spec).await?), Some(p.name))
}
(None, Some((spec, origin))) => {
return Err(format!(
"{origin} ({spec:?}) requires the style to declare a matching `mvt`/`pmtiles` source, but the document has none"
)
.into());
}
(None, None) => {
tracing::info!("no MVT source — `features` bindings will be empty");
(None, None)
}
};
let state = AppState::new(
source,
source_name,
snapshot,
assets_dir,
args.overzoom_levels,
);
if !is_url(style_src) {
let path = PathBuf::from(style_src);
let state_for_watch = state.clone();
tokio::spawn(watch_style_file(path, state_for_watch));
}
let coop = SetResponseHeaderLayer::overriding(
HeaderName::from_static("cross-origin-opener-policy"),
HeaderValue::from_static("same-origin"),
);
let coep = SetResponseHeaderLayer::overriding(
HeaderName::from_static("cross-origin-embedder-policy"),
HeaderValue::from_static("require-corp"),
);
let corp = SetResponseHeaderLayer::overriding(
HeaderName::from_static("cross-origin-resource-policy"),
HeaderValue::from_static("same-origin"),
);
let mut app = handlers::router().with_state(state);
for (route, dir) in [
("/wasm-demo", "crates/ezu-wasm/examples/wasm-demo"),
("/wasm/scalar", "target/wasm/scalar"),
("/wasm/simd", "target/wasm/simd"),
("/wasm/threads", "target/wasm/threads"),
] {
if Path::new(dir).is_dir() {
tracing::info!("serving {} from {}", route, dir);
let svc = ServeDir::new(dir);
app = app.nest_service(
route,
tower::ServiceBuilder::new()
.layer(coop.clone())
.layer(coep.clone())
.layer(corp.clone())
.service(svc),
);
}
}
let app = app
.layer(CorsLayer::permissive())
.layer(TraceLayer::new_for_http());
tracing::info!("listening on http://{}", args.bind);
let listener = tokio::net::TcpListener::bind(args.bind).await?;
axum::serve(listener, app).await?;
Ok(())
}
fn is_url(s: &str) -> bool {
s.starts_with("http://") || s.starts_with("https://")
}
fn mtime_ms(t: SystemTime) -> i64 {
t.duration_since(UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_millis()).ok())
.unwrap_or(0)
}
async fn watch_style_file(path: PathBuf, state: AppState) {
let mut last_mtime: Option<SystemTime> = tokio::fs::metadata(&path)
.await
.ok()
.and_then(|m| m.modified().ok());
let mut ticker = tokio::time::interval(Duration::from_secs(1));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
tracing::info!("watching {} for live reload", path.display());
loop {
ticker.tick().await;
let meta = match tokio::fs::metadata(&path).await {
Ok(m) => m,
Err(_) => continue,
};
let mtime = match meta.modified() {
Ok(t) => t,
Err(_) => continue,
};
if last_mtime == Some(mtime) {
continue;
}
last_mtime = Some(mtime);
let text = match tokio::fs::read_to_string(&path).await {
Ok(t) => t,
Err(e) => {
tracing::warn!("watch: read {} failed: {e}", path.display());
continue;
}
};
let next_version = state.style.read().await.version + 1;
let snap = match StyleSnapshot::build(text.clone(), next_version, state.assets_dir.as_ref())
.await
{
Ok(s) => s,
Err(e) => {
tracing::warn!("watch: rebuild failed: {e}");
continue;
}
};
let v = snap.version;
*state.style.write().await = snap;
let _ = state.events.send(StyleReload {
version: v,
text,
mtime_ms: mtime_ms(mtime),
});
tracing::info!("style reloaded from {} (v{v})", path.display());
}
}