roots-bundle: the bundler and tree shaker
System roots-bundle (package roots.bundle, sources in src/bundle/) turns the modules a build has linked into ES-module chunks: one file per entry, tree-shaken at the granularity of top-level statements, with shared chunks only where dynamic imports split the graph, and minified. It depends on roots-parse alone; roots-build feeds it modules and writes what it returns (docs/build.md describes the roots build side: options and output layout). This page is the bundler's own reference: the module model, the inclusion and liveness rules, the pure-expression grammar, emission and naming, chunks, the error codes, determinism and the minifier.
Run its tests with bin/test-system roots-bundle/tests [filter] (names start with bundle-).
1. API #
(roots.bundle:bundle-modules modules &key entries externals defines define-scope
message-arguments mode minify)
→ (values chunks diagnostics)modules: a list ofbundle-modulerecords,(make-bundle-module :id :code :kind :package-root :side-effects :package-type).idis the output path ("runtime/core/index.js","src/App.js","npm/zod@4.6.7/v4/core/core.js");codethe JavaScript whose import specifiers are already relative output paths (whatlink-unitproduces, so a builtdist/directory can be fed in as it is);kindone of:runtime,:app,:package,:bootstrap. Package modules carry their metadata:package-root, the id prefix of the package ("npm/zod@4.6.7/");side-effects, the package.json field asnil(not declared),:false(every file side-effect free) or a list of glob strings (only matching files have side effects);package-type, the package.json"type"("module"makes a file without import/export a module rather than a script; pass it for.mjssources too).entries: an ordered list of(id . kind)with kind:page(the bootstrap) or:worker(self-contained, never shares a chunk). Dynamic-import targets (import("./x.js")with a literal specifier) and workers created withnew Worker(new URL("./w.js", import.meta.url))are discovered by analysis.externals: a predicate on specifiers, or a list of prefixes (("node:" "bun:")). External imports stay imports, merged per specifier (section 5).defines: an alist.("process.env.NODE_ENV" . "\"production\"")replaces the free member chain in read position (a one-name chain,("__ROOTS_DEV__" . "false"), replaces the free identifier itself);(("runtime/core/index.js" "DEV") . "false")replaces a module's binding (section 3). Values must be literals.define-scope:nil(every define everywhere), or a function of abundle-modulerecord and a member-chain define's name ("process.env.NODE_ENV") that says whether that define applies to that module. The build uses it to replaceprocess.env.NODE_ENVin npm packages on every target but in app code on DOM targets only (process.envis real on term). Module-binding defines name their module already and are not scoped.message-arguments: an alist((("runtime/core/index.js" "runtimeWarn") . 1) …)of module bindings (a function, a class or a variable declared at the top of that module) and a 0-based argument index. At every call ornewof such a binding — named in its own module, through an import or re-export, or as a member of a namespace import; not a local that shadows it — the argument at the index is replaced by""when it is written there as a string literal or a template literal and no spread argument stands before it. Any other expression in that position stays. The replacement happens in the define pass (section 3), before the shaker, so what only the text referenced goes too. The build passes it for--error-codes(docs/build.md). A binding the module does not declare is skipped.mode::productionor:development; it only names the build in messages.minify: true runs(roots.bundle:minify-chunk text)over the text of every chunk (section 9).chunks:((output-path text imported-chunk-paths) …)in deterministic order: entry files in entry order, then stubs, then shared chunks by path.diagnostics: the warnings,((code :warning message module) …).
Errors are signalled as bundle-error (bundle-error-code, -message, -module, -hint); the build turns them into build-errors. Specifier resolution inside the library is path arithmetic against the importer's id; a specifier that is neither relative nor external is a programming error of the caller, not a coded error.
(analyze-modules modules &key externals defines define-scope) runs the analysis without shaking and returns the graph; (module-impure-statements mod) lists a module's impure top-level statements as text. The runtime purity invariant test uses both.
(replace-defines module &key defines externals) → text applies the member-chain defines to one module without a graph (imports are not followed; a specifier that is not relative counts as external) and returns its code with the replacements and the folding of section 3, or the code unchanged when nothing applies. The unbundled build (roots dev, --no-bundle) uses it for process.env.NODE_ENV, so a package behaves the same in both layouts.
2. The module model #
Every module is parsed once (parse-module, TypeScript off: the build has stripped types) and its scope tree computed once (analyze-scopes). From them the model records:
- the top-level statements with their ranges and the comments attached above them (the comment block directly above a statement, with no blank line between; a comment starting on a statement's last line trails that statement; the generated-file header and a hashbang never attach). Dropped statements take their comments with them, except license comments (section 5). A comment below the last statement that is not on its line is dropped.
- the module-scope bindings: lexical declarations,
vars hoisted from nested blocks and loop heads, function and class declarations, import locals, and the synthesized default binding of an anonymousexport default:function <m>_defaultfor an anonymous function/async function/generator (hoisted like the ESM*default*binding),class <m>_defaultfor an anonymous class,const <m>_default = <expr>for any other expression (lexical, with a temporal dead zone as in ESM). A named default declaration keeps its name.<m>is derived from the output path: the file name without.js, sanitized (scroll-area.dom.js→scroll_area_dom); anindex.jstakes its directory's name. Synthesized names take part in the collision pass like any declaration..nameof an anonymous default function, class or parenthesized function/class expression is therefore<m>_default, where ESM gives"default"(a documented deviation esbuild shares). - the export table per the spec's ResolveExport:
export <decl>,export { a as b },export default,export { a as b } from,export * from(a local export shadows star exports; a name two star exports provide from different bindings is ambiguous and excluded;defaultis never re-exported by a star),export * as ns from, string export names (export { a as "b-c" }), andimport * as ns; export { ns }, which exports the namespace object itself. - per statement, the module-scope bindings it references (nested functions included), those it references when it runs (not inside function bodies, methods or instance fields; static fields, static blocks, heritage and computed keys count), and its namespace uses: for
import * as ns,ns.x,ns["x"],ns?.x,(0, ns.x)()andns.x(...)in read position are static; a member in assignment, update, delete or destructuring-target position is B135 (a computed key of a pattern,({ [ns.key]: v } = o), is a read);ns.missingbecomes(void 0)with B134; any other use (typeof ns, an argument, a computed non-literal key, destructuring,export { ns },export * as ns from) makes the namespace escape. - the
new URL("<relative>", import.meta.url)idioms, the worker entries undernew Worker/new SharedWorker, and the literal dynamic imports, per statement: a worker or a dynamic import written in a statement that is dropped names no entry.
Package modules are checked once included: a module without import/export declarations that calls a free require or references a free module/exports is CommonJS (B128); one with neither import/export nor those markers is a script (B136) unless its package says "type": "module".
A module that declares a name twice (let a; var a, { let b; { var b } }, two functions of one name at module level: what an engine rejects with "Identifier … has already been declared") is an error, C110 with the line and column of the second declaration (roots.parse:check-redeclarations). Application and runtime modules are checked when they are analysed, which includes the define replacement of an unbundled build (replace-defines); a package module once it is included, after B128/B136, since a CommonJS file read as a module may legitimately repeat a top-level function. Besides reporting what would fail in the browser anyway, this is what lets the two renaming passes (section 5's names, the minifier) assume one binding per name and scope.
Hostile or generated input. The analyses walk the syntax tree by recursion; the parser bounds syntactic nesting at 1000 levels, and operator chains (a + b + …, a && b && …) of any length are walked in a loop. What remains — a member or call chain of tens of thousands of links — ends as the error C109, never as a crashed build. The passes over a module are linear or n log n in its statements, references, edits and exports (a statement is found by binary search, export names through a hash table); bundle-model-deep-and-long-input holds 40 000 statements, 20 000 exports and a 20 000-name barrel to a time budget.
3. Defines and folding #
Before the model is used, defines are replaced and literal conditions folded; a module whose text changes is parsed and analysed again.
- A member-chain define replaces the exact free chain (
process.env.NODE_ENV, root identifier not bound in the module, non-computed members) in read position.process.env.NODE_ENV.lengthbecomes"production".length; an assignment target or a module with its ownprocessis left alone. A one-name chain (__ROOTS_DEV__) replaces the free identifier itself where it is read:typeof __ROOTS_DEV__folds to"boolean", a shorthand property expands, a declaration, a property key or an assignment target is untouched. Thedefine-scopepredicate (section 1) can restrict a member-chain define to some modules. - A module-binding define
(("id" "NAME") . literal)applies when the module's binding NAME is declared once withvar/let/const, has an initializer and is never written. The initializer may be any expression: a define is the build's assertion about the value, sovar DEV = typeof __ROOTS_DEV__ === "undefined" ? true : __ROOTS_DEV__becomesvar DEV = falsewhether or not__ROOTS_DEV__is defined too. Its initializer and every read of it are replaced: in its module, and in others through imports, re-export chains and static namespace members ($.DEV). Shorthand properties expand ({ DEV }→{ DEV: false }). A define that does not apply changes nothing. - The evaluator knows literals, the free
undefined,! typeof void,=== !== == != < > <= >=of known values (loose equality only within one type or betweennull/undefined),&& || ??with a known left operand (0 && f()is0, notfalse),?:with a known test, and parentheses (kept). It runs everywhere, inside function bodies too: it is a local rewrite, not tree shaking inside functions.x && <literal>is never folded:xmay be impure. - Conditions (
if,?:, the left operand of&& || ??) fold on any known value. An expression that contains no define is rewritten to its value only when the value is shorter than its source:"a" === "a"becomestrue, while the!0,!1andvoid 0of a minified package stay as written (if (!0) …and!1 ? a : bstill fold). A module the pass changes nothing in is not parsed again. - A dead
ifbranch or?:arm is removed; thevardeclarations of a removed branch stay as a barevar a, b;in front of the kept branch (strict code: a live assignment to a removedvarwould throw). Where the grammar takes one statement — anelse, a loop body, the body of anotherif, a label — the declaration and the kept branch share a block (else { var v; kept(); }), and a removed statement becomes{}. An expression statement that folds to a literal is removed. Verified on @vue/reactivity's esm-bundler build: its 34process.env.NODE_ENVuses fold and noprocessreference remains. - No edit joins two tokens. Minified code writes
return!0,case!0:,typeof!1,x in!0with nothing between a keyword and the operand: a replacement or an erasure that would put two word characters side by side gets a space (return!1?a:b→return b,case DEV:→case false:). Verified on Vue's and @vue/reactivity'sesm-browser.prodbuilds.
4. Inclusion, liveness and side effects #
Inclusion and liveness iterate together to a fixpoint (esbuild/webpack semantics):
- A module is included when it is an entry; or it is statically imported (or
export … from) by an included module and not declared side-effect free; or one of its bindings or its namespace is live, or a live resolution passes through its export table (a side-effect free barrel re-exporting a used name; every barrel of a chain, however long:vue→@vue/runtime-dom→@vue/runtime-core→@vue/reactivity; of severalexport *only the ones that provide the name).sideEffects: falsedeclares every file of the package side-effect free;sideEffects: [globs]declares every non-matching file so, under webpack's dialect (package-relative, a leading./stripped, a pattern written without/matches that name in any directory,*within a segment,**across,?,[…],{a,b}); no field declares nothing. An excluded module does not include its own imports. One exception, made byroots buildbefore it calls the bundler: a package module the app imports for its effects alone (import "pkg",import {} from "pkg"), and what that module imports the same way, is handed over without itssideEffectsdeclaration and so is kept: "run it" is all such an import can mean, and it is whatroots devand--no-bundledo (build.md).sideEffectsdecides inclusion only: inside an included module every impure statement is kept (zod 4's(_a = globalThis).__zod_globalConfig ?? …stays onceconfig()is used). - Roots: every impure statement of an included module; the entry's exports; every export of a dynamic-import target (after ResolveExport); workers have none. Dynamic-import and worker targets are entries when the statement naming them is live; they are discovered in emission order, so the result does not depend on the order the modules were listed in.
- Linking: every named import of an included module must name an export of its target, used or not (B134 otherwise): ESM refuses to link such a program, and the bundle must not quietly work where the unbundled modules fail. A target that re-exports everything of an external (
export * from "node:fs") is exempt: its names are unknown at build time. - Mark: a live statement makes the declaring statements of the bindings it references live (a binding with several declaring statements, such as a redeclared
var, keeps them all); a live import follows re-export chains to the exporter's binding; a staticns.xmakesxlive in the target; an escaping namespace makes every export of the target live and materializes the namespace object. Unreferenced side-effect-free statements of included modules are dropped. Statements kept for a dynamic target's exports live in that target's chunk.
Side-effect-free statements #
An import declaration; a function declaration; a class declaration whose heritage is acceptable, without decorators or static blocks, with pure computed keys, pure static field and static accessor initializers (instance field initializers run per instance and are ignored); a variable declaration whose initializers are pure (a destructuring pattern only from a pure object or array literal, with pure defaults); an export declaration or list whose declaration is pure; an empty statement or directive; an expression statement whose expression is pure (/* @__PURE__ */ f();). Everything else is impure: if, loops, try, blocks, labels, throw, and any expression statement with an effect.
Class heritage: absent, null, an identifier that resolves (through imports and re-exports) to a class declaration or expression or to a non-async non-generator function whose binding is never assigned after its declaration, or a free identifier among Error and its standard subclasses, Array, Map, Set, WeakMap, WeakSet, Promise, EventTarget, HTMLElement, Event. A call (mixin(Base)), an arrow, a namespace member, a conditional or anything else is impure. (Function, class, let and var bindings are mutable: a reassigned base may be a Proxy whose prototype trap runs when the class is defined.)
Pure expressions #
A known primitive is a literal, a template of known primitives, the free undefined/NaN/ Infinity, typeof/!/void of a pure operand, + - ~ of a known primitive, an operator (other than in/instanceof) applied to two known primitives, or an identifier bound by a same-module const to a known primitive. Pure expressions are:
- literals, regex literals, templates whose substitutions are known primitives;
- identifiers (a binding or a global: reading one can at most throw),
this,import.meta; - function, arrow and class expressions (class rules as above);
- array literals of pure elements (a spread only of an array literal); object literals of pure values with non-computed keys or computed keys that are known primitives,
Symbol.<name>or a same-module const holding aSymbol(…), without spread; getters, setters and methods are definitions and pure; !,void,typeof,===,!==,&&,||,??,?:, sequences and parentheses of pure operands;+ - ~, arithmetic, relational and loose-equality operators only on known primitives (coercion runs user code otherwise);inandinstanceofare impure;- constructions of free globals:
new Map(),new Set(),new WeakMap(),new WeakSet()with no argument or one array literal of pure elements;new RegExp(lit[, lit]);new Array(known primitives…);new Object();new Date()/new Date(known primitive); typed arrays (new Uint8Array(65536)) with known-primitive arguments or one pure array literal; - calls of free globals:
Symbol()/Symbol(known primitive);Object.create(null | accessor-free spread-free object literal);Object.freeze(pure literal);Object.assign(accessor-free spread-free literals…);Array.from(one array literal);String()/Number()/Boolean()andString.fromCharCode()/fromCodePoint()of known primitives. The whitelist applies only when the identifier (Map,Object,Symbol, …) is free in the module: a local classMapor an importedSymbolis user code; - well-known member reads of free globals:
Symbol.<name>,Array.prototype,Object.prototype,Object.prototype.hasOwnProperty,Math.<name>,Number.<name>,Reflect.<name>; a static namespace memberns.x. No other member access is pure; - a call or
newcarrying a/* @__PURE__ */annotation (the outermost call of the chain starting at the annotated token;/* @__PURE__ */ a().b()covers the chain,/* @__PURE__ */ a().bonlya()), or a call whose callee resolves — through imports, re-exports and static namespace members — to a function declared/* @__NO_SIDE_EFFECTS__ */(onfunction f,export function f,export default function, the first declarator of aconst, or directly before a function or arrow assigned to a never-writtenlet/var): pure iff every argument is pure and none is a spread element. The annotation holds for the function declared, so a function declaration that is reassigned (f = function () { … }) loses it. A line comment// @__NO_SIDE_EFFECTS__counts;/*! … */too.
Everything else is impure: any other call, assignment, update, await, yield, import(), tagged templates, delete, member writes, optional calls, spread of a non-literal.
Documented deviations from esbuild. An expression whose only possible effect is to throw (var u = undeclared, new RegExp("("), new Array(-1)) is pure and dropped when unused. @__NO_SIDE_EFFECTS__ is honoured across modules (esbuild 0.28 honours it only in the declaring module): this is what lets an app that imports one component of roots/ui drop the other kit components. The Lisp compiler emits the annotations for (declaim (pure f)) and for module-level initializers (README, "Bundler annotations").
5. Emission #
Order: a post-order depth-first walk from the entries over the static edges (imports and
export … from) in source order; for an acyclic graph that is ESM's evaluation order. Top-levelawaitkeeps dependency order; the sibling parallelism ESM gives a module next to an awaiting module is serialized (as esbuild).Cycles are detected and tolerated unless a live top-level statement reads, when it runs, a lexical binding (
let/const/classor a synthesized default) of a module later in the order, directly, through an import or as a static namespace member: B132, with--no-bundleas the way out. Function declarations hoist andvars readundefined, as in ESM. A namespace object is never the problem: it exists before any module runs (below).Scope hoisting: the chunk is the live statements of its modules in that order, one statement per line, with a blank line and a
// ---- <module output path>marker per module (profiling attribution until source maps exist), under the header// Generated by roots (bundle of N modules). Do not edit.License comments — a block comment starting/*!, any comment mentioning@licenseor@preserve, never an annotation — that stand between the top-level statements of a module and that no kept statement carries (a package's banner above its imports, a notice followed by a blank line, the comment of a dropped statement) are printed once each directly under the header; the minifier keeps them. Import and export syntax is removed and declarations kept (export function f→function f,export default class {}→class <m>_default {},export { a, b };erased). Every reference to an imported binding is rewritten to the exporter's local, which preserves live bindings in every position, closures included.var/let/constkinds are kept. Top-levelthisis left alone (a chunk is a module).Names: every module-scope binding of the chunk gets a name that is unique in the chunk and valid at every place it is written: not declared in any scope enclosing one of its references (the importers' references included) or, for a
varwritten in a block, one of its declarators (catch (x$1) { var x = … }never becomesvar x$1), not a free identifier of any module in the chunk (a module-localreportErroris renamed away from another module'stypeof reportErrorprobe of the global), and not reserved. The first module to declare a name keeps it; later ones (and invalid firsts) getname$1,name$2, … in module/declaration order, skipping names already present (name$1from rollup-built packages). Declarations are named before the locals of external and cross-chunk imports, so that on a collision the import local takes the suffix (import { join as join$1 } from "node:path") and the function or class keeps its.name; a renamed declaration's.nameis its new name (as in every scope-hoisting bundler). A class that names itself in its own body (static make() { return new Foo() }) keeps its name. A module-levelvarthat redeclares the parameter of the catch clause it stands in (catch (e) { var e = 1 }: one token, the variable's declaration and the parameter's assignment) cannot be renamed at all; such bindings are named first, and B137 reports the rare chunk where the name is not available (taken by another such binding, read as a global by some module, or the name of a local around a reference to it, as in an importer'sfunction f(e) { return imported }). Only references that resolve to the module-scope binding are rewritten; inner declarations of the same name and their references are untouched. One routine serves every rewrite (import to local, rename, namespace member, define): it expands shorthand{ a }→{ a: b }and{ a = d }→{ a: b = d }in object literals, object patterns and assignment patterns.Externals: static imports of external specifiers from all modules are merged into one import declaration per specifier at the top of the chunk, one local per (specifier, imported name) named after its first importer (in emission order) and taking part in the naming pass, at most one
import * asper specifier, and a bareimport "node:x";when nothing is imported from it. Importers' references are rewritten. Dynamicimport("node:…")stays as written.export * from "node:x"cannot be enumerated and is ignored.Namespace objects: one
const <m>_nsper module whose namespace escapes, shared by every importer (identity as in ESM) and emitted before the first module statement of its chunk: the object exists before any module runs, as in ESM, so a module may keep its own namespace (import * as self from "./m.js"; export default self) and a cycle may pass around the namespace of a module that has not run yet. Its getters are lazy: reading an export that is still in its temporal dead zone throws a ReferenceError, as in ESM.const m_ns = Object.freeze(Object.defineProperty({ __proto__: null, get "b-c"() { return bc; }, get x() { return x; } }, Symbol.toStringTag, { value: "Module" }));Getters in UTF-16 code-unit order, string names quoted, the tag non-enumerable, the object frozen (
ns.x = 1throws as in ESM). Documented deviations, shared with esbuild and rollup:ns.f()after a static rewrite is called withthis === undefined(ESM: the namespace object);Object.isFrozen(ns)is true (ESM: false, the object is only non-extensible) andObject.getOwnPropertyDescriptor(ns, "x")is an accessor descriptor (ESM: a writable data property);Object.keys(ns)of a module that has not run does not throw (ESM throws while a lexical export is uninitialized); the namespace a stub chunk givesimport()(section 6) has the same keys and values but is not the object static importers see ((await import("./m.js")) !== staticNs).import.meta.url: everynew URL("<relative literal>", import.meta.url)is rewritten by path arithmetic from the module's output directory to the chunk's, with no graph lookup; as the first argument ofnew Worker(...)/new SharedWorker(...)it also names a worker entry when the literal resolves to a module of the build, also when it is handed through one call on the way (new Worker(policy.createScriptURL(new URL("./w.js", import.meta.url))), which a page that requires Trusted Types has to write). The worker chunk sits at the worker's own output path, so its ownnew URL("./sqlite-wasm/…", import.meta.url)rewrites to itself. Parentheses around the second argument do not matter (the Lisp compiler emits(js "import.meta.url")as(import.meta.url)). An absolute URL is left alone; a relative path that leaves the output root (new URL("../../x", import.meta.url)fromlib/m.js) cannot be rewritten and is B133, as is any other use ofimport.meta.url.ASI: after every kept statement whose source does not end in
;, a;is appended when the next emitted token (the next statement, the next module, inserted text, the export line) is one of(,[,`,+,-,/— decided on the token, so a leading comment does not count and++/--(which a line break separates from the statement before) do not either. A statement that ends in a declaration's or block's closing brace (function and class declarations, blocks,switch,try, anif, loop or label ending in one) cannot be continued and gets none. Hand-written files without semicolons bundle correctly.
6. Chunks #
A module's entry set is the set of non-worker entries whose static graph reaches it; modules with the same entry set form a chunk. The page's chunk is at the bootstrap's output path. A dynamic-import target whose entry set is only itself has its own entry file, <m>-<hash8>.js, holding its private dependencies and exporting its export table; one whose module landed in a shared chunk gets a stub <m>-<hash8>.js that imports the table from the owning chunks and re-exports it under the right names (import { foo$1 as foo, m } from "./chunks/n-…"; export { foo, m };), so the dynamic namespace is exact even when the shared chunk had to alias a colliding export; an external the target re-exports is re-exported by the stub from the specifier itself (export { sep } from "node:path";). Shared chunks are at chunks/<m>-<hash8>.js (<m> of their first module, hash8 the first eight hex digits of the SHA-256 of their sorted module ids) and are linked by real ESM imports, exporting their bindings under their final names; a namespace object needed across chunks is materialized in the owning chunk and exported as <m>_ns. import("./app.js") of the entry itself resolves to the bundle's own URL.
Entry sets grow along static edges (whatever reaches an importer reaches what it imports), so the chunk graph is acyclic and an entry chunk is never imported by another chunk. The order of modules across chunks follows the global post-order, which keeps dependency order but not necessarily the relative order of unrelated modules in different chunks (as esbuild). Worker entries never share: a worker chunk holds everything its entry reaches, copied; it lives at the worker's output path next to its assets. When the graph has no dynamic imports, the result is one chunk, byte-identical to what the general algorithm gives for that case.
7. Error and warning codes #
| Code | Severity | Meaning |
|---|---|---|
| B128 | error | a package module is CommonJS (free require(), module or exports, no import/export); only ES modules can be bundled |
| B132 | error | an import cycle where a module reads, at top level, a lexical binding of a module that has not run yet; break the cycle, move the use into a function, or build with --no-bundle |
| B133 | warning | import.meta.url used outside new URL("./…", import.meta.url), or in a new URL("../…", import.meta.url) whose path leaves the output directory; in the bundle it is the chunk's URL |
| B134 | warning | ns.name where the target does not export name: emitted as (void 0). As an error: a named import (used or not) of a name the module does not export, or that export * provides ambiguously (ESM fails to link) |
| B135 | error | a namespace member assigned, updated or deleted (ns.x = 1, ({ a: ns.x } = o)): ESM throws; export a function from the module instead |
| B136 | error | a package file without import/export and without CommonJS markers is a script, not a module, unless its package.json says "type": "module" |
| B137 | error | a module-level var redeclares the parameter of the catch clause it is written in (catch (e) { var e = … }) and cannot keep its name: another module of the chunk declares such a pair or reads a global e, or an importer reads the variable inside a scope with a local e. The token is the variable's declaration and the parameter's assignment at once, so it cannot be renamed; rename one of the two in the source |
| C109 | error | the parser's code: code nested deeper than 1000 levels, or a member/call chain too long for the analyses to walk |
| C110 | error | the parser's code: a name declared twice in a module (line and column of the second declaration) |
| C111 | error | the parser's code: <!--, or --> at the start of a line, in module code. Engines refuse them in a module, so the bundle would run where the unbundled module does not load |
8. Determinism #
Module order comes from the graph; names are assigned in module/declaration order over ordered candidate lists; chunk names are SHA-256 of sorted module ids; externals and cross-chunk imports are ordered by first appearance; export lists and namespace getters are sorted; no hash table is iterated into the output and nothing carries a timestamp. The order of the modules list is not part of the result either: entries found by analysis are appended in emission order, so the same modules listed in reverse give identical chunks. Bundling the same modules twice gives identical chunks (both tested), and the build's reproducibility test covers the whole pipeline.
9. Minifier #
(minify-chunk text) → text; bundle-modules … :minify t runs it over every chunk, and the build does unless --no-minify. The minifier is driven by the parser, not by a lexer of its own: the chunk is parsed once with the parser's token record on (the record the type stripper uses, so a regular expression or a template part is the token the parser decided on), its scope tree is computed (analyze-scopes), and the token stream is printed back. Four things change on the way:
Comments. All are removed except license comments — a block comment starting with
/*!, or any comment containing@licenseor@preserve— the header line// Generated by roots …and a hashbang. An annotation comment is never a license:/*! #__NO_SIDE_EFFECTS__ */goes. A kept block comment stays inline where it was (a/-piece for the spacing rule below); a kept line comment keeps its own line.Whitespace and
;. Statements are separated by;at the AST's statement boundaries — so a restricted production such asreturn\nx, already two statements in the AST, prints asreturn;x— and so are class fields; before a}the;is left out (function f(){a();b()}), except one that is a statement itself (for(;;);}). Adjacent tokens touch unless one of seven cases requires a space: (a) identifier-like meets identifier-like (names and keywords, numbers, bigints, private names, and the end of a regex, where flags would follow):a in b,typeof a,yield 1,#p in o,/re/ in o; (b)+meets+:a+ +b,a+ ++b; (c)-meets-; (d)/meets/or*:a/ /re/,/re/ /2; (e) an integer literal without.or exponent meets.:1 .toString()(1.5.toFixed,1e3.toString,1..toStringtouch); (f)<meets!:x< !y. Strings, templates (with their newlines and nested substitutions), regexes, numbers, private names and escaped identifiers are copied verbatim from the source. The output is one line after the header, plus the lines kept comments need.Names. Bindings are renamed to short names: parameters, locals, catch parameters,
forheads, inner function and class names, function expressions' own names, and the chunk's module-level bindings — except the chunk's exports (a binding named inexport { … }or declared by anexport) and import locals (external and cross-chunk imports keepimport { readFileSync as readFileSync$1 }as written). A shorthand{ a }becomes{ a: b }wherever a renamed binding is written by its name: object literals, declaration and parameter patterns, catch patterns, assignment patterns, with or without a default. Never renamed: property keys and member names, labels,arguments, free identifiers (globals), import/export specifier names, and every binding visible to a directeval(…)(the call's scope and its ancestors). Names are a–z, A–Z, then two characters, three, …, letters only (a tool that processes the bundle afterwards, such asbun build --compile, makes its own names by appending digits and would collide withf7), handed out per scope in order of first appearance: the parameters of every function area,b,c…, which is what compresses best. Only a scope with more than 52 bindings — in practice a chunk's module level — first gives the one-letter names to its most used bindings (declarations plus references), then the two-letter names to the next 2,704, and inside each such group goes by first appearance again. (Ordering every scope purely by use, as esbuild does, gives the same raw size and a larger gzip: +0.6% on the benchmark app's bundle, +2.2% on the gallery's.) A name is valid for a binding in scope S when no reference in S's subtree names it — a free identifier, or an outer binding whose final name it is — no binding of S has it already, and it is not reserved: the keywords, the strict-mode words (let,static,yield,await,implements,interface,package,private,protected,public,enum),arguments,eval,of, andundefined/NaN/Infinitywhen the chunk reads them as globals. Scopes are visited top-down, so an inner binding may reuse a short name its scope does not see:function f(x){return function(y){return y}}becomesfunction f(a){return function(a){return a}}. A scope sees an outer binding through its references and through its declarators:var failure = problemwritten in a catch clause declares the function'sfailure, but the token stands in the clause (the parser'sbinding-sites), so the catch parameter never gets that name. (Up to 0.2.1 it could:catch(a){var a=a;}initialized the parameter and left the variable undefined, and beside a block'sletthe result was a redeclaration that did not load.) Where the source sayscatch (e) { var e = 1 }, one token is the variable's declaration and the parameter's assignment; both bindings keep their names (binding-pinned). Such a name is handed out to no other binding of the chunk: an outer binding named earlier would otherwise be captured where the clause reads it. One rule concerns no reference at all: the body of a catch clause may not declare the parameter's name withlet,const,classor a function (catch(a){const a=1;}is an early error even when the body never reads the parameter), so the body's bindings avoid the parameters' names.Syntax. A fixed list of rewrites; each moves no token, needs nothing beyond the AST and the scope tree, and leaves the order of evaluation as it was:
- Declarations. Consecutive
var,letorconstdeclarations of one kind in a statement list are one:const a=1,b=2;. Anexportbetween them keeps them apart. - Braces. The block of an
ifbranch or of afor/whilebody loses its braces when it holds one statement that declares nothing in it (if(a)b=1;; an empty block is;). Alet,const, class or function declaration keeps them, avardoes not need them. A block that stands in a statement list loses them when no statement of it declares anything in it; an empty statement in a list goes.do … whilebodies and labelled blocks are left alone. Two guards: braces stay where the text would otherwise end in anifwithoutelsein front of anelse(if(a){if(b)c=1}else d()), and nothing is removed that would make a string literal the first statement of a body (a directive). ifas an expression.if (a) b(); else c()→a?b():c(); several expression statements in a branch become a comma sequence in the parentheses their braces turn into (a?(b(),c=1):d());else ifchains nest (a?b():c?d():e()). When every branch of the chain returns a value:return a?1:b?2:3. Withoutelse:a&&b(), only where neither side needs parentheses for it (the test is not||,??, a conditional, an assignment or a sequence; the statement is a call or another expression tighter than&&), since with parentheses nothing is gained. The test keeps its parentheses where it binds looser than?((a=b)?c():d()) and where the statement would otherwise start like a declaration or a block (function,class,async,let,{).elseafter a branch that always leaves. In a statement list,if (a) return 1; else { b(); c() }→if(a)return 1;b();c(): the first branch ends inreturn,throw,breakorcontinue, so what was theelsesimply follows (in its braces when it declares something). Theifis then two statements, so a block that held it alone keeps its braces.- Literals.
true→!0,false→!1; a read of the globalundefined→void 0(not a local of that name, not a property, not a shorthand{ undefined }); each only where a unary expression may stand: not before.,(or a template, not as a class heritage, not left of**.while (true)→for(;;).typeof x === "…"and!==against a string literal are compared with==/!=(both sides are strings).|,&and^of decimal integer literals below 2^31 are computed, with their parentheses (a.flags&(1|2)→a.flags&3;(4|8).toString()keeps them). - Private symbols. A module-level
const x = Symbol("roots.…")loses its description (Symbol()) when the chunk does not exportxand every use ofxis a key (o[x],{ [x]: … },x in o) or an operand of===,!==,==,!=. Such a symbol never leaves the chunk as a value, so its description can be seen only by reflection (Object.getOwnPropertySymbols(o)[0].description) and in what a debugger prints for the property. The rule looks at the chunk, not at modules: it holds for an app's or a package's symbol with that prefix as for the runtime's (the brands and slots of the reactive core and the DOM backend), and for a symbol its module exports when no other chunk imports it. A symbol that is used as a value anywhere keeps its description — passed to a call (inject(key),provide(key, …),String(key)), assigned, returned, put into a template literal, exported from the chunk — so a message that prints a key (R206:inject(Symbol(roots.router)): no value was provided…) reads as in an unbundled build.letandvardeclarations,Symbol.for(…), a symbol created inside a function and any other description stay.
- Declarations. Consecutive
Nothing else changes: no inlining, no constant propagation beyond the integers above, no property mangling, no dead-code removal (the shaker's work), no change to a string (the CSS scanner reads class strings before bundling anyway).
The check of the output. Before minify-chunk returns, the result is parsed, its scope tree is computed and compared with the input's (check-minified): no name may be declared twice (the redeclaration errors an engine raises at load time, C110 in docs/parser.md); the scopes that declare something must be the same in kind, number and nesting, with the same bindings in each (kind, number of declarators, pinned or not); and the Nth reference of the output must resolve to the binding the Nth reference of the input resolved to, or be the same free name. Two things are counted out, because the syntax rewrites remove them: a block that declares nothing (its braces may be gone; no reference can tell) and each free undefined that was printed as void 0 (those, one by one: one that survives, or one undefined too many, is a difference). Any other difference means a name was captured, set free or merged with another.
The syntax rewrites change how tokens and statements are grouped, which a scope tree does not show. So each if and each for / while of the input leaves the shape it must have in the output (check-shapes): an if whose test starts at this token, with an else or without one (an else that moved to another if — the dangling else — shows here), whose branches are exactly what the tokens of the input's branches printed (a statement that fell out of a branch when braces went, or one that moved into it, shows here); a loop whose body is exactly what its body printed; a conditional with exactly this test, this consequent and this alternate; an && with exactly these two operands. The printer records where every token went, and the shapes are looked up in the parsed output; an operand may stand in parentheses the input did not have, nothing else may differ. So a precedence mistake of the minifier (a=b?c():d() for (a=b)?c():d()) is an error at build time too.
Either error says what differs, names --no-minify as the way out and asks for a report. A minifier bug is an error at build time, never a shipped file that fails to load or computes something else. The check costs one more parse and scope analysis and three linear walks, production builds only. The input is held to the same rule: text that declares a name twice is refused with C110. The substrings the no-eval scans forbid (eval(, new Function, with (, .innerHTML = …) cannot appear unless the source had them.
Documented deviations. fn.name of a function or class that is not an export is its short name; a component's name: property is a string and stays (the SFC compiler always emits one). The description of a private roots. symbol is empty (above).
Tests. bundle-minify-* (roots-bundle/tests): golden strings per rule, among them the dangling-else and directive shapes; bundle-minify-syntax-behaviour runs 28 functions that log what they evaluate, plain bundle against minified bundle, in Node; bundle-minify-shape-check hands check-minified the text a wrong rewrite would print; the benchmark app and the gallery are minified and run in Chromium under the strict CSP. The two real builds must also stay within 2% of esbuild --minify of the same text. Outside the suite, round 7 minified 1,090 files of real packages (297 .mjs / esm files and every third .js file of two node_modules trees, 32 MB, 1,042 different files): each passed the check and Node's parser accepted every output; and acorn 8.19 (235 KB), @preact/signals-core 1.14.5 and @vue/reactivity 3.5.44 gave the same results minified as unminified in a scripted run (acorn: the ASTs and token streams of five files and 19 syntax errors).
Numbers. The bundles of 0.3.2's runtime (the unminified, tree-shaken bundle roots build --no-minify writes, so that every column minifies the same text; B raw / B gzip -9):
| app | 0.3.2's minifier | this minifier | esbuild --minify 0.25.11 | raw against esbuild |
|---|---|---|---|---|
| one component (a button and a counter) | 51,443 / 16,498 | 46,857 / 16,032 | 46,247 / 16,258 | +1.3% |
| the render benchmark app | 62,525 / 20,170 | 57,064 / 19,593 | 56,367 / 20,044 | +1.2% |
examples/todo | 74,651 / 24,106 | 68,417 / 23,467 | 67,377 / 23,934 | +1.5% |
examples/gallery | 156,691 / 45,989 | 145,947 / 44,958 | 143,899 / 46,018 | +1.4% |
What esbuild still writes shorter is expression-level: a block of several statements and a nested if as one comma expression, return merged across statements, constants inlined. The gzip sizes are below esbuild's because of the naming order. minify-chunk takes about 0.1 s on the 330 KB gallery chunk, the checks included (a loaded 3.6 GHz desktop).
10. Not in this layer #
The build options and output layout, modulepreload and SRI of chunks, source maps (M16), cross-module inlining, tree shaking inside functions, expression-level minification beyond the rewrites of section 9 and property mangling.