Pure-Rust tooling for developing Web Components: vendor npm packages, transform
TypeScript/SCSS, and serve or embed a native-ESM frontend, with no Node, no npm and no
bundler at build time. Use it as a web-modules CLI for everyday development, or as a
library from a build.rs / at runtime. Built on npm-utils, oxc, grass and rolldown.
- Vendor - resolve and download npm packages into
web_modules/<name>, targeted or including dependencies. - Transform - compile and convert source files, minify and process.
- Dev server - serve from source, compile on the fly, watch and live-reload.
- Build - vendor, transform and render a deployable
dist/- bake it into your binary, or ship it as a static site (theweb-modules buildCLI or a GitHub Action). - Bundle (opt-in) - fold CommonJS packages and their
node_modules/into ES modules.
Each is a Cargo --features flag:
- typescript / scss - compile to browser JS and CSS
- tera - HTML and import map templating
- minify · dts · i18n · icons - optional processors
- compress - gzip sidecars for static serving
- bundle - CommonJS to ESM
- npm - expose the
npm-utilsAPI asweb_modules::npm(resolve · install · ci) - axum · dev - serve the frontend, with a live-reload dev server
cargo install web_modules --features cli--features cli is required, and deliberately so: the CLI pulls clap and a server runtime, which would land in every library build that took the default features — including the build scripts this crate is mostly used from.
Without the flag cargo installs no binary and says which feature the target wanted.
In CI you need none of this: the action downloads a prebuilt binary.
$ web-modules --help
Buildless web frontend toolchain (no Node)
Usage: web-modules <COMMAND>
Commands:
dev Dev server: compile TS/SCSS on the fly, render `*.tera`, watch the tree, live-reload
build Build a deployable output tree — the static counterpart of `dev`
vendor Vendor npm packages into web_modules/ + an import map
ci Install a package-lock.json's exact tree into node_modules/ - a pure-Rust npm ci
npm Run an npm-utils command (add · install · ci · upgrade · …)
help Print this message or the help of the given subcommand(s)
Options:
-h, --help Print help
-V, --version Print versionbuild is the static counterpart of dev — same source roots and processors, emitted to --out instead of served — and it vendors npm only when you pass --package/--manifest; vendor just fetches dependencies into web_modules/. Each compiler processor (typescript, scss, tera, minify, gzip) has a --<name> / --no-<name> toggle, and --no-default-features turns the default-on set (typescript, scss, tera) off so you re-enable them individually. Run web-modules <command> --help for flags.
A dependency may be a registry range, an https .tgz, or a git reference (github:owner/repo#ref); name it under web_modules.sourceDependencies and its TypeScript is compiled into the layout its own tsconfig.json declares.
Pin a git dependency to a commit rather than a branch: a commit is cached by name and costs no network once vendored, while a branch is re-downloaded every run so that moving it is noticed.
The build never reads or rewrites your HTML.
Pages are only generated where you opt in: a *.tera template (rendered with the generated import map as the {{ importmap }} variable), or the --html/--template fallback when no source provides an index.html at all.
The generated import map is the contract — available as importmap.json, the {{ importmap }} Tera variable, and the {importmap} placeholder — and it is the only map the unresolved-import check validates against; a hand-authored page owns its own inline map.
JavaScript rendered from a template joins the module graph and is validated like any other emitted module, with one ordering rule: runtime-helper vendoring is decided before templates render, so an @oxc-project/runtime import appearing only in template-rendered JavaScript fails the unresolved-import check instead of vendoring the runtime — put such code in a .ts/.js source instead.
When two sources claim one output path — index.html next to index.html.tera, app.js next to app.ts, style.css next to style.scss, or the same relative path in two roots — build fails before writing anything and lists every conflict; dev warns on the console instead.
--skip-duplicates opts into precedence: the earlier root wins, and within a root a Tera template beats a literal file beats a transformed sibling — the same rule in build and dev.
Generated outputs are reserved regardless: a source claiming importmap.json, a path under web_modules/, or (with --gzip) the .gz sidecar of an emitted file fails the build even under --skip-duplicates.
Each build is staged in a temporary sibling directory and then atomically replaces --out, so the output always describes exactly the current sources — nothing from a previous build survives, and a failed build leaves the previous output untouched.
--out must therefore be dedicated: absent, empty, or a previous build's output, which the build recognizes by the .web-modules-out marker it writes.
Anything else — the project directory under --out ., a directory with your own files — is refused rather than deleted; delete a pre-existing output directory once when upgrading.
Vendored packages are not re-downloaded on every build: the web_modules/ cache carries over from the previous output and is re-validated, and packages you no longer request are pruned.
What a symlink in a source tree means is selectable with --symlinks (also Processors::symlinks, the builders' .symlinks(…), and Frontend::symlinks), consistently across build, dev, and the static router:
| Mode | build | serving |
|---|---|---|
follow (default) |
a link resolving outside its own root fails the build | 404 |
follow-unsafe |
every link publishes; a dangling one warns and skips | a dangling one 404s |
redirect |
links are skipped with a warning | 307 Temporary Redirect, the link content is the Location |
move |
links are skipped with a warning | 308 Permanent Redirect, same rule |
Under follow a link works within its own source root and never across roots.
The two redirect modes are the crate's own special sauce and are compiled behind the default-on symlink-move feature — --no-default-features yields a build in which a symlink can never become a redirect, while follow and follow-unsafe are always available.
The redirect modes answer without ever opening the target — the link content is the Location, taken literally (plus the request's remaining components when a directory link is on the way) — which is also why a static build has nothing to emit for a link and skips it.
In every mode, request-path traversal, the reject list, source-hiding, the SCSS import sandbox, and vendor-extraction hardening are unaffected: a symlink mode never relaxes a security sandbox.
The live-reload watcher's behavior through links is backend-defined; under follow-unsafe an edit behind an out-of-tree link may not trigger a reload.
[dependencies]
web_modules = "0.6" # Rust 1.95+typescript, scss and tera are on by default; full enables everything except bundle.
The fluent Build and Dev builders (feature builder, on by default) are the promoted entry points — Build from a build.rs (bake a dist/), Dev for a live-reload server:
use web_modules::{Build, Dev};
// build.rs — vendor lit, compile web/, write dist/
Build::new().root("web").vendor("lit@^3").out("dist").minify(true).run()?;
// a live-reload dev server (the `dev` feature)
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.
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.
Build a dist artifact:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: gronke/web_modules@v0
with:
packages: "lit@^3 bootstrap@^5" # and/or: manifest: web (a dir) or web/package.json
template: web/index.html.tera # or inline `html:`; omit for a minimal default
minify: true
- uses: actions/upload-artifact@v7
with: { name: site, path: dist }Deploy to GitHub Pages — grant the Pages permissions + environment on the job, then build and publish with the standard actions:
jobs:
deploy:
runs-on: ubuntu-latest
permissions: { pages: write, id-token: write }
environment: { name: github-pages, url: "${{ steps.deploy.outputs.page_url }}" }
steps:
- uses: actions/checkout@v7
- uses: gronke/web_modules@v0
with:
packages: "lit@^3 bootstrap@^5"
template: web/index.html.tera
mount: /my-repo/web_modules # project page is served under /<repo>/
- uses: actions/configure-pages@v6
- uses: actions/upload-pages-artifact@v5
with: { path: dist }
- id: deploy
uses: actions/deploy-pages@v5Install the binary only — when the repo's own scripts run web-modules themselves:
jobs:
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: gronke/web_modules@v0
with: { build: "false" } # verified binary on PATH, no build
- run: scripts/frontend-build.sh # your script calls `web-modules build …`
- run: web-modules npm audit webEnable Pages once under Settings → Pages → Source: GitHub Actions. A project page is served under /<repo>/, so pass mount: /<repo>/web_modules and keep entry scripts relative (./app.js); a user/org *.github.io page serves at the root (default mount: /web_modules). This repo dogfoods the action — examples/gh-pages/ is built and deployed to Pages by .github/workflows/pages.yml. Run web-modules build --help for every flag.
The examples/ tree is full of runnable demos; cargo run and open the browser. A few picks:
- lit-element - a Lit 3 component themed with Bootstrap 5, baked at build time, served by axum.
- d3 - a bar chart with D3, a non-Lit npm dependency vendored and served as-is.
- react-esm - React from npm bundled into one browser ES module, entirely in Rust (the
bundlefeature). - embedded - the whole frontend baked into the binary; no filesystem, no network.
- tauri - a Tauri v2 desktop app, frontend live-served (and release-baked) by web_modules.
Repository setup, the release runbook and what a fork must change: MAINTENANCE.md.
MIT. See LICENSE.