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/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()],
})
export class AppModule {}

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.js',
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.js',
url: 'https://unpkg.com/htmx.org@2/dist/htmx.min.js',
});

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.js';
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;
/** Add the defer attribute to the generated script tag. */
defer?: boolean;
/** Embed content inline in a <script> or <style> tag instead of linking. */
inline?: boolean;
/** Ordering constraints relative to other assets. */
dependencies?: Dependency[];
}

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:

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

After(VendorScript) ensures app.js is rendered after vendor.js. Before(target) is the inverse — use it when the asset being declared should precede another.