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 } 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, 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.
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, moduleIds, imports, dynamicImports, map. |
watchFiles | string[] | Every file the build read, plus every file a plugin declared with this.addWatchFile(). |
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.
Plugins
The plugin system is ours, not the bundler's passed through. That matters for a reason beyond taste: the runtime: namespace is a versioned contract, and an API defined by a third party's trait moves when that trait moves. rolldown is an implementation of what follows, not the definition of it.
A plugin is an object with a name and hooks. A hook is an object carrying a handler — there is one form, and rollup's bare-function shorthand is refused, because accepting it would make the filter, the order and the context argument optional extras on somebody else's design.
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 five 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 |
Five, against rollup's twenty-odd. Each one is a promise a future bundler behind this has to keep, so the list is short deliberately and grows only when something cannot be written without it. null means not mine — the one convention worth keeping, because a hook has to be able to decline. Everything else must be the object: a bare string of code is refused with a message saying so.
resolve + load together are how a virtual module works — a specifier that exists on no disk, whose content the plugin invents. That is why this is a plugin API and not a "pipe source through a subprocess" protocol: there is nothing to pipe.
filter — and why it matters more here
transform: { filter: { id: /\.mdx$/, code: /^---/ }, handler(code, id, ctx) { … }, }
filter.id | a string (exact), a RegExp, or an array of either |
filter.code | the same, matched against the source — transform only |
| both given | a module has to satisfy each |
In rollup a hook that returns null costs a function call. Here it costs a round trip into your isolate, so an unfiltered transform is one crossing per module in the graph — four hundred of them on a middling app, to reach a plugin that wanted one .mdx file. The filter is matched on the host's side, before anything crosses, which is why it is declarative rather than a predicate you write.
A pattern the host cannot evaluate (JavaScript's regular expressions are larger than what the matcher supports — lookbehind, backreferences) stops filtering rather than failing: the hook is called for everything, and your own code decides. Excluding modules a plugin was meant to see is the expensive way to be wrong.
dependsOn — dependencies are returned, not declared
load: { filter: { id: "@app/nav" }, async handler(id, ctx) { return { code: `export default ${JSON.stringify(await readNav())};`, dependsOn: ["docs/_meta.js"], // imported by nothing; watched anyway }; }, }
Rollup has this.addWatchFile() — a call you can forget to make, and forgetting it produces a build that serves stale output, which is the failure hardest to notice and worst to debug. Here they are a field of the value you return. Relative paths resolve like every other path in a run, and land in watchFiles as the same absolute path the graph reports.
virtual — no NUL-byte convention
resolve: { filter: { id: "@app/nav" }, handler: () => ({ id: "@app/nav", virtual: true }), }
Rollup signals "there is no file behind this id" by prefixing it with a NUL byte, and every bundler descended from it inherited the convention. Say virtual: true instead; the notation is the backend's business, applied and stripped where it belongs, and your load filter still matches the id you named.
order
transform: { order: "pre", filter: { id: /\.mdx$/ }, handler }
"pre" runs before the unordered plugins, "post" after. For when one pass has to see a module before another does.
ctx — the last argument, not this
| Member | Description |
|---|---|
ctx.resolve(source, importer?) | Asks the bundler's resolver, mid-hook. null if nothing resolves. |
ctx.emit({ type, … }) | Adds a chunk or asset to a running build; returns a reference id. |
ctx.warn(msg) / info / debug | Diagnostics; warnings come back in warnings. |
ctx.error(msg) | Fails the build. Throws — it does not return. |
ctx.isEntry | On resolve: whether the specifier is an entry. |
The context is an argument, so an arrow-function handler keeps it. Rollup's context-as-this is silently lost by an arrow, and a hook whose this.resolve is undefined fails a long way from the arrow that caused it.
It is live only while its hook runs. Stashing ctx and calling resolve() later throws — by then it may name a build that no longer exists.
A plugin is guest code
It runs in your isolate, under the same capability model as the rest of your program: a plugin that reads a file needs FileRead, like anything else. No other bundler's plugin API can say what a plugin is allowed to do, because none of them has a capability model to say it in.
The bundler works on threads of its own; a hook runs here, in your isolate, and the bundler waits for it. Several hooks can be in flight at once, so a slow plugin holds up its own module rather than the whole 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 } }