Skip to content

runtime transpiler: attach module_info to ESM sources, fixing TypeScript type-only re-exports - #35689

Closed
robobun wants to merge 1 commit into
mainfrom
farm/9f40dd29/runtime-module-info-7384
Closed

runtime transpiler: attach module_info to ESM sources, fixing TypeScript type-only re-exports#35689
robobun wants to merge 1 commit into
mainfrom
farm/9f40dd29/runtime-module-info-7384

Conversation

@robobun

@robobun robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Fixes #7384
Fixes #8439

Repro

// EventTypes.ts
export type ValueOf<T> = T[keyof T];
export const SomeConst = 42;

// utils.ts
export { ValueOf, SomeConst } from './EventTypes';

// index.ts
import { SomeConst } from './utils';
console.log(SomeConst);
$ bun index.ts
SyntaxError: export 'ValueOf' not found in './EventTypes'

Cause

The printer already builds a ModuleInfo record while printing (imports, exports, var declarations, and which import entries were erased TypeScript types). When present on a ResolvedSource, ZigSourceProvider tags the provider BunTranspiledModule, and JSModuleLoader::makeModule routes to Bun__analyzeTranspiledModule instead of re-parsing the stripped JavaScript; the resulting JSModuleRecord carries m_isTypeScript=true and ImportEntryType::SingleTypeScript for the erased names, and CyclicModuleRecord / AbstractModuleRecord tolerate a NotFound resolution for those entries instead of throwing.

That record was only attached under bun test --isolate (via the use_isolation_source_provider_cache() gate in RuntimeTranspilerStore / jsc_hooks). Plain bun run / bun test still took the default path, where JSC re-parses the transpiled output: the export { ValueOf } is preserved verbatim, ValueOf does not exist in the stripped ./EventTypes, and module linking throws.

The same mechanism is what #15758 / #16296 were building toward; the infrastructure landed for --compile and --isolate but was never enabled for the runtime.

Fix

  • Drop the use_isolation_source_provider_cache gate on both load paths (async RuntimeTranspilerStore and sync jsc_hooks::transpile_source_code_inner), for both the fresh-transpile branch and the on-disk-cache-hit branch. module_info is now attached for every JS-like ESM source.
  • Add BUN_FEATURE_FLAG_DISABLE_RUNTIME_MODULE_INFO as an escape hatch (falls back to JSC re-parsing).
  • Bump the RuntimeTranspilerCache version (23 -> 24): entries written before this change carry an empty esm_record, which would keep sending cache hits down the JSC re-parse path and reinstate the bug.

The C++ side already handles non-isolation correctly: Bun__analyzeTranspiledModule eagerly frees the record after building the JSModuleRecord when IsolatedModuleCache::canUse is false, and IsolatedModuleCache itself remains gated on --isolate. In BUN_DEBUG builds, fallbackParse continues to verify the Bun-derived record against JSC's own re-parse of the printed output.

