Skip to content

runtime: attach ModuleInfo to ESM transpiles so TypeScript type-only re-exports resolve - #35605

Open
robobun wants to merge 19 commits into
mainfrom
farm/2014f036/runtime-module-info-typescript-reexport
Open

runtime: attach ModuleInfo to ESM transpiles so TypeScript type-only re-exports resolve#35605
robobun wants to merge 19 commits into
mainfrom
farm/2014f036/runtime-module-info-typescript-reexport

Conversation

@robobun

@robobun robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Problem

// EventTypes.ts
export type ValueOf<T> = T[keyof T];
export const BUEvents = { A: "a" } as const;

// utils.ts
export { ValueOf, BUEvents } from "./EventTypes";

// index.ts
import { ValueOf, BUEvents } from "./utils";
$ bun index.ts
SyntaxError: export 'ValueOf' not found in './EventTypes'

bun build and bun build --compile run this fine; bun run and bun test did not. The re-export in utils.ts survives transpilation (Bun cannot know ValueOf is a type without looking at the other file), the export type in EventTypes.ts is erased, and JSC's own module analyzer then rejects the indirect export because the binding is gone.

Cause

#15758 added the infrastructure to fix this: the printer emits a ModuleInfo record alongside the transpiled source that encodes every import/export, marks the file as TypeScript, and flags re-exported single imports as ImportEntryType::SingleTypeScript. JSC's linker (CyclicModuleRecord.cpp, AbstractModuleRecord.cpp) already tolerates Resolution::NotFound for those entries and for indirect exports when m_isTypeScript is set. SourceProvider::create switches to SourceProviderSourceType::BunTranspiledModule whenever module_info is non-null, so Bun__analyzeTranspiledModule builds the JSModuleRecord from Bun's record instead of re-parsing.

That record was only attached when use_isolation_source_provider_cache() was true, which is bun test --isolate / --parallel, and on the --compile standalone path (where it is read back from the embedded graph). Plain bun run / bun test always got module_info = null and fell back to JSC's analyzer.

Fix

Replace the use_isolation_source_provider_cache() gate with use_module_info_for_esm(), which is always true (opt-out via BUN_FEATURE_FLAG_DISABLE_RUNTIME_MODULE_INFO), at the four sites that construct module_info:

  • runtime/jsc_hooks.rs sync transpile (fresh print + cache hit)
  • jsc/RuntimeTranspilerStore.rs async transpile (fresh print + cache hit)

Skipped when the parser emitted errors (log.has_errors()) so a duplicate-export or similar error still surfaces as JSC's own syntax error instead of a later link-time failure. BUN_FEATURE_FLAG_DISABLE_RUNTIME_MODULE_INFO is the escape hatch. It disables ModuleInfo everywhere (under --isolate the provider is then a plain Module, which the isolation cache already handles), and RuntimeTranspilerCache hashes that same decision into features_hash so entries written with and without it never mix.

ZigSourceProvider.cpp already keys off module_info != nullptr. BunAnalyzeTranspiledModule.cpp used to free the record after the first analyze outside --isolate; JSC analyzes the same fetched source twice on the require(esm) sync-replay path (a CJS entry requiring an ESM graph with a shared dependency), so the record now stays on the provider until ~SourceProvider, and an abandoned load (worker teardown mid-transpile, early return before a provider adopts it) frees it from OwnedResolvedSource::drop / ResolvedSourceCodeHolder. The printer records a Bun.plugin namespaced specifier as the same ns:path string it prints, and only interns exported local names into the record.

Two more teardown paths surfaced once every module carried a record (CI build 91634, ASAN leak in worker_threads.test.ts): RuntimeTranspilerStore::run_from_js_thread pops a whole batch of finished jobs and used to return as soon as the microtask drain between jobs reported termination, abandoning the jobs it had not reached (already off the queue, so teardown never released them; this leaked their source, log, promise and record). It now releases the rest of the batch in place. And Bun__analyzeTranspiledModule / fallbackParse returned the null promise rejectWithCaughtException yields under termination, which JSC's BunTranspiledModule arm downcasts (UBSan: member call on null JSCell); they now hand back the still-pending promise with the exception left in place and fulfill() the record the way JSC's own makeModule does.

