Skip to content
Open
33 changes: 33 additions & 0 deletions src/jsc/bindings/ModuleLoader.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,29 @@ class ResolvedSourceCodeHolder {

extern "C" BunLoaderType Bun__getDefaultLoader(JSC::JSGlobalObject*, BunString* specifier);

#if ENABLE(WEBASSEMBLY)
// Rebuild the raw wasm bytes packed into source_code (Latin-1) into a WebAssemblySourceProvider.
static JSC::SourceCode sourceCodeForWasm(ResolvedSource& resolved, BunString* specifier)
{
WTF::String wasmSource = resolved.source_code.toWTFString(BunString::NonNull);
Vector<uint8_t> wasmBytes;
if (wasmSource.is8Bit()) {
wasmBytes.append(wasmSource.span8());
} else {
auto span = wasmSource.span16();
wasmBytes.reserveInitialCapacity(span.size());
for (auto ch : span)
wasmBytes.append(static_cast<uint8_t>(ch & 0xff));
}

auto moduleKey = specifier->toWTFString(BunString::ZeroCopy);
auto sourceUrlString = resolved.source_url.toWTFString(BunString::ZeroCopy);
auto sourceURL = !sourceUrlString.isEmpty() ? WTF::URL::fileURLWithFileSystemPath(sourceUrlString) : WTF::URL();
auto provider = JSC::WebAssemblySourceProvider::create(WTF::move(wasmBytes), JSC::SourceOrigin(sourceURL), WTF::move(moduleKey));
return JSC::SourceCode(WTF::move(provider));
}
#endif

static JSC::JSPromise* rejectedInternalPromise(JSC::JSGlobalObject* globalObject, JSC::JSValue value)
{
auto& vm = JSC::getVM(globalObject);
Expand Down Expand Up @@ -391,6 +414,11 @@ static JSValue handleVirtualModuleResult(
if (!res->success) {
RELEASE_AND_RETURN(scope, reject(JSValue::decode(res->result.err.value)));
}
#if ENABLE(WEBASSEMBLY)
if (res->result.value.tag == SyntheticModuleType::Wasm) {
return resolve(JSC::JSSourceCode::create(vm, sourceCodeForWasm(res->result.value, specifier)));
}
#endif

auto provider = Zig::SourceProvider::create(globalObject, res->result.value);
return resolve(JSC::JSSourceCode::create(vm, JSC::SourceCode(provider)));
Expand Down Expand Up @@ -1176,6 +1204,11 @@ static JSValue fetchESMSourceCode(
JSC::ensureStillAliveHere(value);
RELEASE_AND_RETURN(scope, rejectOrResolve(JSSourceCode::create(globalObject->vm(), WTF::move(source))));
}
#if ENABLE(WEBASSEMBLY)
else if (res->result.value.tag == SyntheticModuleType::Wasm) {
RELEASE_AND_RETURN(scope, rejectOrResolve(JSC::JSSourceCode::create(vm, sourceCodeForWasm(res->result.value, specifier))));
}
#endif

auto provider = Zig::SourceProvider::create(globalObject, res->result.value);
if (useIsolationCache) {
Expand Down
2 changes: 2 additions & 0 deletions src/jsc/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -943,6 +943,8 @@ pub mod resolved_source_tag {
pub const Javascript: Self = Self(0);
pub const PackageJsonTypeModule: Self = Self(1);
pub const PackageJsonTypeCommonjs: Self = Self(2);
/// Raw wasm bytes in `source_code` (Latin-1) → `JSC::WebAssemblySourceProvider`.
pub const Wasm: Self = Self(3);
pub const File: Self = Self(5);
pub const Esm: Self = Self(6);
pub const JsonForObjectLoader: Self = Self(7);
Expand Down
185 changes: 122 additions & 63 deletions src/runtime/jsc_hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3243,13 +3243,64 @@
}));
}
}
// Recurse as `.file`.
// SAFETY: per fn contract — `extra` is live for the call.
unsafe {
(*extra).loader = L::File;
(*extra).module_type = ModuleType::Unknown;

// `?query` specifiers are asset/cache-bust URLs (#16476), not real wasm imports.
if bun_core::strings::contains_char(specifier, b'?') {
// SAFETY: per fn contract — `extra` is live for the call.
unsafe {
(*extra).loader = L::File;
(*extra).module_type = ModuleType::Unknown;
}
return transpile_source_code_inner(jsc_vm, args, extra);
}
Comment thread
robobun marked this conversation as resolved.
transpile_source_code_inner(jsc_vm, args, extra)

let check_magic = |bytes: &[u8]| -> crate::Result<()> {
if bytes.len() < 4 || &bytes[0..4] != b"\x00asm" {
// SAFETY: per fn contract — `args.log` is live for the call.
unsafe { &mut *args.log }.add_error_fmt(
None,
bun_ast::Loc::EMPTY,
format_args!(
"Invalid wasm file {} (missing magic header)",
bun_core::fmt::quote(path.text),
),
);
return Err(crate::Error::ParseError);
}
Ok(())
};

let source_code = if let Some(source) = args.virtual_source {
check_magic(&source.contents)?;
bun_core::String::clone_latin1(&source.contents)
Comment thread
robobun marked this conversation as resolved.
} else {
// Register with the watcher even on read/validate failure so fixing the file triggers a reload.
auto_watch_asset(jsc_vm, path, L::Wasm);
match bun_sys::File::read_from(bun_sys::Fd::cwd(), path.text) {
Ok(bytes) => {
check_magic(&bytes)?;
bun_core::String::create_external_globally_allocated_latin1(bytes)
}
Err(err) => {
// SAFETY: per fn contract — `args.log` is live for the call.
unsafe { &mut *args.log }.add_error_fmt(
None,
bun_ast::Loc::EMPTY,
format_args!("{} reading {}", err, bun_core::fmt::quote(path.text)),
);
return Err(crate::Error::ParseError);
}
}
Comment thread
robobun marked this conversation as resolved.
};

use bun_jsc::resolved_source::Tag as ResolvedSourceTag;
Ok(OwnedResolvedSource::from(ResolvedSource {
source_code,
specifier: input_specifier.dupe_ref(),
source_url: create_if_different(input_specifier, path.text),
tag: ResolvedSourceTag::Wasm,
..Default::default()
}))
}

