runtime:build
The bundler, from a program.
esrun does not serve this module. A production binary that could bundle would have to contain a bundler, and a deployment has nothing to bundle — so importing runtime:build under esrun fails at load with unknown built-in module. Capability: FileRead; write() additionally needs FileWrite.
rolldown is already inside esdev — it is what esdev build runs. What was missing was a way for a program to reach it. Without that, a framework's dev server has to import rolldown from "rolldown", which is a napi addon this runtime does not load; so the dev server has to be a Node program, which is the thing it was trying to stop being.
Import
import { build, resolve } from "runtime:build";
build(options)
const bundle = await build({ input: "app/main.jsx", external: (id) => id.startsWith("/__route/"), resolve: { alias: { "@": "./src" }, extensions: [".js", ".jsx"] }, define: { "process.env.NODE_ENV": '"development"' }, plugins: [mdx, css], }); const { output, watchFiles } = await bundle.generate({ format: "esm", codeSplitting: false, }); serve(output[0].code); // never written to disk
| Option | Type | Description |
|---|---|---|
input | string | string[] | Record<string, string> | The entry, or entries. |
external | string[] | (id, importer, resolved) => boolean | What to leave unbundled. A predicate as well as a list — a dev server externalises a shape, not a set. |
platform | "neutral" | "browser" | "node" | Which environment the output runs in; decides exports conditions. Default neutral, which is what this runtime is. |
resolve | { alias, extensions, conditionNames, mainFields } | Resolution. conditionNames is appended to the platform's, mainFields replaces them. |
define | Record<string, string> | Compile-time replacements. |
plugins | Plugin[] | See below. |
minify, treeshake | boolean | |
cwd | string | Where the build runs. Defaults to the entry module's directory. |
Output options (format, exports, dir, codeSplitting, sourcemap, entryFileNames, …) may be given here or per call to generate()/write(); the per-call ones win.
What a platform asserts
The same resolution defaults esdev build uses, from the same place — a project that resolves one way through the subcommand and another way through this module is a build bug nothing reports, and the bundle dies later on an import.
platform | Conditions | mainFields |
|---|---|---|
neutral (default) | worker | ["module", "main"] |
browser | browser | ["module", "main"] |
node | none of ours | the bundler's own |
worker is the key a Web-API-targeting package uses for the build that does not reach for node: modules: react-dom/server resolves to its Web Streams implementation under it, and to a node:stream one without. browser is the other half — a client bundle built with worker asserted gets the build that expects no document. They are alternatives, not additions, because conditions match in the order the package author wrote them, so the wrong one being present at all is enough to win.
mainFields is what resolves a package too old to have an exports map. A neutral platform leaves it empty, so without it such a package does not resolve at all.
resolve(specifier, from)
The URL specifier names when imported from from — an absolute URL, string or URL, and a directory when it ends in / — rather than from the calling module. Synchronous.
import { resolve } from "runtime:build"; const root = new URL("./", `file://${cwd()}/`); // cwd from runtime:process resolve("@opentf/web", root); // the project's copy of the package resolve("./app/page.jsx", root); // a file in the project
It answers as an import written at from would, through the run's own loader: the root jail, the import policy, the project's aliases and CommonJS conversion. So the target must exist, and a denied run gets a NotAllowedError.
| Needs | --allow-imports, as importing does |
| Throws | TypeError when from is not an absolute URL, or specifier resolves to nothing |
import.meta.resolve takes one argument, as the standard defines it; this is the second half, for tools that run under esdev. See import.meta across runtimes.
Bundle
| Member | Returns | Description |
|---|---|---|
generate(output?) | Promise<BuildResult> | Builds, and returns the chunks in memory. Nothing is written. |
write(output?) | Promise<BuildResult> | The same build, landed under dir. Needs FileWrite. |
close() | Promise<void> | Releases the build. |
watchFiles | string[] | What the last build read. |
BuildResult
| Field | Type | Description |
|---|---|---|
output | (OutputChunk | OutputAsset)[] | Chunks carry code, fileName, isEntry, facadeModuleId, moduleIds, imports, dynamicImports, map. |
watchFiles | string[] | Every file the build read, plus every file a plugin returned as dependsOn. |
warnings | string[] | The bundler's warnings and the plugins'. |
watchFiles is the reason this returns more than code. Paired with runtime:watch, it is what lets a dev server drop the three cached chunks whose dependencies changed and keep the other thirty-seven — instead of clearing everything on every save, which is the same as having no cache.
facadeModuleId is the module a chunk is — the entry it was built for, or the module behind a dynamic import — and null for a shared chunk. It is how you find one entry's chunk; output.find((c) => c.isEntry) asks a different question, since an emitted worker chunk is an entry as well.
When a build fails
generate() and write() throw a BuildError, whose errors is every diagnostic of the batch — a thrown Error has one message, and hiding the other four behind "and 4 more" is how a build error becomes a bug report.
| Field | Description |
|---|---|
message | What went wrong, without the module or the excerpt. |
id | The module it happened in, or null. |
plugin | The plugin that reported it, or null. |
kind | The bundler's classification — PARSE_ERROR, UNRESOLVED_IMPORT, … |
line | 1-based, or null. |
column | 0-based, in UTF-16 code units — what an editor counts in. |
frame | The offending line with the span underlined, uncoloured — null when the diagnostic points at no span. |
try { await bundle.write({ dir: "dist" }); } catch (err) { for (const e of err.errors) { overlay.show(`${e.id}:${e.line}:${e.column}`, e.frame ?? e.message); } }
An error used to be a string — the module id, a colon and "Unexpected token" — so an overlay could name the file and then had to stop. The bundler computed the line, the column and the frame all along for its own terminal output.
A failure a plugin reported is filled in the same way. ctx.error("cannot compile this route") from a transform arrives with id set to the module the hook was called about, plugin set to the plugin's name, message set to what was said, and frame null — there is no span for it, and a frame that is the message a second time under a banner is not one. A plugin that crashed — a TypeError, something undefined — keeps its stack in message instead, because the first frame of that one is the line in the plugin.
Plugins
A plugin is an object with a name and hooks; a hook is an object carrying a handler, and rollup's bare-function shorthand is refused. The system is this project's own rather than the bundler's passed through — see Writing a plugin for how, and Internals: the bundler bridge for why and what it costs.
const mdx = { name: "mdx", transform: { filter: { id: /\.mdx$/ }, handler(code, id, ctx) { const { js, meta } = compile(code, id); return { code: js, type: "jsx", dependsOn: [meta] }; }, }, };
The six hooks
| Hook | Handler | Returns |
|---|---|---|
start | (ctx) | { dependsOn }, or nothing |
resolve | (source, importer, ctx) | { id, external?, virtual? }, or null |
load | (id, ctx) | { code, type?, map?, dependsOn? }, or null |
transform | (code, id, ctx) | { code, type?, map?, dependsOn? }, or null |
end | (error, ctx) | nothing |
bundle | (output, ctx) | nothing |
null or nothing means not mine, and the next plugin gets a turn. Anything else must be the object — a bare string of code is refused by name. Data comes first and the context last, with nothing positional between them.
bundle: what the build produced
bundle runs once, after the graph has been split into chunks and before any of it is written. Each entry is a chunk — { type: "chunk", fileName, name, isEntry, isDynamicEntry, facadeModuleId, moduleIds, imports, dynamicImports } — or an asset, { type: "asset", fileName }.
Route-level modulepreload is the case it exists for: which chunk an entry became, what went into it and what it imports do not exist until the split, and the split happens after end. That is why end is handed null and not the bundle — it fires when the module graph is finished, when there are no chunks yet.
const preload = { name: "preload", bundle: { handler(output) { const entry = output.find((o) => o.facadeModuleId === routeModule); manifest[route] = [entry.fileName, ...entry.imports]; }, }, };
It is read-only, and carries no code. Rollup lets a plugin rewrite chunks in the equivalent hook, which is how one plugin comes to invalidate the source maps of every plugin after it; and the bytes are what generate() already returns, so copying every chunk into the isolate on each rebuild would be a cost paid by a hook that only wanted the shape.
A hook's answer
| Field | Hooks | Description |
|---|---|---|
code | load, transform | Required. The module's source. |
type | load, transform | How to treat it — "js", "jsx", "ts", "css", … Omitted, the extension decides. |
map | load, transform | A source map: an object, or the JSON string every tool that makes one already produces. |
dependsOn | any | Files this answer depends on that the graph cannot discover. Relative paths resolve like every other path in a run and land in watchFiles absolute. |
id | resolve | Where the specifier points. |
external | resolve | true, "absolute" or "relative". |
virtual | resolve | There is no file behind this id; do not go looking for one. |
dependsOn is returned rather than declared through a side effect — there is no this.addWatchFile() — because a dependency you can forget to declare produces a build that serves stale output.
filter
filter.id | a string (exact), a RegExp, or an array of either. On resolve, matched against the specifier |
filter.code | the same, matched against the source — transform only |
| both given | a module has to satisfy each; within one, any pattern will do |
Matched on the host's side, before anything crosses into the isolate — so an unfiltered transform costs one crossing per module in the graph. start, end and bundle run once for the whole build and cannot be filtered.
A filter has to name id, code or both. A bare filter: /\.mdx$/, a typo'd ID, rollup's include, an empty {} and an empty list of patterns are all refused where they were written, and so is a pattern the matcher cannot evaluate (lookbehind, backreferences). The reason none of them is a shrug: a filter with nothing the host recognises in it is not a narrower hook, it is every module in the graph — a hook with no filter is a hook that wants all of them. A transform that quietly became a catch-all runs on modules it was never written for, and says nothing about it.
order
"pre" runs before the unordered plugins, "post" after; within a group, declaration order. Anything else is refused.
ctx — the last argument, not this
| Member | Description |
|---|---|
ctx.resolve(source, importer?, options?) | Asks the build's resolver, mid-hook. null if nothing resolves. Pass { skipSelf: true } from inside a resolve hook, or it re-enters your own. |
ctx.emit({ type, … }) | Adds a chunk ({ type: "chunk", id }) or an asset ({ type: "asset", source, fileName? }) to a running build; returns a reference id. |
ctx.warn(msg) / info / debug | Diagnostics; warnings come back in warnings, prefixed with the plugin's name. |
ctx.error(msg) | Fails the build. Throws — it does not return. |
ctx.isEntry | On resolve: whether the specifier is an entry. |
ctx.type | On transform: what the module is now — "css", "jsx", "js", … Not always what the extension says, since a pass ordered pre may already have changed it. |
ctx.refresh | The hot-reload scheme the target named in esdev.json, on every hook's context — start included, since a scheme's runtime is often a virtual module a load serves. Present only while the dev loop is running that target hot: absent under esdev build, under --no-hot, and in a target that named no scheme. |
An argument, so an arrow-function handler keeps it. Live only while its hook runs: stashing ctx and calling it later throws.
Rejections
Every declaration is read when build() is called, so a bad one rejects there rather than at generate().
| Written | Message |
|---|---|
| a bare function as a hook | a hook is an object, not a function — write { handler(...) {} }, optionally with filter and order |
| an unknown hook name | unknown hook "transfrom". Did you mean "transform"? |
filter on start/end | this hook runs once, for the whole build, so it cannot be filtered |
filter.code off transform | only transform can filter on code — it is the only hook given any |
| a bare pattern as the filter | filter must be an object — write { id: /\.mdx$/ } |
| a filter key that is not ours | filter: unknown key "ID". Did you mean "id"? |
| a filter naming neither | filter must say what it matches: { id }, { code }, or both |
order other than pre/post | order must be "pre" or "post", got "first" |
a string from load/transform | must return an object or null — return { code } instead |
A plugin is guest code in your isolate, under the capability model: one that reads a file needs FileRead. The bundler works on threads of its own and waits for each hook; several can be in flight at once, so a slow plugin holds up its own module rather than the build — but a hook that blocks the isolate synchronously blocks everything, your server included. Keep them async.
Example: a dev server that rebuilds one route
import { build } from "runtime:build"; import { watch } from "runtime:watch"; const chunks = new Map(); // route → { code, deps } async function bundleRoute(route) { const b = await build({ input: route, plugins: [mdx] }); const { output, watchFiles } = await b.generate({ codeSplitting: false }); await b.close(); chunks.set(route, { code: output[0].code, deps: new Set(watchFiles) }); for (const file of watchFiles) changes.add(file); } const changes = watch(["app"], { recursive: true }); for await (const { path } of changes) { for (const [route, chunk] of chunks) { if (chunk.deps.has(path)) chunks.delete(route); // only what used it } }