Verification

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

  • Un-skipped the 18 "run"-mode cases (re-export via export from / import then export / export * / star-merge, consumed via require / import * / await import / named import). All 18 fail on released bun (USE_SYSTEM_BUN=1: 10 fail, 8 pass) and pass with the debug build.
  • Un-skipped check ownkeys from a star import > run and import only used in decorator (#8439) > run.
  • Added the exact export not found when executing typescript file #7384 repro for both the async transpiler store path and the sync path (BUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER=1), plus a test that the disable flag restores the old error.

Sanity: test/cli/test/isolation.test.ts (19/19), test/bundler/transpiler/runtime-transpiler.test.ts (15/15), test/js/bun/resolve/import-meta.test.js (32/32), test/js/bun/resolve/esModule-annotation.test.js (8/8) all pass on the debug build; no Imports different between parseFromSourceCode and fallbackParse diagnostics observed.

Error message for SyntaxError: export 'ValueOf' not found in './EventTypes' is preserved for dedup search.

The printer already builds a ModuleInfo record (imports, exports, var
declarations, TypeScript-erased names) that lets JSC skip re-parsing the
transpiled output during the module analyze phase. It was only attached
under `bun test --isolate`, so `bun run` / `bun test` still had JSC
re-parse the stripped JavaScript and throw on any name that was erased
as a TypeScript type:

    // EventTypes.ts
    export type ValueOf<T> = T[keyof T];
    // utils.ts
    export { ValueOf } from './EventTypes';
    // -> SyntaxError: export 'ValueOf' not found in './EventTypes'

With ModuleInfo attached, the source provider becomes `BunTranspiledModule`
and `Bun__analyzeTranspiledModule` builds the JSModuleRecord directly,
marking the module `m_isTypeScript` and the erased import
`SingleTypeScript`. CyclicModuleRecord/AbstractModuleRecord then tolerate
a NotFound resolution for those entries instead of throwing.

- Drop the `use_isolation_source_provider_cache` gate on both the async
  (RuntimeTranspilerStore) and sync (jsc_hooks) load paths, for both the
  fresh-transpile and on-disk-cache-hit branches.
- Add `BUN_FEATURE_FLAG_DISABLE_RUNTIME_MODULE_INFO` as an escape hatch.
- Bump the RuntimeTranspilerCache version so old entries (which carry an
  empty `esm_record`) are invalidated and users get the fix on cache hit.
- Un-skip the "run"-mode tests in type-export.test.ts (18 cases); they
  were gated on this being enabled. Add the #7384 repro for both load
  paths and a test for the disable flag.

Fixes #7384
Fixes #8439
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 2 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d72beb45-f6fc-41e2-bb77-4b504f2b55e5

📥 Commits

Reviewing files that changed from the base of the PR and between df6c7ee and e6228e0.

📒 Files selected for processing (5)
  • src/bun_core/env_var.rs
  • src/jsc/RuntimeTranspilerCache.rs
  • src/jsc/RuntimeTranspilerStore.rs
  • src/runtime/jsc_hooks.rs
  • test/js/bun/typescript/type-export.test.ts

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

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 4:42 AM PT - Jul 25th, 2026

@robobun, your commit e6228e0 is building: #80689

@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 - "Exports named not found" in Nest.JS when only types are exported via __exportStar; same root cause of type-only exports being stripped then JSC failing to find them at link time
  2. SyntaxError: Import named 'ClassA' not found in module with emitDecoratorMetadata: true #6172 - SyntaxError: Import named 'ClassA' not found with emitDecoratorMetadata: true; type imports stripped by transpiler but referenced in decorator metadata

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

Fixes #15006
Fixes #6172

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. runtime: attach ModuleInfo to ESM transpiles so TypeScript type-only re-exports resolve #35605 - Same fix: attaches ModuleInfo to ESM transpiles to resolve TypeScript type-only re-exports, touches the same files (RuntimeTranspilerStore.rs, jsc_hooks.rs, RuntimeTranspilerCache.rs, env_var.rs, type-export.test.ts), and references the same issues (export not found when executing typescript file #7384, emitDecoratorMetadata fails when interfaces are imported without import type #8439)

🤖 Generated with Claude Code

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Duplicate of #35605, which is the same fix and was opened first. Closing this one.

One finding from verifying related issues: the actual #8439 reproduction (import used only in decorator metadata, no re-export) still throws Export named 'TestInterface' not found with this fix applied, because emitDecoratorMetadata emits typeof TestInterface === "undefined" ? Object : TestInterface, making the import a value use, so it stays ImportEntryType::Single rather than SingleTypeScript and NotFound is not tolerated. The existing #8439 test in type-export.test.ts adds export {TestInterface} which routes it through the indirect-export path and does work. #35605 may want to drop the Fixes #8439 claim.

Comment on lines 955 to 961
dump_source_string(vm, specifier, entry.output_code.byte_slice());
}

let module_info: *mut c_void = if use_isolation_source_provider_cache
let module_info: *mut c_void = if generate_module_info
&& entry.metadata.module_type != CacheModuleType::Cjs
&& !entry.esm_record.is_empty()
{

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.

🟡 BUN_FEATURE_FLAG_DISABLE_RUNTIME_MODULE_INFO shapes the cached esm_record payload but doesn't participate in hash_for_runtime_transpiler, so a v24 entry written with the flag ON (empty esm_record) is served as a hit after the flag is unset — sending the module back down the JSC re-parse path and reinstating #7384 for that file until the source changes or the cache is cleared. Consider folding the flag into the features hash, or treating generate_module_info && module_type == Esm && esm_record.is_empty() as a cache miss on the hit branches here and in jsc_hooks.rs.

Extended reasoning...

What the bug is

The new escape hatch BUN_FEATURE_FLAG_DISABLE_RUNTIME_MODULE_INFO gates whether the printer serializes an esm_record into the on-disk RuntimeTranspilerCache entry. When the flag is set, module_info is None, so have_module_info is false in js_printer/lib.rs and cache.put(..., &srlz_res) writes an entry with esm_record_byte_length == 0. But the flag does not participate in hash_for_runtime_transpiler (src/js_parser/parser.rs:362, called from parse_entry.rs:223) — that hasher only covers parser-option bools, JSX config, and bundler_feature_flags. So entries written with the flag ON and OFF share the same features_hash and cache filename.

Code path

On a subsequent run with the flag unset, RuntimeTranspilerCache::from_file accepts the entry (same input_hash, same features_hash, same cache_version = 24). The cache-hit branch at RuntimeTranspilerStore.rs:958 (and the mirror at src/runtime/jsc_hooks.rs:2810) then evaluates:

let module_info = if generate_module_info
    && entry.metadata.module_type != CacheModuleType::Cjs
    && !entry.esm_record.is_empty()   // ← FALSE for the flag-ON entry
{ ... } else { ptr::null_mut() };

module_info comes back null, ZigSourceProvider::create leaves the source type as plain Module, JSC re-parses the stripped output, and a type-only re-export throws SyntaxError: export 'X' not found — exactly the #7384 regression.

Why the version-24 bump doesn't cover this

The EXPECTED_VERSION bump comment at RuntimeTranspilerCache.rs:47-50 calls out precisely this failure mode ("Old entries have an empty esm_record, which would regress TypeScript type-only re-exports (#7384) to the JSC re-parse path on cache hit") — but it only invalidates pre-v24 entries. A v24 entry written under the escape hatch is indistinguishable from a normal v24 entry at load time, so the bump doesn't help.

Note also that cache.put runs at the end of the printer, before JSC module linking, so the poisoned entry is written even though the flag-ON run then fails with the SyntaxError.

Step-by-step proof

  1. User sets BUN_FEATURE_FLAG_DISABLE_RUNTIME_MODULE_INFO=1 and runs a >4 KiB TypeScript barrel file that re-exports a type without export type (the file must exceed MINIMUM_CACHE_SIZE = 4096 to be cached).
  2. generate_module_info = falsemodule_info = None → printer writes a v24 cache entry with esm_record_byte_length = 0. The run then fails at link time with the export not found when executing typescript file #7384 error (expected — the escape hatch restores the old behavior), but the cache entry is already on disk.
  3. User unsets the flag and re-runs. features_hash is unchanged, so from_file returns the entry from step 2.
  4. The hit branch checks !entry.esm_record.is_empty() → false → module_info = null.
  5. JSC re-parses the transpiled output; the erased type name still appears in export { … }; module linking throws SyntaxError: export 'ValueOf' not found. export not found when executing typescript file #7384 recurs on a run where the fix is nominally enabled.

A serialized ModuleInfo is never zero bytes (it always writes u32 length prefixes even for an empty module), so esm_record.is_empty() on a v24 ESM entry reliably signals "written under the escape hatch" — which makes the miss-on-empty option robust.

Impact

Low. Requires a user to deliberately toggle a debug escape-hatch env var between runs on a >4 KiB file, and the failure they see (#7384's error) is the same one the escape hatch is meant to reproduce — it just persists one extra run. Clearing the cache or touching the source file recovers. Filed as a nit because it's the exact cache-coherence hole the PR's own version-bump rationale describes and it violates REVIEW.md "Cache keys cover every input that shapes the output", but it should not block the PR.

How to fix

Either:

  • Fold BUN_FEATURE_FLAG_DISABLE_RUNTIME_MODULE_INFO into the features hash (e.g., mix it into the Wyhash in RuntimeTranspilerCache::get after hash_for_runtime_transpiler), or
  • On the cache-hit branches in RuntimeTranspilerStore.rs and jsc_hooks.rs, treat generate_module_info && module_type == Esm && entry.esm_record.is_empty() as a miss and fall through to a fresh transpile.

Comment thread src/runtime/jsc_hooks.rs
Comment on lines 2955 to 2966

let is_commonjs_module = parse_result.ast.has_commonjs_export_names
|| parse_result.ast.exports_kind == bun_ast::ExportsKind::Cjs;
// Collect the ESM record while printing, for the isolation
// source-provider cache (same shape as `RuntimeTranspilerStore`).
// SAFETY: per fn contract — `jsc_vm` is the live per-thread VM.
// Collect the ESM record while printing (same shape as
// `RuntimeTranspilerStore`).
let mut module_info: Option<
Box<bun_bundler::analyze_transpiled_module::ModuleInfo>,
> = if unsafe { &*jsc_vm }.use_isolation_source_provider_cache()
> = if !bun_core::env_var::feature_flag::BUN_FEATURE_FLAG_DISABLE_RUNTIME_MODULE_INFO::get()
.unwrap_or(false)
&& !is_commonjs_module
&& loader.is_java_script_like()
{

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.

🟡 There's a third runtime print_with_source_map caller — AsyncModule::resume_loading_module (src/jsc/AsyncModule.rs:1312) — that still passes None for module_info. It's reached via the branch just above this block (jsc_hooks.rs:2906, parse_result.pending_imports.len() > 0 → auto-install / --install=fallback / async plugin onResolve), so a TS type-only re-export in such a module still hits the JSC re-parse path. Not a regression (it was None before too) and the trigger is narrow, but per REVIEW.md's "grep for every sibling site" it'd be worth wiring the same gate there (loader and is_commonjs_module are already at hand at that call site) or noting the intentional exclusion.

Extended reasoning...

What

This PR drops the use_isolation_source_provider_cache gate at two of the three runtime print_with_source_map call sites so module_info is attached for every JS-like ESM source: RuntimeTranspilerStore::run (src/jsc/RuntimeTranspilerStore.rs:1113, async transpiler thread) and jsc_hooks::transpile_source_code_inner (src/runtime/jsc_hooks.rs:3028, sync path). The third caller — AsyncModule::resume_loading_module at src/jsc/AsyncModule.rs:1301–1313 — still passes None as the module_info argument (line 1312).

Code path

transpile_source_code_inner parses and links the source, then at jsc_hooks.rs:2906 checks parse_result.pending_imports.len() > 0. When true — i.e., linker.link produced deferred imports (auto-install / --install=fallback, or an async Bun.plugin onResolve on the sync path) — it enqueues the parse result into (*jsc_vm).modules (jsc_hooks.rs:2933) and returns Err(AsyncModule) before reaching the block this PR modified at jsc_hooks.rs:2955–2977. The queued parse result is later printed by AsyncModule::resume_loading_module, which calls print_with_source_map(..., None). The resulting ResolvedSource therefore has module_info == null, so ZigSourceProvider leaves sourceType as plain Module instead of BunTranspiledModule, JSC re-parses the stripped output, and a TypeScript type-only re-export still throws SyntaxError: export 'X' not found.

Step-by-step

  1. utils.ts contains export { ValueOf, SomeConst } from './EventTypes' and import 'pkg-not-yet-installed', run under bun --install=fallback (or with an async plugin onResolve) via a path that hits the sync transpiler (e.g., BUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER=1 or require(esm)).
  2. transpile_source_code_inner parses utils.ts; linker.link records pkg-not-yet-installed as a pending import.
  3. jsc_hooks.rs:2906 sees pending_imports.len() > 0, enqueues to the AsyncModule queue, returns early.
  4. After the pending import resolves, resume_loading_module prints with module_info: None (AsyncModule.rs:1312).
  5. ResolvedSource.module_info == null → JSC re-parses the transpiled output, sees export { ValueOf } where ValueOf was erased from ./EventTypes, throws — the exact export not found when executing typescript file #7384 symptom.

Why existing code doesn't cover it

Both fixed call sites live after the pending_imports early return, so a module that takes the AsyncModule queue never reaches them. Grepping the three runtime print_with_source_map callers confirms AsyncModule.rs:1301 is the only one left passing None.

Impact and severity

This is not a regression — the AsyncModule path passed None before this PR too, so it was equally broken (only fixed under --isolate, which never routes through this queue anyway). The trigger is narrow: a module must both (a) contain a TypeScript type-only re-export without export type and (b) have a deferred/pending import on the sync transpile path. The PR strictly improves on the status quo and fixes the overwhelmingly common paths. Filing as a nit per the rubric; REVIEW.md's "Fix the whole class in the same PR — grep for every sibling site sharing the pattern… If a site is intentionally excluded, say so in the PR" makes it worth flagging, not blocking.

Fix

Mechanical: at AsyncModule.rs:1256–1260, is_commonjs_module is already computed and self.loader (stored via InitOpts at jsc_hooks.rs:2938, recoverable as bun_ast::Loader::from_api(self.loader)) is available. Wire the same !BUN_FEATURE_FLAG_DISABLE_RUNTIME_MODULE_INFO && !is_commonjs_module && loader.is_java_script_like() gate + has_tla propagation there, and pass the resulting module_info through to print_with_source_map and the ResolvedSource. Alternatively, note the intentional exclusion in the PR description.

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.

emitDecoratorMetadata fails when interfaces are imported without import type export not found when executing typescript file

1 participant