Skip to content

runtime: implement WebAssembly/ESM integration for .wasm imports - #35587

Open
robobun wants to merge 9 commits into
mainfrom
farm/dd4bd17b/wasm-esm-integration
Open

runtime: implement WebAssembly/ESM integration for .wasm imports#35587
robobun wants to merge 9 commits into
mainfrom
farm/dd4bd17b/wasm-esm-integration

Conversation

@robobun

@robobun robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

What

import * as m from './file.wasm' (and await import('./file.wasm')) now compiles and instantiates the module, exposing its exports as named ES module exports. This matches Node's --experimental-wasm-modules and the WebAssembly/ES Module Integration proposal.

Repro

$ cp test/bundler/fixtures/add.wasm /tmp/
$ bun -e 'console.log(await import("/tmp/add.wasm"))'
# before:
Module { __esModule: true, default: "/tmp/add.wasm" }
# after:
[Module: null prototype] { add: [Function: 0], memory: Memory [WebAssembly.Memory] {} }

$ node --experimental-wasm-modules -e 'import("/tmp/add.wasm").then(m => console.log(m))'
[Module: null prototype] { add: [Function: 0], memory: Memory [WebAssembly.Memory] {} }

Cause

The runtime .wasm loader in transpile_source_code_inner recursed into .file, which returns { __esModule: true, default: pathString } via the ExportDefaultObject tag. JSC's JSModuleLoader already dispatches SourceProviderSourceType::WebAssembly to JSWebAssembly::instantiate; we just never handed it a WebAssemblySourceProvider.

Fix

  • src/runtime/jsc_hooks.rs: in the L::Wasm arm, read the wasm bytes (from virtual_source or disk via bun_sys::File::read_from), validate the magic header, register with the watcher, and return ResolvedSource { tag: Wasm, source_code }. The disk-read path wraps the Vec<u8> as an external string to avoid a copy. Factored the open-fd-and-watch block into auto_watch_asset() shared with the .file arm, and keyed its fd-close on REQUIRES_FILE_DESCRIPTORS so FreeBSD matches macOS.
  • src/jsc/bindings/ModuleLoader.cpp: new sourceCodeForWasm() reads the 8-bit span back out and builds JSC::WebAssemblySourceProvider::create(bytes, origin, key). Called from fetchESMSourceCode (filesystem imports) and handleVirtualModuleResult (Bun.plugin virtual modules whose specifier picks the wasm loader).
  • src/jsc/lib.rs: add ResolvedSourceTag::Wasm = 3 (matching the existing SyntheticModuleType::Wasm in the generated header).

