runtime:build

The bundler, from a program.

esdev only

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

JavaScript
import { build } from "runtime:build";

build(options)

JavaScript
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
OptionTypeDescription
inputstring | string[] | Record<string, string>The entry, or entries.
externalstring[] | (id, importer, resolved) => booleanWhat 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.
defineRecord<string, string>Compile-time replacements.
pluginsPlugin[]See below.
minify, treeshakeboolean
cwdstringWhere 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.

platformConditionsmainFields
neutral (default)worker["module", "main"]
browserbrowser["module", "main"]
nodenone of oursthe 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

MemberReturnsDescription
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.
watchFilesstring[]What the last build read.

BuildResult

FieldTypeDescription
output(OutputChunk | OutputAsset)[]Chunks carry code, fileName, isEntry, moduleIds, imports, dynamicImports, map.
watchFilesstring[]Every file the build read, plus every file a plugin declared with this.addWatchFile().
warningsstring[]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.

JavaScript
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

HookHandlerReturns
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

JavaScript
transform: {
  filter: { id: /\.mdx$/, code: /^---/ },
  handler(code, id, ctx) { … },
}
filter.ida string (exact), a RegExp, or an array of either
filter.codethe same, matched against the source — transform only
both givena 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

JavaScript
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

JavaScript
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

JavaScript
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

MemberDescription
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 / debugDiagnostics; warnings come back in warnings.
ctx.error(msg)Fails the build. Throws — it does not return.
ctx.isEntryOn 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.

Where hooks run

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

JavaScript
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
  }
}
Last updated on
Edit this page