Skip to content

Repository files navigation

web_modules

CI crates.io docs.rs License: MIT

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.

What it does

  • 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 (the web-modules build CLI or a GitHub Action).
  • Bundle (opt-in) - fold CommonJS packages and their node_modules/ into ES modules.

Features

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-utils API as web_modules::npm (resolve · install · ci)
  • axum · dev - serve the frontend, with a live-reload dev server

CLI

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 version

build 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.

HTML policy

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.

Duplicate output paths

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.

Output directory

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.

Symlinks

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.

Library

[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.

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.

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@v5

Install 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 web

Enable 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.

Examples

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 bundle feature).
  • 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.

Maintaining

Repository setup, the release runbook and what a fork must change: MAINTENANCE.md.

License

MIT. See LICENSE.

About

Pure-Rust, buildless toolchain for ES modules and Web Components

Resources

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Used by

Contributors

Languages