Legacy behaviour preserved where an asset path is the intent:

  • ?query-suffixed specifiers ('./x.wasm?1', see Regression: release 1.1.45 broke ?url imports #16476) keep the old path-string demotion.
  • with { type: 'file' } already overrides the loader before reaching the .wasm arm.
  • require('./x.wasm') keeps the path-string behaviour via an is_commonjs_require demotion in transpile_file (Node rejects CJS wasm entirely; Bun has always returned the path here and users rely on it).

Bundler note

bun build still treats .wasm as a copied asset (unchanged by this PR), so import x from './a.wasm' means two different things at runtime vs in a bundle. The two existing bundler tests that relied on import wasmPath from './x.wasm' returning a path now spell that intent explicitly with with { type: 'file' }, which works the same under both. Teaching the bundler to emit ESM-integration semantics for target=bun is tracked by #22026 and out of scope here; import source (#31677) is the spec-blessed way to get a compiled-but-not-instantiated module in bundled output.

Why this is the right shape

The ESM integration proposal is the standards-track answer to "what does import './x.wasm' mean", Node ships it behind a flag (unflagged for source-phase in ≥ 24), and JSC already has the whole instantiation path wired to SourceProviderSourceType::WebAssembly. Returning a path string was a placeholder from before any of that existed. The compat escape hatches (?query, type: "file", require()) cover every case where someone was intentionally treating it as an asset.

Previously attempted as #30371 against the Zig tree; that PR was closed when the source files moved under the Rust rewrite.

Verification

test/js/bun/wasm/esm-integration.test.ts (8 spawn-based tests) covers: dynamic import, static import * as, named imports, wasm importing from a JS module (exercises the .wasm-keyed referrer resolve→fetch→link chain), ?query path-string preservation, with { type: 'file' } path-string preservation, require() path-string preservation, and bad-magic-header rejection.

$ USE_SYSTEM_BUN=1 bun test test/js/bun/wasm/esm-integration.test.ts   # 5 fail (no fix)
$ bun bd test test/js/bun/wasm/esm-integration.test.ts                 # 8 pass

Also passing under the debug build: test/regression/issue/16476 (?query wasm imports), test/js/bun/resolve/import-empty.test.js (updated: type: "wasm" on an empty file now throws the magic-header error, matching Node), test/js/bun/wasm/wasi.test.js (CLI bun ./foo.wasm path unchanged), test/js/web/fetch/wasm-streaming.test.ts, test/bundler/bundler_loader.test.ts -t wasm-is-copied-to-outdir, test/bundler/bun-build-compile-wasm.test.ts, test/bundler/bundler_plugin.test.ts -t FileLoaderMultipleAssets.

Fixes #12434
Fixes #30369


[review] gate passed · iteration 2 · 8 files touched

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/bun-build-compile-wasm.test.ts test/bundler/bundler_loader.test.ts test/js/bun/resolve/import-empty.test.js test/js/bun/wasm/esm-integration.test.ts
ninja: Entering directory `/workspace/bun/build/debug'
[1/123] gen ErrorCode+*.h
[2/123] gen bindgenv2
[3/122] gen cpp.rs (cppbind)
[4/122] gen BunProcess.lut.h
Generating /workspace/bun/build/debug/codegen/BunProcess.lut.h from /workspace/bun/src/jsc/bindings/BunProcess.cpp
[5/122] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (13 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts
  - FileSystemRouter (5 fiel
... (truncated)

release without fix: 17 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/bundler/bundler_loader.test.ts:
(pass) bundler > bun loader > bun/loader-yaml-file [106.38ms]
(pass) bundler > bun loader > bun/loader-text-file [69.36ms]
(pass) bundler > bun loader > bun/loader-json-file [20.74ms]
(pass) bundler > bun loader > bun/loader-toml-file [18.76ms]
(pass) bundler > bun loader > bun/loader-text-file [20.91ms]
(pass) bundler > node loader > bun/loader-yaml-file [44.20ms]
(pass) bundler > node loader > bun/loader-text-file [38.08ms]
(pass) bundler > node loader > bun/loader-json-file [21.39ms]
(pass) bundler > node loader > bun/loader-toml-file [27.77ms]
(pass) bundler > node loader > bun/loader-text-file [24.29ms]
(pass) bundler > bun/loader-text-file [29.16ms]
runtime failed file: /tmp/bun-build-tests/bun-i6HwLS/bun/loader-json-proto-key-is-own-property/out.js
stdout output:
[false,false,1,"{\"a\":2}"]
---
expected stdout:
[true,true,null,"{\"__proto__\":{\"x\":1},\"a\":2}"]
---
1843 |               console.log(`---`);
1844 |               console.log(`expected ${name}:`);
1845 |               console.log(expected);
1846 |               console.log(`---`);
1847 |             }
1848 |             e
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/bun-build-compile-wasm.test.ts test/bundler/bundler_loader.test.ts test/js/bun/resolve/import-empty.test.js test/js/bun/wasm/esm-integration.test.ts
bun test v1.4.0 (1263c029c)

test/bundler/bundler_loader.test.ts:
(pass) bundler > bun loader > bun/loader-yaml-file [1116.61ms]
(pass) bundler > bun loader > bun/loader-text-file [490.11ms]
(pass) bundler > bun loader > bun/loader-json-file [469.58ms]
(pass) bundler > bun loader > bun/loader-toml-file [415.72ms]
(pass) bundler > bun loader > bun/loader-text-file [529.12ms]
(pass) bundler > node loader > bun/loader-yaml-file [464.99ms]
(pass) bundler > node loader > bun/loader-text-file [994.11ms]
(pass) bundler > node loader > bun/loader-json-file [653.21ms]
(pass) bundler > node loader > bun/loader-toml-file [661.59ms]
(pass) bundler > node loader > bun/loader-text-file [1202.73ms]
(pass) bundler > bun/loader-text-file [680.30ms]
(pass) bundler > bun/loader-json-proto-key-is-own-property [749.16ms]
(pass) bundler > bun/loader-toml-proto-key-is-own-property [716.09ms]
(pass) 
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     1263c029c0
  features     baseline

22 deps, 108 codegen, 1171 objects in 2416ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1234] fetch zlib
[zlib] up to date
[2/1234] fetch tinycc
[tinycc] up to date
[3/1234] fetch picohttpparser
[picohttpparser] up to date
[4/1234] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[5/1234] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[6/1234] subst deps/zlib/zlib.h
[7/1234] subst deps/zlib/zconf.h
[8/1234] gen .bind.ts → GeneratedBindings.cpp
[9/1234] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[10/1234] gen bindgenv2
[11/1234] gen ErrorCode+*.h
[12/1234] fetch nodejs (prebuilt)
[nodejs] up to date
[13/1234] subst deps/libjpeg-turbo/jconfigint.h
[14/1234] subst deps/libjpeg-turbo/jconfig.h
[15/1234] gen ProcessBindingB
... (truncated)
diff hotspot
src/jsc/bindings/ModuleLoader.cpp           |  33 ++++
 src/jsc/lib.rs                              |   2 +
 src/runtime/jsc_hooks.rs                    | 185 +++++++++++-------
 test/bundler/bun-build-compile-wasm.test.ts |   4 +-
 test/bundler/bundler_loader.test.ts         |   2 +-
 test/js/bun/resolve/import-empty.test.js    |  16 +-
 test/js/bun/wasm/add.wasm                   | Bin 0 -> 93 bytes
 test/js/bun/wasm/esm-integration.test.ts    | 282 ++++++++++++++++++++++++++++
 8 files changed, 457 insertions(+), 67 deletions(-)

gate history · 1 passed · 2 rejected · iteration 2

evidence per changed file
file                                         reads  edits  tests
src/jsc/bindings/ModuleLoader.cpp                8      8      0
src/jsc/lib.rs                                   1      2      0
src/runtime/jsc_hooks.rs                        10     16      0
test/bundler/bun-build-compile-wasm.test.ts      3      4      0
test/bundler/bundler_loader.test.ts              1      1      0
test/js/bun/resolve/import-empty.test.js         1      1      0
test/js/bun/wasm/add.wasm                        0      0      0
test/js/bun/wasm/esm-integration.test.ts         3      8      0

import('./x.wasm') and `import * as x from './x.wasm'` previously fell
through to the file loader and resolved to { default: '<path>' }. The
wasm loader now reads the bytes, validates the magic header and hands
them to JSC as a WebAssemblySourceProvider so the module namespace is
the instance's exports, matching Node with --experimental-wasm-modules.

Legacy path-string behaviour is kept for ?query specifiers (#16476),
`with { type: 'file' }`, and require('./x.wasm').

Fixes #12434
Fixes #30369
@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: 13 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: 452d0290-6d5b-4b3a-bff3-d8dfd05ad057

📥 Commits

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

⛔ Files ignored due to path filters (1)
  • test/js/bun/wasm/add.wasm is excluded by !**/*.wasm
📒 Files selected for processing (7)
  • src/jsc/bindings/ModuleLoader.cpp
  • src/jsc/lib.rs
  • src/runtime/jsc_hooks.rs
  • test/bundler/bun-build-compile-wasm.test.ts
  • test/bundler/bundler_loader.test.ts
  • test/js/bun/resolve/import-empty.test.js
  • test/js/bun/wasm/esm-integration.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 1:09 PM PT - Jul 25th, 2026

@robobun, your commit 1263c02 is building: #80998

Comment thread src/jsc/bindings/ModuleLoader.cpp Outdated
Comment thread src/jsc/lib.rs Outdated
Comment thread src/runtime/jsc_hooks.rs Outdated
Comment thread src/runtime/jsc_hooks.rs Outdated
Comment thread src/runtime/jsc_hooks.rs Outdated
Comment thread src/runtime/jsc_hooks.rs Outdated
Comment thread src/runtime/jsc_hooks.rs Outdated
Comment thread src/runtime/jsc_hooks.rs Outdated
Comment thread src/jsc/bindings/ModuleLoader.cpp Outdated
Comment thread test/js/bun/wasm/esm-integration.test.ts
Comment thread src/runtime/jsc_hooks.rs
Comment thread test/js/bun/wasm/esm-integration.test.ts
Comment thread src/jsc/bindings/ModuleLoader.cpp Outdated
…est, bundler fixture updates

- Factor 'auto_watch' block into auto_watch_asset() shared by the wasm
  and file arms; gate the fd close on REQUIRES_FILE_DESCRIPTORS so
  FreeBSD doesn't leak when add_file fails.
- Use create_external_globally_allocated_latin1 for disk-read wasm to
  avoid one memcpy of the file.
- Factor sourceCodeForWasm() in ModuleLoader.cpp and call it from both
  fetchESMSourceCode and handleVirtualModuleResult so a plugin whose
  specifier ends in .wasm no longer hands raw bytes to a JS parser.
- Use bun_core::fmt::quote for path quoting in the two error messages.
- Add test for a wasm module importing from a JS module (exercises the
  .wasm-keyed referrer resolve→fetch→link chain).
- Move test to test/js/bun/wasm/esm-integration.test.ts (feature, not
  regression).
- Update the two bundler fixtures that used import-default-from-wasm as
  an asset path to spell the intent with { type: 'file' }, so the same
  source works both bundled and under bun run.
- Trim multi-line comments per comment-cop.
Comment thread src/jsc/bindings/ModuleLoader.cpp Outdated
Comment thread src/runtime/jsc_hooks.rs Outdated
Comment thread src/runtime/jsc_hooks.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.

Thanks for addressing the earlier feedback — the watcher is now registered before the read/magic check, handleVirtualModuleResult handles the Wasm tag, error messages use bun_core::fmt::quote, the wasm→JS import chain is tested via simple.wasm, and the test file lives in test/js/bun/wasm/. No new issues found in this pass.

This is a user-facing behavior change to what import './x.wasm' returns, threaded through the module loader on both the Rust and C++ sides, so it's worth a human look before landing. The one remaining open nit (the ?query demotion overriding an explicit with { type: 'wasm' }) is minor and non-blocking.

What was reviewed

  • auto_watch_asset extraction: verified behavior matches the inlined block it replaces; fd-close now keys on REQUIRES_FILE_DESCRIPTORS (covers FreeBSD alongside macOS, as the PR description notes).
  • sourceCodeForWasm is called from both fetchESMSourceCode and handleVirtualModuleResult; the is_commonjs_require demotion in transpile_file keeps CJS on the path-string route so fetchCommonJSModuleNonBuiltin never sees a Wasm tag.
  • ResolvedSourceTag::Wasm = 3 matches the pre-existing SyntheticModuleType::Wasm slot in the generated header.
Extended reasoning...

Overview

This PR implements WebAssembly/ESM integration: import './x.wasm' now compiles and instantiates the module and exposes its exports as named ESM bindings, matching Node's --experimental-wasm-modules and the WebAssembly/ESM integration proposal. Changes span src/runtime/jsc_hooks.rs (the L::Wasm arm of transpile_source_code_inner, a new auto_watch_asset helper factored out of the .file arm, and a CJS-require demotion in transpile_file), src/jsc/bindings/ModuleLoader.cpp (new sourceCodeForWasm helper called from fetchESMSourceCode and handleVirtualModuleResult), src/jsc/lib.rs (ResolvedSourceTag::Wasm = 3), a new 8-test spawn-based suite at test/js/bun/wasm/esm-integration.test.ts, and updates to three existing test files whose fixtures relied on the old path-string behavior.

Security risks

None identified. Wasm bytes are read via bun_sys::File::read_from and validated with a 4-byte magic-header check before being handed to JSC's WebAssemblySourceProvider; JSC owns the actual parse/validate/instantiate. No user-controlled data reaches shell/SQL/path-traversal surfaces. The Latin-1 byte packing in source_code round-trips through span8() on the C++ side without interpretation as text.

Level of scrutiny

High. This changes what a bare import './x.wasm' evaluates to — a documented, intentional break with escape hatches (?query, with { type: 'file' }, require()), but still a runtime-semantics change in the module loader that every .wasm import will hit. The implementation crosses the Rust↔C++ FFI boundary with a new ResolvedSource tag and touches watcher registration and the plugin virtual-module path. That's squarely in "a human should sign off" territory even though the code looks correct.

Other factors

The author has iterated through three rounds of review feedback on this PR and addressed each: fmt::quote for path quoting, the missing Wasm-tag branch in handleVirtualModuleResult, a wasm→JS-import test exercising the .wasm-keyed referrer resolve chain, test-file placement per CLAUDE.md, and watcher registration ordering so --watch reloads after a bad-magic error. Test coverage is solid (8 concurrent spawn tests covering dynamic/static/named imports, wasm-imports-JS, the three legacy escape hatches, and the bad-magic error path; USE_SYSTEM_BUN=1 divergence documented). One prior nit remains open (the ?query demotion firing even when type: 'wasm' was explicit) but was flagged as non-blocking. The pre-existing specifier/source_url refcount leak in the non-Zig::SourceProvider branches is unchanged in magnitude by this PR and was noted as out-of-scope. The auto_watch_asset refactor is a straight extraction with one intentional widening (fd-close on REQUIRES_FILE_DESCRIPTORS rather than macOS-only), which the PR description calls out.

Comment thread src/js/internal/inspector/cdp.ts Outdated
Comment thread src/js/internal/inspector/cdp.ts Outdated
Comment thread src/js/internal/inspector/cdp.ts Outdated
Comment thread src/js/internal/inspector/cdp.ts Outdated
Comment thread src/js/internal/inspector/cdp.ts Outdated
Comment thread src/js/internal/inspector/cdp.ts Outdated
Comment thread src/js/internal/inspector/cdp.ts Outdated
Comment thread src/js/internal/inspector/cdp.ts Outdated
Comment thread src/js/internal/inspector/cdp.ts Outdated
Comment thread src/js/internal/inspector/cdp.ts Outdated
Comment thread src/js/internal/inspector/cdp.ts Outdated
Comment thread src/js/internal/inspector/cdp.ts Outdated
Comment thread src/js/internal/inspector/cdp.ts Outdated
Comment thread src/js/internal/inspector/cdp.ts Outdated
Comment thread src/js/internal/inspector/cdp.ts Outdated
Comment thread src/js/internal/inspector/cdp.ts Outdated
Comment thread src/js/internal/inspector/cdp.ts Outdated
Comment thread src/js/internal/inspector/cdp.ts Outdated
Comment thread src/js/internal/inspector/cdp.ts Outdated
Comment thread src/jsc/bindings/BunDebugger.h Outdated
@robobun
robobun force-pushed the farm/dd4bd17b/wasm-esm-integration branch from 4554dfe to 9659bec Compare July 25, 2026 12:50

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

  • 🔴 src/js/internal/inspector/cdp.ts:1-9 — This PR includes two unrelated new files — src/js/internal/inspector/cdp.ts (702 lines) and src/jsc/bindings/BunDebugger.h — added in commit 4554dfe ("ci: retrigger"). Neither is referenced anywhere else in src/, neither is mentioned in the PR description, and both carry 20 unresolved comment-cop findings; they appear to have been committed accidentally and should be removed from this PR and landed separately with tests.

    Extended reasoning...

    What

    Commit 4554dfe7 on this branch is titled "ci: retrigger" — conventionally an empty commit to re-run CI — but git show --stat 4554dfe7 reveals it actually adds two brand-new files totalling 717 lines:

    • src/js/internal/inspector/cdp.ts (702 lines) — a Chrome DevTools Protocol ↔ JSC inspector protocol adapter, exporting InspectorCDPAdapter for node:inspector
    • src/jsc/bindings/BunDebugger.h (15 lines) — JSC_DECLARE_HOST_FUNCTION declarations for jsFunction_openNodeInspector / waitForNodeInspectorConnection / postNodeInspectorControl / closeNodeInspector

    Neither has anything to do with WebAssembly/ESM integration, which is the sole subject of this PR's title, description, and every other changed file.

    Why this is dead code

    Grep across src/ for InspectorCDPAdapter, jsFunction_openNodeInspector, jsFunction_waitForNodeInspectorConnection, jsFunction_postNodeInspectorControl, jsFunction_closeNodeInspector, BunDebugger.h, and inspector/cdp returns only the two new files themselves. Concretely:

    • Nothing #includes BunDebugger.h. The four host functions it declares have no JSC_DEFINE_HOST_FUNCTION counterpart in any .cpp file (BunDebugger.cpp doesn't define them).
    • Nothing imports InspectorCDPAdapter from cdp.ts. The internal-module registry has no entry for it.
    • The PR description's "Fix" section enumerates every file it touches (jsc_hooks.rs, ModuleLoader.cpp, lib.rs) — neither of these is mentioned.

    Step-by-step proof

    1. git log --oneline on this branch shows the wasm work landed across commits 48260d5 ("address review"), 78d67b7 (autofix), 4f686e4 (comment-cop cleanup), 9659bec (watcher fix) — all wasm-related.
    2. git show --stat 4554dfe7 → the "ci: retrigger" commit adds exactly cdp.ts (+702) and BunDebugger.h (+15), nothing else.
    3. rg 'InspectorCDPAdapter|jsFunction_.*NodeInspector|BunDebugger\.h|inspector/cdp' src/ → matches only the two new files.
    4. The PR timeline shows 19 comment-cop inline findings on cdp.ts (lines 9, 19, 31, 42, 70, 171, 189, 202, 209, 215, 222, 235, 247, 265, 277, 322, 370, 416, 485) and 1 on BunDebugger.h:9, all posted at 12:50 (one minute after the 12:49 commit) and none resolved — contrast with the earlier comment-cop findings on ModuleLoader.cpp/jsc_hooks.rs, which the author addressed in 4f686e4 and which are marked resolved.

    Why existing structure doesn't excuse it

    REVIEW.md is explicit on both counts:

    • "Every behavioral change ships an automated test in the same PR"cdp.ts is 702 lines of protocol-translation logic (message parsing, id correlation, scope-type mapping, console-message translation) with zero test coverage in this diff.
    • "Delete dead code in the same PR that makes it dead" — this is the inverse: adding 717 lines of code with no consumers. If it's WIP for a future node:inspector PR, it belongs on that branch.
    • PR-process guidance in .claude/docs/landing-prs.md — unrelated features go in separate PRs so they can be reviewed with appropriate context.

    Impact

    Merging as-is would (a) ship 717 lines of unreviewed, untested dead code under a wasm-titled PR (making it undiscoverable in git log for whoever later works on node:inspector), (b) leave 20 comment-cop findings unaddressed, and (c) land JSC_DECLARE_HOST_FUNCTION declarations with no matching definitions. Since a "ci: retrigger" commit adding 717 lines is almost certainly accidental (bad rebase, wrong-branch commit, or leftover WIP), the author will want to remove these before merge regardless.

    Fix

    git rm src/js/internal/inspector/cdp.ts src/jsc/bindings/BunDebugger.h
    git commit --amend  # or a fresh commit dropping them

    Land the node:inspector CDP adapter in its own PR with tests, where the comment-cop findings can be properly addressed and the four declared host functions can ship alongside their definitions and JS-side callers.

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

The cdp.ts / BunDebugger.h files flagged above were picked up by mistake from a dirty worktree and have been force-pushed out; the diff is back to the 8 intended files.

@robobun
robobun force-pushed the farm/dd4bd17b/wasm-esm-integration branch from 8e32f01 to b0da7af Compare July 25, 2026 13:41
Comment thread src/runtime/jsc_hooks.rs
…atin-1→UTF-8 transcoding

Bun.plugin build.module() whose specifier picks the wasm loader hands an
untagged ZigString over ArrayBufferView bytes; to_slice() expands bytes
≥ 0x80 to their two-byte UTF-8 encoding, corrupting the module past the
(all-ASCII) magic header. byte_slice() returns the raw span.

Adds a plugin virtual-module test (add.wasm contains 0x80/0x88).
Comment thread src/runtime/jsc_hooks.rs
@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Diff is ready at 1263c02. The gate's 4-file set passes 65/65 under the debug+ASAN build; esm-integration.test.ts alone is 9 pass with the fix, 6 fail / 3 pass on system bun.

CI build #80998 shows only known flakes on unrelated lanes (bake/deinitialization, install/bun-install-registry, regression/20144, run/no-orphans), all of which passed on retry and none of which touch the module loader, wasm, or any file in this diff. Ready for review.

Jarred-Sumner pushed a commit that referenced this pull request Aug 12, 2026
…s ESM (#37525)

Builtins implemented in `src/js` are handed to ES module importers by
`generateInternalModuleSourceCode`
(`src/jsc/bindings/ModuleLoader.cpp`), which snapshots the builtin's
CommonJS exports object into a synthetic module record. It did that with
`object->get()` on every own enumerable property, so every accessor on
the exports object ran at import time. Those accessors are the builtins'
lazy-loading mechanism, which means they only ever helped `require()`
callers:

- `node:fs`: `ReadStream`, `WriteStream`, `FileReadStream`,
`FileWriteStream`, `Utf8Stream` each `require("internal/fs/streams")`,
which loads the whole `node:stream` stack.
- `node:tls`: `rootCertificates` parses the bundled CA store,
`DEFAULT_CIPHERS` queries BoringSSL.
- `node:http`: `globalAgent` instantiates the agent. `node:timers` /
`node:stream`: `promises` load `timers/promises` and `stream/promises`.
`node:assert`: `AssertionError` loads `internal/assert/assertion_error`.
`node:repl`, `node:events`, `node:buffer`, `node:os` have a few more.

#35541 is about to turn more of these into accessors (`fs.promises`
among them), which this path would immediately defeat for the most
common import style there is.

## Repro

```js
// probe.mjs, run as `bun --expose-internals probe.mjs esm` vs `... require`
const mode = process.argv[2];
const { createRequire } = await import("node:module");
let t = performance.now();
if (mode === "esm") await import("node:fs"); else createRequire(import.meta.url)("node:fs");
const fsMs = performance.now() - t;
t = performance.now();
createRequire(import.meta.url)("internal/fs/streams");
console.log(mode, "node:fs", fsMs.toFixed(1), "ms; internal/fs/streams afterwards", (performance.now() - t).toFixed(1), "ms");
```

bun 1.4.0 (release): `esm node:fs 13.3 ms; internal/fs/streams
afterwards 0.2 ms` (already loaded) vs `require node:fs 7.8 ms;
internal/fs/streams afterwards 5.0 ms`. On a debug build the difference
is ~370ms per process, and `test/harness.ts` does `import fs from
"node:fs"`, so every test file paid it.

The directly observable form, which the tests use: the `node:fs` getters
replace themselves with data properties when they run, so after `import
fs from "node:fs"`, `Object.getOwnPropertyDescriptor(fs, "ReadStream")`
had a `value` instead of a `get`.

## Fix

An ES module binding cannot be made lazy from the embedder side: once
the record exists, named imports read the exporting environment's slot
directly (`ModuleVar`) and namespace reads go through `getValue()` in
`JSModuleNamespaceObject`, so the slot has to hold the value before
anything binds to it. The engine half is oven-sh/WebKit#408, merged as
7b763944f0ec. `WEBKIT_VERSION` moves from 09e477744721 (what main pins)
to that sha; it is the only commit between the two:

- `SyntheticModuleRecord::tryCreateWithExportNamesAndValues()` gets an
overload taking a source object; an export whose value is the empty
`JSValue` is declared but left in TDZ.
- `materializeLazyExport()` fills such a slot from `source[name]` the
first time something binds to it:
`CyclicModuleRecord::initializeEnvironment` when an importer links a
named import that resolves to it (directly or through `export *` /
`export { x } from` chains),
`JSModuleNamespaceObject::getOwnPropertySlotCommon` when a namespace
read finds the slot empty (it then re-reads and installs the value the
same way as before, so the namespace IC still applies), and
`WebAssemblyModuleRecord::initializeImports`, which snapshots imported
bindings directly (unreachable in bun today, reachable once #35587
lands). It is a no-op for a slot that already has a value, so
`overrideExportValue` (what `mock.module` and `spyOn` use) keeps
winning, `*namespace*` is skipped, a getter that re-enters keeps the
first value that landed, and an exception from the getter propagates and
leaves the slot for the next attempt. The write uses the same
`symbolTablePutTouchWatchpointSet` the eager path used for its one
write, so IC / DFG behaviour after materialization is identical to an
eagerly built record; the baseline namespace IC already bails to the
slow path on an empty slot and the DFG only folds loads that went
through an installed IC.
- `SyntheticSourceProvider::createWithLazyExports()` takes a generator
that returns the source object, and `makeModule` passes it through.
Records built any other way (JSON modules, `vm.SyntheticModule`, every
existing `create()` caller) have no source object and take a pointer
test on the new paths.

The bun half is `generateInternalModuleSourceCode`: it now looks each
property up with `getOwnPropertySlot`, snapshots data properties exactly
as before, declares everything else (accessors) as a lazy export, and
returns the exports object as the source. A builtin without accessors
(most of them) produces a record identical to the old one. `require()`
of a builtin never went through this function (`fetchCommonJSModule`
returns the registry object), so it is unaffected. The engine change is
inert without this opt-in: building the new WebKit with the old
`ModuleLoader.cpp` still fails the new tests the same way the release
does.

### Why this is the right behaviour

Node builds the ESM facade of a builtin by reading the getters too, so
the old behaviour was not wrong, just expensive, and there is no compat
reason to keep it. The only observable difference is *when* an accessor
export is sampled: at import before, at the point something first binds
to it now. Data properties still snapshot at import. A namespace export
or named import of an accessor is a snapshot either way (that is what
`module.syncBuiltinESMExports()` exists for), and the later sample is
never staler than the old one. Everything an importer can do with the
export resolves to the same object `require()` would hand out, which is
what the tests check for each binding path.

`Object.keys(ns)`, `hasOwnProperty`, spread, and `console.log(ns)` still
materialize what they touch, because `[[GetOwnProperty]]` has to produce
the value; `in` ([[HasProperty]]) does not. Plugin `loader: "object"`
modules and user CommonJS imported from ESM are deliberately not
touched: those expose user objects whose getter timing is user-visible
(#36677 is about that), whereas builtins' accessors are our own
lazy-loading idiom.

## Tests

`test/js/bun/resolve/builtin-esm-lazy-exports.test.ts`, next to the
other module-system behaviour tests in that directory. Each case runs in
its own process (a binding can only be materialized once) and checks
both that importing left the `node:fs` accessors untouched and that the
binding is the real class once used, for: default + named data import,
named import of an accessor (exactly that one materializes), `import *
as`, `import defer * as`, dynamic `import()` including `Object.keys` and
that the export list is unchanged, `export *` and `export { x as y }
from` re-exports (named import through the star and a read off the
re-exporter's namespace, which exercises materializing on a record other
than the namespace's own), accessors on `node:assert` (a function
exports object), `node:timers` and `node:stream`, and `spyOn` /
`mock.module` on an already imported builtin. 7 of the 8 cases fail on
the current eager code (everything reports `value`); the
`assert`/`timers`/`stream` identity case is a regression guard.

With the fix, the probe above reports `internal/fs/streams` as not
loaded after `import("node:fs")` (debug build: 338ms to load it
afterwards in both modes, versus 10ms before because the import had
already loaded it). Also run on the new build:
`test/js/node/fs/fs.test.ts` (covers the existing `export { ReadStream,
WriteStream } from "node:fs"` and `export * from "node:fs"` fixtures),
the `test/js/bun/resolve` module tests,
`test/js/bun/test/mock/mock-module*.test.ts`,
`node-module-module.test.js`, `buffer-inspectmaxbytes.test.ts` (which
pins that a named import of an accessor is a snapshot), and the events /
os / timers / assert / tls-internals / node-http suites, all green apart
from two failures that are identical on the released binary in this
environment (`os.userInfo`, the http proxy test).

<!-- robobun:evidence:begin -->

---

**[decide:webkit]** gate passed · iteration 0 · 3 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 7 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/resolve/builtin-esm-lazy-exports.test.ts
bun test v1.4.0 (435641a)

test/js/bun/resolve/builtin-esm-lazy-exports.test.ts:
132 |       afterKeys: kinds(),
133 |       keysMatch: namespaceKeys.sort().join() === [...exportsKeys, "default"].sort().join(),
134 |       allAreTheClasses: STREAMS.every(name => typeof ns[name] === "function" && ns[name] === fs[name]),
135 |     });
136 |   `);
137 |   expect(result).toEqual({
                       ^
error: expect(received).toEqual(expected)

  {
    "afterImport": {
-     "FileReadStream": "accessor",
-     "FileWriteStream": "accessor",
-     "ReadStream": "accessor",
-     "Utf8Stream": "accessor",
-     "WriteStream": "accessor",
+     "FileReadStream": "value",
+     "FileWriteStream": "value",
+     "ReadStream": "value",
+     "Utf8Stream": "value",
+     "WriteStream": "value",
    },
    "afterKeys": {
      "FileReadStream": "value",
      "FileWriteStream": "value",
      "ReadStream": "value",
      "Utf8Stream": "value",
      "WriteStream": "value",
    },
    "after
... (truncated)

release without fix: 7 FAILED
bun test v1.4.0-canary.1 (9008ae7)

test/js/bun/resolve/builtin-esm-lazy-exports.test.ts:
68 |   const result = await runEntry(`
69 |     import { ReadStream } from "node:fs";
70 |     import { fs, kinds, print } from "./helper.mjs";
71 |     print({ kinds: kinds(), isTheClass: typeof ReadStream === "function" && ReadStream === fs.ReadStream });
72 |   `);
73 |   expect(result).toEqual({ kinds: kinds("ReadStream"), isTheClass: true });
                      ^
error: expect(received).toEqual(expected)

  {
    "isTheClass": true,
    "kinds": {
-     "FileReadStream": "accessor",
-     "FileWriteStream": "accessor",
+     "FileReadStream": "value",
+     "FileWriteStream": "value",
      "ReadStream": "value",
-     "Utf8Stream": "accessor",
-     "WriteStream": "accessor",
+     "Utf8Stream": "value",
+     "WriteStream": "value",
    },
  }

- Expected  - 4
+ Received  + 4

      at <anonymous> (/workspace/bun/test/js/bun/resolve/builtin-esm-lazy-exports.test.ts:73:18)
89 |       afterRead: kinds(),
90 |       isTheClass: typeof WriteStream === "function" && WriteStream === fs.WriteStream,
91 |       secondReadIsStable: ns.WriteStream === WriteStream,
92 |     })
... (truncated)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/resolve/builtin-esm-lazy-exports.test.ts
bun test v1.4.0 (435641a)

test/js/bun/resolve/builtin-esm-lazy-exports.test.ts:
(pass) importing the module does not run its accessors [1746.65ms]
(pass) a named import materializes exactly the binding it links [2145.14ms]
(pass) a namespace export materializes when it is read, not on import or `in` [2175.43ms]
(pass) a deferred namespace (import defer) materializes on read as well [2720.80ms]
(pass) import() namespace: same export list as before, enumerating it materializes everything [2873.44ms]
(pass) re-exports bind through to the builtin's own binding [2232.58ms]
(pass) spyOn and mock.module on an imported builtin [2125.50ms]
(pass) accessors on other builtins bind to what require() returns [2735.47ms]

 8 pass
 0 fail
 24 expect() calls
Ran 8 tests across 1 file. [8.97s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     435641a
  features     baseline

22 deps, 107 codegen, 1176 objects in 1540ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1238] gen ErrorCode+*.h
[2/1238] gen bindgenv2
[3/1238] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[4/1238] fetch zlib
[zlib] up to date
[5/1238] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[6/1238] fetch tinycc
[tinycc] up to date
[7/1237] fetch picohttpparser
[picohttpparser] up to date
[8/1237] gen .bind.ts → GeneratedBindings.cpp
[9/1237] gen ProcessBindingBuffer.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingBuffer.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingBuffer.cpp
[10/1237] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[11/1237] gen ProcessBindingHTTPParser.lut.h
Generating /workspac
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
scripts/build/deps/webkit.ts                       |   2 +-
 src/jsc/bindings/ModuleLoader.cpp                  |  28 ++-
 .../bun/resolve/builtin-esm-lazy-exports.test.ts   | 232 +++++++++++++++++++++
 3 files changed, 254 insertions(+), 8 deletions(-)
```

</details>

**gate history** · 1 passed · 0 rejected · iteration 0

<details><summary>evidence per changed file</summary>

```
file                                                  reads  edits  tests
scripts/build/deps/webkit.ts                              3      3      0
src/jsc/bindings/ModuleLoader.cpp                         1      3      0
test/js/bun/resolve/builtin-esm-lazy-exports.test.ts      0      0      0
```

</details>

<!-- robobun:evidence:end -->
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.

importing wasm files returns path instead of module in runtime Wasm import fails

1 participant