// ────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -3323,60 +3374,8 @@
}));
}

// auto-watch for non-virtual absolute paths.
'auto_watch: {
if args.virtual_source.is_some() {
break 'auto_watch;
}
// SAFETY: per fn contract — `jsc_vm` is the live per-thread VM.
if !unsafe { &*jsc_vm }.is_watcher_enabled() {
break 'auto_watch;
}
if !bun_paths::is_absolute(path.text)
|| bun_core::strings::contains(path.text, b"node_modules")
{
break 'auto_watch;
}
// kqueue watchers need a file descriptor to receive event
// notifications on it; inotify/win32 watch by path.
let input_fd = if bun_watcher::REQUIRES_FILE_DESCRIPTORS {
let mut buf = bun_paths::path_buffer_pool::get();
if path.text.len() >= buf.len() {
break 'auto_watch;
}
let z = bun_paths::resolve_path::z(path.text, &mut buf);
match bun_sys::open(z, bun_watcher::WATCH_OPEN_FLAGS, 0) {
Ok(fd) => fd,
Err(_) => break 'auto_watch,
}
} else {
bun_sys::Fd::INVALID
};
let hash = bun_watcher::Watcher::get_hash(path.text);
// SAFETY: `bun_watcher` is the `*mut ImportWatcher`
// set when `is_watcher_enabled()`; cast recovers the concrete
// type.
let watcher =
unsafe { &mut *(*jsc_vm).bun_watcher.cast::<bun_jsc::ImportWatcher>() };
if watcher
.add_file::<true>(
input_fd,
path.text,
hash,
loader,
bun_sys::Fd::INVALID,
None,
)
.is_err()
{
// Close the fd we just opened on macOS;
// not a transpile failure (the user didn't open it).
#[cfg(target_os = "macos")]
if input_fd.is_valid() {
use bun_sys::FdExt as _;
input_fd.close();
}
}
if args.virtual_source.is_none() {
auto_watch_asset(jsc_vm, path, loader);
}

// `export default <path string>`.
Expand Down Expand Up @@ -3442,6 +3441,52 @@
}
}

/// Register a non-virtual asset path with the watcher. Opens its own watch fd on kqueue platforms.
fn auto_watch_asset(jsc_vm: *mut VirtualMachine, path: &Fs::Path, loader: Loader) {
// SAFETY: per fn contract — `jsc_vm` is the live per-thread VM.
if !unsafe { &*jsc_vm }.is_watcher_enabled() {
return;
}
if !bun_paths::is_absolute(path.text) || bun_core::strings::contains(path.text, b"node_modules")
{
return;
}
// kqueue watchers need a file descriptor; inotify/win32 watch by path.
let input_fd = if bun_watcher::REQUIRES_FILE_DESCRIPTORS {
let mut buf = bun_paths::path_buffer_pool::get();
if path.text.len() >= buf.len() {
return;
}
let z = bun_paths::resolve_path::z(path.text, &mut buf);
match bun_sys::open(z, bun_watcher::WATCH_OPEN_FLAGS, 0) {
Ok(fd) => fd,
Err(_) => return,
}
} else {
bun_sys::Fd::INVALID
};
let hash = bun_watcher::Watcher::get_hash(path.text);
// SAFETY: `bun_watcher` is the `*mut ImportWatcher` set when
// `is_watcher_enabled()`; cast recovers the concrete type.
let watcher = unsafe { &mut *(*jsc_vm).bun_watcher.cast::<bun_jsc::ImportWatcher>() };
if watcher
.add_file::<true>(
input_fd,
path.text,
hash,
loader,
bun_sys::Fd::INVALID,
None,
)
.is_err()
&& bun_watcher::REQUIRES_FILE_DESCRIPTORS
&& input_fd.is_valid()
{
use bun_sys::FdExt as _;
input_fd.close();
}
}

