Skip to content

Commit 1472e69

Browse files
authored
fix(bundle): instantiate .wasm imports instead of emitting raw bytes (#34923)
1 parent c0dfd6a commit 1472e69

10 files changed

Lines changed: 251 additions & 5 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ unicode-width.workspace = true
179179
uuid = { workspace = true, features = ["serde"] }
180180
walkdir.workspace = true
181181
wasm-encoder = { version = "0.244.0", features = ["wasmparser"] }
182+
wasm_dep_analyzer = "0.4.0"
182183
wasmparser = "0.244.0"
183184
zip = { workspace = true, features = ["deflate-flate2"] }
184185
zstd.workspace = true

cli/tools/bundle/mod.rs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ mod externals;
55
mod html;
66
mod provider;
77
mod transform;
8+
mod wasm;
89

910
use std::borrow::Cow;
1011
use std::cell::RefCell;
@@ -1315,8 +1316,8 @@ pub enum BundleLoadErrorKind {
13151316
#[error(transparent)]
13161317
ResolveWithGraph(#[from] ResolveWithGraphError),
13171318
#[class(generic)]
1318-
#[error("Wasm modules are not implemented in deno bundle.")]
1319-
WasmUnsupported,
1319+
#[error("Failed to parse Wasm module: {0}")]
1320+
WasmParse(String),
13201321
#[class(generic)]
13211322
#[error("UTF-8 conversion error")]
13221323
Utf8(#[from] std::str::Utf8Error),
@@ -1723,6 +1724,11 @@ impl DenoPluginHandler {
17231724
Some(RequestedModuleType::Other(_) | RequestedModuleType::None)
17241725
| None => {}
17251726
}
1727+
if media_type == MediaType::Wasm {
1728+
let code = wasm::render_js_wasm_module(source)
1729+
.map_err(|e| BundleLoadErrorKind::WasmParse(e.to_string()))?;
1730+
return Ok((code.into_bytes(), esbuild_client::BuiltinLoader::Js));
1731+
}
17261732
if matches!(
17271733
media_type,
17281734
MediaType::JavaScript
@@ -1861,9 +1867,11 @@ impl DenoPluginHandler {
18611867
deno_ast::MediaType::Json,
18621868
esbuild_client::BuiltinLoader::Json,
18631869
),
1864-
deno_graph::Module::Wasm(_) => {
1865-
return Err(BundleLoadErrorKind::WasmUnsupported.into());
1866-
}
1870+
deno_graph::Module::Wasm(wasm_module) => (
1871+
wasm_module.specifier.clone(),
1872+
deno_ast::MediaType::Wasm,
1873+
esbuild_client::BuiltinLoader::Js,
1874+
),
18671875
deno_graph::Module::Npm(_) => {
18681876
let req_ref =
18691877
NpmPackageReqReference::from_specifier(specifier).unwrap();

cli/tools/bundle/wasm.rs

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
// Copyright 2018-2026 the Deno authors. MIT license.
2+
3+
use std::borrow::Cow;
4+
5+
use base64::Engine as _;
6+
use base64::prelude::BASE64_STANDARD;
7+
use capacity_builder::StringBuilder;
8+
use indexmap::IndexMap;
9+
10+
/// Render a Wasm module as a self-contained JavaScript module that, when run,
11+
/// instantiates the Wasm module and re-exports its exports.
12+
///
13+
/// Deno natively treats a `.wasm` import as an ES module whose exports are the
14+
/// Wasm instance's exports (see `render_js_wasm_module` in `deno_core`). esbuild
15+
/// has no such loader, so for `deno bundle` we generate equivalent JavaScript
16+
/// that:
17+
///
18+
/// 1. imports the Wasm module's imports from their respective specifiers (these
19+
/// get resolved/bundled by esbuild like any other import),
20+
/// 2. inlines the Wasm bytes as base64 and compiles + instantiates them
21+
/// synchronously, and
22+
/// 3. re-exports the instance's exports as named (and possibly default) exports.
23+
///
24+
/// Tradeoffs of keeping the bundle self-contained: compilation happens
25+
/// synchronously at module-eval time (which blocks the thread for that module)
26+
/// and the base64 inline grows the output by ~1.33x. Async compilation isn't an
27+
/// option here because esbuild can't consume the source-phase
28+
/// `import source ... from` / `import.meta.WasmInstance` form Deno uses natively.
29+
pub fn render_js_wasm_module(
30+
bytes: &[u8],
31+
) -> Result<String, wasm_dep_analyzer::ParseError> {
32+
let wasm_deps = wasm_dep_analyzer::WasmDeps::parse(
33+
bytes,
34+
wasm_dep_analyzer::ParseOptions { skip_types: true },
35+
)?;
36+
37+
struct ImportInfo {
38+
key_escaped: String,
39+
escaped_named_imports: Vec<String>,
40+
}
41+
42+
let mut aggregated_imports: IndexMap<&str, ImportInfo> =
43+
IndexMap::with_capacity(wasm_deps.imports.len());
44+
for import in &wasm_deps.imports {
45+
let entry =
46+
aggregated_imports
47+
.entry(import.module)
48+
.or_insert_with(|| ImportInfo {
49+
key_escaped: import.module.escape_default().to_string(),
50+
escaped_named_imports: Vec::new(),
51+
});
52+
entry
53+
.escaped_named_imports
54+
.push(import.name.escape_default().to_string());
55+
}
56+
57+
let escaped_export_names = wasm_deps
58+
.exports
59+
.iter()
60+
.map(|e| {
61+
if e.name == "default" {
62+
Cow::Borrowed(e.name)
63+
} else {
64+
Cow::Owned(e.name.escape_default().to_string())
65+
}
66+
})
67+
.collect::<Vec<_>>();
68+
69+
let base64_bytes = BASE64_STANDARD.encode(bytes);
70+
71+
Ok(
72+
StringBuilder::build(|builder| {
73+
for (i, (_, import_info)) in aggregated_imports.iter().enumerate() {
74+
builder.append("import { ");
75+
for (name_index, named_import) in
76+
import_info.escaped_named_imports.iter().enumerate()
77+
{
78+
if name_index > 0 {
79+
builder.append(", ");
80+
}
81+
builder.append('"');
82+
builder.append(named_import);
83+
builder.append("\" as __deno_wasm_import_");
84+
builder.append(i);
85+
builder.append('_');
86+
builder.append(name_index);
87+
builder.append("__");
88+
}
89+
builder.append(" } from \"");
90+
builder.append(&import_info.key_escaped);
91+
builder.append("\";\n");
92+
}
93+
94+
builder.append(
95+
"const __deno_wasm_bytes__ = Uint8Array.from(atob(\"",
96+
);
97+
builder.append(&base64_bytes);
98+
builder.append("\"), (c) => c.charCodeAt(0));\n");
99+
100+
if aggregated_imports.is_empty() {
101+
builder.append(
102+
"const __deno_wasm_instance__ = new WebAssembly.Instance(new WebAssembly.Module(__deno_wasm_bytes__)).exports;\n",
103+
);
104+
} else {
105+
builder.append("const __deno_wasm_imports__ = {\n");
106+
for (i, (_, import_info)) in aggregated_imports.iter().enumerate() {
107+
builder.append(" \"");
108+
builder.append(&import_info.key_escaped);
109+
builder.append("\": {\n");
110+
for (name_index, named_import) in
111+
import_info.escaped_named_imports.iter().enumerate()
112+
{
113+
builder.append(" \"");
114+
builder.append(named_import);
115+
builder.append("\": __deno_wasm_import_");
116+
builder.append(i);
117+
builder.append('_');
118+
builder.append(name_index);
119+
builder.append("__,\n");
120+
}
121+
builder.append(" },\n");
122+
}
123+
builder.append("};\n");
124+
builder.append(
125+
"const __deno_wasm_instance__ = new WebAssembly.Instance(new WebAssembly.Module(__deno_wasm_bytes__), __deno_wasm_imports__).exports;\n",
126+
);
127+
}
128+
129+
for (idx, escaped_name) in escaped_export_names.iter().enumerate() {
130+
if escaped_name == "default" {
131+
builder.append(
132+
"export default __deno_wasm_instance__.default;\n",
133+
);
134+
} else {
135+
builder.append("const __deno_wasm_export_");
136+
builder.append(idx);
137+
builder.append("__ = __deno_wasm_instance__[\"");
138+
builder.append(escaped_name.as_ref());
139+
builder.append("\"];\nexport { __deno_wasm_export_");
140+
builder.append(idx);
141+
builder.append("__ as \"");
142+
builder.append(escaped_name.as_ref());
143+
builder.append("\" };\n");
144+
}
145+
}
146+
})
147+
.unwrap(),
148+
)
149+
}
150+
151+
#[cfg(test)]
152+
mod test {
153+
use super::*;
154+
155+
// A minimal valid Wasm module: `(module (func (export "add") (param i32 i32)
156+
// (result i32) local.get 0 local.get 1 i32.add))`.
157+
const ADD_WASM: &[u8] = &[
158+
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x07, 0x01, 0x60,
159+
0x02, 0x7f, 0x7f, 0x01, 0x7f, 0x03, 0x02, 0x01, 0x00, 0x07, 0x07, 0x01,
160+
0x03, 0x61, 0x64, 0x64, 0x00, 0x00, 0x0a, 0x09, 0x01, 0x07, 0x00, 0x20,
161+
0x00, 0x20, 0x01, 0x6a, 0x0b,
162+
];
163+
164+
// A minimal Wasm module that imports `addOne` from `./dep.js` and exports
165+
// `callImport`: `(module (import "./dep.js" "addOne" (func (param i32)
166+
// (result i32))) (func (export "callImport") (param i32) (result i32)
167+
// local.get 0 call 0))`.
168+
const CALC_WASM: &[u8] = &[
169+
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x06, 0x01, 0x60,
170+
0x01, 0x7f, 0x01, 0x7f, 0x02, 0x13, 0x01, 0x08, 0x2e, 0x2f, 0x64, 0x65,
171+
0x70, 0x2e, 0x6a, 0x73, 0x06, 0x61, 0x64, 0x64, 0x4f, 0x6e, 0x65, 0x00,
172+
0x00, 0x03, 0x02, 0x01, 0x00, 0x07, 0x0e, 0x01, 0x0a, 0x63, 0x61, 0x6c,
173+
0x6c, 0x49, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x00, 0x01, 0x0a, 0x08, 0x01,
174+
0x06, 0x00, 0x20, 0x00, 0x10, 0x00, 0x0b,
175+
];
176+
177+
#[test]
178+
fn renders_exports_without_imports() {
179+
let rendered = render_js_wasm_module(ADD_WASM).unwrap();
180+
assert!(
181+
rendered.contains("const __deno_wasm_bytes__ = Uint8Array.from(atob(")
182+
);
183+
assert!(rendered.contains(
184+
"new WebAssembly.Instance(new WebAssembly.Module(__deno_wasm_bytes__)).exports"
185+
));
186+
assert!(rendered.contains("__deno_wasm_instance__[\"add\"]"));
187+
assert!(rendered.contains("as \"add\""));
188+
assert!(!rendered.contains("__deno_wasm_imports__"));
189+
}
190+
191+
#[test]
192+
fn renders_imports_object_and_named_imports() {
193+
let rendered = render_js_wasm_module(CALC_WASM).unwrap();
194+
// The wasm import is emitted as an ES import esbuild can resolve.
195+
assert!(rendered.contains(
196+
"import { \"addOne\" as __deno_wasm_import_0_0__ } from \"./dep.js\";"
197+
));
198+
// ...and forwarded into the imports object passed to `WebAssembly.Instance`.
199+
assert!(rendered.contains("const __deno_wasm_imports__ = {"));
200+
assert!(rendered.contains("\"./dep.js\": {"));
201+
assert!(rendered.contains("\"addOne\": __deno_wasm_import_0_0__,"));
202+
assert!(rendered.contains(
203+
"new WebAssembly.Instance(new WebAssembly.Module(__deno_wasm_bytes__), __deno_wasm_imports__).exports"
204+
));
205+
// The export is re-exported.
206+
assert!(rendered.contains("__deno_wasm_instance__[\"callImport\"]"));
207+
assert!(rendered.contains("as \"callImport\""));
208+
}
209+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"tempDir": true,
3+
"steps": [{
4+
"args": "bundle -o=./out.js main.ts",
5+
"output": "[WILDCARD]"
6+
}, {
7+
"args": "run ./out.js",
8+
"output": "wasm.out"
9+
}]
10+
}
41 Bytes
Binary file not shown.
67 Bytes
Binary file not shown.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function addOne(n) {
2+
return n + 1;
3+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import * as add from "./add.wasm";
2+
import * as calc from "./calc.wasm";
3+
4+
// `import * as` on a `.wasm` module should expose the instance's exports, not
5+
// the raw bytes (see denoland/deno#32104).
6+
console.log("add keys:", Object.keys(add));
7+
console.log("add(2, 3):", add.add(2, 3));
8+
9+
// A `.wasm` module that imports a function from a sibling JS module should have
10+
// that import resolved and bundled too.
11+
console.log("callImport(41):", calc.callImport(41));
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
add keys: [ "add" ]
2+
add(2, 3): 5
3+
callImport(41): 42

0 commit comments

Comments
 (0)