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, .htmHtml
.cssCss
.js, .mjsJs
.jpg, .jpegJpeg
.pngPng
.avifAvif
anything elseOther (passthrough)

Asset.path is a logical artifact keyferro-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)
    }
}

Error Reference

VariantMeaning
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).