Assets
The asset pipeline is a five-package system for serving versioned static files (JavaScript, CSS, fonts, images) with content-hash URLs and immutable browser caching. It's modeled on Symfony's AssetMapper: each package contributes static files to a pool, a collector hashes their content and writes a manifest, and a runtime service resolves logical names to versioned URLs.
The motivation is twofold:
- CDN/nginx caching — production deployments can serve
/static/*directly from disk withCache-Control: max-age=31536000, immutableheaders; the app handles only dynamic routes. - Framework-shipped client glue — packages like
http-csrfship browser scripts they don't want apps to wire by hand. Each package registers aStaticAssetentry; the app picks a serving strategy.
Packages
| Package | Role |
|---|---|
@modularityjs/assets | Contract: pool, manifest, URL service, abstract collector |
@modularityjs/assets-local | Filesystem driver — writes content-hashed files + manifest under an outputDir |
@modularityjs/assets-minify-esbuild | Optional minify extension — esbuild-backed @Plugin on StaticAsset.source |
@modularityjs/http-assets | HTTP extension — serves GET /static/* from the pool |
@modularityjs/template-assets | Template extension — registers the helper |
@modularityjs/assets-cli | CLI extension — exposes assets:collect |
Setup
A typical app wires all five at boot. assets-local and assets-cli are deploy-time-only and http-assets is the runtime fallback for dev:
import { AssetsModule } from '@modularityjs/assets';
import { AssetsCliModule } from '@modularityjs/assets-cli';
import { AssetsLocalModule } from '@modularityjs/assets-local';
import { HttpAssetsModule } from '@modularityjs/http-assets';
import { TemplateAssetsModule } from '@modularityjs/template-assets';
const modules = [
// ... HTTP, Template, Session, etc.
AssetsModule, // contract
AssetsLocalModule.forRoot({ outputDir: 'dist/static' }), // collector driver
HttpAssetsModule, // serves /static/* in dev
TemplateAssetsModule, // {{assetUrl}} helper
AssetsCliModule, // assets:collect command
];Concepts
StaticAsset pool
Packages register entries in StaticAssetsPool. Each entry subclasses the abstract StaticAsset and declares a logical name, content type, and lazy source():
import { StaticAsset, StaticAssetsPool } from '@modularityjs/assets';
import { Injectable } from '@modularityjs/di';
import { Module } from '@modularityjs/modularity';
@Injectable()
class WelcomeBanner extends StaticAsset {
readonly name = 'welcome-banner.svg';
readonly contentType = 'image/svg+xml';
async source(): Promise<string> {
return '<svg xmlns="http://www.w3.org/2000/svg">…</svg>';
}
}
@Module({
name: 'my-app',
imports: [AssetsModule],
providers: [WelcomeBanner],
pools: [
{ pool: StaticAssetsPool, key: 'welcome-banner', useClass: WelcomeBanner },
],
})
class MyAppModule {}The source() method returns Promise<string | Buffer> (sync subclasses just declare async and return a literal — TypeScript auto-wraps). Called once at collect time and per request in dev mode. Async-only because plugins on StaticAsset.source are themselves async (e.g. esbuild minify); aligning the contract avoids a footgun where a plugin would silently widen T | Promise<T> to Promise<T> for downstream callers.
Vendoring third-party assets
For libraries hosted on a CDN (HTMX, Pico CSS, Alpine, etc.), extend RemoteStaticAsset instead of writing your own source(). Declare the upstream URL and (optionally) an SRI integrity string; the framework fetches once, caches in memory, verifies the SRI, and runs the bytes through the same content-hash pipeline as first-party assets:
import { RemoteStaticAsset, StaticAssetsPool } from '@modularityjs/assets';
import { Injectable } from '@modularityjs/di';
import { Module } from '@modularityjs/modularity';
@Injectable()
class HtmxAsset extends RemoteStaticAsset {
readonly name = 'htmx.min.js';
readonly contentType = 'application/javascript; charset=utf-8';
readonly url = 'https://unpkg.com/htmx.org@2.0.10/dist/htmx.min.js';
readonly expectedIntegrity = 'sha384-…'; // optional but recommended in production
}
@Module({
name: 'my-vendor',
providers: [HtmxAsset],
pools: [{ pool: StaticAssetsPool, key: 'htmx', useClass: HtmxAsset }],
})
class MyVendorModule {}After assets:collect runs, HTMX lives at a hashed URL like /static/htmx-e209dda5.js and your CSP script-src can be 'self' only — no CDN allowlist required. Bumping the version is a two-line change (URL + integrity).
Minification
@modularityjs/assets-minify-esbuild wires a single @Plugin against the abstract StaticAsset.source method. Plugin hierarchy resolution dispatches it to every concrete subclass at activation, so one declaration covers all assets — first-party (csrf-client.js, welcome-banner.svg) and vendored (htmx.js, pico.css) alike. esbuild handles JS and CSS in one dependency.
import { AssetsMinifyEsbuildModule } from '@modularityjs/assets-minify-esbuild';
const modules = [
// ...
AssetsModule,
AssetsMinifyEsbuildModule.forRoot({
enabled: process.env['NODE_ENV'] === 'production',
}),
];Branching:
application/javascript/text/javascript→ esbuildjsloader,minify: truetext/css→ esbuildcssloader,minify: true- Anything else (SVG, fonts, images) → returned unchanged
Skip rules:
- Filename matches
*.min.jsor*.min.css→ returned unchanged. Lets vendored already-minified files (htmx.min.js,pico.min.css) pass through without redundant work. Disable viaskipMinifiedFilenames: falseto force re-minify. enabled: false(the default) → plugin is wired but no-ops. Apps flip the flag in production. Defaulting off keeps dev fast —source()runs per request in dev, and per-request minification adds latency.
Each StaticAsset instance is minified once per process (cached in a WeakMap keyed on the instance). Per-request source() calls in dev pay the cost on the first hit only.
AssetsMinifyEsbuildModule.forRoot({
enabled: true,
skipMinifiedFilenames: true,
js: { target: 'es2020', drop: ['console'] }, // forwarded to esbuild.transform
css: {},
});Pass-through options match esbuild's transform() API; loader and minify are managed by the plugin.
Manifest
The collector writes an assets-manifest.json mapping logical names to versioned URLs (and optional SRI hashes):
{
"csrf-client.js": {
"url": "/static/csrf-client-a1b2c3d4.js"
},
"welcome-banner.svg": {
"url": "/static/welcome-banner-9e8f7d6c.svg",
"integrity": "sha384-…"
}
}AssetUrlService reads the manifest at boot via AssetsModule.onInit. When the manifest is missing (dev mode without a collect run), url(name) falls back to <urlPrefix>/<name> — e.g. /static/csrf-client.js — and http-assets serves at that path so dev "just works."
Cache headers
The http-assets driver serves with Cache-Control chosen by URL shape:
- Hashed URL (
/static/csrf-client-a1b2c3d4.js):public, max-age=31536000, immutable— safe to cache forever because the URL changes on content change. - Unhashed fallback (
/static/csrf-client.js):public, max-age=300— short TTL because the URL is stable across content changes.
In production, with the manifest written by assets:collect, every URL is hashed and immutable.
Templates
After wiring TemplateAssetsModule, layouts use the helper to resolve URLs:
Under the hood, TemplateAssetsModule registers an AssetUrlHelper (a TemplateHelper) into TemplateHelpersPool; the active template engine driver picks it up. Currently only template-handlebars consumes the pool.
Collecting at deploy time
Run assets:collect (via the CLI extension) before deploying. It walks the pool, hashes each source() output via SHA-256 (truncated to hashLength, default 12 hex chars), and writes both the files and the manifest atomically:
$ pnpm cli assets:collect
Collected 3 assets.
csrf-client.js → /static/csrf-client-a1b2c3d4.js
welcome-banner.svg → /static/welcome-banner-9e8f7d6c.svg
main.css → /static/main-2a8b1c3e.cssThe output directory and manifest path come from AssetsLocalConfig and AssetsConfig respectively (see Configuration).
Production with nginx
After assets:collect writes dist/static/, point nginx at it and let the app handle dynamic routes only:
location /static/ {
alias /var/app/dist/static/;
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri @app; # fall through to the app for cache misses
}
location @app {
proxy_pass http://localhost:3000;
}The try_files fallback covers the rare case of an asset that exists in the pool but isn't on disk — http-assets serves it from memory.
Configuration
AssetsConfig
AssetsModule.forRoot({
urlPrefix: '/static', // URL prefix for served assets (no trailing slash)
manifestPath: './dist/assets-manifest.json',
hashLength: 12, // SHA-256 hex chars retained in filename (default 12; minimum 8)
generateIntegrity: false, // when true, manifest entries include sha-384 SRI
});| Option | Default | Description |
|---|---|---|
urlPrefix | '/static' | URL prefix for asset URLs. Must start with / and have no trailing slash. Must match the http-assets controller route — currently hard-coded to /static/*; setting any other value throws a ValidationException at boot |
manifestPath | './dist/assets-manifest.json' | Path the collector writes to and AssetUrlService reads at boot |
hashLength | 12 | Hex chars from the SHA-256 digest used in filenames. Range 8–64. Matches esbuild / webpack / vite defaults |
generateIntegrity | false | When true, the collector populates integrity per manifest entry as sha384-<base64> |
AssetsLocalConfig
AssetsLocalModule.forRoot({
outputDir: './dist/static', // collector writes content-hashed files here
});| Option | Default | Description |
|---|---|---|
outputDir | './dist/static' | Filesystem destination for collected files (CWD-relative). The CLI's positional outputDir argument overrides this per invocation |
API reference
StaticAsset
Abstract class. Subclass and register in StaticAssetsPool.
| Member | Description |
|---|---|
name: string | Logical asset name (e.g. 'csrf-client.js'). Becomes the manifest key |
contentType: string | MIME type sent with the response |
source(): Promise<string | Buffer> | Returns the bytes. Called once at collect time; per request in dev mode |
headers?: Record<string,string> | Optional extra response headers for http-assets |
RemoteStaticAsset
Abstract subclass of StaticAsset for vendoring third-party libraries. Subclasses declare url and (optionally) expectedIntegrity; the default source() fetches once, caches the bytes in memory, dedupes concurrent calls, and verifies the SRI when set.
| Member | Description |
|---|---|
url: string | Upstream location to fetch from |
expectedIntegrity?: string | One or more SRI tokens (e.g. 'sha384-…', space-separated for multi-value); throws on mismatch when set |
source() (provided) | Fetches once, caches, verifies. Concurrent calls share a single in-flight fetch |
AssetUrlService
Injectable. Reads the manifest at boot via AssetsModule.onInit.
| Method | Description |
|---|---|
url(name) | Returns the manifest URL for name, or <urlPrefix>/<name> when no manifest is loaded |
integrity(name) | Returns the sha384-… SRI hash for name, or undefined |
setManifest(m) | Replaces the in-memory manifest. Called by AssetsModule.onInit; useful for tests |
AssetCollector
Abstract class. assets-local provides the filesystem implementation; future drivers can target S3, Cloudflare R2, etc.
| Method | Description |
|---|---|
collect(options?) | Walks the pool, writes (or uploads) each asset under a content-hash filename, persists the manifest, returns it |
loadManifest(path) / saveManifest(path, manifest)
Helper functions for reading and atomically writing the manifest JSON. Used internally by AssetsModule and assets-local; exposed for custom drivers and tooling.