ezu-cli 0.4.2

Command-line renderer for the Ezu Style Spec
//! `ezu serve` — live editor + tile server.
//!
//! Hosts the same surface area the now-retired `ezu-server` binary
//! used to: an inline HTML editor at `/`, `GET/PUT /style`, rendered
//! tiles at `/tiles/{z}/{x}/{y}.{png,webp}`, raw MVT bytes for the
//! WASM demo at `/mvt/{z}/{x}/{y}`, and a registry-derived JSON
//! Schema at `/schemas/ezu-style.json`. The editor uses the schema
//! for client-side validation as you type.

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 {
    /// PMTiles archive — local path or http(s):// URL.
    #[arg(long, conflicts_with = "mvt", env = "EZU_PMTILES_URL")]
    pmtiles: Option<String>,
    /// Templated MVT tile source (URL or path) containing `{z}`,
    /// `{x}`, `{y}` placeholders; or a `.json` TileJSON document.
    #[arg(long, conflicts_with = "pmtiles", env = "EZU_MVT_URL")]
    mvt: Option<String>,
    /// Initial Ezu Style document — local path or http(s):// URL.
    /// Accepts either a positional argument (`ezu serve foo.json`) or
    /// `--style`. Positional wins when both are given.
    #[arg(value_name = "STYLE")]
    style_arg: Option<String>,
    /// Same as the positional `STYLE` argument; kept for back-compat
    /// and so `EZU_STYLE` still works.
    #[arg(
        long = "style",
        default_value = "crates/ezu/examples/styles/watercolor.json",
        env = "EZU_STYLE"
    )]
    style_flag: String,
    /// Base directory for resolving asset `src` paths (brushes, images).
    /// Defaults to the style file's parent directory (or the current
    /// directory when the style is a URL).
    #[arg(long, env = "EZU_ASSETS")]
    assets_dir: Option<PathBuf>,
    /// Bind address.
    #[arg(long, default_value = "127.0.0.1:8080", env = "EZU_BIND")]
    bind: SocketAddr,
    /// When a requested tile is missing, fall back to a parent tile
    /// up to this many zoom levels up and re-project its geometry
    /// onto the requested tile (MVT "overzoom"). `0` disables.
    #[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}");

    // Resolve relative asset `src` paths against the style file's parent
    // directory by default (the current directory when the style is a
    // URL), matching the `tile`/`check` commands. `--assets-dir` overrides.
    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(),
    );

    // Resolve the feature-tile source. CLI flag overrides the URL,
    // but the binding name always comes from the style's `sources`
    // block so the document's `features` nodes can reference it. A
    // CLI override without any matching style source is an error —
    // we'd have nothing to bind it under.
    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,
    );

    // Spawn a polling watcher when the style was loaded from a local
    // path. URL-sourced styles aren't watched (we don't know how the
    // remote would notify us). The watcher reloads on mtime change and
    // broadcasts to /style/events subscribers.
    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));
    }

    // The multithreaded WASM build (wasm-bindgen-rayon) needs the page to
    // be cross-origin isolated, which requires these two headers on the
    // *document* — otherwise `SharedArrayBuffer` (and the worker pool) is
    // unavailable. Scoped to the demo and the WASM asset dirs only, so
    // the editor and tile endpoints are untouched; the demo's own
    // subresources (`/style`, `/mvt`, `/wasm/*`) are all same-origin and
    // satisfy COEP without extra headers. Harmless for the scalar/SIMD
    // builds, which simply don't use SharedArrayBuffer.
    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 the cross-origin-isolated document embed these same-origin
    // assets (belt-and-suspenders under COEP: 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)
}

/// Poll the file mtime once a second and reload the live snapshot
/// whenever it advances. Polling (vs. an OS-native notify watcher)
/// keeps the dependency surface zero and works the same on macOS,
/// Linux, and Windows — fine for an interactive dev tool where the
/// only watcher is the editor that's already attached to it.
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());
    }
}