Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 `<link rel="stylesheet">` 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.
Expand Down Expand Up @@ -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

Expand Down
16 changes: 1 addition & 15 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 9 additions & 7 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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 }

Expand All @@ -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"
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `</body>` of every served page) hot-swaps a changed stylesheet's `<link>` 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 `<meta name="web-modules-live">`, 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.
Expand Down
1 change: 1 addition & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 22 additions & 1 deletion src/bin/web-modules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,20 @@ enum Command {
/// Address to bind (default 127.0.0.1:8080).
#[arg(long)]
addr: Option<SocketAddr>,
/// 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<web_modules::ReloadMode>,
/// Serve the sources without the live-reload stream, client and file watcher.
#[arg(long)]
no_live_reload: bool,
#[command(flatten)]
compiler: CompilerConfig,
},
Expand Down Expand Up @@ -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).
Expand All @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"))]
Expand Down
56 changes: 54 additions & 2 deletions src/processors/scss.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<PathBuf>>,
/// 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<Vec<PathBuf>>,
}

impl SandboxFs {
Expand All @@ -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<PathBuf> {
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
Expand Down Expand Up @@ -122,7 +134,13 @@ impl Fs for SandboxFs {

fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
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"),
Expand Down Expand Up @@ -164,11 +182,21 @@ pub fn compile_str(input: &str, load_paths: &[&Path]) -> Result<String> {
/// 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<String> {
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<PathBuf>)> {
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
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading