Manifest integrations
The resolver from pitlane/assets reads a manifest of emitted browser URLs and per-module JavaScript and CSS dependencies. The Vite plugin produces this manifest for Vite apps. Other build systems need an integration that supplies the same information.
createAssetManifest(build) from pitlane/assets/build (or @pitlane/assets/build) does the shared work once you describe what your bundler emitted. It walks the dependency graph to order preloads and stylesheets, then resolves public URLs into the manifest createAssetResolver reads. The Vite plugin calls this same function, so a manifest from your integration behaves exactly like one from Vite.
The generator does no file I/O: your integration reads the bundler's output and writes the manifest. It adds no dependency edges of its own and generates no import map. It imports no bundler, Vite included.
What your integration supplies
import { createAssetManifest } from "pitlane/assets/build";
let manifest = createAssetManifest({
base: "/",
environments: {
client: {
role: "client",
chunks: {
entry: {
file: "assets/entry.browser-a1.js",
modules: ["app/entry.browser.ts"],
imports: ["runtime"],
dynamicImports: ["counter"],
stylesheets: ["assets/entry.browser-c3.css"],
},
counter: {
file: "assets/counter-d4.js",
modules: ["app/counter.tsx"],
imports: ["runtime"],
dynamicImports: [],
stylesheets: ["assets/counter-e5.css"],
},
runtime: {
file: "assets/runtime-b2.js",
modules: ["app/runtime.ts"],
imports: [],
dynamicImports: [],
stylesheets: [],
},
},
entries: { "app/entry.browser.ts": "entry", "app/counter.tsx": "counter" },
assets: { "app/styles.css": "assets/styles-i9.css" },
},
ssr: {
role: "server",
chunks: {
server: {
file: "entry.server.js",
modules: ["app/entry.server.tsx", "app/counter.tsx"],
imports: [],
dynamicImports: [],
stylesheets: ["assets/document-g7.css", "assets/counter-e5.css"],
},
},
entries: {},
assets: {},
},
},
});The fields of build:
| Field | Contents |
|---|---|
base |
The public base relative filenames resolve against: /, /docs/, https://cdn.example/app/, ./, or "" |
environments |
Every environment that participated, by name. Exactly one has role: "client"; the others are "server" |
importMap |
The import map your bundler generated for the client, if it generated one |
Each environment has:
| Field | Contents |
|---|---|
chunks |
Every emitted JavaScript chunk, keyed by an identity you choose. Keys only need to be unique within the environment |
entries |
Browser-entry source keys mapped to chunk identities. Only the client environment has entries |
assets |
Source keys mapped to emitted non-script files, such as a stylesheet entry or an image |
Each chunk has:
| Field | Contents |
|---|---|
file |
The emitted filename, or an absolute public URL |
modules |
Source keys of every module the chunk contains |
imports |
Identities of the chunks it imports statically, in import order |
dynamicImports |
Identities of the chunks it loads through import() |
stylesheets |
Emitted stylesheet filenames, or absolute public URLs, the chunk requires |
All four lists are required on every chunk, even when they are empty.
Source keys
Source keys are paths relative to the project root with forward slashes, such as app/counter.tsx. A module outside the root, such as a linked workspace package, keeps leading .. segments: ../shared/widget.ts. Convert your bundler's absolute paths and file:/// URLs to this form before passing them in. A key the build machine's checkout path leaks into cannot match on another machine. The generator rejects absolute file URLs.
Keys are normalized the way the resolver normalizes them: ./app/a.ts, /app/a.ts, and file:app/a.ts are all app/a.ts, and . and .. segments inside a key collapse. Resolve symlinks the way your bundler does, consistently, so one module has one key.
Public URLs
A file, stylesheet, or asset value that is relative resolves against base, with a / inserted when base lacks a trailing one. An absolute value is used as is. That means a value starting with / (including //) or with a URL scheme such as https:.
base |
assets/a.js becomes |
|---|---|
/ |
/assets/a.js |
/docs/ or /docs |
/docs/assets/a.js |
https://cdn.example/app/ |
https://cdn.example/app/assets/a.js |
./ |
./assets/a.js |
"" |
assets/a.js |
What the generator computes
For every module in every environment, it records the module's preloads and stylesheets:
- Preloads start with the chunk holding the module, followed by every chunk reachable through static
imports, shallowest first. Chunks at the same depth keep the order yourimportslists give them. Each URL appears once. - Stylesheets come from the
stylesheetsof the same chunks, with a dependency's listed before those of the chunk importing it. Each URL appears once. - Edges in
dynamicImportsmust name real chunks. The generator validates those references without adding their preloads or stylesheets to the importer. - A cycle of imports is walked once. Each chunk in it appears once.
- A module in several chunks gets the union of every chunk's results, so report every chunk that contains it.
A browser entry's preloads always start with its own entry chunk, even when your modules lists leave the entry module out.
Environments stay separate. A server environment's graph never contributes to the client's metadata, and a module that appears only in a server graph never becomes a browser entry. That is what lets an app ask for a server module's stylesheets without compiling the server module for the browser.
The returned manifest has mode: "build", entries and assets mapped to public URLs, per-environment modules metadata, and importMap set to your bundler's map or { imports: {} }. It is plain JSON-compatible data.
Writing and consuming the manifest
The generator leaves serverEnvironment unset, because one build can serve several servers. Set it to the environment a server was built as when you hand that server its manifest. It decides which server graph getStylesheets reads by default:
import { createAssetResolver } from "pitlane/assets";
let assets = createAssetResolver({ ...manifest, serverEnvironment: "ssr" });
await assets.getScriptEntry("app/counter.tsx");
// { href: "/assets/counter-d4.js",
// preloads: ["/assets/counter-d4.js", "/assets/runtime-b2.js"],
// importMap: { imports: {} } }
await assets.getStylesheets("app/entry.server.tsx");
// ["/assets/document-g7.css", "/assets/counter-e5.css"]Getting the manifest to the server is your integration's job. Serialize it with JSON.stringify into a module in the server's output. Then either alias pitlane/assets/manifest to that module, so application code written for the Vite plugin runs unchanged, or import the object directly and pass it to createAssetResolver.
Copy any files a server environment emitted, such as stylesheets imported only by server code, into the client's public output, so their URLs resolve.
Errors
The generator stops at the first problem and names the environment and the chunk or key involved.
| Message | Cause |
|---|---|
createAssetManifest() needs exactly one client environment; found none. |
No environment has role: "client", or more than one does |
chunk "…" in the "…" environment imports "…", which is not a chunk in that environment. |
An imports or dynamicImports edge names a missing chunk |
browser entry "…" in the "…" environment names chunk "…", which is not a chunk in that environment. |
An entry points at a missing chunk |
the "…" environment lists browser entries, but only the client environment has them. |
A server environment has entries |
chunk "…" in the "…" environment has no file. |
A chunk has no file |
chunk "…" in the "…" environment has no imports list. |
A chunk is missing one of its four lists |
source key "…" maps to "…" in the "…" environment and "…" in the "…" environment. |
Two spellings of one key, or two environments, name different entries or assets for it |
source key "…" in the "…" environment is an absolute file URL. |
A key was not normalized to a project-relative path |
The generator cannot detect a fact your integration omitted. A chunk whose imports list is incomplete produces incomplete preloads without an error.
Rsbuild integration example
Pitlane has no Rsbuild plugin. The Rsbuild example shows what an integration for another bundler looks like end to end. Its small plugin, pitlane-assets.ts, hands Rsbuild's real compilations to createAssetManifest and writes the result for a Node server, which reads it with createAssetResolver. The plugin is a file the application owns, not a supported Pitlane API. The server renders the document with @remix-run/html-template, which escapes each URL the resolver returns, and serves it through @remix-run/node-fetch-server, the adapter Pitlane's Fetch server plugin also uses. The example installs those two, @pitlane/assets, and @rsbuild/core@2.2.12, and neither Vite nor the remix package.
The output it assumes
The example builds two Rsbuild environments:
webtargets browsers withoutput.module: true. Rspack emits every chunk as an ES module and loads lazy chunks withimport(). An entry chunk statically imports the other chunks its entrypoint needs at startup, so the entry's URL is the only module script a document needs.nodetargets Node as ES modules, withoutput.emitCss: trueso stylesheets the server imports are written as files.
The plugin checks that the web compilation uses output.module, the module chunk format, and import chunk loading, and fails the build for any other combination. Rsbuild's generated HTML is turned off, because the server renders the document.
What the plugin reads
In onAfterEnvironmentCompile, the plugin reads each environment's Rspack compilation from stats.compilation:
createAssetManifest input |
Read from the compilation |
|---|---|
environment role |
client for the web target, server for node |
| chunk identity | the chunk's id |
file |
the chunk's one .js file |
stylesheets |
the chunk's .css files |
modules |
every module in the chunk, with concatenated modules expanded into their parts |
imports |
for an entrypoint's entry chunk, the entrypoint's other chunks |
dynamicImports |
the chunks of every lazy chunk group whose import() sits in a module of this chunk |
entries |
each client entrypoint's entry module, mapped to its entry chunk |
assets |
each emitted file whose asset info names a sourceFilename |
Source keys are module paths relative to the project root, taken after Rspack resolves symlinks, so a linked module outside the root keeps its leading ../. Once every environment has compiled, onAfterBuild passes the collected graphs to createAssetManifest with the web environment's output.assetPrefix as base. It then copies the server's stylesheets into the web output and writes the manifest, with serverEnvironment: "node", to dist/server/pitlane-assets-manifest.json. The server reads that file once at startup and passes it to createAssetResolver.
Limits
- Only
rsbuild build. The development server, hot module replacement, and watch rebuilds are not translated. - Only the browser ES module output above. Classic scripts,
output.module: false, and runtime chunks that HTML loads with separate script tags are not handled. - A lazily imported module's preloads name only its own chunk. Chunks Rspack loads beside it in parallel go in the importing chunk's
dynamicImports, never in its staticimports. output.assetPrefix: "auto"fails the build, because the manifest needs a known public base.- Every chunk must have exactly one JavaScript file. A configuration that splits CSS into chunks of its own fails the build.
- Rspack generates no import map, so
importMapis{ imports: {} }andrenderImportMapreturns an empty string.
Run it
The example needs Node 22.18 or later, which runs its TypeScript configuration and checks directly. Copy the example directory out of the Pitlane repository, so that Node cannot find the Vite that Pitlane itself builds with, then run:
npm install
npm run check
npm startnpm run check runs the example's type check against its own tsconfig.json, then the build, then npm run verify.
npm run verify first confirms that neither Vite nor the remix package is installed. It then reads the written manifest through createAssetResolver and checks each answer against the files in dist/web. The entry's preloads hold the entry chunk and its shared startup chunk. The lazy module is observed but is neither a preload of the entry nor a browser entry. The server's stylesheet is found through the node graph. The SVG resolves to its emitted file. It prints what the resolver returned. npm start serves the page at http://localhost:3000/, where the client entry inserts the logo and the Load the lazy module button imports the lazy chunk and its stylesheet.