Skip to content
Open
25 changes: 25 additions & 0 deletions src/jsc/bindings/ModuleLoader.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1176,6 +1176,31 @@ 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) {
// WebAssembly/ESM integration: the Rust side packed the raw wasm bytes
// into source_code as Latin-1 (one byte per char). Hand them to JSC as a
// WebAssemblySourceProvider so JSModuleLoader dispatches to
// JSWebAssembly::instantiate and the module namespace is the instance's
// exports.
Comment thread
robobun marked this conversation as resolved.
Outdated
WTF::String wasmSource = res->result.value.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 = res->result.value.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));
RELEASE_AND_RETURN(scope, rejectOrResolve(JSC::JSSourceCode::create(vm, JSC::SourceCode(WTF::move(provider)))));
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
Comment thread
robobun marked this conversation as resolved.
Outdated
}
#endif

auto provider = Zig::SourceProvider::create(globalObject, res->result.value);
if (useIsolationCache) {
Expand Down
4 changes: 4 additions & 0 deletions src/jsc/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -943,6 +943,10 @@ 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 packed into `source_code` as Latin-1; `fetchESMSourceCode`
/// feeds them to `JSC::WebAssemblySourceProvider` so the module namespace is
/// the instance's exports (WebAssembly/ESM integration).
Comment thread
robobun marked this conversation as resolved.
Outdated
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
126 changes: 120 additions & 6 deletions src/runtime/jsc_hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3243,13 +3243,120 @@
}));
}
}
// Recurse as `.file`.
// SAFETY: per fn contract — `extra` is live for the call.
unsafe {
(*extra).loader = L::File;
(*extra).module_type = ModuleType::Unknown;

// WebAssembly/ESM integration: `import * as x from './x.wasm'` should
// compile+instantiate the module and expose its exports as named
// bindings. `?query` specifiers keep the legacy path-as-default
// behaviour (they are used as asset/cache-bust URLs, see #16476).
Comment thread
robobun marked this conversation as resolved.
Outdated
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.

let owned;
let wasm_bytes: &[u8] = if let Some(source) = args.virtual_source {
&source.contents
} else {
match bun_sys::File::read_from(bun_sys::Fd::cwd(), path.text) {
Ok(bytes) => {
owned = bytes;
&owned
}
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,
bstr::BStr::new(path.text),
),
);
return Err(crate::Error::ParseError);
}
}
Comment thread
robobun marked this conversation as resolved.
};

if wasm_bytes.len() < 4 || &wasm_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)",
bstr::BStr::new(path.text),
),
);

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

View check run for this annotation

Claude / Claude Code Review

Error messages hand-roll path quoting instead of bun_core::fmt::quote

The two new error messages hand-roll path quoting with `\"{}\"` around `bstr::BStr::new(path.text)`; REVIEW.md ("Error messages are reviewed word-for-word as code") calls for `bun.fmt.quote`, and the existing `add_error_fmt` calls in this file (lines 745-763) use `bun_core::fmt::format_json_string_latin1(...)` for the same purpose. Consider `bun_core::fmt::quote(path.text)` so paths with quotes/newlines/non-ASCII escape consistently.
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
return Err(crate::Error::ParseError);
}

// Register with the watcher so `--watch` / hot-reload notices edits,
// mirroring the generic `.file` arm below.
Comment thread
robobun marked this conversation as resolved.
Outdated
'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;
}
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,
L::Wasm,
bun_sys::Fd::INVALID,
None,
)
.is_err()
{
#[cfg(target_os = "macos")]
if input_fd.is_valid() {
use bun_sys::FdExt as _;
input_fd.close();
}
}
}

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

View check run for this annotation

Claude / Claude Code Review

Duplicated auto_watch block; macOS-only fd-close guard leaks on FreeBSD

This ~50-line `'auto_watch:` block is a near-verbatim copy of the one in the `.file` arm at lines 3434–3487 (only the loader argument differs) — per REVIEW.md's dedup rule, extract a shared `fn auto_watch(jsc_vm, args, path, loader)` and call it from both sites. That also lets you fix the fd-close guard once: `#[cfg(target_os = "macos")]` here (and at line 3481) should also cover FreeBSD, since `bun_watcher::REQUIRES_FILE_DESCRIPTORS` is `true` on both kqueue platforms (src/watcher/Watcher.rs:30
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
transpile_source_code_inner(jsc_vm, args, extra)

// Latin-1 is a byte↔char mapping; the C++ side (`fetchESMSourceCode`)
// reads the 8-bit span back out to build a `WebAssemblySourceProvider`.
Comment thread
robobun marked this conversation as resolved.
Outdated
use bun_jsc::resolved_source::Tag as ResolvedSourceTag;
Ok(OwnedResolvedSource::from(ResolvedSource {
source_code: bun_core::String::clone_latin1(wasm_bytes),
specifier: input_specifier.dupe_ref(),
source_url: create_if_different(input_specifier, path.text),
tag: ResolvedSourceTag::Wasm,
..Default::default()
}))
}

// ────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -4263,6 +4370,13 @@
}
}

// WebAssembly/ESM integration is ESM-only; `require('./x.wasm')` keeps the
// legacy path-string behaviour. Node rejects CJS wasm entirely, but Bun
// has always returned the path here and users rely on it.
Comment thread
robobun marked this conversation as resolved.
Outdated
if is_commonjs_require && lr.loader == Some(Loader::Wasm) {
lr.loader = Some(Loader::File);
}
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
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
Loading
Loading