diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e81259..5ee7a14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ Per-release notes are also published on each [GitHub Release](https://github.com ### Changed +- The dev server's live reload hot-swaps stylesheets in place instead of reloading the page, and streams every change over SSE (`/_web_modules/live/events`) to a client of its own (`/_web_modules/live/live.js`); `tower-livereload` is gone. + Non-stylesheet changes still reload the page by default; `--live-reload css` (`Dev::live_reload(ReloadMode::Css)`) turns that into a console note, `--no-live-reload` (`ReloadMode::Off`) serves without watcher, stream and client. + The `dev` feature now pulls `futures-core` (the `Stream` trait axum's SSE response takes; already compiled in every axum build) and tokio's `sync`. + - **Breaking:** `--minify` now strips comments from emitted JS: normal, JSDoc and annotation comments go, legal comments (`//!`, `/*!`, `@license`, `@preserve`) stay inline — deliberately not oxc's own minify preset, which drops those too. Previously every comment survived minification. Pass `--comments keep` for the old behavior. @@ -19,6 +23,10 @@ Per-release notes are also published on each [GitHub Release](https://github.com ### Added +- `web_modules::live`: the live-reload hub behind the dev server, for hosts with their own compilers or watchers: `LiveReload::watch(mounts)` / `::new`, `record_dependencies(url, paths)`, `notify(path)`, `publish(change)`, `router()` / `events_router()`, `script_tag()` / `meta_tag()`, `inject_script(router, prefix)`. + The stream says what changed as a kind and a served URL, never as a filesystem path; the browser client swaps a changed `` without a flash and dispatches `web-modules:css-reloaded`. +- `scss::compile_file_tracked`: `compile_file` plus the list of files the compile read (the entry and every partial), for caches and dependency maps. +- `dev::dev_router_with_live` / `dev::serve_with_live`: the `dev_router_with` / `serve_with` pair with the live-reload policy. - `typescript::rewrite_str` and a public `RewriteOptions`: apply an output policy (minify, comments, inline source map) to plain JavaScript through the transformer-free rewrite pass the build already uses internally. Consumers no longer route generated or copied JS through `compile_str_with`, whose Lit-preset transform may alter hand-written semantics. - `PackageSpec::keep_tagged`: a keep-filter with a tag that joins the extraction cache key. @@ -52,6 +60,8 @@ Per-release notes are also published on each [GitHub Release](https://github.com - `vendor` follows the `url()` references in the stylesheets it keeps, so a font or an image that only a stylesheet names is vendored alongside it instead of 404ing in the browser. References are read through the CSS tokenizer (`cssparser`), so a `url(` inside a comment or a string never counts as one. +- The dev server served a stale stylesheet after editing a partial: its cache was keyed on the entry's mtime alone. + A compiled stylesheet now revalidates every file it read. ## [0.7.0] - 2026-08-21 diff --git a/Cargo.lock b/Cargo.lock index 7b31b2c..3fe1102 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4347,20 +4347,6 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" -[[package]] -name = "tower-livereload" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df210bd982165dee3c20e31deed79a3585ee6b255c070d9c73ae015cc40d2f3" -dependencies = [ - "bytes", - "http", - "http-body", - "pin-project-lite", - "tokio", - "tower", -] - [[package]] name = "tower-service" version = "0.3.3" @@ -4703,6 +4689,7 @@ dependencies = [ "clap", "cssparser", "flate2", + "futures-core", "grass", "http-body-util", "ico", @@ -4733,7 +4720,6 @@ dependencies = [ "tera", "tokio", "tower", - "tower-livereload", "tracing", "url", "walkdir", diff --git a/Cargo.toml b/Cargo.toml index bd9afa2..9c57687 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ edition = "2021" rust-version = "1.95" # Leading slashes anchor to the crate root — bare patterns (gitignore semantics) match at any # depth and would pull in stray files like examples/tauri/README.md. -include = ["/src/**/*.rs", "/Cargo.toml", "/README.md", "/LICENSE"] +include = ["/src/**/*.rs", "/src/serve/live.js", "/Cargo.toml", "/README.md", "/LICENSE"] publish = true # docs.rs: document the full lean toolchain so the whole API renders. The heavy, opt-in @@ -49,9 +49,11 @@ compress = ["dep:flate2"] # Embedded serving pulls no compiler, so release binaries stay lean. (Other web # servers can be added as parallel features later.) axum = ["dep:axum", "dep:tokio", "dep:mime_guess", "dep:include_dir"] -# Dev server on top of `axum`: compile TS/SCSS on the fly, watch the source tree, -# live-reload the browser. -dev = ["axum", "typescript", "scss", "dep:notify", "dep:tower-livereload"] +# Dev server on top of `axum`: compile TS/SCSS on the fly, watch the source tree, and +# stream the changes to the browser (stylesheets hot-swap, the rest reloads the page). +# `futures-core` is the `Stream` trait axum's SSE response takes: the trait crate alone, +# already compiled in every axum build. +dev = ["axum", "typescript", "scss", "dep:notify", "dep:futures-core"] # The `web-modules` CLI binary (dev / build / vendor / ci / npm). `npm-utils/cli` powers the # `npm` passthrough; it stays behind this opt-in feature so the library path never pulls clap. # `tera` is pulled in so a plain `--features cli` build renders `.tera` in both `dev` and `build` @@ -141,10 +143,10 @@ flate2 = { version = "1", optional = true } # Axum server integration (embedded serving + dev server) + CLI. Optional; off the # build-only path. axum = { version = "0.8", optional = true } -tokio = { version = "1", features = ["rt-multi-thread", "macros", "net"], optional = true } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync"], optional = true } include_dir = { version = "0.7", optional = true } notify = { version = "8", optional = true } -tower-livereload = { version = "0.10", optional = true } +futures-core = { version = "0.3", default-features = false, optional = true } mime_guess = { version = "2", optional = true } clap = { version = "4", features = ["derive"], optional = true } @@ -169,7 +171,7 @@ tempfile = "3" # axum, dev-only, and not part of the published crate's dependencies. tower = { version = "0.5", features = ["util"] } http-body-util = "0.1" -tokio = { version = "1", features = ["rt", "macros"] } +tokio = { version = "1", features = ["rt", "macros", "time"] } # Bake an embedded (`include_dir!`) root in the traversal tests. Needed directly (not via the # crate's re-export) because the `include_dir!` macro emits `include_dir::`-qualified paths. include_dir = "0.7" diff --git a/README.md b/README.md index 66366d8..a3601e1 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,16 @@ Dev::new().root("web").serve("127.0.0.1:8080".parse()?).await?; Both layer over the lower-level `build(&BuildOptions { … })` / `dev::serve_with`, still public for fine-grained use. For the full `build.rs` / runtime API see the **[API docs][docs.rs]**. +### Live reload + +The dev server watches every source root and streams what changed to the browser over SSE (`/_web_modules/live/events`); a small client (`/_web_modules/live/live.js`, injected before `` of every served page) hot-swaps a changed stylesheet's `` in place and reloads the page for anything else, since ES modules cannot be hot-replaced. +Stylesheets compiled by the server record the partials they read, so an edit to `_vars.scss` names exactly the stylesheets that include it (and recompiles them: the mtime cache revalidates every dependency, not just the entry). +The policy for non-stylesheet changes is the server's: `--live-reload full` (the default) reloads, `--live-reload css` only logs, `--no-live-reload` serves without the watcher, stream and client; `Dev::live_reload(ReloadMode)` is the builder form. +The stream carries change kinds and served URLs, never filesystem paths. + +Hosts that compile or watch on their own plug into the same hub: `LiveReload::watch(mounts)` (or `::new` without a watcher), `record_dependencies(url, paths)` after each compile, `notify(path)` from their own watcher, `router()` / `events_router()` to mount the stream, `script_tag()` / `meta_tag()` for pages they render themselves (the client reads its endpoint from its own `src`, else from ``, so it also works when `import()`ed after a login). +The client dispatches `web-modules:css-reloaded` on the document after each swap, for code that mirrors document stylesheets elsewhere (constructable sheets adopted by shadow roots, say). + ## GitHub Actions A composite action builds a deployable `dist/` (vendor + transform + render, with the import map injected) — **no Node on the runner**. It downloads a prebuilt `web-modules` binary for the runner's OS/arch (Linux x86_64/arm64, macOS arm64/x86_64, Windows x86_64/arm64), or compiles from this action's source with `from-source: true`. Pin `@v0` to track the latest 0.x, or an exact `@v0.3.1` — which fetches the matching binary (reproducible); the `version` input overrides this. With `build: "false"` the action installs the verified binary onto `PATH` and stops — for jobs whose own scripts drive `web-modules` (`build`, `vendor`, `npm audit`). Publishing stays composed with the official actions. diff --git a/SECURITY.md b/SECURITY.md index bd122b0..d8e9938 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -8,6 +8,7 @@ The design treats that content as hostile and keeps it away from anything it sho - **Dev server** rejects path traversal on two independent layers — a lexical check on the request path and a containment check on the resolved filesystem path, which also defeats a symlink pointing outside a source root. A reject list (config manifests, dotfiles, source extensions, keys / certificates / database dumps) is checked on the request string and re-checked on the resolved file name, so case folding or a trailing dot cannot serve a rejected file under an allowed name. Compile failures answer with a generic 500 body; the detail, which can embed local paths, goes to the developer's console only. + The live-reload stream (`/_web_modules/live/events`) names what changed as a kind and a served URL, never as a filesystem path; an edit the server cannot attribute to a served URL arrives as a bare "reload". - **Symlink policy** is explicit (`--symlinks`): the default `follow` confines a link to its own source root (the build fails on an escape, serving 404s), `redirect`/`move` answer with the link's content as a sanitized `Location` without ever opening the target, and `follow-unsafe` follows anywhere — an opt-in escape hatch, never the default. - **Source files are never served raw**: `.ts`/`.tsx`/`.mts`, `.scss`, and `.tera` are reachable only through their compiled targets, matched case-insensitively on the resolved path. - **CLI config is contained**: path fields of a `package.json` `web_modules` block (`roots`, `out`, `template`, `scss.loadPaths`) must be purely relative and, when they exist on disk, canonically resolve inside the project — an untrusted repository cannot steer `dev`/`build` into serving, reading, or writing outside itself. diff --git a/src/bin/web-modules.rs b/src/bin/web-modules.rs index b495816..691af97 100644 --- a/src/bin/web-modules.rs +++ b/src/bin/web-modules.rs @@ -42,6 +42,20 @@ enum Command { /// Address to bind (default 127.0.0.1:8080). #[arg(long)] addr: Option, + /// What the browser does with a non-stylesheet change (stylesheets always hot-swap): + /// `full` (the default) reloads the page, `css` only logs it. A bare `--live-reload` + /// means `css`. + #[arg( + long, + value_name = "MODE", + num_args = 0..=1, + default_missing_value = "css", + conflicts_with = "no_live_reload" + )] + live_reload: Option, + /// Serve the sources without the live-reload stream, client and file watcher. + #[arg(long)] + no_live_reload: bool, #[command(flatten)] compiler: CompilerConfig, }, @@ -658,6 +672,8 @@ async fn main() -> Res { Command::Dev { roots, addr, + live_reload, + no_live_reload, compiler, } => { // Config from a `web_modules` block in ./package.json (flags only — dev never vendors). @@ -667,7 +683,12 @@ async fn main() -> Res { let roots = roots_or_cwd(pick_vec(roots, cfg.roots)); let addr = addr.unwrap_or_else(|| "127.0.0.1:8080".parse().expect("valid default addr")); - web_modules::dev::serve_with(roots, addr, config).await?; + let live = if no_live_reload { + web_modules::ReloadMode::Off + } else { + live_reload.unwrap_or_default() + }; + web_modules::dev::serve_with_live(roots, addr, config, live).await?; } Command::Build { roots, diff --git a/src/lib.rs b/src/lib.rs index 178a159..d1e36e9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -116,6 +116,10 @@ mod serve; #[cfg(feature = "dev")] pub use serve::dev; +#[cfg(feature = "dev")] +pub use serve::live; +#[cfg(feature = "dev")] +pub use serve::live::{LiveReload, ReloadMode}; /// The fluent dev-server builder (feature `builder`), at the crate root alongside [`Frontend`]. #[cfg(all(feature = "builder", feature = "dev"))] diff --git a/src/processors/scss.rs b/src/processors/scss.rs index 72270dc..d326516 100644 --- a/src/processors/scss.rs +++ b/src/processors/scss.rs @@ -45,6 +45,10 @@ struct SandboxFs { /// the difference between "the import is a typo" and "a load path is missing", kept as /// data instead of silence. refused: Mutex>, + /// Every file `grass` read through this sandbox (the entry and each partial it pulled + /// in), canonical, in read order: a compile's dependency list, see + /// [`compile_file_tracked`]. + reads: Mutex>, } impl SandboxFs { @@ -54,9 +58,17 @@ impl SandboxFs { Self { roots: roots.iter().filter_map(|p| p.canonicalize().ok()).collect(), refused: Mutex::new(Vec::new()), + reads: Mutex::new(Vec::new()), } } + /// The files read so far, each once, in first-read order. + fn take_reads(&self) -> Vec { + let mut reads = self.reads.lock().expect("sandbox read log poisoned"); + let mut seen = std::collections::HashSet::new(); + reads.drain(..).filter(|p| seen.insert(p.clone())).collect() + } + /// The real location of `path` if it resolves inside an allowed root, else `None`. A path that /// does not resolve — a probe for a candidate that isn't on disk — is not contained, matching /// how a missing file reads on the default [`grass::StdFs`]. A path that resolves but sits @@ -122,7 +134,13 @@ impl Fs for SandboxFs { fn read(&self, path: &Path) -> io::Result> { match self.contained(path) { - Some(real) => std::fs::read(real), + Some(real) => { + self.reads + .lock() + .expect("sandbox read log poisoned") + .push(real.clone()); + std::fs::read(real) + } None => Err(io::Error::new( io::ErrorKind::NotFound, format!("SCSS import {path:?} escapes the source roots"), @@ -164,11 +182,21 @@ pub fn compile_str(input: &str, load_paths: &[&Path]) -> Result { /// Compile a single `.scss` file to CSS. Imports resolve within `load_paths` and the file's own /// directory, and cannot escape them. pub fn compile_file(path: &Path, load_paths: &[&Path]) -> Result { + compile_file_tracked(path, load_paths).map(|(css, _)| css) +} + +/// [`compile_file`], also returning every file the compile read: the entry and each +/// `@use`/`@import`ed partial, canonical, in first-read order. A cache keyed on the entry's +/// mtime alone serves stale CSS after a partial edit; this list is what such a cache (the +/// dev server's) revalidates, and what maps a partial back to the stylesheets it belongs to. +pub fn compile_file_tracked(path: &Path, load_paths: &[&Path]) -> Result<(String, Vec)> { let entry = entry_dir(path); let mut roots = load_paths.to_vec(); roots.push(entry.as_path()); let sandbox = SandboxFs::new(&roots); - grass::from_path(path, &options(&sandbox, load_paths)).map_err(|e| scss_error(&sandbox, e)) + let css = grass::from_path(path, &options(&sandbox, load_paths)) + .map_err(|e| scss_error(&sandbox, e))?; + Ok((css, sandbox.take_reads())) } /// Compile every `.scss` under `src_dir` (skipping `_` partials) into a mirrored @@ -324,6 +352,30 @@ mod tests { assert!(!out.join("linked.css").exists()); } + #[test] + fn tracked_compile_lists_the_entry_and_its_partials() { + let tmp = tempfile::tempdir().unwrap(); + let src = tmp.path().join("src"); + let vendor = tmp.path().join("vendor"); + create_dir_all(&src).unwrap(); + create_dir_all(&vendor).unwrap(); + write(vendor.join("_theme.scss"), "$t: green;").unwrap(); + write(src.join("_vars.scss"), "@use 'theme'; $c: theme.$t;").unwrap(); + write(src.join("app.scss"), "@use 'vars'; a { color: vars.$c; }").unwrap(); + let (css, deps) = compile_file_tracked(&src.join("app.scss"), &[vendor.as_path()]).unwrap(); + assert!(css.contains("color:green")); + let names: Vec<_> = deps + .iter() + .map(|p| p.file_name().unwrap().to_str().unwrap().to_string()) + .collect(); + assert_eq!( + names, + ["app.scss", "_vars.scss", "_theme.scss"], + "entry first, then imports, each once" + ); + assert!(deps.iter().all(|p| p.is_absolute()), "canonical paths"); + } + #[test] fn import_within_the_tree_still_resolves() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src/serve/dev.rs b/src/serve/dev.rs index af69444..5a568d3 100644 --- a/src/serve/dev.rs +++ b/src/serve/dev.rs @@ -1,6 +1,8 @@ //! A buildless **dev server**: serve a frontend straight from source, compiling -//! TypeScript and SCSS on the fly per request (mtime-cached) and live-reloading the -//! browser when files change. +//! TypeScript and SCSS on the fly per request (mtime-cached, with a compiled +//! stylesheet revalidated against every partial it read) and telling the browser what +//! changed: stylesheets hot-swap in place, everything else reloads the page (see +//! [`live`](super::live)). //! //! Sources are [`Mount`]s, each a URL prefix + a source dir; the default is one at //! `/`. Resolution is **dir-observation-order-dominant**: most-specific prefix first, @@ -13,7 +15,7 @@ //! fallback (a baked `include_dir!` tree) supplies whatever the source dirs don't: //! vendored `web_modules/`, a baked `index.html` — and its baked `importmap.json` is //! the import map live `.tera` renders receive. The watcher watches every source dir -//! identically and reloads on any change. +//! identically; the reload policy is [`ReloadMode`]. //! //! Enable the `dev` feature. @@ -30,8 +32,8 @@ use axum::{ Router, }; use include_dir::Dir; -use tower_livereload::LiveReloadLayer; +use super::live::{self, LiveReload, ReloadMode}; use super::serving::{ content_type, has_source_extension, has_traversal, is_source_file, relative_under, resolve_file, Resolved, @@ -41,7 +43,17 @@ use crate::build::Processors; use crate::builder_shared::source_builder_methods; use crate::mount::Mount; -type Cache = Mutex)>>; +/// A compiled target, valid while the entry file and every dependency keep their mtimes. +struct CacheEntry { + mtime: SystemTime, + /// The partials a stylesheet pulled in (empty for TS/Tera) with their mtimes at compile + /// time; the cache was keyed on the entry alone once, and an edited partial served + /// stale CSS until the entry itself was touched. + deps: Vec<(PathBuf, SystemTime)>, + bytes: Vec, +} + +type Cache = Mutex>; /// Which processors the dev server applies — **unified with the build pipeline's /// [`Processors`](crate::build::Processors)**, so `dev` and `build` configure the same set @@ -52,7 +64,8 @@ type Cache = Mutex)>>; pub type DevConfig = Processors; /// Fluent builder for the dev server: compile TS/SCSS on the fly, render `*.tera`, watch -/// the source roots and live-reload the browser. +/// the source roots and live-reload the browser (stylesheets hot-swap; the policy for the +/// rest is [`live_reload`](Self::live_reload)). /// /// ```no_run /// use web_modules::Dev; @@ -71,6 +84,7 @@ pub type DevConfig = Processors; pub struct Dev { roots: Vec, processors: Processors, + live_reload: ReloadMode, } #[cfg(feature = "builder")] @@ -83,16 +97,24 @@ impl Dev { Self::default() } + /// What the browser does with non-stylesheet changes: [`ReloadMode::Full`] (the + /// default) reloads the page, [`ReloadMode::Css`] only hot-swaps stylesheets and logs + /// the rest, [`ReloadMode::Off`] serves no live routes at all. + pub fn live_reload(mut self, mode: ReloadMode) -> Self { + self.live_reload = mode; + self + } + /// The dev [`Router`] (compile-on-the-fly, watch, live-reload) over the roots, each /// mounted at `/` and resolved first-match-wins. Compose it into your own axum app, or /// use [`serve`](Self::serve) to bind and run. pub fn router(self) -> Router { - dev_router_with(self.roots, self.processors) + dev_router_with_live(self.roots, self.processors, self.live_reload) } /// Bind `addr` and serve [`router`](Self::router) until the process stops. pub async fn serve(self, addr: SocketAddr) -> std::io::Result<()> { - serve_with(self.roots, addr, self.processors).await + serve_with_live(self.roots, addr, self.processors, self.live_reload).await } } @@ -108,6 +130,9 @@ struct DevState { importmap: Arc, /// Which processors to apply (and how), shared with the bin's `--` toggles. config: Arc, + /// The change hub: compiled stylesheets register their partials here, and the watcher + /// publishes what an edit means for the browser. + live: LiveReload, } enum Kind { @@ -125,9 +150,20 @@ pub fn dev_router(roots: Vec) -> Router { } /// Like [`dev_router`], but with an explicit [`DevConfig`] (which processors run, and -/// how) — the toggle-aware entry the `web-modules dev` command uses. +/// how). pub fn dev_router_with(roots: Vec, config: DevConfig) -> Router { - build_router(roots.into_iter().map(Mount::root).collect(), None, config) + dev_router_with_live(roots, config, ReloadMode::default()) +} + +/// Like [`dev_router_with`], choosing the live-reload policy as well; the entry the +/// `web-modules dev` command uses (`--live-reload [css|full]`, `--no-live-reload`). +pub fn dev_router_with_live(roots: Vec, config: DevConfig, live: ReloadMode) -> Router { + build_router_live( + roots.into_iter().map(Mount::root).collect(), + None, + config, + live, + ) } /// Like [`dev_router`], but unmatched requests fall back to a baked `include_dir!` @@ -159,6 +195,18 @@ pub(crate) fn build_router( mounts: Vec, fallback: Option<&'static Dir<'static>>, config: DevConfig, +) -> Router { + build_router_live(mounts, fallback, config, ReloadMode::default()) +} + +/// [`build_router`] with the live-reload policy: `Off` serves the sources alone; otherwise +/// the watcher runs, `/_web_modules/live/{events,live.js}` are mounted and every HTML +/// response gets the client injected before ``. +pub(crate) fn build_router_live( + mounts: Vec, + fallback: Option<&'static Dir<'static>>, + config: DevConfig, + mode: ReloadMode, ) -> Router { // The same preflight the build runs, warn-only: a contested target is served by // its winner and an escaping symlink is refused per-request, but both are worth @@ -166,8 +214,12 @@ pub(crate) fn build_router( for warning in preflight_warnings(&mounts, &config) { eprintln!("{warning}"); } - let livereload = LiveReloadLayer::new(); - spawn_watcher(mounts.clone(), livereload.reloader()); + let live = if mode == ReloadMode::Off { + LiveReload::new(&mounts) + } else { + LiveReload::watch(mounts.clone()) + } + .with_mode(mode); let state = DevState { mounts: Arc::new(mounts), cache: Arc::new(Mutex::new(HashMap::new())), @@ -175,11 +227,16 @@ pub(crate) fn build_router( importmap: Arc::new(fallback_importmap(fallback)), fallback, config: Arc::new(config), + live: live.clone(), }; - Router::new() - .fallback(serve_asset) - .with_state(state) - .layer(livereload) + let router = Router::new().fallback(serve_asset).with_state(state); + if mode == ReloadMode::Off { + return router; + } + live::inject_script( + router.nest(live::DEFAULT_PREFIX, live.router()), + live::DEFAULT_PREFIX, + ) } /// The import map live `.tera` renders receive: the embedded fallback's baked @@ -298,7 +355,17 @@ pub async fn serve_with( addr: SocketAddr, config: DevConfig, ) -> std::io::Result<()> { - let app = dev_router_with(roots, config); + serve_with_live(roots, addr, config, ReloadMode::default()).await +} + +/// Like [`serve_with`], choosing the live-reload policy as well. +pub async fn serve_with_live( + roots: Vec, + addr: SocketAddr, + config: DevConfig, + live: ReloadMode, +) -> std::io::Result<()> { + let app = dev_router_with_live(roots, config, live); let listener = tokio::net::TcpListener::bind(addr).await?; println!("web-modules dev server on http://{addr}/ (Ctrl-C to stop)"); axum::serve(listener, app).await @@ -423,7 +490,7 @@ fn resolve(state: &DevState, requested: &str) -> Result, String> .and_then(|n| n.to_str()) .is_some_and(|n| n.starts_with('_')); if !is_partial { - let body = compile_cached(state, &src, Kind::Tera)?; + let body = compile_cached(state, &src, Kind::Tera, requested)?; return Ok(Some(Served::Bytes { body, content_type: content_type(&rel), @@ -473,7 +540,7 @@ fn resolve(state: &DevState, requested: &str) -> Result, String> if let Some(stem) = rel.strip_suffix(".js") { for ext in ["ts", "tsx", "mts"] { if let Some(src) = source_candidate(mount, &format!("{stem}.{ext}"), mode) { - let body = compile_cached(state, &src, Kind::Ts)?; + let body = compile_cached(state, &src, Kind::Ts, requested)?; return Ok(Some(Served::Bytes { body, content_type: "text/javascript; charset=utf-8".into(), @@ -486,7 +553,7 @@ fn resolve(state: &DevState, requested: &str) -> Result, String> if state.config.scss { if let Some(stem) = rel.strip_suffix(".css") { if let Some(src) = source_candidate(mount, &format!("{stem}.scss"), mode) { - let body = compile_cached(state, &src, Kind::Scss)?; + let body = compile_cached(state, &src, Kind::Scss, requested)?; return Ok(Some(Served::Bytes { body, content_type: "text/css; charset=utf-8".into(), @@ -510,24 +577,38 @@ fn resolve(state: &DevState, requested: &str) -> Result, String> Ok(None) } -/// Compile `src` (TS, SCSS, or Tera), caching by modification time. SCSS `@use`/`@import` -/// load paths span every mounted dir (plus any `extra_scss_load_paths`); Tera renders -/// with the `importmap` variable from the embedded fallback's baked map (empty -/// without one — a pure source tree vendors nothing). -fn compile_cached(state: &DevState, src: &Path, kind: Kind) -> Result, String> { - let mtime = std::fs::metadata(src) - .and_then(|m| m.modified()) - .map_err(|e| e.to_string())?; - if let Some((cached_mtime, bytes)) = state +/// Compile `src` (TS, SCSS, or Tera), caching by modification time: the entry's, and +/// for a stylesheet every partial's it read, so an edited partial recompiles the +/// stylesheets that include it. SCSS `@use`/`@import` load paths span every mounted dir +/// (plus any `extra_scss_load_paths`); Tera renders with the `importmap` variable from +/// the embedded fallback's baked map (empty without one; a pure source tree vendors +/// nothing). `requested` is the served path (no leading slash): a stylesheet's +/// dependencies are registered under its URL with the live-reload hub. +fn compile_cached( + state: &DevState, + src: &Path, + kind: Kind, + requested: &str, +) -> Result, String> { + let mtime = modified(src).map_err(|e| e.to_string())?; + // Copy the entry out and validate its dependencies with the lock released: the stats + // are the slow part, and another request may be compiling meanwhile. + let cached = state .cache .lock() .unwrap_or_else(|e| e.into_inner()) .get(src) - { - if *cached_mtime == mtime { - return Ok(bytes.clone()); + .filter(|entry| entry.mtime == mtime) + .map(|entry| (entry.deps.clone(), entry.bytes.clone())); + if let Some((deps, bytes)) = cached { + if deps + .iter() + .all(|(dep, seen)| modified(dep).ok() == Some(*seen)) + { + return Ok(bytes); } } + let mut deps: Vec<(PathBuf, SystemTime)> = Vec::new(); let out = match kind { Kind::Ts => { let source = std::fs::read_to_string(src).map_err(|e| e.to_string())?; @@ -551,9 +632,16 @@ fn compile_cached(state: &DevState, src: &Path, kind: Kind) -> Result, S .iter() .map(PathBuf::as_path), ); - crate::scss::compile_file(src, &load_paths) - .map_err(|e| e.to_string())? - .into_bytes() + let (css, read) = + crate::scss::compile_file_tracked(src, &load_paths).map_err(|e| e.to_string())?; + deps = read + .iter() + .filter_map(|dep| modified(dep).ok().map(|seen| (dep.clone(), seen))) + .collect(); + state + .live + .record_dependencies(&format!("/{requested}"), read); + css.into_bytes() } #[cfg(feature = "tera")] Kind::Tera => { @@ -572,39 +660,19 @@ fn compile_cached(state: &DevState, src: &Path, kind: Kind) -> Result, S .cache .lock() .unwrap_or_else(|e| e.into_inner()) - .insert(src.to_path_buf(), (mtime, out.clone())); + .insert( + src.to_path_buf(), + CacheEntry { + mtime, + deps, + bytes: out.clone(), + }, + ); Ok(out) } -/// Watch each watched mount's dir and trigger a browser reload on any change. -fn spawn_watcher(mounts: Vec, reloader: tower_livereload::Reloader) { - std::thread::spawn(move || { - use notify::{RecursiveMode, Watcher}; - let mut watcher = - match notify::recommended_watcher(move |res: notify::Result| { - if let Ok(event) = res { - if event.kind.is_modify() || event.kind.is_create() || event.kind.is_remove() { - reloader.reload(); - } - } - }) { - Ok(w) => w, - Err(e) => { - eprintln!("web-modules: file watcher unavailable ({e}); live-reload off"); - return; - } - }; - for mount in &mounts { - if mount.is_watched() { - if let Err(e) = watcher.watch(mount.dir(), RecursiveMode::Recursive) { - eprintln!("web-modules: cannot watch {}: {e}", mount.dir().display()); - } - } - } - loop { - std::thread::park(); - } - }); +fn modified(path: &Path) -> std::io::Result { + std::fs::metadata(path).and_then(|m| m.modified()) } #[cfg(test)] @@ -629,6 +697,7 @@ mod tests { } fn state_with(mounts: Vec, config: DevConfig) -> DevState { + let live = LiveReload::new(&mounts); DevState { mounts: Arc::new(mounts), cache: Arc::new(Mutex::new(HashMap::new())), @@ -636,9 +705,46 @@ mod tests { #[cfg(feature = "tera")] importmap: Arc::new(crate::importmap::Importmap::new()), config: Arc::new(config), + live, } } + #[test] + fn an_edited_partial_recompiles_the_stylesheet_that_includes_it() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("web"); + std::fs::create_dir_all(&root).unwrap(); + let t0 = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000); + let t1 = t0 + std::time::Duration::from_secs(60); + let stamp = |path: &Path, content: &str, at: SystemTime| { + std::fs::write(path, content).unwrap(); + std::fs::File::options() + .write(true) + .open(path) + .unwrap() + .set_modified(at) + .unwrap(); + }; + stamp(&root.join("_vars.scss"), "$c: blue;", t0); + stamp( + &root.join("app.scss"), + "@use 'vars'; a { color: vars.$c; }", + t0, + ); + let state = state(vec![Mount::root(root.clone())]); + let (bytes, _) = resolve(&state, "app.css").unwrap().unwrap().bytes(); + assert!(String::from_utf8(bytes).unwrap().contains("blue")); + + // Only the partial changes; the entry keeps its mtime. + stamp(&root.join("_vars.scss"), "$c: red;", t1); + let (bytes, _) = resolve(&state, "app.css").unwrap().unwrap().bytes(); + let css = String::from_utf8(bytes).unwrap(); + assert!( + css.contains("red"), + "recompiled after the partial edit: {css}" + ); + } + #[test] fn dev_rejects_config_and_dotfiles() { // The default (all-presets) reject list 404s config / secret / dotfile paths even though diff --git a/src/serve/live.js b/src/serve/live.js new file mode 100644 index 0000000..d321afa --- /dev/null +++ b/src/serve/live.js @@ -0,0 +1,105 @@ +/*! web_modules live reload client. Served by the dev server at /live.js. + * + * Works as a classic ``, for hosts rendering their own page. +pub fn script_tag(prefix: &str) -> String { + format!( + "", + prefix.trim_end_matches('/') + ) +} + +/// ``: tells a client loaded via +/// `import()` where the endpoint lives. +pub fn meta_tag(prefix: &str) -> String { + format!( + "", + prefix.trim_end_matches('/') + ) +} + +/// The client alone, stateless: `GET /live.js`. +pub fn script_router() -> Router { + Router::new().route("/live.js", get(client_script)) +} + +/// Inject [`script_tag`] before `` of every `text/html` response the router +/// produces (uncompressed ones; a `Content-Encoding` is left alone). +pub fn inject_script(router: Router, prefix: &str) -> Router { + let tag: Arc = Arc::from(script_tag(prefix)); + router.layer(axum::middleware::from_fn_with_state(tag, inject_tag)) +} + +async fn inject_tag(State(tag): State>, request: Request, next: Next) -> Response { + let response = next.run(request).await; + let is_html = response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .is_some_and(|ct| ct.starts_with("text/html")); + if !is_html || response.headers().contains_key(header::CONTENT_ENCODING) { + return response; + } + let (mut parts, body) = response.into_parts(); + let bytes = match axum::body::to_bytes(body, usize::MAX).await { + Ok(bytes) => bytes, + Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }; + parts.headers.remove(header::CONTENT_LENGTH); + let mut html = match String::from_utf8(bytes.to_vec()) { + Ok(html) => html, + // Not text after all: pass the bytes through untouched. + Err(e) => return Response::from_parts(parts, Body::from(e.into_bytes())), + }; + match html.to_ascii_lowercase().rfind("") { + Some(at) => html.insert_str(at, &tag), + None => html.push_str(&tag), + } + Response::from_parts(parts, Body::from(html)) +} + +async fn client_script() -> impl IntoResponse { + ( + [ + (header::CONTENT_TYPE, "text/javascript; charset=utf-8"), + (header::CACHE_CONTROL, "no-cache"), + ], + CLIENT_SCRIPT, + ) +} + +async fn events(State(live): State) -> impl IntoResponse { + let hello = serde_json::json!({ + "session": live.session(), + "reload": live.reload_mode(), + }); + let stream = ChangeStream { + hello: Some( + Event::default() + .event("hello") + .data(hello.to_string()) + .retry(Duration::from_secs(1)), + ), + rx: live.subscribe(), + }; + Sse::new(stream).keep_alive(KeepAlive::default()) +} + +/// The `hello` frame, then one `change` frame per published [`Change`]. +struct ChangeStream { + hello: Option, + rx: mpsc::UnboundedReceiver, +} + +impl Stream for ChangeStream { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + if let Some(hello) = self.hello.take() { + return Poll::Ready(Some(Ok(hello))); + } + match self.rx.poll_recv(cx) { + Poll::Ready(Some(change)) => { + let data = serde_json::to_string(&change).unwrap_or_else(|_| "{}".into()); + Poll::Ready(Some(Ok(Event::default().event("change").data(data)))) + } + Poll::Ready(None) => Poll::Ready(None), + Poll::Pending => Poll::Pending, + } + } +} + +/// Watch the mounts on a thread of their own; every coalesced batch of filesystem events +/// becomes a set of published changes. +fn spawn_watcher(live: LiveReload, mounts: Vec) { + std::thread::spawn(move || { + use notify::{RecursiveMode, Watcher}; + let (tx, rx) = std::sync::mpsc::channel::(); + let mut watcher = + match notify::recommended_watcher(move |res: notify::Result| { + if let Ok(event) = res { + let _ = tx.send(event); + } + }) { + Ok(watcher) => watcher, + Err(e) => { + eprintln!("web-modules: file watcher unavailable ({e}); live reload off"); + return; + } + }; + for mount in &mounts { + if mount.is_watched() { + if let Err(e) = watcher.watch(mount.dir(), RecursiveMode::Recursive) { + eprintln!("web-modules: cannot watch {}: {e}", mount.dir().display()); + } + } + } + while let Ok(first) = rx.recv() { + let started = Instant::now(); + let mut batch = BTreeSet::new(); + collect_paths(&first, &mut batch); + loop { + let elapsed = started.elapsed(); + if elapsed >= COALESCE_MAX { + break; + } + match rx.recv_timeout(COALESCE_QUIET.min(COALESCE_MAX - elapsed)) { + Ok(event) => collect_paths(&event, &mut batch), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => break, + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return, + } + } + live.notify_all(batch); + } + }); +} + +/// The paths of an event worth reacting to: creations, removals, renames and content or +/// write-time changes (`touch`, `File::set_modified`). Access and other metadata events are noise. +fn collect_paths(event: ¬ify::Event, batch: &mut BTreeSet) { + use notify::event::{MetadataKind, ModifyKind}; + use notify::EventKind; + let relevant = match &event.kind { + EventKind::Create(_) | EventKind::Remove(_) | EventKind::Any => true, + EventKind::Modify(kind) => matches!( + kind, + ModifyKind::Data(_) + | ModifyKind::Any + | ModifyKind::Name(_) + | ModifyKind::Metadata(MetadataKind::WriteTime | MetadataKind::Any) + ), + EventKind::Access(_) | EventKind::Other => false, + }; + if relevant { + batch.extend(event.paths.iter().cloned()); + } +} + +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(|e| e.into_inner()) +} + +fn canonical_dir(dir: &Path) -> Option { + dir.canonicalize().ok() +} + +/// The canonical form of a path that may no longer exist (a removed file): its parent's +/// canonical form plus the file name. +fn canonical_path(path: &Path) -> PathBuf { + if let Ok(real) = path.canonicalize() { + return real; + } + match (path.parent(), path.file_name()) { + (Some(parent), Some(name)) => parent + .canonicalize() + .map(|p| p.join(name)) + .unwrap_or_else(|_| path.to_path_buf()), + _ => path.to_path_buf(), + } +} + +fn extension(name: &str) -> String { + Path::new(name) + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_ascii_lowercase()) + .unwrap_or_default() +} + +fn swap_extension(url: &str, ext: &str) -> String { + match url.rfind('.') { + Some(dot) if !url[dot..].contains('/') => format!("{}.{ext}", &url[..dot]), + _ => format!("{url}.{ext}"), + } +} + +/// Editors and tools leave transient files next to the sources: vim's swap files and its +/// `4913` write probe, emacs' `#name#` autosaves, `name~` backups, partial downloads. +fn is_editor_scratch(name: &str) -> bool { + name.starts_with('.') + || name.ends_with('~') + || (name.starts_with('#') && name.ends_with('#')) + || (!name.is_empty() && name.bytes().all(|b| b.is_ascii_digit())) + || matches!( + extension(name).as_str(), + "swp" | "swx" | "swo" | "tmp" | "part" | "orig" | "bak" + ) +} + +fn session_id() -> String { + let mut hasher = std::collections::hash_map::RandomState::new().build_hasher(); + hasher.write_u128( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0), + ); + format!("{:016x}", hasher.finish()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tree() -> (tempfile::TempDir, LiveReload) { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join("web")).unwrap(); + std::fs::create_dir_all(tmp.path().join("modules/x")).unwrap(); + let live = LiveReload::new(&[ + Mount::root(tmp.path().join("web")), + Mount::new("modules/x", tmp.path().join("modules/x")), + ]); + (tmp, live) + } + + fn one(live: &LiveReload, path: PathBuf) -> Vec { + live.changes_for([path]) + } + + #[test] + fn stylesheets_and_scripts_map_to_their_served_urls() { + let (tmp, live) = tree(); + assert_eq!( + one(&live, tmp.path().join("web/app.scss")), + vec![Change::css("/app.css")] + ); + assert_eq!( + one(&live, tmp.path().join("web/lib/util.ts")), + vec![Change::new(ChangeKind::Js, Some("/lib/util.js".into()))] + ); + assert_eq!( + one(&live, tmp.path().join("modules/x/list.tsx")), + vec![Change::new( + ChangeKind::Js, + Some("/modules/x/list.js".into()) + )] + ); + assert_eq!( + one(&live, tmp.path().join("web/vendor.css")), + vec![Change::css("/vendor.css")] + ); + } + + #[test] + fn a_partial_names_its_dependents_or_every_stylesheet() { + let (tmp, live) = tree(); + let partial = tmp.path().join("web/_vars.scss"); + assert_eq!(one(&live, partial.clone()), vec![Change::css_all()]); + live.record_dependencies( + "/app.css", + [partial.clone(), tmp.path().join("web/app.scss")], + ); + live.record_dependencies("/modules/x/style.css", [partial.clone()]); + assert_eq!( + one(&live, partial), + vec![Change::css("/app.css"), Change::css("/modules/x/style.css")] + ); + } + + #[test] + fn templates_take_the_kind_of_their_target() { + let (tmp, live) = tree(); + assert_eq!( + one(&live, tmp.path().join("web/index.html.tera")), + vec![Change::new(ChangeKind::Html, Some("/index.html".into()))] + ); + assert_eq!( + one(&live, tmp.path().join("web/config.js.tera")), + vec![Change::new(ChangeKind::Js, Some("/config.js".into()))] + ); + assert_eq!( + one(&live, tmp.path().join("web/robots.txt.tera")), + vec![Change::reload()] + ); + } + + #[test] + fn declarations_and_editor_scratch_files_are_ignored() { + let (tmp, live) = tree(); + assert!(one(&live, tmp.path().join("web/types.d.ts")).is_empty()); + for name in [ + ".app.scss.swp", + "4913", + "#app.ts#", + "app.ts~", + "app.scss.tmp", + ] { + assert!( + one(&live, tmp.path().join("web").join(name)).is_empty(), + "{name} is scratch" + ); + } + // An empty directory carries no change of its own… + assert!(one(&live, tmp.path().join("web")).is_empty()); + } + + #[test] + fn a_directory_stands_for_the_files_inside_it() { + // …a populated one means its files changed (a new folder's files arrive before the + // watcher covers the folder). + let (tmp, live) = tree(); + std::fs::create_dir_all(tmp.path().join("web/lib")).unwrap(); + std::fs::write(tmp.path().join("web/lib/a.ts"), "").unwrap(); + std::fs::write(tmp.path().join("web/lib/b.scss"), "").unwrap(); + let changes = one(&live, tmp.path().join("web/lib")); + assert!( + changes.contains(&Change::new(ChangeKind::Js, Some("/lib/a.js".into()))), + "{changes:?}" + ); + assert!(changes.contains(&Change::css("/lib/b.css")), "{changes:?}"); + assert_eq!(changes.len(), 2); + } + + #[test] + fn unknown_files_and_paths_outside_every_mount_reload_without_a_name() { + let (tmp, live) = tree(); + assert_eq!( + one(&live, tmp.path().join("web/notes.md")), + vec![Change::reload()] + ); + assert_eq!( + one(&live, tmp.path().join("elsewhere.scss")), + vec![Change::reload()] + ); + } + + #[test] + fn a_batch_is_deduplicated() { + let (tmp, live) = tree(); + let changes = live.changes_for([ + tmp.path().join("web/app.scss"), + tmp.path().join("web/app.scss"), + tmp.path().join("web/a.ts"), + tmp.path().join("web/b.ts"), + tmp.path().join("web/notes.md"), + tmp.path().join("web/other.md"), + ]); + assert_eq!( + changes, + vec![ + Change::css("/app.css"), + Change::new(ChangeKind::Js, Some("/a.js".into())), + Change::new(ChangeKind::Js, Some("/b.js".into())), + Change::reload(), + ] + ); + } + + #[test] + fn tags_and_mode_serialise_as_the_client_expects() { + assert_eq!( + script_tag("/_web_modules/live/"), + "" + ); + assert_eq!( + meta_tag("/-/dev/live"), + "" + ); + assert_eq!(serde_json::to_string(&ReloadMode::Css).unwrap(), "\"css\""); + assert_eq!( + serde_json::to_string(&Change::css("/a.css")).unwrap(), + "{\"kind\":\"css\",\"url\":\"/a.css\"}" + ); + assert_eq!( + serde_json::to_string(&Change::reload()).unwrap(), + "{\"kind\":\"other\",\"url\":null}" + ); + } +} diff --git a/src/serve/mod.rs b/src/serve/mod.rs index adfb2c0..c16cf5f 100644 --- a/src/serve/mod.rs +++ b/src/serve/mod.rs @@ -1,8 +1,9 @@ //! Runtime serving (feature `axum`): a `Frontend` router that serves the built frontend //! from baked-in (embedded) assets, or (with `dev`) compiles TypeScript/SCSS on the fly -//! with file-watching and live-reload. Both routers share the private `serving` -//! containment boundary, so no request can resolve to a file outside a known root. -//! `server` and `dev` are re-exported at the crate root; `serving` stays private. +//! with file-watching and live reload (`live`: the change hub, its SSE stream and the +//! browser client). Both routers share the private `serving` containment boundary, so +//! no request can resolve to a file outside a known root. `server`, `dev` and `live` +//! are re-exported at the crate root; `serving` stays private. #[cfg(feature = "axum")] mod serving; @@ -17,3 +18,6 @@ pub mod server; #[cfg(feature = "dev")] pub mod dev; + +#[cfg(feature = "dev")] +pub mod live; diff --git a/src/serve/server.rs b/src/serve/server.rs index a854d55..a2c12df 100644 --- a/src/serve/server.rs +++ b/src/serve/server.rs @@ -55,7 +55,7 @@ enum Source { struct Root { prefix: String, source: Source, - /// Whether `live()` watches this root. Read only by the dev server, so it's dead in + /// Whether `dev()` watches this root. Read only by the dev server, so it's dead in /// a lean static build (`axum` without `dev`). #[cfg_attr(not(feature = "dev"), allow(dead_code))] watch: bool, diff --git a/tests/live.rs b/tests/live.rs new file mode 100644 index 0000000..fe25645 --- /dev/null +++ b/tests/live.rs @@ -0,0 +1,257 @@ +//! The live-reload surface of the dev server: the SSE stream and its frames, the watcher's +//! mapping from an edited file to a browser change, the dependency index, the injected +//! client, and what the stream never says (filesystem paths). +//! +//! Needs the `dev` feature; on under `--all-features`. +#![cfg(feature = "dev")] + +use std::path::Path; +use std::time::Duration; + +use axum::body::Body; +use axum::http::{header, Request, StatusCode}; +use axum::Router; +use http_body_util::BodyExt; +use tokio::time::timeout; +use tower::ServiceExt; +use web_modules::live::{Change, ChangeKind, LiveReload, ReloadMode, DEFAULT_PREFIX}; +use web_modules::{Frontend, Mount}; + +fn write(path: &Path, content: &str) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, content).unwrap(); +} + +async fn get(router: Router, uri: &str) -> axum::response::Response { + router + .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) + .await + .unwrap() +} + +/// The next data frame of an SSE body, as text. +async fn next_frame(body: &mut Body) -> String { + let frame = timeout(Duration::from_secs(5), body.frame()) + .await + .expect("a frame within 5s") + .expect("the stream is open") + .expect("no body error"); + String::from_utf8(frame.into_data().expect("a data frame").to_vec()).unwrap() +} + +/// The next published change, within 5 seconds. +async fn next_change(rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> Change { + timeout(Duration::from_secs(5), rx.recv()) + .await + .expect("a change within 5s") + .expect("the hub is alive") +} + +#[tokio::test] +async fn events_stream_says_hello_then_relays_published_changes() { + let tmp = tempfile::tempdir().unwrap(); + let live = LiveReload::new(&[Mount::root(tmp.path())]).with_mode(ReloadMode::Css); + let response = get(live.router(), "/events").await; + assert_eq!(response.status(), StatusCode::OK); + let header = |name: header::HeaderName| { + response + .headers() + .get(name) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string() + }; + assert!(header(header::CONTENT_TYPE).starts_with("text/event-stream")); + assert_eq!(header(header::CACHE_CONTROL), "no-cache"); + + let mut body = response.into_body(); + let hello = next_frame(&mut body).await; + assert!(hello.contains("event: hello"), "{hello}"); + assert!( + hello.contains(&format!("\"session\":\"{}\"", live.session())), + "{hello}" + ); + assert!(hello.contains("\"reload\":\"css\""), "{hello}"); + assert!(hello.contains("retry: 1000"), "{hello}"); + + live.publish(Change::css("/app.css")); + let change = next_frame(&mut body).await; + assert!(change.contains("event: change"), "{change}"); + assert!( + change.contains("data: {\"kind\":\"css\",\"url\":\"/app.css\"}"), + "{change}" + ); +} + +#[tokio::test] +async fn the_watcher_maps_edits_to_browser_changes() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("web"); + std::fs::create_dir_all(&root).unwrap(); + write(&root.join("app.scss"), "a { color: red }"); + let live = LiveReload::watch(vec![Mount::root(root.clone())]); + let mut rx = live.subscribe(); + // Let the watcher settle before the first edit. + tokio::time::sleep(Duration::from_millis(300)).await; + + write(&root.join("app.scss"), "a { color: blue }"); + assert_eq!(next_change(&mut rx).await, Change::css("/app.css")); + + // Coalescing: nothing else follows from that one save. + tokio::time::sleep(Duration::from_millis(500)).await; + assert!(rx.try_recv().is_err(), "one change per edit"); + + // A partial with no known dependents refreshes every stylesheet… + write(&root.join("_vars.scss"), "$c: red;"); + assert_eq!(next_change(&mut rx).await, Change::css_all()); + tokio::time::sleep(Duration::from_millis(500)).await; + + // …and names its stylesheets once the index knows them. + live.record_dependencies("/app.css", [root.join("_vars.scss"), root.join("app.scss")]); + write(&root.join("_vars.scss"), "$c: blue;"); + assert_eq!(next_change(&mut rx).await, Change::css("/app.css")); + tokio::time::sleep(Duration::from_millis(500)).await; + + // A script reloads the page. + write(&root.join("lib/util.ts"), "export const v = 1;"); + assert_eq!( + next_change(&mut rx).await, + Change::new(ChangeKind::Js, Some("/lib/util.js".into())) + ); +} + +#[tokio::test] +async fn the_stream_never_names_a_filesystem_path() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("web"); + std::fs::create_dir_all(&root).unwrap(); + let live = LiveReload::new(&[Mount::root(root.clone())]); + let response = get(live.router(), "/events").await; + let mut body = response.into_body(); + next_frame(&mut body).await; // hello + + live.notify(&root.join("notes.txt")); + let frame = next_frame(&mut body).await; + assert!( + frame.contains("{\"kind\":\"other\",\"url\":null}"), + "{frame}" + ); + let local = tmp.path().canonicalize().unwrap().display().to_string(); + assert!( + !frame.contains(&local), + "no local path in the stream: {frame}" + ); + assert!(!frame.contains("notes"), "not even the file name: {frame}"); +} + +#[tokio::test] +async fn the_dev_router_injects_and_serves_the_client() { + let tmp = tempfile::tempdir().unwrap(); + write( + &tmp.path().join("index.html"), + "

hi

", + ); + write(&tmp.path().join("app.scss"), "a { color: red }"); + let app = Frontend::dir(tmp.path()).dev(); + + let page = get(app.clone(), "/").await; + assert_eq!(page.status(), StatusCode::OK); + let declared_length = page + .headers() + .get(header::CONTENT_LENGTH) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()); + let html = String::from_utf8( + page.into_body() + .collect() + .await + .unwrap() + .to_bytes() + .to_vec(), + ) + .unwrap(); + // The original body's length header must not survive the injection: a length, when + // declared, is the injected body's. + if let Some(length) = declared_length { + assert_eq!( + length, + html.len(), + "Content-Length matches the injected body" + ); + } + let tag = format!(""); + assert!(html.contains(&tag), "{html}"); + assert!( + html.find(&tag).unwrap() < html.find("").unwrap(), + "injected before : {html}" + ); + + let script = get(app.clone(), &format!("{DEFAULT_PREFIX}/live.js")).await; + assert_eq!(script.status(), StatusCode::OK); + assert!(script + .headers() + .get(header::CONTENT_TYPE) + .unwrap() + .to_str() + .unwrap() + .starts_with("text/javascript")); + assert_eq!( + script.headers().get(header::CACHE_CONTROL).unwrap(), + "no-cache" + ); + let js = String::from_utf8( + script + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .to_vec(), + ) + .unwrap(); + assert!( + js.contains("EventSource"), + "the client connects to the stream" + ); + + // Non-HTML responses are left alone. + let css = get(app.clone(), "/app.css").await; + let css = + String::from_utf8(css.into_body().collect().await.unwrap().to_bytes().to_vec()).unwrap(); + assert!(!css.contains(""); + let app = web_modules::dev::dev_router_with_live( + vec![tmp.path().to_path_buf()], + web_modules::dev::DevConfig::default(), + ReloadMode::Off, + ); + let page = get(app.clone(), "/").await; + let html = String::from_utf8( + page.into_body() + .collect() + .await + .unwrap() + .to_bytes() + .to_vec(), + ) + .unwrap(); + assert!(!html.contains("