RuntimeTranspilerCache EXPECTED_VERSION is bumped to 26 so pre-existing cache entries (written with esm_record_byte_length == 0) are invalidated; a hit on one of those would fall back to JSC's analyzer and reinstate the error.

Record order (CI build 91765)

With every ESM import going through the Bun-built record, require-cache.test.ts "via import() with a lot of long export names" (300 imports of a module with 10k export const name<i>) timed out on the x64-asan lane on all four attempts, and its concurrent sibling starved along with it; the file passes on the other PR builds on that lane. Profiling the release-asan build (gdb sampling, that 10k-export module, steady-state import()):

JSC analyzer (main's path) Bun record, before Bun record, now
JSModuleNamespaceObject constructor 39% of samples 58% 46%
std::__adjust_heap (introsort's heapsort fallback) 0% 13% 0%
analyze (makeModule parse, or Bun__analyzeTranspiledModule) 21% 3% 4%
wall time per import, measured in the same session 306 to 318 ms ~361 ms 288 ms

JSC keeps export entries in insertion order and std::sorts them whenever it builds a namespace object. ModuleAnalyzer inserts local exports by iterating the variable environment, so in hash order, which introsort handles well; the printer inserted them in source order, and on name0 .. name9999 that input drives libstdc++'s introsort past its depth limit into heapsort, roughly doubling the comparisons (each of which scans a 100-character common prefix). ModuleInfo::finalize now moves the local export records after all other records and sorts them by export name, the sort's best case. The other records keep their relative order, so which unresolvable indirect export gets reported first is unchanged, and nothing else observes the order (JSC sorts the namespace itself). The same module with its exports shuffled or pre-sorted in the source was already 20 ms per import faster than main's path, which is what pointed at the order. The serialized format is unchanged, so no further cache version bump: older entries are just unsorted.

The debug build cannot show this; there the difference is the debug-only fallbackParse cross-check plus WTF's StringView lifetime tracking inside the same sort.

WebKit dependency (oven-sh/WebKit#405)

Every runtime ESM provider now reaches JSC as SourceProviderSourceType::BunTranspiledModule. Three switches in JSC still only matched Module (DebuggerParseData.cpp gatherDebuggerParseDataForSource, CachedTypes.cpp CachedSourceProvider::encode/decode, Completion.cpp getSourceType); the first is user-visible: bun --inspect could not resolve a breakpoint in any user module. (A fourth site, InspectorDebuggerAgent::didParseSource, was already fixed by the upstream merge in oven-sh/WebKit#383.) This was latent before because only bun test --isolate and --compile output used that provider type.

oven-sh/WebKit#405 (merged as 723cea6c; #345 was the earlier version of the same change) adds the missing case BunTranspiledModule: arms. WEBKIT_VERSION is pinned to that merged commit; #37352 carries the bare bump so it can land first.

test/cli/inspect/inspect-module-breakpoints.test.ts pins the behaviour. Matrix verified locally on this branch:

type-export.test.ts #7384 cases inspect-module-breakpoints.test.ts
main's src/, WebKit 447082ab fail pass
this src/, WebKit 447082ab pass fail: Could not resolve breakpoint, locations: [] (CI build 91627 reproduced this on every lane)
this src/, WebKit 723cea6c pass pass

#35754 carried this bump for the --isolate case only; this PR supersedes it.

Behaviour change: missing-value re-exports in .ts files

m_isTypeScript tolerates an unresolved indirect export at link time for the whole file; the re-exporting file has no local signal for which names are types. So

// lib.ts: export const foo = 1;
// barrel.ts:
export { foo, fooo } from "./lib";   // fooo is a typo, not a type

now links without error when consumed via import * as b from "./barrel" (b.fooo === undefined, the key is absent from Object.keys(b)) and when imported for side effects. A direct import { fooo } from "./barrel" still errors. This matches what bun build and --compile already produced and what ts-node/tsx do; Node with --experimental-strip-types still throws. The test file pins this so the trade-off is explicit.

Tests

test/js/bun/typescript/type-export.test.ts:

  • Un-skip the 18 run-mode cases Skip javascriptcore's first parse step for ES Modules #15758 left behind the "module_info is not yet enabled in the runtime transpiler" TODO.
  • Add the export not found when executing typescript file #7384 repro driven through both the sync (BUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER=1) and async transpile paths, plus the same through the on-disk transpiler cache hit path (padded source + BUN_RUNTIME_TRANSPILER_CACHE_PATH), plus a check that BUN_FEATURE_FLAG_DISABLE_RUNTIME_MODULE_INFO=1 restores the old error.
  • Pin the missing-value re-export behaviour above.
  • Two pre-existing debug-build issues in the same file: the --compile cases queue behind a 4-slot semaphore and the tail tests exceeded the 5 s default under debug+ASAN (now given a debug-only timeout), and each compile leaked a ~1 GB standalone executable into the temp dir (now deleted after the run).

test/js/web/workers/worker-terminate-lifetime.test.ts gains an ASAN-only case that terminates a worker while it has dynamic imports in flight, with LeakSanitizer enabled in the child: on the commit before the teardown fixes it reports about 12 KB of leaked records per run plus the UBSan error under the debug cross-check; now it exits 0 with empty stderr. type-export.test.ts also covers the require(esm) shared-dependency graph and plugins.test.ts the namespaced virtual module.

test/cli/run/transpiler-cache.test.ts decodes the record stored in a cache entry and pins the order above (the decoding constants it already had for the out-of-range test moved to file scope); on the released binary plain bun run stores no record at all, and with the ordering change alone reverted the records come out in source order.

Also adds test/js/third_party/astro/fixtures/.gitignore for .astro/ so running that test locally stops dirtying the worktree.

The .d.mts re-export repro from #36751 and the export { ... } from shape in #20632 are the same bug and now run. The direct-import decorator-metadata variants (#15006, #17902) are a different mechanism and are not changed here.

12 of the 94 type-export tests fail on the released binary (the 8 export from/import then export run cases, check ownkeys/#8439 run cases, and the new #7384 cases); all 94 pass on this branch, as does the new inspector test against the pinned WebKit. Also ran import-meta, transpiler-cache, isolation, import-defer, resolve, and import-attributes test files with no failures.

Fixes #7384
Fixes #36751


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

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Changes

Runtime ESM transpilation now controls ModuleInfo generation through a feature flag and VM method. Cache identity and ownership handling reflect this setting. Printer paths preserve emitted namespace-qualified metadata. Tests cover re-exports, cache hits, disabled metadata, virtual modules, and inspector breakpoints.

Runtime ESM ModuleInfo

Layer / File(s) Summary
ModuleInfo flag and cache contract
src/bun_core/env_var.rs, src/jsc/VirtualMachine.rs, src/jsc/RuntimeTranspilerCache.rs
Adds the runtime ModuleInfo feature flag and VM check. Increments the cache format to 26 and includes the setting in the feature hash.
Runtime ModuleInfo generation flow
src/jsc/RuntimeTranspilerStore.rs, src/runtime/jsc_hooks.rs
Uses the VM setting for cached and fresh ESM transpilation. JavaScript-like modules require parser logs without errors before generating ModuleInfo.
ModuleInfo records and ownership
src/js_printer/lib.rs, src/bundler/analyze_transpiled_module.rs, src/jsc/ResolvedSource.rs, src/jsc/bindings/*
Records emitted namespace-qualified paths, releases temporary indexes, exposes ModuleInfo cleanup, and transfers or releases metadata across Rust and C++ boundaries.
Regression coverage
test/cli/inspect/inspect-module-breakpoints.test.ts, test/js/bun/typescript/type-export.test.ts, test/js/bun/plugin/plugins.test.ts, test/js/third_party/astro/fixtures/.gitignore
Adds coverage for re-exports, cache hits, disabled ModuleInfo, missing exports, shared dependencies, virtual modules, inspector breakpoints, and test cleanup.

WebKit preview update

Layer / File(s) Summary
WebKit version pin
scripts/build/deps/webkit.ts
Changes WEBKIT_VERSION to an autobuild preview release identifier.

Possibly related issues

Possibly related PRs

  • oven-sh/bun#34719 — Relates to transpiled module metadata used by inspector infrastructure.
  • oven-sh/bun#35754 — Relates to runtime-transpiled ESM inspector and breakpoint coverage.
  • oven-sh/bun#35971 — Relates to runtime transpiler caching and ModuleInfo restoration.

Suggested reviewers: alii

🚥 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 clearly summarizes the primary change: attaching ModuleInfo to runtime ESM transpiles to resolve TypeScript type-only re-exports.
Description check ✅ Passed The description explains the problem, cause, fix, behavior changes, verification, test coverage, and remaining WebKit dependency.

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

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 9:21 PM PT - Aug 10th, 2026

@robobun, your commit 7ffa0b4 is building: #91923

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. "Exports named not found" in Nest.JS when only types are exported #15006 - NestJS "Export named 'Cache' not found" when a dependency re-exports a type through __exportStar; exact type-only re-export pattern fixed by enabling ModuleInfo at runtime
  2. Support Ditsmod #10438 - "export 'ValueProvider' not found in './types-and-models.js'" where ValueProvider is a TypeScript interface re-exported from compiled JS; Bun fails to recognize it as type-only without ModuleInfo

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #15006
Fixes #10438

🤖 Generated with Claude Code

@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 flips a runtime default that changes how every ESM module is analyzed by JSC (ModuleInfo attached on all transpiles instead of only under --isolate), so it warrants a human look before landing.

Checked: the four gate sites (sync/async × fresh/cache-hit) are updated consistently; ZigSourceProvider.cpp already keys on module_info != nullptr and frees it in the destructor; BunAnalyzeTranspiledModule.cpp frees the record after use when the isolation cache is inactive, so no per-module leak. Cache version bump to 24 correctly invalidates pre-existing entries with esm_record_byte_length == 0. Test changes un-skip the previously-TODO'd run-mode cases and add sync/async coverage plus an opt-out check.

Extended reasoning...

Overview

Replaces the use_isolation_source_provider_cache() gate with a new always-on use_module_info_for_esm() (opt-out via BUN_FEATURE_FLAG_DISABLE_RUNTIME_MODULE_INFO) at four sites in RuntimeTranspilerStore.rs and jsc_hooks.rs, adds the flag to env_var.rs, adds the helper to VirtualMachine.rs, bumps RuntimeTranspilerCache version to 24, and un-skips/extends type-export.test.ts.

Security risks

None. No untrusted input parsing, no auth/crypto/permissions. The change routes existing printer output through an existing JSC consumer.

Level of scrutiny

High. Module loading is on the hot path of every bun run/bun test invocation, and this flips a default that #15758 deliberately left gated behind --isolate (the removed TODO comment explicitly deferred enabling it in the runtime transpiler). The infrastructure exists and is exercised under --isolate and --compile, and the PR description reports passing adjacent test suites, but enabling it unconditionally is a product-level decision with a large blast radius (any divergence between Bun's printer-emitted ModuleInfo and JSC's own analyzer now surfaces for all users, not just --isolate users).

Other factors

  • The code change itself is small and mechanically correct; the inlined predicate in RuntimeTranspilerStore.rs matches VirtualMachine::use_module_info_for_esm exactly.
  • Verified the C++ side (ZigSourceProvider.cpp:178-181, BunAnalyzeTranspiledModule.cpp:188-194) already handles module_info != nullptr outside isolation mode and frees it, so no leak is introduced.
  • The cache-version bump is necessary and correctly justified.
  • Test additions follow harness conventions (bunEnv spread, concurrent pipe drain, exit-code asserted last, debug-only timeout branch).
  • CI (#80362) is still building at time of review; a human should confirm it's green given the breadth of the behavior change.

Deferring because flipping a previously-gated runtime default across all module loads is exactly the kind of decision a maintainer should sign off on, even when the diff is small.

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Heads-up from a parallel attempt (#35689, closed as duplicate): the actual #8439 reproduction still fails with this fix.

The issue's repro is:

// index.ts
import { TestInterface } from "./interface";
class TestClass {
  @Decorator()
  test?: TestInterface;
}
// interface.ts
export interface TestInterface {}

With emitDecoratorMetadata: true, Bun emits typeof TestInterface === "undefined" ? Object : TestInterface, making TestInterface a value use, so the import is recorded as ImportEntryType::Single (not SingleTypeScript) and CyclicModuleRecord still throws NotFound. Verified against a debug build with the fix applied.

The existing import only used in decorator (#8439) test in type-export.test.ts adds export {TestInterface} at the end, which routes it through the indirect-export path that m_isTypeScript does tolerate. That case works, but it is not the issue's repro. Suggest dropping Fixes #8439 from this PR (and not adding #6172 / #15006 as the find-issues bot suggested, for the same reason).

@robobun
robobun requested a review from alii as a code owner July 25, 2026 12:24
Comment thread src/bun_core/env_var.rs Outdated
Comment thread src/jsc/RuntimeTranspilerCache.rs Outdated
Comment thread src/jsc/RuntimeTranspilerStore.rs Outdated
Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/runtime/jsc_hooks.rs
Comment thread src/runtime/jsc_hooks.rs
@robobun
robobun force-pushed the farm/2014f036/runtime-module-info-typescript-reexport branch from ad3a50d to 029a1a6 Compare July 25, 2026 12:41

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 test/js/third_party/astro/fixtures/.astro/actions.d.ts:1-5 — These five files under test/js/third_party/astro/fixtures/.astro/ are Astro's auto-generated build artifacts and appear to have been committed by accident — they're unrelated to the ModuleInfo change, and actions.d.ts even hardcodes the machine-specific absolute path /workspace/bun/.... Since astro-post.test.js's beforeAll calls build({ root: fixtureDir }) and regenerates .astro/ on every run, these files will show as dirty in git whenever the test runs on any other checkout; suggest removing them from the PR and adding .astro/ to a local .gitignore instead.

    Extended reasoning...

    What the issue is

    Commit ad3a50d in this PR adds five new files under test/js/third_party/astro/fixtures/.astro/:

    • actions.d.ts
    • content.d.ts
    • types.d.ts
    • content-assets.mjs
    • content-modules.mjs

    These are Astro's standard auto-generated type/module manifest files (.astro/ is Astro's equivalent of .next/ or dist/), not hand-written test fixtures. They have nothing to do with the ModuleInfo runtime change — the PR otherwise touches only src/bun_core/env_var.rs, src/jsc/*.rs, src/runtime/jsc_hooks.rs, and test/js/bun/typescript/type-export.test.ts. They appear to have been committed accidentally after running the astro test locally.

    Why it's a problem

    Machine-specific absolute path. actions.d.ts line 2 contains:

    type Actions = typeof import("/workspace/bun/test/js/third_party/astro/fixtures/src/actions")["server"];

    The /workspace/bun/... path only exists on the author's machine.

    Regenerated on every test run. test/js/third_party/astro/astro-post.test.js line 29 does:

    beforeAll(async () => {
      await build({ root: fixtureDir, ... });

    Astro's build() regenerates the entire .astro/ directory each time, embedding the current checkout's absolute path into actions.d.ts.

    Not gitignored. git check-ignore test/js/third_party/astro/fixtures/.astro/actions.d.ts returns nothing, and there is no .gitignore in test/js/third_party/astro/ or its fixtures/ subdirectory.

    Step-by-step: how the churn manifests

    1. This PR merges with .astro/actions.d.ts containing import("/workspace/bun/...").
    2. A developer at ~/dev/bun (or CI at /home/runner/work/bun/bun) runs bun bd test test/js/third_party/astro/astro-post.test.js.
    3. beforeAll calls Astro's build({ root: fixtureDir }), which rewrites .astro/actions.d.ts with import("/home/runner/work/bun/bun/test/js/...") (their absolute path).
    4. git status now shows test/js/third_party/astro/fixtures/.astro/actions.d.ts as modified.
    5. Every subsequent developer/CI runner with a different checkout path sees the same spurious diff, forever.

    The other four files (content.d.ts, types.d.ts, content-*.mjs) may or may not diff depending on the pinned Astro version, but they're equally generated and equally not source.

    Impact

    Nothing breaks at runtime — the test regenerates and overwrites these files before using them, so the committed contents are never actually read. This is why the severity is nit, not normal. But committing machine-specific generated output creates permanent working-tree noise and risks accidentally landing in unrelated future PRs from anyone who ran the astro test.

    Fix

    git rm -r test/js/third_party/astro/fixtures/.astro
    echo '.astro/' >> test/js/third_party/astro/fixtures/.gitignore

    (and commit the .gitignore).

Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/bun_core/env_var.rs Outdated
Comment thread src/jsc/RuntimeTranspilerCache.rs Outdated
Comment thread src/jsc/RuntimeTranspilerStore.rs
Comment thread src/jsc/VirtualMachine.rs Outdated
@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Status (66dc26b = 2886915 plus a merge of main that touches only CI config and an unrelated process fix): ready to merge once #37352 (the bare WebKit bump) is in, or directly since this carries the same pin. Re-verified on 2886915: inspector, type-export, plugins, isolation and import-defer suites 177/177, the terminate-while-importing test passes, and the LSAN repro is clean at 1/4/8 workers (the evaluate-time free does not collide with the holder / ~SourceProvider / Rust-drop paths).

Comment thread test/js/bun/typescript/type-export.test.ts
@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

One more finding from reviewing the closed duplicate: this change drops link-time detection for typo'd export { X } from '...' in .ts files when the name is never consumed as a value.

CyclicModuleRecord.cpp:108 gates NotFound suppression for indirect exports on the module-wide m_isTypeScript flag, not a per-entry marker (unlike the import path at :206 which checks ImportEntryType::SingleTypeScript). With module_info now attached to every .ts source, m_isTypeScript is true for all of them:

// values.ts
export const realValue = 42;
// reexport.ts
export { typoedName } from './values';
// main.ts
import './reexport';
console.log('OK');
released bun this branch
bun run main.ts SyntaxError: export 'typoedName' not found OK (exit 0)
bun build --compile + run OK (exit 0) OK (exit 0)

A consumer that actually import { typoedName } from './reexport' still throws (per-entry check), and tsc --noEmit catches TS2305, so the practical blast radius is side-effect imports / import * as ns of a re-export barrel with a typo. This matches what --compile already does, so the argument for owning it as a tradeoff is that bun run now agrees with bun build.

Probably worth either a test pinning the new behavior (and a sentence in the PR body), or a per-entry ExportInfoIndirectTypeScript variant if you want to keep the error for the raw export { X } from form.

@alii

alii commented Aug 10, 2026

Copy link
Copy Markdown
Member

@robobun get this mergable

…resolve

export { SomeType } from './mod' in a .ts file produced
  SyntaxError: export 'SomeType' not found in './mod'
at runtime because the re-export survived transpilation while the type
declaration in the target module was erased, and JSC's own module analyzer
has no way to know the missing binding was a TypeScript type.

record that encodes every import/export and marks the potentially-elided
bindings as ImportEntryType::SingleTypeScript plus m_isTypeScript on the
JSModuleRecord, which JSC already honours at link time. That record was
only attached under bun test --isolate (for the isolation source-provider
cache) and for --compile standalone bytecode.

Attach it on every runtime ESM transpile of a js/ts file (both the sync
jsc_hooks path and the async RuntimeTranspilerStore path), gated by a new
BUN_FEATURE_FLAG_DISABLE_RUNTIME_MODULE_INFO escape hatch. Bump the on-disk
transpiler cache version so pre-existing entries without an esm_record are
invalidated.

test/js/bun/typescript/type-export.test.ts: un-skip the 18 'run' mode tests
that #15758 left behind a TODO for, add the #7384 repro covering both
transpile paths, and fix two pre-existing debug-build issues (compile tests
timing out behind the 4-slot semaphore, and ~18 x ~1 GB standalone binaries
leaking into the temp dir per run).

Fixes #7384
Comment thread src/js_printer/lib.rs Outdated
Comment thread src/js_printer/lib.rs
Comment thread src/jsc/ResolvedSource.rs
Comment thread src/jsc/bindings/BunAnalyzeTranspiledModule.cpp Outdated
Comment thread src/jsc/bindings/ModuleLoader.cpp
Comment thread src/jsc/bindings/ZigSourceProvider.cpp
Comment thread src/runtime/jsc_hooks.rs
@alii

alii commented Aug 10, 2026

Copy link
Copy Markdown
Member

@robobun pushed four commits on top of 61db16a, please keep them when you rebase:

  • require(esm) of an ESM graph with a shared dependency failed with module_info is null: JSC's sync replay calls makeModule twice on the same source and the record was freed after the first analyze. It now lives as long as the provider. Test in type-export.test.ts (cjs entry, js and ts variants).
  • A Bun.plugin namespaced specifier was recorded as the bare path while the printer emits ns:path, so JSC requested the wrong module. Test in plugins.test.ts.
  • module_info is freed when a load is abandoned before a provider adopts it (worker teardown with async transpiles in flight, the early return in Bun__onFulfillAsyncModule).
  • Only exported local names are interned into the record.

Dropped Fixes #8439 from the body; the issue's own repro (decorator metadata, no re-export) is unchanged by this.

WebKit side: oven-sh/WebKit#405 is #345 rebased plus the CachedStringSourceProvider::encode signature fix from review. I'll merge that one and repin here to its autobuild-<sha>; leave the pin alone until then. Don't close #345 before the repin lands, its preview tag is what CI is building against right now.

@robobun

robobun commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks, keeping all four. Heads up on overlap: build 91634 (the 61db16a push) flagged an ASAN leak in worker_threads.test.ts and I had been chasing the same abandoned-load path locally. On top of your commits I still have two things to add that your third commit does not cover, verified with a terminate-while-importing repro: RuntimeTranspilerStore::run_from_js_thread returns early when the microtask drain reports termination and abandons the rest of the popped batch (those jobs are off the queue, so teardown's release never sees them; this is where most of the leaked records came from), and the debug-only fallbackParse returns a null promise to makeModule under termination (UBSan: member call on null JSCell). Will rebase those plus a regression test onto 24a3936 and push without touching the pin; #345 stays open.

@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: 1

🤖 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 `@scripts/build/deps/webkit.ts`:
- Line 6: Update the WEBKIT_VERSION constant from the temporary
autobuild-preview-pr-345-b7c69395 pin to the immutable autobuild-<sha>
identifier for the merged WebKit commit. Do not add or modify any separate
process.versions.webkit assertion.
🪄 Autofix

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: b5f1c1d1-7dce-4f39-850e-8c0f055f5b21

📥 Commits

Reviewing files that changed from the base of the PR and between 05c547f and 24a3936.

📒 Files selected for processing (12)
  • scripts/build/deps/webkit.ts
  • src/bun_core/env_var.rs
  • src/bundler/analyze_transpiled_module.rs
  • src/js_printer/lib.rs
  • src/jsc/ResolvedSource.rs
  • src/jsc/VirtualMachine.rs
  • src/jsc/bindings/BunAnalyzeTranspiledModule.cpp
  • src/jsc/bindings/ModuleLoader.cpp
  • src/jsc/bindings/ZigSourceProvider.cpp
  • src/runtime/jsc_hooks.rs
  • test/js/bun/plugin/plugins.test.ts
  • test/js/bun/typescript/type-export.test.ts

Comment thread scripts/build/deps/webkit.ts Outdated
RuntimeTranspilerStore::run_from_js_thread pops a whole batch of finished
jobs off the queue, then returns as soon as draining microtasks between
jobs reports termination. The jobs it had not reached yet were already off
the queue, so release_queued_jobs_for_teardown never saw them and their
transpiled source, log, promise and ModuleInfo were never dropped. Release
them in place instead.

Bun__analyzeTranspiledModule / fallbackParse also returned the null promise
that rejectWithCaughtException yields under termination; JSModuleLoader's
BunTranspiledModule arm downcasts the returned value before its caller
checks the throw scope, which UBSan reports as a member call on a null
JSCell. Return the still-pending promise with the exception left in place,
and fulfill() the record like JSC's own makeModule does instead of
resolve().

Regression test: terminate a worker while it has dynamic imports in flight,
with LeakSanitizer enabled in the child. On the previous commit it reports
~12 KB of leaked ModuleInfo per run plus the UBSan error under the debug
cross-check; with this it exits 0 with empty stderr.
Comment thread src/jsc/RuntimeTranspilerStore.rs
Comment thread src/jsc/RuntimeTranspilerStore.rs
Comment thread src/jsc/bindings/BunAnalyzeTranspiledModule.cpp
Comment thread test/js/bun/plugin/plugins.test.ts
@alii

alii commented Aug 10, 2026

Copy link
Copy Markdown
Member

oven-sh/WebKit#405 is merged as 723cea6c8c6c; repinned WEBKIT_VERSION to it here (875178f) and opened #37352 with just the bump so it can land first. #345 and its preview tag can go now.

@robobun

robobun commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator Author

Rebuilt locally against 723cea6c: inspector, type-export and plugins suites all pass (136/136) and process.versions.webkit reports the new sha. #345 is closed, #35754 has a note pointing here, the repin thread is resolved, and the body now describes the final state. Nothing further from my side; CI on 875178f is the last check.

Comment thread src/js_printer/lib.rs
Comment thread src/jsc/bindings/BunAnalyzeTranspiledModule.cpp
Comment thread src/jsc/bindings/BunAnalyzeTranspiledModule.h
JSC keeps export entries in insertion order and std::sort()s them every
time it builds a module namespace object. Its own ModuleAnalyzer inserts
local exports in hash-table order; the printer-built ModuleInfo inserted
them in source order, and on modules like the 10k `export const name<i>`
fixture in require-cache.test.ts that order makes introsort fall back to
heapsort, so import() of such a module got slower than letting JSC parse
it (release-asan: ~45 ms per import on a 10k-export module, __adjust_heap
at 13% of samples). Move local export records after everything else and
sort them by export name in ModuleInfo::finalize, which is the sort's
best case; the other records keep their relative order. With this the
Bun-built record is faster than JSC's own analysis on that fixture.

The transpiler cache test decodes the stored record and pins the order.
Comment thread src/js_printer/lib.rs
Comment on lines +640 to +648
/// JSC keeps export entries in insertion order and `std::sort`s them when
/// it builds the namespace object. Its own analyzer inserts local exports
/// in hash-table order; the printer sees them in source order, and on
/// inputs like `a0, a1, ..., a9999` introsort falls back to heapsort,
/// making `import()` of a wide module measurably slower than JSC's own
/// analysis. Hand JSC the sort's best case instead: local exports after
/// every other record, already in byte order of their export name. The
/// remaining records keep their relative order, so which unresolvable
/// indirect export gets reported first is unchanged.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

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.

Bun 1.4 loads .d.mts as runtime modules, breaking packages that use TS-only export syntax export not found when executing typescript file

2 participants