ezu-cli 0.13.0

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}` (or `{y}@2x.png` and so on, for
//! the retina tile at that pixel ratio), 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::sync::Arc;
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::{FeatureSources, SourceSpec, TileByteCache};
use state::{AppState, Sources, 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,
    /// Render each tile this many pixels square instead of at the style's
    /// `tile-size`. The style's own pixel sizes (line widths, text, `pad`)
    /// are not scaled with it, so they draw finer.
    #[arg(long, value_parser = clap::value_parser!(u32).range(1..))]
    tile_size: Option<u32>,
    /// Device pixels per style pixel for a tile requested without `@Nx`,
    /// from 1 to 4. A request asks for its own with `@2x` (up to `@4x`)
    /// before the extension: the same map at that density, its line
    /// widths, text and blurs scaled with it, as MapLibre's retina tiles.
    #[arg(long, value_parser = crate::parse_pixel_ratio)]
    pixel_ratio: Option<f32>,
    /// Margin rendered around each tile before it is cropped, in pixels,
    /// in place of the style's `pad` raised to what its filters reach, for
    /// every style the server loads. Narrower than that reach, a tile's
    /// edge seams against its neighbour.
    #[arg(long)]
    pad: Option<u32>,
    #[command(flatten)]
    png: crate::PngArgs,
}

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 doc = ezu::style::Document::from_json(&style_text)?;

    // Open every feature-tile source the style declares. A CLI flag
    // overrides the first one's URL, but the binding names always come
    // from the style's `sources` block so the document's `features`
    // nodes can reference them. A CLI override without any matching
    // style source is an error — we'd have nothing to bind it under.
    // Remote tiles are kept once fetched; local ones are read for every
    // request, so a tile edited on disk shows up on the next.
    let byte_cache = Arc::new(TileByteCache::new(crate::tile_cache_budget_bytes()));
    let feature_sources = FeatureSources::open(&doc, cli_source, &assets_dir)
        .await?
        .with_byte_cache(&byte_cache, true);
    if feature_sources.is_empty() {
        tracing::info!("no MVT source — `features` bindings will be empty");
    }
    let sources = Sources {
        assets_dir,
        feature_sources: Arc::new(feature_sources),
        overzoom_levels: args.overzoom_levels,
        byte_cache,
        tile_size: args.tile_size,
        pixel_ratio: args.pixel_ratio.unwrap_or(1.0),
        pad: args.pad,
        png_compression: args.png.compression(),
    };

    let snapshot = StyleSnapshot::build(style_text, 1, &sources).await?;
    tracing::info!(
        "loaded style {} ({} nodes, tile={}, pad={})",
        snapshot.doc.name,
        snapshot.doc.nodes.len(),
        snapshot.doc.tile_size,
        snapshot.pad,
    );

    let state = AppState::new(sources, snapshot);

    // 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.sources).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());
    }
}