Skip to content

parser: .cjs/"type":"commonjs" rejects ESM export and top-level await - #33899

Closed
robobun wants to merge 8 commits into
mainfrom
claude/farm/ec4f907c/cjs-type-rejects-export
Closed

robobun wants to merge 8 commits into
mainfrom
claude/farm/ec4f907c/cjs-type-rejects-export

Conversation

@robobun

@robobun robobun commented Jul 10, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

A .js file under "type":"commonjs" (or a .cjs file) containing export or top-level await is silently classified and run as ESM. In Node those are a hard SyntaxError: an explicit "type":"commonjs" pins the format of every .js in the tree, and .cjs is always CommonJS. A program that cannot load on any Node loads and runs on Bun, and nothing warns.

// pkg/package.json: {"type":"commonjs"}
// pkg/t.js:
export const x = 7;
console.log("ran");
Node v26 Bun (before)
bun pkg/t.js SyntaxError: Unexpected token 'export' ran
import {x} from SyntaxError: Named export 'x' not found (CJS) named 7

Cause

In parse_entry.rs the exports_kind decision checks esm_export_keyword || top_level_await_keyword before ever consulting options.module_type, so the syntax out-ranks the explicit declaration.

Fix

  • parse_entry.rs: when module_type == Cjs at runtime (not bundling, not TypeScript), emit a range error at the export/await with a note saying why the file is CommonJS. TypeScript is excluded because export in a CommonJS-typed .ts/.cts is idiomatic (tsc compiles it to exports.x = ...).
  • RuntimeTranspilerStore.rs: thread the caller's extension-aware module_type onto TranspilerJob and use it in the worker. The worker was re-deriving it from resolved_source.tag, which never carried the .cjs/.mjs extension signal. Also check log.errors after a successful parse (matching the sync path in transpile_source_code_inner); without this, parser-emitted errors on the async path were silently dropped and the output executed anyway.
  • jsc_hooks.rs: derive the runtime module_type for .js/.ts from the nearest package.json'''s "type" by walking DirInfo parents, including nameless ones that enclosing_package_json skips. Without this, dual-package layouts like puppeteer (root {"type":"commonjs"}, nameless lib/esm/package.json = {"type":"module"}) were seeing the outer type and the new error fired on valid ESM files. Carried as a separate LoaderResult field so pkg_name semantics are unchanged. Also pass the computed module_type to transpiler_store.transpile().
  • Hash module_type into the runtime transpiler features hash and bump EXPECTED_VERSION to 23, since byte-identical sources now parse differently under different module types.
  • resolve-ts.test.ts: the type:commonjs && jsFile matrix rows wrote ESM export into a .js under "type":"commonjs" and relied on the old lenient behaviour; write CJS-shaped sources for that combination (the resolution behaviour under test is the same either way).
  • bun-test.test.ts: the "cjs dynamic import" fixture used top-level await in a .cjs file; rewrite it to use .then() so the fixture is actually CommonJS.
  • type-export.test.ts: the "through export merge" main.* cases expected JSC's Cannot export a duplicate name error, which only surfaced because the async transpile path was silently dropping Bun's own Multiple exports with the same name parser error. With the log-errors check, Bun's parser error now surfaces on both the direct and imported paths.

Relationship to #33807

#33807 makes .mjs/"type":"module" authoritative over bare module/exports references (the ESM direction of the same decision-order bug). This PR is the CommonJS direction. The RuntimeTranspilerStore module_type threading, the jsc_hooks nearest-package.json walk, and the cache-hash change here are the same as in #33807; whichever lands second resolves a small conflict.

Tests

test/cli/run/run-cjs.test.ts gains a describe block covering .cjs, "type":"commonjs" .js, top-level await, loaded directly / via static import from .mjs / via dynamic import() / via require(), plus negative controls (.mjs, "type":"module", typeless .js, .mjs inside "type":"commonjs", .cts/.ts excluded, Bun.build unaffected). 7 of the 16 new tests fail on the unfixed build and all pass with this change.

Also ran: test/js/bun/resolve/, test/bundler/bundler_cjs2esm.test.ts, test/bundler/transpiler/transpiler.test.js, test/cli/test/isolation.test.ts, test/integration/jsdom/ with no new failures.


no test proof · iteration 13 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/cli/test/bun-test.test.ts test/js/bun/typescript/type-export.test.ts

@robobun

robobun commented Jul 10, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:06 AM PT - Jul 18th, 2026

❌ @robobun, your commit 0142918 has 2 failures in Build #75465 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33899

That installs a local version of the PR into your bun-33899 executable, so you can run:

bun-33899 --bun

@coderabbitai

coderabbitai Bot commented Jul 10, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The runtime transpiler now carries explicit module type through parsing, includes it in cache hashing, rejects ES module syntax in applicable CommonJS contexts, and detects logged parse errors before output generation. Tests cover module-format enforcement and type-aware fixtures.

CommonJS module enforcement

Layer / File(s) Summary
Module type parsing and cache compatibility
src/js_parser/parse/parse_entry.rs, src/jsc/RuntimeTranspilerCache.rs
Runtime-transpiler hashes include module_type; explicit CommonJS parsing reports diagnostics for export and top-level await, and the cache format version is advanced.
Module type resolution and runtime transpilation
src/runtime/jsc_hooks.rs, src/jsc/RuntimeTranspilerStore.rs
Loader results resolve the nearest package module type, transpilation passes it explicitly, jobs use it for parsing, and logged parse errors stop output generation.
CommonJS behavior validation
test/cli/run/run-cjs.test.ts, test/js/bun/resolve/resolve-ts.test.ts, test/cli/test/bun-test.test.ts, test/js/bun/typescript/type-export.test.ts
Tests cover CommonJS syntax rejection, valid ESM and CJS cases, TypeScript exclusions, build behavior, dynamic-import fixtures, parse-error propagation, and type-aware generated JavaScript fixtures.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title accurately summarizes the main change: CommonJS entries now reject ESM export and top-level await.
Description check ✅ Passed The PR description is detailed and covers the change and verification, though it uses custom headings instead of the template labels.

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't find any bugs, but this is a deliberate breaking change to module-format detection in the core parser (previously-running code now hard-errors), with judgment calls around the TS/bundler exclusions and coordination with #33807 — worth a maintainer's sign-off.

Extended reasoning...

Overview

This PR changes src/js_parser/parse/parse_entry.rs to emit a hard parse error when export or top-level await appears in a file explicitly declared CommonJS (.cjs extension or "type":"commonjs"). It threads module_type through the async transpiler path (RuntimeTranspilerStore.rs, jsc_hooks.rs), adds a post-parse log.errors check on the async path, hashes module_type into the transpiler-cache features hash, bumps the cache version to 23, and updates two test files (16 new tests in run-cjs.test.ts, plus a fixture rewrite in resolve-ts.test.ts to stop relying on the old lenient behavior).

Security risks

None identified. No auth, crypto, network, or untrusted-input parsing surface is touched beyond what the parser already handles; the change only adds an error-emission branch on already-parsed state.

Level of scrutiny

High. This is a behavioral breaking change to core module-loading semantics: code that previously ran under Bun (ESM syntax in .cjs / "type":"commonjs" .js) will now fail with a SyntaxError. While it aligns Bun with Node, the decision to hard-error (vs. warn), the scope of the TypeScript and bundler exclusions, and the ecosystem-impact tradeoff are design decisions a maintainer should own. It also touches the parser hot path and the transpiler cache-key derivation, both of which are critical infrastructure.

Other factors

  • The PR is well-reasoned and thoroughly tested (positive cases, negative controls, multiple load paths, Bun.build exclusion, TS exclusion).
  • The resolve-ts.test.ts fixture change is a real signal that existing code patterns will break — a maintainer should confirm the ecosystem-compat tradeoff is acceptable.
  • The description notes overlap with #33807 (same module_type threading and cache-hash change); landing order needs coordination.
  • The new log.errors > 0 check on the async transpile path is a secondary behavior change (previously-silent parser errors now surface) that is correct but broadens impact beyond just this feature.

Comment thread test/cli/run/run-cjs.test.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/cli/run/run-cjs.test.ts (1)

65-73: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Missing "reason" assertion for consistency with sibling tests.

Sibling tests for .cjs/.js under "type":"commonjs" (lines 41-43, 52-53, 57-63) all assert both the generic error message and the specific reason (.cjs extension or package.json type). This test only asserts the generic top-level-await message, omitting the 'the nearest package.json sets "type": "commonjs"' reason check that the sibling export test at line 53 verifies.

✏️ Suggested addition
     const { stdout, stderr, exitCode } = await run(String(dir), "t.js");
     expect(stderr).toContain("Cannot use top-level 'await' in a CommonJS module");
+    expect(stderr).toContain('the nearest package.json sets "type": "commonjs"');
     expect({ stdout, exitCode }).toEqual({ stdout: "", exitCode: 1 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/cli/run/run-cjs.test.ts` around lines 65 - 73, Add an assertion in the
top-level-await CommonJS test to verify stderr also contains the specific reason
that the nearest package.json sets "type": "commonjs", matching the sibling
tests and retaining the existing generic error assertion.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@test/cli/run/run-cjs.test.ts`:
- Around line 65-73: Add an assertion in the top-level-await CommonJS test to
verify stderr also contains the specific reason that the nearest package.json
sets "type": "commonjs", matching the sibling tests and retaining the existing
generic error assertion.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: dd0b60fb-4bf5-4fd0-9de2-569843e4acee

📥 Commits

Reviewing files that changed from the base of the PR and between e371dde and 2ff465f.

📒 Files selected for processing (3)
  • test/cli/run/run-cjs.test.ts
  • test/cli/test/bun-test.test.ts
  • test/js/bun/typescript/type-export.test.ts

Comment thread src/js_parser/parse/parse_entry.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/jsc_hooks.rs`:
- Around line 3944-3945: Update the `RuntimeTranspilerStore::transpile` call
site to seed `ResolvedSourceTag` and `is_commonjs_module` from the same nearest
package that provides `nearest_module_type`, rather than the potentially outer
`package_json.module_type`; alternatively pass the nearest package explicitly.
Add a regression test covering nested nameless packages with dynamic imports and
verify parsing metadata matches the advertised module type.

In `@test/cli/run/run-cjs.test.ts`:
- Around line 143-167: Update the nested ESM test using the existing
`exportBody` and `entry.mjs` setup to export a known value from
`lib/esm/inner/t.js`, capture the namespace returned by the dynamic import, and
assert that value directly in addition to checking stdout and exitCode. Ensure
the assertion validates the resolved ESM module shape rather than only
successful execution.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 60a5185d-c72e-448d-8fdb-b01833de7c56

📥 Commits

Reviewing files that changed from the base of the PR and between 2ff465f and 7f04674.

📒 Files selected for processing (2)
  • src/runtime/jsc_hooks.rs
  • test/cli/run/run-cjs.test.ts

Comment thread src/runtime/jsc_hooks.rs
Comment thread test/cli/run/run-cjs.test.ts
@robobun

robobun commented Jul 10, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI status: the new run-cjs.test.ts block (18 tests), resolve-ts, bun-test, type-export, isolation, and jsdom all pass on every lane across every build (75048, 75169, 75311, 75465). The previously-real regressions (next-pages/dev-server puppeteer, bun-test.test.ts cjs-dynamic-import, type-export.test.ts merge cases) were fixed in 269c8dd/7f04674 and have not reappeared.

Remaining red across the post-rebase builds is all on tests this diff does not touch: test-fs-promises-file-handle-readFile.js / 30205.test.ts / node-net.test.ts (main breaks owned by other sessions), plus single-lane flakes in es-module-lexer (90s spawn timeout on one Windows lane per build, passes locally in 6s and passed on Windows in 75048/75311), installer/cron/webview/spawn/repl/timer-heap/bake/no-orphans/require-cache/handle-leak/20144/21311/security-scanner/net-memleak/worker-cleanexit tests.

Ready for review.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found after the fixes — deferring to a maintainer since this is a user-facing behavior change to core module loading (new hard error where code previously ran, plus the async-path log.errors > 0 check surfaces previously-swallowed parser errors more broadly than just this feature).

Extended reasoning...

Overview

This PR makes .cjs files and .js files under "type":"commonjs" reject ESM export and top-level await with a hard parse error, matching Node's behavior. It touches the JS parser (parse_entry.rs), the runtime transpiler store/cache (RuntimeTranspilerStore.rs, RuntimeTranspilerCache.rs), and the module loader hooks (jsc_hooks.rs), plus four test files. Beyond the headline change, it also (a) rewires module_type derivation to walk to the nearest package.json including nameless ones, and (b) adds a log.errors > 0 check after parse on the async transpile path so parser-emitted errors surface instead of being silently dropped.

Security risks

None identified. No auth, crypto, network, or untrusted-input parsing changes; the new code paths gate on already-parsed AST state and existing resolver metadata.

Level of scrutiny

High. This is a deliberate user-facing breaking change: code that ran on Bun before will now hard-error. The rationale (Node compat) is sound and the PR description is thorough, but whether Bun should be strict here — and the TypeScript carve-out — is a product decision. Separately, the log.errors > 0 check on the async path (RuntimeTranspilerStore.rs:962) has effects beyond this feature: it changes which error message users see for any parser-logged error on the async path, as demonstrated by the type-export.test.ts update. That's the right fix for a real bug (silently executing output despite parse errors), but it widens the blast radius and a maintainer should confirm it's intended.

Other factors

  • All four prior review comments (two from me, two from CodeRabbit) were addressed in follow-up commits and are resolved.
  • Test coverage is comprehensive (16 new cases covering positive/negative/nested/TS-exclusion/bundler paths).
  • The PR notes overlap with #33807 (the ESM-direction sibling); whichever lands second resolves a conflict, so merge order matters.
  • The jsc_hooks.rs nearest-package.json walk changes resolution semantics for dual-package layouts — well-tested here but touches a hot path every module load goes through.

@robobun
robobun force-pushed the claude/farm/ec4f907c/cjs-type-rejects-export branch from 631a33a to 1117ebd Compare July 18, 2026 02:04
Comment thread src/js_parser/parse/parse_entry.rs

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new findings — all four prior review threads are addressed. This is a deliberate breaking change to runtime module classification (code that ran before now hard-errors), so leaving the merge decision to a maintainer.

What was reviewed:

  • The new log.errors > 0 check on the async transpile path — confirmed it mirrors the sync path and the collateral behaviour change (Bun's "Multiple exports" parser error now surfacing instead of JSC's) is covered by the updated type-export.test.ts.
  • The nearest-package.json walk in jsc_hooks.rs — checked that leaving resolved_source.tag keyed to the enclosing named package (per the resolved CodeRabbit thread) doesn't reintroduce a mismatch, since is_commonjs_module is recomputed from the parse result.
  • Cache-hash / EXPECTED_VERSION bump covers the new module_type input.
  • The import-only-in-explicit-CJS gap is now documented at the fallthrough branch.
Extended reasoning...

Overview

Makes export and top-level await in .cjs files (and .js under "type":"commonjs") a hard parse error at runtime, matching Node. Touches parse_entry.rs (the error itself), RuntimeTranspilerStore.rs (thread module_type to the worker + check log errors post-parse), jsc_hooks.rs (derive module_type from the nearest package.json including nameless ones), RuntimeTranspilerCache.rs (hash module_type, bump version to 24), and four test files.

Security risks

None. No untrusted-input parsing surface is widened; the change narrows accepted syntax.

Level of scrutiny

High — this is an intentional user-visible breaking change to runtime behaviour, and the author explicitly noted it's awaiting product sign-off. The log.errors > 0 check on the async path is also a broader behaviour change than the headline (any parser-emitted log error on the async path now surfaces where it previously didn't), which is correct but worth a maintainer's eye. Not appropriate for bot approval.

Other factors

I've reviewed this across four passes; all inline comments (test stderr shape, .cjs remedy-note wording, import-only exclusion) were applied. The 18-test matrix in run-cjs.test.ts covers the variant space well (direct/imported/dynamic/require, both nested-nameless directions, TS carve-out, bundler exclusion). The bug hunter found nothing this run. The overlap with #33807 (same module_type threading / cache-hash change) is called out in the description.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No bugs found, but deferring to a human: this is a deliberate breaking change to runtime module classification (code that ran under Bun will now hard-error), and the author noted it's awaiting product sign-off.

What was reviewed:

  • Parser guard in parse_entry.rs — gated on !bundle && !ts && commonjs_at_runtime; bundler/TS/JSX paths unaffected.
  • RuntimeTranspilerStore log-errors check placement — runs after the watcher add_file, matching the parse-None branch, so hot-reload isn't skipped on the new error.
  • jsc_hooks nearest-package.json walk — pkg_name/ResolvedSourceTag semantics unchanged; only the parser's module_type input widens.
  • Cache: module_type hashed + EXPECTED_VERSION bumped, so no stale-hit path reinstates the old behaviour.
Extended reasoning...

Overview

The PR makes export and top-level await in an explicitly-CommonJS file (.cjs, or .js under "type":"commonjs") a hard runtime parse error, matching Node. It threads an extension-and-nearest-package.json-aware module_type from jsc_hooks through RuntimeTranspilerStore::transpile into the parser, adds a post-parse log.errors > 0 check on the async transpile path (previously only the sync path had it), hashes module_type into the transpiler cache key, and bumps the cache version. Three existing test files are adjusted to stop relying on the old lenient behaviour, and run-cjs.test.ts gains 17 new tests covering the error, negative controls, TS exclusion, nested nameless-package.json layouts, and the bundler carve-out.

Security risks

None identified. No untrusted input parsing changes shape; the new code only reads already-parsed module_type/extension state and emits a diagnostic. No auth, crypto, network, or filesystem-write surface is touched.

Level of scrutiny

High. This is core runtime behaviour in the parser and module loader — every .js/.cjs file loaded at runtime flows through the changed decision. It is an intentional breaking change: files that Bun previously ran will now fail with a SyntaxError. That is a product/compat call (how strictly to match Node vs. tolerate mixed-syntax packages in the wild) that a maintainer should sign off on. The author explicitly flagged it as "a deliberate breaking change awaiting product sign-off."

Other factors

  • All four of my prior inline comments were addressed (stderr-assertion shape, .cjs remedy-note wording, import-only exclusion rationale, and — via the CodeRabbit thread — nested-ESM namespace assertions). The latest commits since my last comment are just the documenting comment (23d485c) and a CI retrigger.
  • The type-export.test.ts change is a real behaviour change (async path now surfaces Bun's parser error instead of JSC's), not just a fixture tweak — worth a maintainer glance to confirm that's the desired outcome.
  • The sibling PR #33807 (ESM direction) shares the jsc_hooks/RuntimeTranspilerStore/cache-hash changes; whichever lands second has a small merge conflict to resolve.
  • Test coverage is thorough (direct/static-import/dynamic-import/require entry paths, both nested-nameless-package directions, TS/bundler exclusions), and CI on the last full build was green for all touched suites per the author's build-71435 summary.

@robobun
robobun force-pushed the claude/farm/ec4f907c/cjs-type-rejects-export branch from 63007bb to 5168d62 Compare July 18, 2026 05:59

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No bugs found, but this is a deliberate breaking change (previously-working .cjs/"type":"commonjs" files with export/TLA now hard-error) that the author notes is awaiting product sign-off — deferring to a maintainer for that call.

What was reviewed:

  • Parser gate in parse_entry.rs: confirmed it's runtime-only (!bundle, commonjs_at_runtime), TS-excluded, and the .cjs remedy note no longer suggests the ineffective package.json fix.
  • RuntimeTranspilerStore async path: the new log().errors > 0 check runs after the watcher add_file block (matching the sync path's ordering), and module_type is now threaded from the caller instead of re-derived from this_tag.
  • jsc_hooks.rs nearest-package.json walk: leaves package_json/ResolvedSourceTag semantics unchanged (only the new nearest_module_type field walks nameless parents), so ignoreESModuleAnnotation behaviour is preserved.
  • Cache: module_type is hashed and EXPECTED_VERSION bumped, so byte-identical sources under different module types don't collide.
Extended reasoning...

Overview

The PR changes runtime module-type enforcement so that .cjs files and .js files under an explicit "type":"commonjs" package.json reject ESM export and top-level await with a parser error, matching Node's SyntaxError behaviour. It touches the parser's exports_kind decision (parse_entry.rs), threads module_type through the async transpile worker (RuntimeTranspilerStore.rs), derives module_type from the nearest package.json including nameless ones (jsc_hooks.rs), hashes module_type into the transpiler cache key with a version bump (RuntimeTranspilerCache.rs), and updates four test files.

Security risks

None identified. This is module-format classification and error reporting; no auth, crypto, untrusted-input parsing, or filesystem-escape surface is touched. The new DirInfo parent walk reuses the existing resolver cache.

Level of scrutiny

High. This is an intentional breaking change to core runtime module-loading semantics: code that ran on every prior Bun release will now fail. The author explicitly flagged it as "a deliberate breaking change awaiting product sign-off". That is precisely the kind of decision a maintainer must own — the correctness of the implementation (which looks sound and is well-tested across 18 new cases including negative controls, TS carve-outs, dual-package layouts, and the bundler exclusion) is separate from the product decision to ship it. The sibling PR #33807 handles the ESM direction of the same decision-order bug and shares plumbing, so whichever lands second will need a small merge resolution.

Other factors

All three of my prior inline comments (stderr-assertion shape, .cjs remedy-note wording, and the import-only exclusion) were addressed in follow-up commits, as were both CodeRabbit findings. The async-path log.errors check is a nice incidental fix (it's why the type-export.test.ts expectation changed). CI on build 75048 was green for the touched tests; the three unrelated failures noted there (fs-promises FileHandle, macOS RSS threshold, perl-daemon timeout) are pre-existing on main. Given the scope and the author's own note that this needs maintainer sign-off, approving without human review isn't appropriate here.

@robobun
robobun force-pushed the claude/farm/ec4f907c/cjs-type-rejects-export branch from 5168d62 to 8ac57f0 Compare July 18, 2026 10:01

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No further issues from my side — the three earlier inline comments are all addressed. Deferring because this is a deliberate breaking change to runtime module classification (code that previously ran now hard-errors), which as you noted is awaiting product sign-off from a maintainer.

What was reviewed:

  • parse_entry.rs guard: confirmed it's gated on !bundle && !ts && commonjs_at_runtime, so bundler and TS paths are untouched; .cjs vs package.json branching in the diagnostic is correct.
  • RuntimeTranspilerStore log.errors > 0 check matches the sync path in transpile_source_code_inner; the type-export.test.ts change is a correct consequence, not a weakened assertion.
  • jsc_hooks.rs nearest-package.json walk: package_json return value is unchanged (still enclosing_package_json), so resolved_source.tag / ignoreESModuleAnnotation semantics are preserved; only the parser's module_type input widens.
  • Cache: module_type hashed + version bump to 24, so no stale-hit risk.
Extended reasoning...

Overview

Touches the runtime module-format decision across four layers: parse_entry.rs (emit a range error when export/TLA appears under explicit ModuleType::Cjs), jsc_hooks.rs (derive module_type from the nearest package.json including nameless ones, and thread it to the async transpile call), RuntimeTranspilerStore.rs (carry module_type on TranspilerJob instead of re-deriving from resolved_source.tag; check log.errors after a successful parse so parser-emitted errors surface on the async path), and RuntimeTranspilerCache.rs (hash module_type + bump EXPECTED_VERSION). Four test files updated: 18 new cases in run-cjs.test.ts, plus fixture rewrites in resolve-ts, bun-test, and type-export that were relying on the old lenient behaviour.

Security risks

None. No untrusted-input parsing, no auth/crypto/permissions. The change tightens what runs rather than loosening it.

Level of scrutiny

High — this is the core module-loading hot path executed for every file, and it is an intentional user-facing breaking change: .cjs files and .js under "type":"commonjs" that contain export or top-level await used to run and will now error. The PR author explicitly notes it is "a deliberate breaking change awaiting product sign-off". That is a design/compat call a maintainer needs to make; the sibling PR #33807 (the ESM direction) is in the same boat and whichever lands second has a small merge conflict to resolve. The secondary behaviour change — the async transpile path now surfaces parser log errors instead of silently executing the output — is also a strict tightening that could surface pre-existing errors in user code.

Other factors

All three of my earlier inline comments (stderr-empty assertions, .cjs remedy-note wording, and the import-only exclusion) have been addressed in follow-up commits. CodeRabbit's two comments were also addressed/withdrawn. Test coverage is thorough (error cases × entry paths, negative controls, TS exclusion, nested nameless-package.json both directions, bundler exclusion). The implementation looks correct and internally consistent to me; deferral is purely for the product decision on the breaking change, not for a code concern.

robobun added 8 commits July 18, 2026 13:57
A .js file under "type":"commonjs" (or a .cjs file) containing
`export` or top-level `await` was silently classified and run as ESM.
In Node those are a hard SyntaxError: an explicit "type":"commonjs"
pins the format of every .js in the tree, and .cjs is always CommonJS.

The exports_kind decision in parse_entry.rs checked
`esm_export_keyword || top_level_await_keyword` before ever consulting
`options.module_type`, so the syntax out-ranked the explicit declaration.

- parse_entry.rs: when `module_type == Cjs` at runtime (not bundling, not
  TypeScript), emit a range error pointing at the `export`/`await` with a
  note saying why the file is CommonJS. TypeScript is excluded because
  `export` in a CommonJS-typed .ts/.cts is idiomatic (tsc compiles it to
  `exports.x = ...`).
- RuntimeTranspilerStore.rs: thread the caller's extension-aware
  `module_type` onto `TranspilerJob` and use it in the worker. The worker
  was re-deriving it from `resolved_source.tag`, which never carried the
  .cjs/.mjs extension signal. Also check `log.errors` after a successful
  parse (matching the sync path in transpile_source_code_inner); without
  this, parser-emitted errors on the async path were silently dropped and
  the output executed anyway.
- jsc_hooks.rs: pass the computed `module_type` to
  `transpiler_store.transpile()`.
- parse_entry.rs/RuntimeTranspilerCache.rs: hash `module_type` into the
  runtime transpiler features hash and bump EXPECTED_VERSION to 23, since
  byte-identical sources now parse differently under different module types.
- resolve-ts.test.ts: the `type:commonjs && jsFile` matrix rows wrote ESM
  `export` into a .js under "type":"commonjs" and relied on the old
  lenient behaviour; write CJS-shaped sources for that combination.

Sibling of #33807, which makes "type":"module" authoritative over
bare `module`/`exports` references (the ESM direction of the same
decision-order bug). The RuntimeTranspilerStore module_type threading
and cache-hash change here are the same as in that PR; whichever lands
second resolves a small conflict.
- bun-test.test.ts: the "cjs dynamic import" fixture used top-level
  `await` in a .cjs file, which has never been valid CommonJS. Rewrite
  it to use `.then()` so the fixture is actually CJS and still exercises
  dynamic `import()`.
- type-export.test.ts: the "through export merge" main.* cases expected
  JSC's "Cannot export a duplicate name" SyntaxError. That only happened
  because the async transpile path silently dropped Bun's own
  "Multiple exports with the same name" parser error and handed the
  broken output to JSC. With the log-errors check, Bun's parser error
  now surfaces on both the direct and imported paths.
Match the shape used by the other tests in the block so benign debug/ASAN
stderr noise can't produce a false failure.
…g nameless ones

`enclosing_package_json` skips package.json files that have no "name"
field, so for dual-package layouts like puppeteer (root
`{"type":"commonjs"}` with a nameless `lib/esm/package.json`
`{"type":"module"}`) the module_type for `lib/esm/**/*.js` was
resolved from the outer package.json instead of the nearer one, and the
new ESM-in-CommonJS error fired on valid ESM files.

Walk DirInfo parents until a package.json is found (regardless of name)
and use that for the .js/.ts module_type. Carry it as a separate
LoaderResult field so `pkg_name` semantics are unchanged.

Two tests for the nested-override in each direction.
The .cjs extension overrides package.json's "type" field, so that
half of the remedy note was ineffective. Branch on the extension the
same way the 'why' note does.
Prove the named export is reachable and no synthetic default is
attached, not just that execution succeeds.
Node rejects a static import statement in a .cjs/"type":"commonjs" .js
the same way it rejects export/TLA, but that case is deliberately left
alone here: import-only in explicit-CJS is far more prevalent in the
wild than export/TLA, and rejecting it would be much more breaking.
@robobun
robobun force-pushed the claude/farm/ec4f907c/cjs-type-rejects-export branch from 8ac57f0 to 0142918 Compare July 18, 2026 13:58

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No further findings — the earlier nits (stderr assertions, .cjs remedy note, import-only exclusion) are all addressed. Deferring to a maintainer since this is a deliberate breaking change to module-format classification and, per the thread, is awaiting product sign-off.

What was reviewed:

  • The new log.errors > 0 check on the async transpile path — confirmed it mirrors the sync path and the type-export.test.ts update reflects a real behaviour change (previously-swallowed parser errors now surface).
  • nearest_module_type walk in jsc_hooks.rs — checked it only feeds the parser's module_type and leaves resolved_source.tag semantics unchanged (per the resolved CodeRabbit thread).
  • Cache-key change — module_type is hashed and EXPECTED_VERSION bumped, so stale entries can't bypass the new error.
  • Test fixture rewrites in resolve-ts.test.ts / bun-test.test.ts — verified they preserve what the original tests asserted.
Extended reasoning...

Overview

This PR changes Bun's runtime module-format classification so that .cjs files and .js files under "type":"commonjs" reject ESM export and top-level await with a parse error, matching Node's SyntaxError behaviour. It touches four native files (parse_entry.rs, RuntimeTranspilerCache.rs, RuntimeTranspilerStore.rs, jsc_hooks.rs) and four test files. The infrastructure changes — threading module_type through TranspilerJob, walking to the nearest package.json (including nameless ones) for the runtime module_type, hashing module_type into the transpiler cache key, and checking log.errors after a successful async parse — are shared with #33807 and have broader effect than the headline feature.

Security risks

None identified. No untrusted input parsing beyond what the parser already does; no auth/crypto/permissions surface.

Level of scrutiny

High. This is an intentional breaking change to core runtime module-loading semantics: code that ran on every prior Bun release will now hard-error. The log.errors > 0 check on the async transpile path is independently significant — it changes which parser diagnostics surface at runtime for any module transpiled off-thread, not just the new CJS-rejects-export case (the type-export.test.ts update demonstrates this). The nearest_module_type derivation in jsc_hooks.rs also subtly changes what module_type the parser sees for .js/.ts under nameless nested package.json layouts. These are the right changes, well-tested (18 new tests covering the matrix, plus negative controls and the dual-package layout), and CI is green on the touched suites — but the product decision of whether/when to ship the breaking change is a maintainer call, and the author's own comment says it is "awaiting product sign-off".

Other factors

  • All three of my prior inline nits and both CodeRabbit findings are resolved with follow-up commits.
  • The PR description explicitly flags a merge-order conflict with #33807 (same infrastructure changes); a human should coordinate landing order.
  • The import-only-in-explicit-CJS case is deliberately left out and now documented at the branch site, per the resolved thread.
  • Test coverage is thorough: direct/static-import/dynamic-import/require entry points, .cjs vs "type":"commonjs", extension-wins, nameless-nested-package both directions, TypeScript exclusion, and Bun.build unaffected.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-18, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

@robobun robobun closed this Sep 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant