esbuild Metafile to Asset Manifest
esbuild has no manifest: true option and no plan to add one, so a build that emits app-a1b2c3d4.js leaves your server with no way to answer “which file is app.js today?”. What esbuild does give you is metafile: true, and the metafile already contains every fact a manifest needs: the hashed output paths, the entry point that produced each one, and the stylesheet a JavaScript entry emitted on the side. This page turns that structure into a flat manifest.json you can ship.
The work splits into four decisions — which outputs become keys, what the key is called, what prefix the value carries, and whether the value also carries an integrity digest. Get those right and the generator is forty lines. The esbuild fingerprinting guide covers the naming templates that produce the hashes in the first place; this page assumes they are already set.
What the Metafile Actually Contains
Set metafile: true and esbuild returns result.metafile — a plain object with exactly two top-level keys, inputs and outputs. Here is a complete metafile for a two-file entry (src/app.ts importing src/app.css and lazily importing src/panel.ts), trimmed of nothing:
{
"inputs": {
"src/app.css": { "bytes": 1180, "imports": [] },
"src/app.ts": {
"bytes": 4210,
"format": "esm",
"imports": [
{ "path": "src/app.css", "kind": "import-statement" },
{ "path": "src/panel.ts", "kind": "dynamic-import" }
]
}
},
"outputs": {
"dist/app-a1b2c3d4.js": {
"bytes": 39512,
"inputs": { "src/app.ts": { "bytesInOutput": 3980 } },
"imports": [
{ "path": "dist/chunks/panel-3a4b5c6d.js", "kind": "dynamic-import" }
],
"exports": [],
"entryPoint": "src/app.ts",
"cssBundle": "dist/app-7e8f9a0b.css"
},
"dist/app-7e8f9a0b.css": {
"bytes": 1042,
"inputs": { "src/app.css": { "bytesInOutput": 1042 } },
"imports": []
},
"dist/chunks/panel-3a4b5c6d.js": {
"bytes": 8140,
"inputs": { "src/panel.ts": { "bytesInOutput": 7902 } },
"imports": [],
"exports": ["mount"]
}
}
}
Read it from the outputs side. Every key is a path relative to the working directory, already carrying its hash. Only one of the three outputs has an entryPoint, and that field is the back-reference: it names a key in inputs, which is the logical identity the rest of your system knows the file by. The CSS output has no entryPoint at all — it is claimed instead by the JavaScript entry’s cssBundle field. The lazily imported chunk has neither, because nothing outside the bundle ever names it directly.
entryPoint turns an output path back into a logical name.Field by field, this is what survives into a manifest and what does not:
| Metafile field | Where it lives | What it contributes |
|---|---|---|
outputs key |
top level | the hashed path on disk — the manifest value |
entryPoint |
an outputs entry | the logical name — the manifest key |
cssBundle |
an outputs entry | the stylesheet emitted by a JS entry, which has no entryPoint of its own |
imports[] |
an outputs entry | runtime-fetched chunks; the loader resolves these, so they are never manifest keys |
bytes |
an outputs entry | a cheap integrity signal to compare against the file on disk |
inputs |
top level | source-level attribution for bundle analysis; a manifest needs none of it |
Choosing the Manifest Key
entryPoint gives you a source path, not a URL name. Turning src/pages/app.ts into something a template can reference is the one genuinely opinionated step, and the four reasonable answers behave differently once a project grows past a single package.
| Key strategy | Example key | Breaks when | Choose it when |
|---|---|---|---|
| Output basename | app-a1b2c3d4.js |
never — but the key changes every build | you only need a reverse lookup from URL to source |
| Entry basename | app.js |
two entries in different folders share a filename | one package, flat src/ layout |
Path relative to outbase |
pages/app.js |
never within a single build | nested entries, monorepo packages |
| Full entry path | src/pages/app.ts |
never, but templates now hardcode .ts |
the consumer is a bundler-aware framework |
The outbase-relative form is the one to reach for by default. It is stable across builds, unique within a build, and it mirrors the directory structure entryNames: '[dir]/[name]-[hash]' already produces on disk, so key and value stay visually parallel. Note the extension swap: the key must carry the output extension (.js), not the source extension (.ts), or every template lookup misses.
The stylesheet you did not ask for
Any JavaScript entry that contains import './app.css' produces a second output file. Because that file was never an entry point, iterating outputs and skipping anything without an entryPoint silently drops it — a very common bug whose symptom is an unstyled page in production and a perfectly styled page in development. Since esbuild 0.19 the JS output carries cssBundle naming that file, so the fix is one branch inside the same loop rather than a second pass over the outputs.
Getting the Prefix Right: outdir, outbase, publicPath
Metafile keys are relative to the process working directory, so they start with your outdir. A browser must never see that segment. Two more options shift the path further: outbase decides how much of the source directory structure is preserved in the output tree, and publicPath decides what gets prepended to asset references inside the bundles. Your manifest values must agree with publicPath exactly, or the HTML and the CSS will disagree about where assets live.
publicPath value in both places.The Generator Script
This runs against a metafile already written to disk, which is the shape most CI pipelines want: the build job writes dist/meta.json, a later job turns it into dist/manifest.json. Save it as scripts/manifest-from-metafile.mjs.
// scripts/manifest-from-metafile.mjs
// Usage: node scripts/manifest-from-metafile.mjs dist/meta.json dist/manifest.json
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync, renameSync } from 'node:fs';
import path from 'node:path';
const [, , metaPath = 'dist/meta.json', outPath = 'dist/manifest.json'] = process.argv;
// These four must match the esbuild options the build actually used.
const OUTDIR = process.env.ESBUILD_OUTDIR ?? 'dist';
const OUTBASE = process.env.ESBUILD_OUTBASE ?? 'src';
const PUBLIC_PATH = process.env.CDN_ORIGIN ?? '/';
const WITH_INTEGRITY = process.env.WITH_INTEGRITY === '1';
const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
/** src/pages/app.ts + dist/pages/app-a1b2c3d4.js -> pages/app.js */
function logicalKey(entryPoint, outputPath) {
const rel = path.relative(OUTBASE, entryPoint);
const dir = path.dirname(rel) === '.' ? '' : `${path.dirname(rel)}/`;
const stem = path.basename(rel, path.extname(rel));
return `${dir}${stem}${path.extname(outputPath)}`;
}
/** dist/pages/app-a1b2c3d4.js -> <publicPath>pages/app-a1b2c3d4.js */
function publicUrl(outputPath) {
const rel = path.relative(OUTDIR, outputPath).split(path.sep).join('/');
return PUBLIC_PATH.endsWith('/') ? PUBLIC_PATH + rel : `${PUBLIC_PATH}/${rel}`;
}
function entryValue(outputPath) {
if (!WITH_INTEGRITY) return publicUrl(outputPath);
const digest = createHash('sha384').update(readFileSync(outputPath)).digest('base64');
return { url: publicUrl(outputPath), integrity: `sha384-${digest}` };
}
const manifest = {};
for (const [outputPath, info] of Object.entries(meta.outputs)) {
if (outputPath.endsWith('.map')) continue; // source maps are not manifest entries
if (!info.entryPoint) continue; // shared chunks resolve through imports
manifest[logicalKey(info.entryPoint, outputPath)] = entryValue(outputPath);
// A JS entry that imports CSS emits a stylesheet with no entryPoint of its own.
if (info.cssBundle) {
manifest[logicalKey(info.entryPoint, info.cssBundle)] = entryValue(info.cssBundle);
}
}
// Sort keys so two identical builds produce byte-identical manifests.
const sorted = Object.fromEntries(
Object.keys(manifest).sort().map((key) => [key, manifest[key]]),
);
const tmp = `${outPath}.tmp`;
writeFileSync(tmp, `${JSON.stringify(sorted, null, 2)}\n`);
renameSync(tmp, outPath);
console.log(`wrote ${Object.keys(sorted).length} entries to ${outPath}`);
Run it and inspect the result:
CDN_ORIGIN=https://cdn.example.com/ node scripts/manifest-from-metafile.mjs
cat dist/manifest.json
{
"admin.js": "https://cdn.example.com/admin-e5f6a7b8.js",
"app.css": "https://cdn.example.com/app-7e8f9a0b.css",
"app.js": "https://cdn.example.com/app-a1b2c3d4.js"
}
Sorting the keys is not cosmetic. Object.entries follows insertion order, which follows esbuild’s output order, which can shift when an entry point is added — and an unsorted manifest produces a spurious diff on every release, defeating the build-to-build comparison that CI asset pipeline integration relies on to decide what to invalidate.
The Same Logic as a Plugin
If you drive esbuild from a build script rather than a CI shell step, the same mapping belongs in an onEnd hook so the manifest lands with the bundle and no intermediate meta.json has to exist. The plugin form also reads outdir and outbase from the live options object instead of environment variables, which removes the main failure mode of the standalone script — configuration drift between the build and the generator.
// plugins/manifest.mjs
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync, renameSync } from 'node:fs';
import path from 'node:path';
export function manifestPlugin({
outfile = 'dist/manifest.json',
publicPath = '/',
withIntegrity = false,
} = {}) {
return {
name: 'metafile-manifest',
setup(build) {
build.initialOptions.metafile = true; // the plugin cannot work without it
build.onEnd((result) => {
if (result.errors.length > 0 || !result.metafile) return;
const outdir = build.initialOptions.outdir ?? 'dist';
const outbase = build.initialOptions.outbase ?? 'src';
const prefix = publicPath.endsWith('/') ? publicPath : `${publicPath}/`;
const url = (p) => prefix + path.relative(outdir, p).split(path.sep).join('/');
const key = (entryPoint, outputPath) => {
const rel = path.relative(outbase, entryPoint);
const dir = path.dirname(rel) === '.' ? '' : `${path.dirname(rel)}/`;
return dir + path.basename(rel, path.extname(rel)) + path.extname(outputPath);
};
const value = (p) => {
if (!withIntegrity) return url(p);
const digest = createHash('sha384').update(readFileSync(p)).digest('base64');
return { url: url(p), integrity: `sha384-${digest}` };
};
const manifest = {};
for (const [outputPath, info] of Object.entries(result.metafile.outputs)) {
if (outputPath.endsWith('.map') || !info.entryPoint) continue;
manifest[key(info.entryPoint, outputPath)] = value(outputPath);
if (info.cssBundle) {
manifest[key(info.entryPoint, info.cssBundle)] = value(info.cssBundle);
}
}
const sorted = Object.fromEntries(
Object.keys(manifest).sort().map((k) => [k, manifest[k]]),
);
const tmp = `${outfile}.tmp`;
writeFileSync(tmp, `${JSON.stringify(sorted, null, 2)}\n`);
renameSync(tmp, outfile);
});
},
};
}
// build.mjs
import { build } from 'esbuild';
import { manifestPlugin } from './plugins/manifest.mjs';
await build({
entryPoints: ['src/app.ts', 'src/admin.ts'],
bundle: true,
minify: true,
splitting: true,
format: 'esm',
outdir: 'dist',
outbase: 'src',
entryNames: '[dir]/[name]-[hash]',
chunkNames: 'chunks/[name]-[hash]',
assetNames: 'assets/[name]-[hash]',
metafile: true,
plugins: [
manifestPlugin({
publicPath: process.env.CDN_ORIGIN ?? '/',
withIntegrity: true,
}),
],
});
One ordering constraint: the digest is computed with readFileSync, so the plugin requires the default write: true. If your build sets write: false to post-process output in memory, hash file.contents from result.outputFiles instead — the bytes are identical, but the files are not on disk when onEnd runs.
Adding Integrity Digests
With withIntegrity: true each value becomes an object carrying a sha384- digest in the exact format the integrity attribute expects, so a template can emit both attributes from one lookup:
{
"app.js": {
"url": "https://cdn.example.com/app-a1b2c3d4.js",
"integrity": "sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
}
}
SHA-384 is the practical default: SHA-256 is also valid but browsers pick the strongest declared algorithm anyway, and the filename hash and the integrity digest serve different purposes — one addresses the file, the other authenticates it.
Verifying Every Manifest Target Exists
The failure this catches is a manifest that references a file the upload step never produced, which surfaces as a 404 for real users rather than a build error. Run it after the build and before the upload; it handles both the plain-string and the integrity-object value shapes.
#!/usr/bin/env bash
# scripts/verify-manifest.sh — every manifest target must exist under dist/
set -euo pipefail
MANIFEST="${1:-dist/manifest.json}"
OUTDIR="${2:-dist}"
PUBLIC_PATH="${CDN_ORIGIN:-/}"
missing=0
total=0
while IFS= read -r url; do
total=$((total + 1))
rel="${url#"$PUBLIC_PATH"}"
if [ ! -f "$OUTDIR/$rel" ]; then
echo "MISSING $OUTDIR/$rel"
missing=$((missing + 1))
fi
done < <(jq -r '.[] | if type == "string" then . else .url end' "$MANIFEST")
echo "checked $total manifest targets, $missing missing"
[ "$missing" -eq 0 ]
CDN_ORIGIN=https://cdn.example.com/ bash scripts/verify-manifest.sh
# checked 3 manifest targets, 0 missing
The final [ "$missing" -eq 0 ] is the exit status, so the script fails the job under set -e without an explicit exit. Extend it to assert the reverse direction too — every hashed file on disk is named by the manifest — using the approach in validating asset manifests in CI.
When to Reconsider
Generating your own manifest is the right call whenever something resolves asset URLs at request time, but three situations argue the other way.
Nothing reads the manifest. If a static host serves dist/ directly and your HTML is itself a build output with the hashed names already inlined, the manifest is a file nobody opens. Skip it and keep metafile: true purely for CI diffing.
You need chunk-level dependency data. A flat key-to-URL map deliberately omits the imports graph. If you are emitting <link rel="modulepreload"> tags for an entry’s dynamic chunks, a flat manifest cannot answer the question and you need to walk metafile.outputs[...].imports at build time instead — or adopt the richer per-entry schema described in asset manifest generation.
You are already inside Vite or Rollup. Both emit a manifest natively with a superset of this schema. Running esbuild alongside them to produce a second, differently shaped manifest gives your server two sources of truth for the same question — pick one.
Frequently Asked Questions
Why does my CSS file never appear in the manifest?
Because the CSS output has no entryPoint field, so an if (!info.entryPoint) continue filter drops it. Read cssBundle on the JavaScript entry’s output instead, as the loop above does. If cssBundle is missing entirely, your esbuild is older than 0.19 — upgrade, or match the CSS output by swapping the extension on the JS output path, which works only while the two files share a hash-bearing basename.
Can I use esbuild’s own hashes instead of computing SHA-384?
Not for integrity. The hash in the filename is esbuild’s internal, undocumented digest of the output bytes; the integrity attribute requires a specific algorithm and base64 encoding that browsers verify. They are independent values with independent purposes, and you need both.
Should the manifest value include the origin, or just a path?
Whichever your publicPath uses — they must be identical strings. If publicPath is / and CSS files reference /assets/logo-3a4b5c6d.png, a manifest carrying absolute origins makes your HTML load from the CDN while your CSS loads from the app origin, which quietly doubles connections and breaks any CORS-sensitive asset.
Do source maps belong in the manifest?
No. A .map file is fetched by devtools from the sourceMappingURL comment inside the bundle, never by your templates. The generator skips them explicitly. Upload them (or do not, if they are private), but keep them out of the lookup table.
Related
- esbuild Fingerprinting Plugins — parent guide covering the naming templates and hash configuration this page consumes
- Asset Manifest Generation — schema conventions and richer per-entry formats shared across bundlers
- Validating Asset Manifests in CI — turning the existence check above into a blocking pipeline gate
- CI/CD Asset Pipeline Integration — where the metafile and manifest artifacts live between build and deploy jobs
- Integrating esbuild with CDN Fingerprinting Workflows — upload sequencing and targeted purge once the manifest exists