Asset Pipeline
ferro-assets provides a composable, content-type-aware asset pipeline for publish-time
optimization over an in-memory file set.
A Pipeline runs over a heterogeneous Asset collection (HTML, CSS, JS, images, and any
other files such as JSON-UI spec bundles). Each transform declares the content types it
accepts; every other file passes through byte-for-byte unchanged — the passthrough
guarantee. This makes the same pipeline safe for mixed artifact sets without any
per-file branching in the consumer.
ferro-assets uses only pure-Rust codecs. No C system packages (libvips, libavif,
libwebp) are required; cargo build adds nothing to the production image.
Quick Start
use ferro_assets::{Asset, Pipeline};
use ferro_assets::transforms::{
HtmlMinify, CssMinify, JsMinify,
ImageTranscode, ResponsiveImages,
InjectBeforeTag, ReplaceTokens,
};
use std::collections::HashMap;
let pipeline = Pipeline::new()
.add(HtmlMinify::new())
.add(CssMinify::new())
.add(JsMinify::new())
.add(ImageTranscode::new())
.add(ResponsiveImages::new())
.add(InjectBeforeTag::new("</body>", r#"<script src="/sdk.js"></script>"#))
.add(ReplaceTokens::new(HashMap::new()));
// pipeline.run() is synchronous (blocking). Wrap in spawn_blocking
// when calling from an async context.
let result = tokio::task::spawn_blocking(move || pipeline.run(assets)).await??;
The spawn_blocking wrapper is required: pipeline.run() is CPU-bound synchronous work.
Calling it directly on the async executor stalls every concurrent HTTP request for the
duration of the pipeline.
Asset and ContentType Model
An Asset is an in-memory file with a logical path, a content type, and its bytes:
use ferro_assets::{Asset, ContentType};
use bytes::Bytes;
// ContentType is inferred from the path extension.
let html = Asset::new("index.html", Bytes::from(html_bytes));
let css = Asset::new("app.css", Bytes::from(css_bytes));
let image = Asset::new("hero.jpg", Bytes::from(jpeg_bytes));
let spec = Asset::new("spec.json", Bytes::from(json_bytes));
// spec has ContentType::Other — no built-in transform touches it.
// Override content type explicitly when needed.
let asset = Asset::new("file", bytes).with_content_type(ContentType::Js);
infer_content_type maps path extensions to ContentType variants:
| Extension(s) | ContentType |
|---|---|
.html, .htm | Html |
.css | Css |
.js, .mjs | Js |
.jpg, .jpeg | Jpeg |
.png | Png |
.avif | Avif |
| anything else | Other (passthrough) |
Asset.path is a logical artifact key — ferro-assets never reads or writes
files. If the consumer uses a path to write output to disk, it must sanitize it against
path traversal before doing so.
Built-in Transforms
HtmlMinify
Collapses redundant whitespace in visible body text using lol_html. The content of
<script> and <style> elements is treated as opaque — whitespace inside those
elements is never touched. Template literals, multi-line strings, and inline JSON blobs
inside <script> survive the transform byte-correct.
use ferro_assets::transforms::HtmlMinify;
let transform = HtmlMinify::new();
CssMinify
Minifies CSS using lightningcss (pinned to =1.0.0-alpha.71 — this alpha version pins
a stable API; a relaxed semver range would silently pick up breaking alpha bumps).
use ferro_assets::transforms::CssMinify;
let transform = CssMinify::new();
JsMinify
Minifies JavaScript using swc compress+mangle. The transform operates on JS files only;
JSON and other text files that happen to contain JavaScript-like content are not touched
because their ContentType is Other.
use ferro_assets::transforms::JsMinify;
let transform = JsMinify::new();
ImageTranscode
Transcodes JPEG, PNG, and AVIF source images into AVIF and JPEG variants at configurable
responsive widths. Variants are added to the asset set alongside the original source (the
original is retained as the JPEG fallback for <img src>).
Default configuration:
- Widths:
[480, 768, 1200, 1920]— only widths≤ source.width()are emitted (no upscaling). - Max concurrent encodes: 2 — bounds peak memory on small instances.
- AVIF quality: 70.0, AVIF speed: 4 (faster than the slowest setting without quality loss).
- JPEG quality: 80.
use ferro_assets::transforms::ImageTranscode;
let transform = ImageTranscode::new()
.with_widths(vec![640, 1280])
.with_max_concurrent(4)
.with_avif_quality(75.0)
.with_jpeg_quality(85);
Variant naming is deterministic: {stem}-{width}w.avif and {stem}-{width}w.jpg (e.g.
assets/hero-768w.avif). ResponsiveImages discovers these by name — run ImageTranscode
before ResponsiveImages in the pipeline.
ResponsiveImages
Rewrites every <img src="…"> element in HTML files to a <picture> element referencing
the AVIF and JPEG variants already present in the asset set. Relies on the deterministic
variant naming from ImageTranscode. Run after ImageTranscode.
use ferro_assets::transforms::ResponsiveImages;
let transform = ResponsiveImages::new();
Example output (for <img src="assets/hero.jpg">):
<picture>
<source type="image/avif" srcset="assets/hero-480w.avif 480w, assets/hero-768w.avif 768w, assets/hero-1200w.avif 1200w">
<img src="assets/hero.jpg" ...>
</picture>
InjectBeforeTag
Inserts a literal snippet immediately before a given closing tag in every HTML file. The
primary use is injecting an SDK <script> before </body>.
use ferro_assets::transforms::InjectBeforeTag;
let transform = InjectBeforeTag::new("</body>", r#"<script src="/sdk.js"></script>"#);
The tag name is validated — only alphanumeric and hyphen characters are accepted. An invalid tag name is a no-op (the transform returns the asset unchanged).
ReplaceTokens
Performs raw byte substitution of %%TOKEN%%-style placeholders across every file,
regardless of content type. Tokens can appear in HTML attributes, inline scripts, CSS
custom properties, or JSON values — the transform operates on raw bytes, not a parsed DOM.
use ferro_assets::transforms::ReplaceTokens;
use std::collections::HashMap;
let tokens = HashMap::from([
("%%CDN_URL%%".to_string(), "https://cdn.example.com".to_string()),
("%%APP_VERSION%%".to_string(), "1.0.3".to_string()),
]);
let transform = ReplaceTokens::new(tokens);
Substitution is literal with no evaluation or recursion. The caller is responsible for sanitizing token values before constructing the map — values are written verbatim into the output bytes.
Writing Custom Transforms
Implement Transform and use map_matching to gate work by content type:
use ferro_assets::{Asset, ContentType, Error, Transform, map_matching};
pub struct StripHtmlComments;
impl Transform for StripHtmlComments {
fn run(&self, assets: Vec<Asset>) -> Result<Vec<Asset>, Error> {
map_matching(assets, &[ContentType::Html], |a| {
let out = remove_comments(&a.bytes)
.map_err(|e| Error::transform("strip_html_comments", &a.path, e))?;
Ok(Asset { bytes: out.into(), ..a })
})
}
}
map_matching iterates the asset set: files in accepted are passed to the closure;
everything else passes through unchanged. The collection short-circuits on the first Err
— no partial output is produced.
For transforms that operate on the whole asset set (e.g. cross-referencing variant names),
implement run directly without map_matching:
impl Transform for MyBatchTransform {
fn run(&self, assets: Vec<Asset>) -> Result<Vec<Asset>, Error> {
// process the full set and return a new Vec<Asset>
Ok(assets)
}
}
Compile-time Asset Embedding
asset!("path") is a proc-macro that embeds a static file at compile time and registers it in the content-hashed bundle registry at first use. It is part of the framework layer (ferro::asset!), not a pipeline transform — it works independently of the ferro-assets pipeline described above.
use ferro::asset;
// Returns a content-hashed URL as &'static str, e.g. "/bundles/assets_app.a1b2c3d4.js"
let url: &'static str = asset!("assets/app.js");
The path is resolved relative to the call site's source file (using include_bytes! semantics). The bytes are registered once via OnceLock on the first call and never re-read. The content hash is derived from the bytes at compile time.
Requirements:
- The app must mount
ferro::bundleserving (e.g.ferro::bundle::serve) so that the hashed URL resolves to a response. Without this mount, the URL is emitted correctly but requests to it return 404. - The MIME type is inferred from the file extension (
.js→text/javascript,.css→text/css, etc.).
Return type: &'static str — the content-hashed URL string, valid for the lifetime of the process.
ferro assets fetch
Downloads third-party assets (icon sets, font files) to a local path at author time, so they can be embedded with asset!() without depending on a CDN at runtime.
ferro assets fetch iconify
ferro assets fetch fontsource
ferro assets fetch iconify downloads the Iconify offline bundle. ferro assets fetch fontsource downloads font files from the Fontsource CDN.
The fetched files are written to a local directory. They are not auto-wired into asset!() calls or route generation. After fetching, reference the downloaded paths manually in asset!() calls.
Error Reference
| Variant | Meaning |
|---|---|
Error::Transform { transform, path, cause } | A transform failed on a specific asset. transform names the stage (e.g. "html_minify"), path is the logical asset path, cause is the underlying message. |
Error::Setup(msg) | Thread pool or initialisation failure (e.g. rayon ThreadPoolBuilder error). |