Asset resolution
A server-rendered page has to name its browser assets: the script that boots the page, the stylesheets it needs, modulepreload hints for the chunks that script imports, and the chunk of every island it hydrates. Those URLs differ between vite dev and a production build, and only the bundler knows them.
pitlane/assets answers those questions through an asset resolver you construct from a manifest. The Vite plugin supplies the manifest. The resolver turns source paths such as app/entry.browser.ts into the URLs the current dev server or build produced for them. It imports nothing from Remix or any UI library, so the same object serves a Remix document, a React or Vue renderer, or a hand-written HTML string.
The resolver has the resolution methods of the asset server from remix/assets: getScriptEntry, getHref, getPreloads, and getImportMap, plus getStylesheets. A Remix document written for that asset server runs unchanged. Only the file that constructs it differs.
Install
Through the umbrella package, which most apps already depend on:
npm
npm add pitlaneyarn
yarn add pitlanepnpm
pnpm add pitlanebun
bun add pitlanedeno
deno add npm:pitlanevp
vp add pitlanevlt
vlt add pitlanenub
nub add pitlaneThe scoped package also works on its own. Add it to dependencies, not devDependencies, since application code imports its runtime:
npm
npm add @pitlane/assetsyarn
yarn add @pitlane/assetspnpm
pnpm add @pitlane/assetsbun
bun add @pitlane/assetsdeno
deno add npm:@pitlane/assetsvp
vp add @pitlane/assetsvlt
vlt add @pitlane/assetsnub
nub add @pitlane/assetsExamples on this page import from pitlane/assets. With the scoped package, import the same names from @pitlane/assets.
The Vite adapter needs Vite 8.1 or later. The resolver and manifest generator do not require Vite.
Setup
remix() from pitlane/vite-plugin-remix installs the assets plugin itself. Pass its options as remix({ assets: { … } }). Any other Vite app adds assets() directly:
import { assets } from "pitlane/assets/vite-plugin";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [assets()],
});Then construct the resolver once, in a module of your own:
import { createAssetResolver } from "pitlane/assets";
import manifest from "pitlane/assets/manifest";
export let assets = createAssetResolver(manifest);
export let scriptEntry = await assets.getScriptEntry("app/entry.browser.ts");
export let stylesheetHref = await assets.getHref("app/styles.css");The plugin replaces pitlane/assets/manifest with the manifest for the current dev server or build. Each resolver method returns a promise. In app/assets.ts above, the document's own entry and stylesheet resolve once, when the module loads.
The scoped packages work the same way: @pitlane/assets, @pitlane/assets/manifest, and @pitlane/assets/vite-plugin.
Source keys
Every method takes a source key: the module's path relative to the project root, with forward slashes. These all name the same module:
| Spelling | Why you might see it |
|---|---|
app/counter.tsx |
The plain form |
./app/counter.tsx |
A relative-looking path |
/app/counter.tsx |
A leading slash marks the project root, not the filesystem root |
file:app/counter.tsx |
The island identity remix() writes |
app/counter.tsx#Counter |
An export fragment, which lookups ignore |
. and .. segments inside a key collapse, so app/lib/../counter.tsx is app/counter.tsx. A module outside the project root keeps its leading .. segments. A linked workspace package might have the key ../shared/widget.ts. That key works from any checkout directory as long as the project and the linked package keep the same relative layout.
Absolute file URLs are not keys. file:///Users/me/app/counter.tsx and file:/Users/me/app/counter.tsx throw an error asking for the portable key. A manifest never contains the build machine's checkout path, so a deployed server has nothing to compare an absolute path against.
# starts the export fragment, so a source file whose name contains # cannot be a key.
Resolver methods
| Method | Returns |
|---|---|
getScriptEntry(key) |
{ href, preloads, importMap } for a browser entry |
getHref(key) |
The URL of a browser entry, a stylesheet, or another emitted asset |
getPreloads(key | key[]) |
modulepreload URLs for scripts |
getStylesheets(key | key[]) |
Stylesheet URLs a module needs, from the client graph and this server's |
getImportMap(key | key[]) |
The client build's import map |
Methods that take an array return one list in argument order, with duplicate URLs removed.
getScriptEntry
let { href, preloads, importMap } = await assets.getScriptEntry("app/entry.browser.ts");In a build, href is the emitted chunk and preloads lists that chunk followed by its static imports, shallowest first. Dynamic imports are not preloaded. importMap is the complete client map when chunk import maps are on, and { imports: {} } otherwise.
Under vite dev, href is the module's dev URL (/app/entry.browser.ts), preloads is [], and importMap is { imports: {} }.
getHref
Returns the URL of a registered browser entry, a stylesheet, or another asset, such as an image you reference by key. Under vite dev it returns the dev URL.
getPreloads
Each script key contributes its chunk and its static JavaScript dependencies, never their CSS, so you can render every result as <link rel="modulepreload">. A stylesheet key passed explicitly contributes its own URL. Render those as <link rel="preload" as="style">, not as modulepreload. Under vite dev the result is always [].
getStylesheets
Returns the stylesheets a module needs, following its static imports. A dependency's stylesheets come before the stylesheets of the module importing it. Modules loaded only through import(), such as lazy routes, are left out.
Every lookup reads the client build's graph and the graph of the server environment the running server was built as. A key only has to appear in one of them, and the result combines both:
let stylesheets = await assets.getStylesheets(["app/entry.server.tsx", "app/entry.browser.ts"]);When both builds emit a stylesheet with the same contents, the result links it once, as the client build's file. Vite emits one CSS file per chunk. A server chunk that combines a shared stylesheet with its own produces a file unlike the client's, and both are linked. The Remix document queries the server entry alone, with no overlap.
A server's manifest describes those two graphs and no other server environment's, in development and in a build alike. A key that only another server environment imports fails with the missing-module error. There is no option to read one graph alone: a document needs the stylesheets of both. A stylesheet lookup never turns the module into a browser entry, so asking for the CSS of app/entry.server.tsx does not compile your server code for the browser.
With Vite's build.cssCodeSplit: false, the environment emits one combined stylesheet. Lookups return that stylesheet rather than a route-specific subset. It also contains the lazy-module CSS that Vite combines into it.
Under vite dev, CSS from server graphs comes back as dev URLs. CSS a browser module imports is injected by Vite itself, so the client graph reports none.
getImportMap
Checks that each key is part of the client graph, then returns the complete client import map, not a subset for those keys. Lazy modules and later islands need the rest of it. An empty array returns { imports: {} }, as do vite dev and builds with chunk import maps off.
Registering browser entries
Only a browser entry has a script URL. The plugin registers one when:
- a server module calls
getScriptEntryorgetHrefon a resolver with a string literal, asapp/assets.tsdoes above; - you list it in
assets({ include: ["app/widgets/chart.ts"] }), which is how a computed path gets registered; - it is already a client input in your Vite config;
- another plugin declares it, as
remix()does for each island. See Declaring entries from another plugin.
A literal call counts only when its receiver is the result of createAssetResolver(), imported from pitlane/assets or @pitlane/assets, or a top-level variable holding that result. The variable still counts when the module copies it (const resolver = assets) or calls a copy of the constructor (const make = createAssetResolver), and when another module imports it by any name, through export { assets } from "./assets.ts" or export * from "./assets.ts", or as a default export. A copy counts only as a plain variable, not a destructured one. Each of those names needs exactly one declaration in its module. A second one anywhere in that module, even a parameter of an unrelated function, disqualifies every call through the name. A same-named method on another object registers nothing, and neither does a createAssetResolver imported from another module, a call through a namespace import (import * as), a function parameter, or this. Nor does a resolver imported from a dependency that Vite externalizes, since the plugin does not read that package's source; construct the resolver in a module of your own app, or list those keys in include.
getStylesheets, getPreloads, and getImportMap only read graphs the build already has. They register nothing, and routes discovered through import.meta.glob need no second list.
A script registered this way is emitted with its exports intact, and a stylesheet as a CSS file. Asking for a server module's stylesheets never puts that module in the client build.
An app with no browser entries, such as a server-rendered app that only links stylesheets, still builds without an index.html.
Declaring entries from another plugin
During development, plugins declare discovered browser entries through api.setBrowserEntries() rather than emitting chunks. Find the pitlane-assets plugin in configResolved:
import type { AssetsPluginApi } from "pitlane/assets/vite-plugin";
let api: AssetsPluginApi;
// Inside your plugin object:
configResolved(config) {
let assetsPlugin = config.plugins.find(plugin => plugin.name === "pitlane-assets");
if (!assetsPlugin) throw new Error("my-islands needs assets() from pitlane/assets/vite-plugin.");
api = assetsPlugin.api;
},Then, in the transform hook where your plugin already finds its islands in a server module, report them for that module:
if (this.environment.mode === "dev") {
api.setBrowserEntries({ environment: this.environment.name, owner: id, entries: browserModules });
}environment is the server environment doing the transform, and owner is the resolved id of the module being transformed. browserModules holds the resolved ids of the browser modules that module declares, such as (await this.resolve(specifier, id))?.id for each island specifier your plugin found.
- Each call replaces everything that module declared in that environment. The same module can declare different entries in
ssrand in another server environment without either overwriting the other. - An empty
entrieslist removes the declaration. Call the method on every transform that could declare entries, including ones that find none. Otherwise a deleted island stays registered. - A declaration counts only while its owner is part of that environment's server graph, including lazy routes. Once nothing imports the owner, or the file is deleted, its entries are dropped.
Builds keep using ordinary client inputs and this.emitFile. remix() calls this API for its islands, so a Remix app needs none of this code.
Under vite dev
No bundle exists during development. The plugin builds the manifest from Vite's module graph the first time a server environment imports pitlane/assets/manifest. To learn which stylesheets each module imports, it transforms the server graph and the client graphs of registered browser entries without running that code, so lazy routes stay unloaded until a request needs them. The result is plain data with no live connection to Vite, so it reaches a Cloudflare worker the same way it reaches Node.
Development follows server entries imported by Vite's module runner as well as configured build inputs. The development entry can differ from the production entry without losing its asset metadata.
When you add or remove an import, a glob route, or a browser entry, the plugin invalidates that environment's manifest and every module that imports it. The next import of your server entry builds a fresh manifest and re-runs those modules, so await assets.getStylesheets(…) at the top of a module sees the change too. A rebuild transforms only the files that changed. Every other module keeps the imports recorded the last time it was transformed. fetchServer() and Cloudflare's dev server re-import the entry on each request. A custom server that holds on to a handler across requests will keep serving the old one. Editing only a stylesheet's rules leaves the manifest alone, and Vite updates the CSS in place.
A stylesheet used on both sides keeps its server-rendered <link> instead of gaining a second browser-injected copy. This preserves the document's CSS cascade order. Rule edits update the linked stylesheet without resetting browser state.
Browser code cannot import the manifest. It describes your server graph, so a browser module that imports it throws a server-only error when it runs.
Plugin options
| Option | Default | Meaning |
|---|---|---|
include |
[] |
Source keys of browser entries the server code names with computed strings |
serverEnvironments |
["ssr"] |
The Vite environments that run your server. Each one's server bundle, or dev server graph, receives a manifest naming it, which getStylesheets reads by default alongside the client. Importing the manifest from any other server environment is an error naming the environment and this option |
chunkImportMap |
off | Turns on chunk import maps for the client build |
allowFiles |
unset | Files and globs the browser may load. Setting it turns on the browser boundary |
allowPackages |
[] |
Packages the browser may load, with their dependencies. Requires allowFiles |
denyFiles |
[] |
Files and globs the browser may not load, even when allowed. Requires allowFiles |
Before building the client, the plugin builds the server environments, because they reveal which modules are browser entries. A platform plugin that orchestrates builds itself, such as Cloudflare's, keeps doing so, and each environment builds once. vite build --watch is not supported: its watchers start every environment at once, so the client fails with the build-order error. Use vite dev while editing.
The build writes each server environment's manifest as a separate module at the root of that environment's output directory, and the server bundle imports it by a relative path. pitlane/assets/vite-plugin exports the file name as ASSETS_MANIFEST_FILE. A host that deploys a single module has to bundle that file into the server bundle after vite build finishes. Read the name from the constant rather than writing it out.
The plugin replaces both pitlane/assets/manifest and @pitlane/assets/manifest, so either specifier works in app/assets.ts.
Browser boundary
By default, every file a browser module imports reaches the browser. The build bundles it into the client output, and the dev server serves it. The asset server from remix/assets is stricter and serves only the files you allow. assets() accepts the same three options to hold a Vite app to that boundary:
assets({
allowFiles: ["app/**/public/**"],
allowPackages: ["remix", "@shopify/hydrogen"],
denyFiles: ["app/**/*.test.*", "app/**/*.spec.*"],
});With remix(), pass them as remix({ assets: { allowFiles, allowPackages, denyFiles } }). The same values then describe what the Node asset server serves and what the Vite build lets through.
allowFiles turns the boundary on. allowPackages and denyFiles only refine it, so passing either one without allowFiles is a configuration error.
The options match the way remix/assets reads them:
allowFilesanddenyFileshold file paths and globs. Relative values resolve from the Vite root. An absolute file path matches that file, and a directory path matches every file below it.allowPackagesholds exact package names, such asremixor@shopify/hydrogen, never a path inside one. Surrounding whitespace is trimmed. Each package'sdependenciesand installedoptionalDependenciesare allowed with it, and so are their own dependencies. Peer dependencies are not, so list each peer the browser loads. A name that the project root cannot resolve fails withCould not resolve allowed package "name".denyFilestakes precedence over both lists.@oxc-project/runtimeis always allowed, because compilers inject imports of its helpers into code you wrote.
What a build checks
vite build fails when a file outside the boundary would reach the browser:
- any file in the client module graph, even a module whose code tree-shaking drops, because the asset server would refuse its import;
- any stylesheet or asset a stylesheet reads in, such as an
@imported stylesheet, a preprocessor partial, or aurl()target the build emits or inlines; - any file a worker bundle contains, for a worker the client starts with
new Worker(new URL(…, import.meta.url))or imports with?worker; - the stylesheets a server environment links and the assets its code imports, which the build copies into the client output.
Vite's CSS plugin also reports files that PostCSS plugins watch, such as Tailwind's content files. The boundary takes only stylesheets and the file types Vite treats as assets from those reports, so a watched source file passes. A watched stylesheet counts as read in even when the plugin never inlines it. A file a CSS module names in composes: … from is not checked, because Vite reports no dependency on it.
The error lists every such file, with the module or stylesheet that imports it where there is one:
[assets] Outside the browser boundary:
app/data/storefront.ts (imported by app/public/island.ts): not in allowFiles or allowPackages
app/public/island.test.ts (imported by app/public/island.ts): matches denyFiles "app/**/*.test.*"What vite dev checks
Transforming a module outside the boundary for the browser fails with the same message, and Vite shows it in its error overlay. So does transforming a stylesheet that imports a stylesheet, or references an image, font, or other Vite asset type, from outside the boundary. Any other file a stylesheet references fails when the browser requests it. The manifest transforms the client graphs of browser entries too, so a server request that builds the manifest fails with the same error. A file the dev server sends as it is, such as an image a stylesheet references, gets a 500 response carrying that message.
Vite prebundles dependencies under vite dev, and the boundary checks a prebundled dependency by the package entry it came from. A package that reaches the browser only inside another prebundled package is caught by the build.
Neither mode checks HTML documents, Vite's own client runtime, virtual modules, or files in Vite's publicDir, which Vite publishes as they are. The modules and assets an HTML document loads are checked like any others, and so is an HTML file a module imports as text with ?raw.
Public URLs and base
Build URLs carry Vite's base. With base: "/docs/", an entry resolves to /docs/assets/entry.browser-a1.js. A CDN base such as https://cdn.example/app/ produces absolute URLs on that host.
A relative base such as ./ produces relative URLs like ./assets/entry.browser-a1.js, which the browser resolves against the URL of the document it is reading. A document served at a nested route, such as /blog/hello, would look for /blog/assets/…. So a server-rendered app with nested routes needs an absolute base, or a <base href> element pointing at the directory its assets are served from.
With Remix
Pass the resolver to Remix's own render() middleware. When an island renders, Remix calls assets.getScriptEntry("file:app/counter.tsx") and hoists the result's preloads into the head, so islands get preload hints without any code of yours:
import { render } from "remix/middleware/render";
import { createRouter } from "remix/router";
import { assets } from "./assets.ts";
let renderMiddleware = render({ assets });
export let router = createRouter({ middleware: [renderMiddleware] });The document reads the browser entry like the remix CLI template's document does:
import type { Handle, RemixNode } from "remix/component";
import { ImportMap } from "remix/component/server";
import { scriptEntry, stylesheetHref } from "./assets.ts";
export function Document(handle: Handle<{ children?: RemixNode }>) {
return () => {
let { href, importMap, preloads } = scriptEntry;
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<link rel="stylesheet" href={stylesheetHref} />
<ImportMap value={importMap} />
{preloads.map(preloadHref => (
<link key={preloadHref} rel="modulepreload" href={preloadHref} />
))}
<script type="module" src={href} />
</head>
<body>{handle.props.children}</body>
</html>
);
};
}A renderer you call directly, such as renderToStream, resolves an island by splitting off the export name itself: { ...(await assets.getScriptEntry(entryId)), exportName }.
Without Remix
Any renderer can use the returned strings. This builds a document head as plain HTML, escaping each URL for the quoted attribute it goes into:
import { renderImportMap } from "pitlane/assets";
import { assets } from "./assets.ts";
let attribute = (text: string) =>
text.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<");
let entry = await assets.getScriptEntry("src/entry.client.ts");
let stylesheets = await assets.getStylesheets("src/entry.server.ts");
let head = [
renderImportMap({ value: entry.importMap }),
...stylesheets.map(href => `<link rel="stylesheet" href="${attribute(href)}">`),
...entry.preloads.map(href => `<link rel="modulepreload" href="${attribute(href)}">`),
`<script type="module" src="${attribute(entry.href)}"></script>`,
].join("\n");Escape URLs before putting them in HTML attributes. A configured base or filename can contain a quote or <. JSX handles attribute escaping for you. Plain HTML strings need the attribute function above. renderImportMap escapes its own output.
Framework examples
Five small apps under packages/assets/examples show the resolver with a renderer that is not Remix. Each one installs @pitlane/assets as an ordinary dependency and serves development requests through fetchServer(). The document is an @remix-run/html-template template. The build has a client and a server environment, with a verify script and a browser check run against the output.
| Example | Shows |
|---|---|
react-router |
React 19 with React Router's data router. Routes come from import.meta.glob with a source key each; the matched keys go to getPreloads and getStylesheets |
vue-router |
Vue Router with pages written in Vue's Vapor mode inside a server-rendered shell |
solid-router |
Solid 2.0 with Solid Router: the pages are lazy routes registered as browser entries, so Solid's own asset resolver gets each page's script URL |
preact-islands |
Preact islands served through @remix-run/fetch-router; a transform registers each island with a literal getScriptEntry call |
lit-islands |
Lit elements rendered with declarative shadow roots and hydrated in place; a registry module holds the literal getScriptEntry calls |
No route-specific API is involved. The manifest indexes every module the glob discovered, so a computed key works in the metadata methods. Vue Vapor and Solid 2.0 are prereleases, pinned exactly in those two examples.
Chunk import maps
Off by default for assets(), and on by default under remix(). With them off, chunks import each other by their final URLs, and a document needs no import map.
Turning them on makes Vite give each chunk a stable identifier and rewrite imports to use it, then emit a map from those identifiers to the current hashed files. When only a dependency's code changes, its map target can change while importing chunks keep the same bytes and filenames.
assets({ chunkImportMap: true });
// the Remix plugin has them on; turn them off with:
remix({ assets: { chunkImportMap: false } });Vite's own build.chunkImportMap in the client environment's config also sets it. When both are set, the plugin option wins, so chunkImportMap: false overrides a native true. With neither set, the plugin's default applies.
With maps on, getScriptEntry and getImportMap return the complete map. Every document must deliver it before any modulepreload link or module script. Remix's <ImportMap> component handles that, and other renderers can use renderImportMap.
Preloads keep listing emitted chunk URLs. An island's moduleUrl in the page is also the emitted chunk URL.
Requirements and limits:
- Browsers need import maps and
import.meta.resolve. - The option cannot be combined with Vite's
experimental.renderBuiltUrl. The build stops with an error naming both.renderBuiltUrlalone works with maps off, so a Remix app that uses it passesremix({ assets: { chunkImportMap: false } }). - Caching holds only for changes to a dependency's content. Changing exports, chunk membership, CSS, assets, or compiler output can still change importing chunks.
- Vite derives some identifiers from the module's absolute path. Building the same source from a different checkout directory changes every chunk that imports an entry or island chunk.
- Cache headers and keeping old hashed files available are your deployment's job.
Choose vendor groups through Vite's environments.client.build.rolldownOptions.output.codeSplitting.groups. [{ name: "vendor", test: /node_modules/ }] puts dependency modules in a vendor group. Pitlane does not choose groups for you.
Rendering the import map
renderImportMap({ value, nonce }) returns a complete <script type="importmap"> element as a string:
import { renderImportMap } from "pitlane/assets";
renderImportMap({
value: { imports: { "/assets/counter-Bj6c.js": "/assets/counter-BuA5.js" } },
nonce: "r4nd0m",
});
// <script type="importmap" nonce="r4nd0m">{"imports":{"/assets/counter-Bj6c.js":"/assets/counter-BuA5.js"}}</script>- It writes every
<in the map as the JSON escape\u003c, so no key or URL can close the script element. The browser parses the same strings back. nonceis written as an escaped, quoted attribute. The helper neither generates a nonce nor sets a Content Security Policy.- It returns
""when the map has no mappings inimports,scopes, orintegrity, including scopes that are all empty. That coversvite devand builds with maps off, where it renders nothing. - It serializes one map. It does not merge maps or install them in a live page. Insert the string into server-rendered HTML before your module scripts, through your renderer's raw-HTML mechanism. Setting it with client-side
innerHTMLdoes not install a map.
Outside Vite
Published as is, pitlane/assets/manifest exports { mode: "unavailable" }. Constructing a resolver from it succeeds, so importing app/assets.ts in a test runner or type checker does not fail. Every method call then rejects with an error explaining how to supply a manifest.
A test, or a build that Vite did not run, can pass a manifest object of its own to createAssetResolver, or alias pitlane/assets/manifest to a module that exports one. Manifest integrations describes how another bundler produces one with createAssetManifest.
Errors
Each error names the method, the key, and the environment it looked in.
| Message begins | Cause | Fix |
|---|---|---|
assets.getScriptEntry("…") has no asset manifest to read |
The manifest is the published { mode: "unavailable" } |
Add assets() or remix() to your Vite config, or pass a manifest |
… found no browser entry "…" in the "client" environment. The module is in the "ssr" graph but is not registered as a browser entry. |
A server module, or one the build saw, was never registered | Pass the key to the resolver's getScriptEntry as a string literal, or list it in assets({ include }) |
… found no browser entry "…" in the "client" environment. No environment's graph contains the module. |
Nothing in the build has that key | Check the spelling, then register it as above |
assets.getHref("…") found no browser entry or asset |
The key is neither a registered entry nor an emitted asset | Register it as above |
… found no module "…" in the "client" environment's graph |
A preload, stylesheet, or import-map lookup for a module the graphs it reads do not contain | Check the key and the environment |
… received an absolute file URL |
The key was file:///… or file:/… |
Use the key relative to the project root |
Only a missing browser entry suggests assets({ include }). A stylesheet or preload lookup never asks you to emit a server module into the client build.