Skip to content

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 with Cache-Control: max-age=31536000, immutable headers; the app handles only dynamic routes.
  • Framework-shipped client glue — packages like http-csrf ship browser scripts they don't want apps to wire by hand. Each package registers a StaticAsset entry; the app picks a serving strategy.

Packages

PackageRole
@modularityjs/assetsContract: pool, manifest, URL service, abstract collector
@modularityjs/assets-localFilesystem driver — writes content-hashed files + manifest under an outputDir
@modularityjs/assets-minify-esbuildOptional minify extension — esbuild-backed @Plugin on StaticAsset.source
@modularityjs/http-assetsHTTP extension — serves GET /static/* from the pool
@modularityjs/template-assetsTemplate extension — registers the helper
@modularityjs/assets-cliCLI 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:

typescript
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():

typescript
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:

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

typescript
import { AssetsMinifyEsbuildModule } from '@modularityjs/assets-minify-esbuild';

const modules = [
  // ...
  AssetsModule,
  AssetsMinifyEsbuildModule.forRoot({
    enabled: process.env['NODE_ENV'] === 'production',
  }),
];

Branching:

  • application/javascript / text/javascript → esbuild js loader, minify: true
  • text/css → esbuild css loader, minify: true
  • Anything else (SVG, fonts, images) → returned unchanged

Skip rules:

  • Filename matches *.min.js or *.min.css → returned unchanged. Lets vendored already-minified files (htmx.min.js, pico.min.css) pass through without redundant work. Disable via skipMinifiedFilenames: false to 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.

typescript
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):

json
{
  "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:

handlebars
<script src='{{assetUrl "csrf-client.js"}}' defer></script>
<link rel='stylesheet' href='{{assetUrl "main.css"}}' />
<img src='{{assetUrl "welcome-banner.svg"}}' alt='Welcome' />

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:

bash
$ 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.css

The 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:

nginx
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

typescript
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
});
OptionDefaultDescription
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
hashLength12Hex chars from the SHA-256 digest used in filenames. Range 8–64. Matches esbuild / webpack / vite defaults
generateIntegrityfalseWhen true, the collector populates integrity per manifest entry as sha384-<base64>

AssetsLocalConfig

typescript
AssetsLocalModule.forRoot({
  outputDir: './dist/static', // collector writes content-hashed files here
});
OptionDefaultDescription
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.

MemberDescription
name: stringLogical asset name (e.g. 'csrf-client.js'). Becomes the manifest key
contentType: stringMIME 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.

MemberDescription
url: stringUpstream location to fetch from
expectedIntegrity?: stringOne 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.

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

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