Skip to content

Asset Manager

@voltage/asset-manager manages client-side assets — JavaScript files, stylesheets, or any static resource — for NestJS applications. Assets are served at hashed URLs for long-lived caching, included per-request via a decorator or programmatically, and injected into <head> automatically after rendering. Load order is resolved through a dependency graph, so you never need to worry about script ordering by hand.

Terminal window
yarn add @voltage/asset-manager @voltage/label @voltage/logger @voltage/jsx

Call forRoot() once in your root module. It registers the AssetManager service globally and installs the include interceptor:

import { AssetModule } from '@voltage/asset-manager';
@Module({
imports: [AssetModule.forRoot({ watch: process.env.NODE_ENV === 'development' })],
})
export class AppModule {}

Pass watch: true to enable chokidar file watchers. When active, BundleAsset instances recompile whenever any of their input files change. See Configuration for all options.

Register asset classes in the module that owns them. forFeature() makes each asset available to the DI container and registers its HTTP middleware for serving:

import { AssetModule } from '@voltage/asset-manager';
import { AppScript } from './app.script';
@Module({
imports: [AssetModule.forFeature([AppScript])],
})
export class AppModule {}

StaticAsset() creates an asset backed by a file on disk. The MIME type is inferred from the file extension:

import { StaticAsset } from '@voltage/asset-manager';
import { join } from 'path';
export const AppScript = StaticAsset({
name: 'app',
file: join(__dirname, 'app.js'),
});

ExternalAsset() fetches a remote resource. The content is downloaded at bootstrap to compute the hash; subsequent requests are served from the remote URL:

import { ExternalAsset } from '@voltage/asset-manager';
export const HtmxScript = ExternalAsset({
name: 'htmx',
url: 'https://unpkg.com/htmx.org@2/dist/htmx.min.js',
});

BundleAsset() compiles a JavaScript or TypeScript entry point with esbuild into a single in-memory bundle. The hash is computed from the bundle output, so the immutable-cache contract is maintained automatically.

import { BundleAsset } from '@voltage/asset-manager';
import { join } from 'path';
export const AppScript = BundleAsset({
name: 'app',
entry: join(__dirname, 'client/app.ts'),
tags: [$$attribute({ defer: true })],
esbuild: {
external: ['petite-vue'],
minify: true,
target: 'es2020',
},
});

Any option that esbuild’s BuildOptions accepts can be passed through esbuild, except entryPoints, bundle, write, metafile, outfile, and outdir — those are managed internally. When watch: true is set in forRoot(), the bundle recompiles on every file change and all input files discovered by esbuild (including transitive imports) are watched automatically.

Extend Asset directly when you need to generate content at runtime:

import { Asset } from '@voltage/asset-manager';
import { Readable } from 'stream';
export class InlineConfigAsset extends Asset {
name = 'config';
content(): Readable {
return Readable.from(`window.__CONFIG__ = ${JSON.stringify(this.config)};`);
}
type(): string {
return 'application/javascript';
}
length(): number {
return Buffer.byteLength(/* ... */);
}
}

All asset factories accept a common set of options:

interface AbstractAssetOptions {
/** Identifier used in the served URL: /assets/managed/{name} */
name: string;
/** Labels that control load order, rendered attributes, and other behaviours. */
tags?: AnyLabel[];
/** Embed content inline in a <script> or <style> tag instead of linking. */
inline?: boolean;
}

Attach @Include() to a controller method or a component render() method. The asset is added to the current request’s inclusion list automatically:

import { Include } from '@voltage/asset-manager';
import { AppScript } from './app.script';
@Controller('dashboard')
export class DashboardController {
@Get()
@Include(AppScript)
index() {
return this.view.render();
}
}

On a JSX component, @Include() fires when the component is rendered — useful when the component itself owns the asset it needs:

@Injectable()
export class ChartComponent extends InjectableComponent {
@Include(ChartScript)
render() {
return <canvas id="chart" />;
}
}

Inject AssetManager and call include() when you need conditional logic:

@Injectable()
export class WidgetService {
constructor(private readonly assets: AssetManager) {}
async render(type: string) {
if (type === 'chart') {
this.assets.include(ChartScript);
}
}
}

Assets are served at /assets/managed/{name}. The content hash is appended as a query string — assetManager.url(AppScript) returns /assets/managed/app.js?v=a1b2c3d4. Use this when you need to reference an asset URL in a template:

@Injectable()
export class AppView extends InjectableComponent {
constructor(private readonly assets: AssetManager) {
super();
}
render() {
return (
<img src={this.assets.url(FaviconAsset)} alt="Logo" />
);
}
}

Responses for hashed URLs carry cache-control: public, max-age=31536000, immutable. Browsers cache them forever; the hash changes when the content changes. ETag validation is also supported — unchanged assets return 304 Not Modified.

Use $$before and $$after to declare load order constraints. The asset manager performs a topological sort before injecting tags into the page:

import { StaticAsset, $$after } from '@voltage/asset-manager';
export const VendorScript = StaticAsset({
name: 'vendor',
file: join(__dirname, 'vendor.js'),
});
export const AppScript = StaticAsset({
name: 'app',
file: join(__dirname, 'app.js'),
tags: [$$after(VendorScript)],
});

$$after(target) ensures app.js is rendered after vendor.js. $$before(target) is the inverse — declare it on the asset that should come first.

Use $$attribute to add arbitrary HTML attributes to the rendered <script> or <link> tag. Pass true for boolean attributes, a string for valued attributes, or false to omit:

import { StaticAsset, $$attribute } from '@voltage/asset-manager';
export const PetiteVueScript = StaticAsset({
name: 'petite-vue',
file: join(__dirname, 'petite-vue.js'),
tags: [$$attribute({ defer: true, init: true })],
});

This produces <script src="..." defer init></script>. Multiple $$attribute tags are merged in order.

interface AssetModuleOptions {
/**
* Enable chokidar file watchers for BundleAsset instances.
* Intended for development only.
* @default false
*/
watch?: boolean;
}