/// Register the just-opened file
/// with the dev-server watcher (if enabled, absolute, and not in
/// `node_modules`). Factored out of the two call sites.
Expand Down Expand Up @@ -4263,6 +4308,11 @@
}
}

// WebAssembly/ESM integration is ESM-only; `require('./x.wasm')` keeps returning the path.
if is_commonjs_require && lr.loader == Some(Loader::Wasm) {
lr.loader = Some(Loader::File);
}

Check warning on line 4314 in src/runtime/jsc_hooks.rs

View check run for this annotation

Claude / Claude Code Review

is_commonjs_require wasm→file demotion missing from transpile_virtual_module sibling entry

This CJS-only demotion enforces "WebAssembly/ESM integration is ESM-only" for filesystem paths, but the sibling entry `transpile_virtual_module` (jsc_hooks.rs:4601+) has no `is_commonjs_require` parameter — so `require('virtual-add.wasm')` on a `Bun.plugin` `b.module()` reaches the new `SyntheticModuleType::Wasm` branch in `handleVirtualModuleResult` (ModuleLoader.cpp:417-421) via `fetchCommonJSModule` and attempts wasm ESM instantiation through `provideFetch`+`drainSynchronousModuleQueue` inste
Comment thread
robobun marked this conversation as resolved.

// ── module_type sniff from extension / package.json ─────────────────────
let module_type: ModuleType = 'brk: {
let ext = lr.path.name().ext;
Expand Down Expand Up @@ -4572,11 +4622,8 @@
let specifier_slice = unsafe { &*specifier_ptr }.to_utf8();
let specifier = specifier_slice.slice();
// SAFETY: per fn contract.
let source_code_slice = unsafe { &*source_code }.to_slice();
// SAFETY: per fn contract.
let referrer_slice = unsafe { &*referrer_ptr }.to_utf8();

let virtual_source = bun_ast::Source::init_path_string(specifier, source_code_slice.slice());
let mut log = bun_ast::Log::init();
// SAFETY: `TranspileExtra::path` is typed `'static` for the cross-crate
// fn-ptr ABI; the borrow actually lives only for this call (the `extra`
Expand Down Expand Up @@ -4606,6 +4653,18 @@
})
};

// SAFETY: per fn contract.
let source_code_ref = unsafe { &*source_code };
// Wasm is binary: read the raw bytes instead of Latin-1→UTF-8 transcoding.
let source_code_slice;
let source_bytes: &[u8] = if loader == Loader::Wasm {
source_code_ref.byte_slice()
} else {
source_code_slice = source_code_ref.to_slice();
source_code_slice.slice()
};
let virtual_source = bun_ast::Source::init_path_string(specifier, source_bytes);

// Reset the module loader's arena on scope exit.
// `jsc_vm` is the live per-thread VM (BackRef invariant).
let _reset_arena = ArenaResetGuard::new(jsc_vm);
Expand Down
4 changes: 2 additions & 2 deletions test/bundler/bun-build-compile-wasm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ describe("Bun.build compile with wasm", () => {
const dir = tempDirWithFiles("build-compile-wasm", {
"app.js": `
// Import a wasm module and properly instantiate it
import wasmPath from "./test.wasm";
import wasmPath from "./test.wasm" with { type: "file" };

async function main() {
try {
Expand Down Expand Up @@ -122,5 +122,5 @@ describe("Bun.build compile with wasm", () => {
expect(stdout).toContain("WASM result: 5");
expect(stdout).toContain("WASM module loaded successfully");
expect(stderr).toBe("");
});
}, 60_000);
});
2 changes: 1 addition & 1 deletion test/bundler/bundler_loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ describe("bundler", async () => {

files: {
"/entry.ts": /* js */ `
import wasm from './add.wasm';
import wasm from './add.wasm' with { type: "file" };
import { join } from 'path';
const { instance } = await WebAssembly.instantiate(await Bun.file(join(import.meta.dir, wasm)).arrayBuffer());
console.log(instance.exports.add(1, 2));
Expand Down
16 changes: 15 additions & 1 deletion test/js/bun/resolve/import-empty.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,6 @@ it("importing empty jsonc/toml file returns module with empty object as default

it("importing empty file returns module with path as default export", async () => {
const other_types = [
"wasm",
// "napi", // marked unreachable in src/jsc/ModuleLoader.zig:1956:22
"base64",
"dataurl",
Expand All @@ -87,6 +86,21 @@ it("importing empty file returns module with path as default export", async () =
}
});

// WebAssembly/ESM integration instantiates the module at import time; an
// empty file has no magic header so it fails to load. Node with
// --experimental-wasm-modules behaves the same way.
it("importing empty file with type wasm throws a magic-header error", async () => {
delete require.cache[require.resolve(`./empty-file`)];
let err;
try {
await import("./empty-file", { with: { type: "wasm" } });
} catch (e) {
err = e;
}
expect(err).toBeDefined();
expect(String(err?.message ?? "")).toMatch(/magic header/i);
});

// MARK: - sqlite

it("importing empty sqlite files returns database object", async () => {
Expand Down
Binary file added test/js/bun/wasm/add.wasm
Binary file not shown.
Loading
Loading