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, resolve } 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, 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.

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.

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.

JavaScript
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
ThrowsTypeError 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

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, facadeModuleId, moduleIds, imports, dynamicImports, map.
watchFilesstring[]Every file the build read, plus every file a plugin returned as dependsOn.
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.

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.

FieldDescription
messageWhat went wrong, without the module or the excerpt.
idThe module it happened in, or null.
pluginThe plugin that reported it, or null.
kindThe bundler's classification — PARSE_ERROR, UNRESOLVED_IMPORT, …
line1-based, or null.
column0-based, in UTF-16 code units — what an editor counts in.
frameThe offending line with the span underlined, uncoloured — null when the diagnostic points at no span.
JavaScript
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.

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 six 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
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.

JavaScript
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

FieldHooksDescription
codeload, transformRequired. The module's source.
typeload, transformHow to treat it — "js", "jsx", "ts", "css", … Omitted, the extension decides.
mapload, transformA source map: an object, or the JSON string every tool that makes one already produces.
dependsOnanyFiles this answer depends on that the graph cannot discover. Relative paths resolve like every other path in a run and land in watchFiles absolute.
idresolveWhere the specifier points.
externalresolvetrue, "absolute" or "relative".
virtualresolveThere 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.ida string (exact), a RegExp, or an array of either. On resolve, matched against the specifier
filter.codethe same, matched against the source — transform only
both givena 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

MemberDescription
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 / debugDiagnostics; warnings come back in warnings, prefixed with the plugin's name.
ctx.error(msg)Fails the build. Throws — it does not return.
ctx.isEntryOn resolve: whether the specifier is an entry.
ctx.typeOn 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.refreshThe 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().

WrittenMessage
a bare function as a hooka hook is an object, not a function — write { handler(...) {} }, optionally with filter and order
an unknown hook nameunknown hook "transfrom". Did you mean "transform"?
filter on start/endthis hook runs once, for the whole build, so it cannot be filtered
filter.code off transformonly transform can filter on code — it is the only hook given any
a bare pattern as the filterfilter must be an object — write { id: /\.mdx$/ }
a filter key that is not oursfilter: unknown key "ID". Did you mean "id"?
a filter naming neitherfilter must say what it matches: { id }, { code }, or both
order other than pre/postorder must be "pre" or "post", got "first"
a string from load/transformmust return an object or null — return { code } instead
Where hooks run

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

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