Skip to content

Load bundled CommonJS dependencies: settled exports, merged requires, semver's tokens, fs.realpath, whole-export members - #1558

Closed
cramforce wants to merge 38 commits into
mainfrom
opt/npm-cjs-var-export
Closed

cramforce wants to merge 38 commits into
mainfrom
opt/npm-cjs-var-export

Conversation

@cramforce

@cramforce cramforce commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #1458. One commit per fix, in the order a bundled package hits them while it loads.

Summary

  • Settled variable exports. module.exports = x and module.exports = { x } copy x's value at that statement. Every var or let export used to be fenced. esbuild hoists a bundled module's top-level const to a var in its __commonJS chunk, so a bundle stopped at load (into-stream's module.exports = intoStream2, async-sema's Sema). The export now compiles when the binding's declaration and every write sit in top-level code above it, outside functions and classes. A later write keeps the fence.
  • Members of a var- or const-bound function export. The package rewrite that reaches module.exports.x through a whole-function export now accepts a function expression or arrow bound to a var/const, and require bindings above the export. Without this, into-stream's .default/.object read undefined.
  • Merged require declarations. A minifier merges var minimatch = require_minimatch(), Minimatch = minimatch.Minimatch, … (glob). Inside such a statement the required module was never stored, so the member read undefined and later references failed. A package-file stage now splits a top-level declaration that mixes require initializers with other declarators into one declaration per declarator, in order and on the same lines.
  • semver's token table. exports = module.exports = SemVer comes before var t = exports.tokens = {}, and the hoisted SemVer reads t. The export store counted as a possible early call, so t became runtime-optional and tok's t[n] = R++ was fenced at load. A reference that only stores or extends the function now counts as settled when no top-level code up to the settling statement can re-enter the module: no user-code call, construction, require, or read of module/exports. The split stage also respells var t = exports.tokens = {} as var t = {}; exports.tokens = t;; before, writes through t silently never reached exports.tokens.
  • fs.realpathSync as a value (fs.realpath, under glob). The value is an adapter over the native call. Node's options can change the result's encoding, so the adapter takes the call only for a string path with UTF-8 options (none, "utf8", or an object without another encoding, such as fs.realpath's cache object). Other shapes throw a located SC1090 fence. Documented on the builtin function values page.
  • Members attached to a whole export, in program files. module.exports = format; module.exports.shout = shout (with or without exports =) attaches shout to the function. Reading format.shout through a requirer's binding answered undefined, and calling it threw. Reads now resolve to the member's value when it is written once; calls resolve when the function ignores this, and otherwise fence by name. Applying the package rewrite to program files was not the fix: it turns member exports into expandos, which breaks destructured requires (corpus 6431).

Follow-up (int/bc round 7 regression). The relaxation above (require bindings before the export count as inert) also let the rewrite respell a function declaration's member exports as members of the function. cross-spawn 7's typed module.exports.sync = spawnSync then needed a dynamic value and failed at load (SC1101). Behind requires, only a function bound to a variable is respelled now; declarations keep their member exports (npm-static case 2596-function-export-members, plus a unit test). Gate for that commit against the previous tip: all 3434 programs identical except the fs-extra-package shim-path artifact. The driver passes.

Evidence

All npm-static cases use real esbuild 0.27 output (--bundle --platform=node --format=cjs --minify-whitespace --minify-syntax) unless noted:

  • 2591-bundled-var-export (into-stream's shape);
  • 2592-bundled-merged-requires (glob's merged declaration over a minimatch-like ES5 constructor);
  • 2593-bundled-semver-tokens (semver 6's load-time shape, with the hoisted constructor reading the table);
  • 2594-fs-realpath-values, with fs.realpath 1.0.0 vendored unmodified.

Each matches Node byte for byte. Each fails on #1458's tip, either with the fence or with wrong output.

Corpus programs:

  • 6484-cjs-settled-exports/: a hoisted var function export, a table settled by loops and conditionals, and var/let classes;
  • 6485-cjs-whole-export-members/: both export spellings, reads and calls.

Unit and coverage tests:

  • cjs-settled-exports.test.ts: fences stay for later writes and for this-reading member calls;
  • npm-static-cjs-exports-alias.test.ts and npm-static-split-declarations.test.ts: rewritten modules run against their originals under Node.

The real packages: semver 6.3.1's chunk now loads, and glob 8.0.3's bundle gets past Minimatch and fs.realpath. Remaining stops are listed in the notes.

  • Zero-cost gate against Compile polyfill packages that are Node builtins as the builtins #1458's tip, with the full corpus (--corpus=all, 3432 programs): bench, self and corpus are byte-identical except fs-extra-package, whose IR differs only by the fs-extra shim's absolute checkout path (identical once /vercel/sandbox/{pbase,cand} is normalized). Bench geomean −1.21%.
  • The self-hosted compiler builds, and self-hosting-native-driver.test.ts passes on the final tip (1213 s).
  • pnpm lint, pnpm format:check and test:ts7 --baselines-only pass; only the new fixtures' baselines were added.
  • The npm, npm-static and package-shims suites, the frontend unit tests and cjs-settled-exports.test.ts pass 1137 of 1138. The one failure, node24-url-authority, comes from the sandbox's Node 24.15 versus the pinned 24.22.

Note for reviewers: fs.realpathSync as a value (commit "Store fs.realpathSync as a function value") was also on another agent's queue; it is complete here, so that work can be dropped.

🤖 Generated with Claude Code

cramforce and others added 11 commits October 20, 2026 11:07
…function exports

`module.exports = x` and `module.exports = { x }` copy x's value at that
statement, so every var or let export was fenced. esbuild hoists a bundled
module's top-level const to a var in its __commonJS chunk, so a bundle's
`module.exports = toText2` stopped the program at load. The export now
compiles when the declaration and every write sit in top-level code above
it, outside functions and classes: the copied value is then the binding's
final value. A write that can run later keeps the fence.

The package-file rewrite that reaches members through a whole-function
export now also takes a var or const bound to a function expression or
arrow above the export, and treats bindings of required modules before the
export as inert. Without it the export's members (.default, .object) read
as undefined.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A minifier merges a module's consecutive declarations, so its requires
and the bindings read from them share one statement (glob's `var
minimatch = require_minimatch(), Minimatch = minimatch.Minimatch, …` in
a bundle). Require bindings are modeled per statement: inside a mixed
statement the required module was never stored, so `minimatch.Minimatch`
read undefined, and a later reference to `Minimatch` met "a binding form
with no lowering". A package-file rewrite stage now splits a top-level
declaration that mixes require() initializers with other declarators
into one declaration per declarator, in order and on the same lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
semver 6 starts with `exports = module.exports = SemVer`, then builds
`var t = exports.tokens = {}` and fills it through `function tok(n) {
t[n] = R++ }`. The hoisted SemVer reads `t`, and its export store counted
as a reference that could call it before `t` was set, so `t` became a
runtime-optional global and tok's computed write was fenced at load.

A function stored as a CommonJS export, or given members, can only run
before a binding settles if code re-enters the module first. When every
top-level statement up to the settling one makes no call into user code,
no construction, no require and no read of `module`/`exports`, such a
reference now counts as settled (builtin methods of literals and standard
globals cannot run user code).

The declaration split also respells `var t = exports.tokens = {}` as
`var t = {}; exports.tokens = t;`: writes through `t` were lost to the
export otherwise (`exports.tokens` printed `{}`).

Also records the order baselines of the new 6484 and 2591 fixtures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fs.realpath (glob 7 and 8 reach the filesystem through it) keeps Node's
own functions as values, `var origRealpathSync = fs.realpathSync`, and
calls them with its legacy cache object as the options argument. Reading
the function as a value was refused ("library functions as values").

The value is now an adapter over the native call. Node's second argument
can change the result's encoding, so the adapter takes the call only when
the path is a string and the options keep UTF-8 (none, "utf8", or an
object without another `encoding`); any other shape throws a located
SC1090 fence where the call runs instead of answering differently.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`module.exports = format; module.exports.shout = shout` (or the
`exports = module.exports = format` spelling) attaches `shout` to the
function. Destructured requires already found the member, but a read or
call through the requirer's binding of the function (`format.shout`)
answered undefined, and a call threw "is not a function".

A read through a `const` require binding now resolves to the member's
value when the module writes it once and the use site can read it again;
a call resolves to the member function when it ignores `this` (Node passes
the export as `this`) and otherwise fences by name.

Applying the package-file rewrite (`module.exports.x` respelled as `F.x`)
to program files was not the fix: it turns member exports into expandos
of the function, so a requirer's `const { shout } = require(…)` found no
member export and met "a binding form with no lowering" (corpus 6431).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oes not write

A member read through a binding of a factory-returned object (corpus 2923)
or one the requirer writes later is a runtime slot: it keeps reading the
object.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tsc wraps every CommonJS import in its helpers: `const events_1 =
__importDefault(require("events"))`, `const path =
__importStar(require("path"))`. cjs-tsc-interop.ts reads such bindings of
program modules as plain require bindings, so the helper declarations go
dead, but a builtin module is not a program module: its binding kept the
helpers live, and their probe of the module's top-level `this`
(`(this && this.__importDefault) ||`) failed at load ("property access
through top-level 'this'"). That is the shape of hundreds of tsc-compiled
files in a typical dependency tree.

A builtin carries no `__esModule`, so `events_1.default` is the module and
the namespace copy reads the module's members. When every reference has
that form, a package-file stage now respells the binding as the plain
require() of the builtin and `x.default` as `x`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ry namespace

A bundled module that exports only types keeps `var x_exports = {}` with
no __export call. Its star re-export into the entry,
`__reExport(main, (init_x(), __toCommonJS(x_exports)), module.exports)`,
was left in place because the rewrite only knew namespaces with an export
table, and failed at load on the `module.exports` argument ("the CommonJS
'module' object in this position"). Such a namespace is now an empty
table: the re-export runs the initializer and adds nothing.

Two silent results on the same paths: a member read of a name a module
does not export now answers undefined (`__esModule` true) where it was
refused, and a read through the bundle's own entry namespace inside the
bundle (`(init_index(), __toCommonJS(index_exports)).KIND`) answered
undefined; it now reads the entry's export table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
scriptc Ready Ready Preview, v0 Oct 21, 2026 6:48am UTC

Comment thread packages/compiler/src/frontend/lowering/var-hoisting.ts Outdated
Comment thread packages/compiler/src/frontend/lowering/cjs-exports-object.ts Outdated
cramforce and others added 3 commits October 20, 2026 14:50
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ar-bound functions

#1558 let the function-export rewrite run when require bindings come
before `module.exports = F`. For a function declaration that respelled its
member exports as members of the function (`module.exports.sync =
spawnSync` became `spawn.sync = spawnSync`), which need dynamic values: a
typed member such as cross-spawn 7's spawnSync (returning
SpawnSyncReturns) failed at load with SC1101. A declaration's member
exports already compile as member exports, so behind requires only a
function bound to a variable (esbuild's hoisted `var`, whose members have
no other form) is respelled now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	tests/harness/npm-cases.ts
#	tests/harness/npm-static.test.ts
Base automatically changed from opt/npm-polyfill-shims to main October 20, 2026 23:28
cramforce and others added 3 commits October 20, 2026 16:38
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s members

A builtin call above a function export's settling statement can call back
into the module (`Array.from(xs, () => module.exports())`), so it counts
as a possible early call unless each argument is a primitive: a literal,
`undefined`, a read under `process`, or an operator over those.

A member of a function whole export is a runtime property of the function
when program code may write it through a require: an assignment, update
or delete of `fmt.name`, `fmt["name"]` or `require("./lib").name` in any
program file, the module's own writes through the function's name, and,
for every member, a computed key or a use that hands the value elsewhere
(`Object.assign(fmt, …)`). The module's `module.exports.name = …` then
also stores the member on the function, so requirers read the value Node
reads before and after those writes.
Comment thread packages/compiler/src/frontend/lowering/var-hoisting.ts Outdated
…ling exports

retry 0.13.1's `exports.timeouts` reads `this.createTimeout`, and
`exports.operation` calls it through the exports object; async-retry wraps
every request in an operation. A call through the exports object passes
that object as `this`, which compiles to a static table, so the call was
fenced ("calling 'exports.timeouts' through the module's exports object
when the function may read 'this'").

When every `this` in an exported function expression names another
export the module writes (`this.X` with `exports.X = …`), the exports
object is never replaced, and the module calls the function only through
`exports.f(…)`, a package-file stage now respells `this.X` as `exports.X`:
the member Node reads for those calls. Only a caller that detaches the
function or rebinds its `this` would observe a difference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread packages/compiler/src/frontend/npm-static-tsc-helpers.ts
cramforce and others added 5 commits October 20, 2026 17:41
… members

`module.exports = spawn; module.exports.spawn = spawn` (cross-spawn 7)
stores the function as one of its own member exports. That use of `spawn`
counted as handing the value to code that can write its members, so every
member was stored on the function as a runtime property, and the typed
`module.exports.sync = spawnSync` failed at load again (SC1101; npm-static
case 2596). A top-level member export store writes no member of the
function.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cramforce and others added 2 commits October 20, 2026 17:54
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tion exports, and leave written interop wrappers

- A class runs its heritage, decorators, computed member names, static
  field initializers and static blocks where it is defined, so a call
  there can call a function export before the variables it reads are
  assigned. The re-entry check now skips only method bodies and instance
  field initializers.
- Passing a function whole export to other code (a re-export, a callback
  argument) no longer marks all of its members as runtime properties.
  Named writes, computed-key writes and the keys builtins such as
  Object.assign and Object.defineProperty write still do, so typed members
  such as `module.exports.sync = spawnSync` stay static exports.
- A tsc interop binding of a builtin whose `.default` or namespace members
  are written keeps its helper: the write changes the helper's object, and
  respelling `x.default = …` as `x = …` assigned the const binding.
Comment thread packages/compiler/src/frontend/lowering/cjs-exports-object.ts
cramforce and others added 4 commits October 20, 2026 18:42
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A module that binds `const {a, b} = require(...)` above
`module.exports = f; module.exports.g = ...` (execa 5's index.js shape)
skipped the function-export rewrite, because only identifier require
bindings counted as inert. The members then never reached the inferred
export type, and `execa.command` read as missing. A plain object pattern
(identifier elements, no defaults, rest, computed keys or nesting) runs
no user code, so it counts as inert too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	tests/harness/npm-cases.ts
#	tests/harness/npm-static.test.ts
ctate added 2 commits October 20, 2026 21:18
…t shadow exports

- A sibling read as a value through `this.f` inside the module (`var g =
  this.b; g()`) now counts as a detached use, as `exports.f` already did,
  so `b` keeps its `this`.
- An exported function that declares its own `exports` binding (a
  parameter, a `var`, an arrow parameter or its own name) keeps its text,
  and so does a module that declares `exports` at module scope: the
  respelled reads would name that binding instead of the exports object.
A use of a function whole export other than a member access, a call or
`typeof` hands it to code that can write any member (`mutate(fmt)`), so
every member becomes a runtime property of the function again. A module
that re-exports the value whole (`module.exports = require("./lib")`)
contributes its own requirers' writes instead. A member whose value has no
dynamic form, such as a typed function, stays the export's static value,
so `module.exports.sync = spawnSync` keeps loading (npm case 2596).
@cramforce

Copy link
Copy Markdown
Contributor Author

Closing this under the scope-B plan the maintainer approved (native compilation for typed code; Node islands for the rest). Loading bundled CommonJS dependencies, which covers settled var exports, merged require declarations, semver's token table and members attached to a whole export, is CommonJS export-object emulation. Under scope B those packages run as Node islands rather than through more native emulation (D1/D2 in the plan), so this branch will not merge.

What is salvaged:

What is not salvaged:

The branch stays in place for reference.

@cramforce cramforce closed this Oct 21, 2026

This branch was successfully deployed

1 active deployment
Preview — ac058614 Deployed Oct 21, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants