From 896d20b99e79b9c11be966505f3c710c02bbd8ba Mon Sep 17 00:00:00 2001 From: Brendan Dahl Date: Thu, 3 Apr 2025 17:50:15 +0000 Subject: [PATCH 1/8] feat: support direct ESM imports in wasm Directly import from an ES module following the ESM integration proposal. e.g. `(import "./add-esmi-deps.mjs" "getValue" (func $getValue (result i32)))` --- README.md | 18 ++++++++++++++++++ examples/add-esmi-deps.mjs | 3 +++ examples/add-esmi.wasm | Bin 0 -> 116 bytes examples/add-esmi.wat | 9 +++++++++ examples/build.mjs | 14 ++++++++++++++ package.json | 3 ++- pnpm-lock.yaml | 9 +++++++++ src/plugin/runtime/imports.ts | 11 ++++++++++- test/fixture/esm-integration.mjs | 8 ++++++++ .../@fixture/wasm/add-esmi-deps.mjs | 3 +++ test/node_modules/@fixture/wasm/add-esmi.wasm | Bin 0 -> 116 bytes test/plugin.test.ts | 13 +++++++++++++ 12 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 examples/add-esmi-deps.mjs create mode 100644 examples/add-esmi.wasm create mode 100644 examples/add-esmi.wat create mode 100644 test/fixture/esm-integration.mjs create mode 100644 test/node_modules/@fixture/wasm/add-esmi-deps.mjs create mode 100644 test/node_modules/@fixture/wasm/add-esmi.wasm diff --git a/README.md b/README.md index 0697485..67b3179 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,24 @@ To hint to the bundler how to resolve imports needed by the `.wasm` file, you ne **Note:** The imports can also be prefixed with `#` like `#env` if you like to respect Node.js conventions. +## Wasm ESM Imports + +unwasm also supports importing from other ES modules in Wasm (read more: [ESM Integration Spec](https://github.com/WebAssembly/esm-integration/tree/main/proposals/esm-integration)). + +**Example:** + +```wast +(module + (import "./add-esmi-deps.mjs" "getValue" (func $getValue (result i32))) + + (func (export "addImported") (param $a i32) (result i32) + local.get $a + call $getValue + i32.add + ) +) +``` + ## Contribution
diff --git a/examples/add-esmi-deps.mjs b/examples/add-esmi-deps.mjs new file mode 100644 index 0000000..b2a042c --- /dev/null +++ b/examples/add-esmi-deps.mjs @@ -0,0 +1,3 @@ +export function getValue() { + return 41; +} diff --git a/examples/add-esmi.wasm b/examples/add-esmi.wasm new file mode 100644 index 0000000000000000000000000000000000000000..f9e6321b48dfd5f108d6a0aed6f0b2951d620f91 GIT binary patch literal 116 zcmX|)Jr06E5QX0xNI83G`mi#$J+tTD~Y8Trd`Z^4#glu|-!rXoPe0PhbaAJc2XRHC&`mJsZ#NU!u%7&o+ E0PL+8mjD0& literal 0 HcmV?d00001 diff --git a/examples/add-esmi.wat b/examples/add-esmi.wat new file mode 100644 index 0000000..77ea98a --- /dev/null +++ b/examples/add-esmi.wat @@ -0,0 +1,9 @@ +(module + (import "./add-esmi-deps.mjs" "getValue" (func $getValue (result i32))) + + (func (export "addImported") (param $a i32) (result i32) + local.get $a + call $getValue + i32.add + ) +) diff --git a/examples/build.mjs b/examples/build.mjs index d4d1834..77278bd 100644 --- a/examples/build.mjs +++ b/examples/build.mjs @@ -1,6 +1,11 @@ import { fileURLToPath } from "node:url"; +import fs from 'node:fs/promises'; +import Module from "node:module"; import { main as asc } from "assemblyscript/asc"; +const require = Module.createRequire(import.meta.url); +const wabt = await require("wabt")(); + async function compile(name) { // https://www.assemblyscript.org/compiler.html#programmatic-usage const res = await asc([`${name}.asc.ts`, "-o", `${name}.wasm`], {}); @@ -14,7 +19,16 @@ async function compile(name) { } } +async function compileWat(name) { + const module = wabt.parseWat(`${name}.wat`, await fs.readFile(`${name}.wat`)); + module.resolveNames(); + const binaryOutput = module.toBinary({write_debug_names:true}); + const binaryBuffer = binaryOutput.buffer; + await fs.writeFile(`${name}.wasm`, binaryBuffer); +} + process.chdir(fileURLToPath(new URL(".", import.meta.url))); await compile("sum"); await compile("rand"); +await compileWat("add-esmi"); diff --git a/package.json b/package.json index 271b72d..3a21882 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,8 @@ "typescript": "^5.7.3", "unbuild": "^3.3.1", "vite": "^6.1.0", - "vitest": "^3.0.5" + "vitest": "^3.0.5", + "wabt": "^1.0.37" }, "resolutions": { "@webassemblyjs/helper-wasm-bytecode": "1.14.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1597bc0..9eb2a15 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,9 @@ importers: vitest: specifier: ^3.0.5 version: 3.0.5(@types/node@22.13.1)(jiti@2.4.2)(yaml@2.7.0) + wabt: + specifier: ^1.0.37 + version: 1.0.37 packages: @@ -2580,6 +2583,10 @@ packages: jsdom: optional: true + wabt@1.0.37: + resolution: {integrity: sha512-2B/TH4ppwtlkUosLtuIimKsTVnqM8aoXxYHnu/WOxiSqa+CGoZXmG+pQyfDQjEKIAc7GqFlJsuCKuK8rIPL1sg==} + hasBin: true + webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} @@ -5095,6 +5102,8 @@ snapshots: - tsx - yaml + wabt@1.0.37: {} + webpack-virtual-modules@0.6.2: {} which@2.0.2: diff --git a/src/plugin/runtime/imports.ts b/src/plugin/runtime/imports.ts index 76bcfc7..fb328dc 100644 --- a/src/plugin/runtime/imports.ts +++ b/src/plugin/runtime/imports.ts @@ -5,6 +5,8 @@ import { genString, genImport, } from "knitwork"; +import fs from "node:fs"; +import path from "node:path"; import { WasmAsset, UnwasmPluginOptions } from "../shared"; @@ -28,8 +30,11 @@ export async function getWasmImports( const imports: string[] = []; const importsObject: Record> = {}; + const directory = path.dirname(asset.id); + for (const moduleName of importNames) { const importNames = asset.imports[moduleName]; + let importFound = false; const pkgImport = pkgJSON.imports?.[moduleName] || pkgJSON.imports?.[`#${moduleName}`]; @@ -37,7 +42,11 @@ export async function getWasmImports( // TODO: haandle pkgImport as object if (pkgImport && typeof pkgImport === "string") { + importFound = true; imports.push(genImport(pkgImport, { name: "*", as: importName })); + } else if (fs.existsSync(path.resolve(directory, moduleName))) { + importFound = true; + imports.push(genImport(moduleName, { name: "*", as: importName })); } else { resolved = false; } @@ -45,7 +54,7 @@ export async function getWasmImports( importsObject[moduleName] = Object.fromEntries( importNames.map((name) => [ name, - pkgImport + importFound ? `${importName}[${genString(name)}]` : `() => { throw new Error(${genString(moduleName + "." + importName)} + " is not provided!")}`, ]), diff --git a/test/fixture/esm-integration.mjs b/test/fixture/esm-integration.mjs new file mode 100644 index 0000000..720b604 --- /dev/null +++ b/test/fixture/esm-integration.mjs @@ -0,0 +1,8 @@ +import { addImported } from "@fixture/wasm/add-esmi.wasm"; + +export function test() { + if (addImported(1) !== 42) { + return "FALED: sum"; + } + return "OK"; +} diff --git a/test/node_modules/@fixture/wasm/add-esmi-deps.mjs b/test/node_modules/@fixture/wasm/add-esmi-deps.mjs new file mode 100644 index 0000000..b2a042c --- /dev/null +++ b/test/node_modules/@fixture/wasm/add-esmi-deps.mjs @@ -0,0 +1,3 @@ +export function getValue() { + return 41; +} diff --git a/test/node_modules/@fixture/wasm/add-esmi.wasm b/test/node_modules/@fixture/wasm/add-esmi.wasm new file mode 100644 index 0000000000000000000000000000000000000000..f9e6321b48dfd5f108d6a0aed6f0b2951d620f91 GIT binary patch literal 116 zcmX|)Jr06E5QX0xNI83G`mi#$J+tTD~Y8Trd`Z^4#glu|-!rXoPe0PhbaAJc2XRHC&`mJsZ#NU!u%7&o+ E0PL+8mjD0& literal 0 HcmV?d00001 diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 8f5bb31..8339a26 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -48,6 +48,19 @@ describe("plugin:rollup", () => { const mod = await evalModule(code, { url: r("fixture/rollup-module.mjs") }); expect(mod.test()).toBe("OK"); }); + + it("esm-integration", async () => { + const { output } = await _rollupBuild( + "fixture/esm-integration.mjs", + "rollup-inline", + {}, + ); + const code = output[0].code; + const mod = await evalModule(code, { + url: r("fixture/esm-integration.mjs"), + }); + expect(mod.test()).toBe("OK"); + }); }); // --- Utils --- From 3e0a01ff3f6f87a4de27a04ff828b1087483b00b Mon Sep 17 00:00:00 2001 From: Brendan Dahl Date: Wed, 23 Apr 2025 18:53:02 +0000 Subject: [PATCH 2/8] Use exsolve. Add test for missing module. --- package.json | 1 + pnpm-lock.yaml | 8 ++++++++ src/plugin/runtime/imports.ts | 8 ++++++-- test/plugin.test.ts | 14 ++++++++++++++ 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 3a21882..b51d5a4 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "test:types": "tsc --noEmit --skipLibCheck" }, "dependencies": { + "exsolve": "^1.0.4", "knitwork": "^1.2.0", "magic-string": "^0.30.17", "mlly": "^1.7.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9eb2a15..8b7f4b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: .: dependencies: + exsolve: + specifier: ^1.0.4 + version: 1.0.4 knitwork: specifier: ^1.2.0 version: 1.2.0 @@ -1453,6 +1456,9 @@ packages: resolution: {integrity: sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==} engines: {node: '>=12.0.0'} + exsolve@1.0.4: + resolution: {integrity: sha512-xsZH6PXaER4XoV+NiT7JHp1bJodJVT+cxeSH1G0f0tlT0lJqYuHUP3bUx2HtfTDvOagMINYp8rsqusxud3RXhw==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -3987,6 +3993,8 @@ snapshots: expect-type@1.1.0: {} + exsolve@1.0.4: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: diff --git a/src/plugin/runtime/imports.ts b/src/plugin/runtime/imports.ts index fb328dc..e618ac2 100644 --- a/src/plugin/runtime/imports.ts +++ b/src/plugin/runtime/imports.ts @@ -7,6 +7,7 @@ import { } from "knitwork"; import fs from "node:fs"; import path from "node:path"; +import { resolveModulePath } from "exsolve"; import { WasmAsset, UnwasmPluginOptions } from "../shared"; @@ -41,12 +42,15 @@ export async function getWasmImports( const importName = "_imports_" + genSafeVariableName(moduleName); // TODO: haandle pkgImport as object + let esmPath; if (pkgImport && typeof pkgImport === "string") { importFound = true; imports.push(genImport(pkgImport, { name: "*", as: importName })); - } else if (fs.existsSync(path.resolve(directory, moduleName))) { + } else if ( + (esmPath = resolveModulePath(moduleName, { from: directory, try: true })) + ) { importFound = true; - imports.push(genImport(moduleName, { name: "*", as: importName })); + imports.push(genImport(esmPath, { name: "*", as: importName })); } else { resolved = false; } diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 8339a26..715ce49 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -61,6 +61,20 @@ describe("plugin:rollup", () => { }); expect(mod.test()).toBe("OK"); }); + + it("esm-integration-missing-import", async () => { + await expect(() => + _rollupBuild( + "fixture/esm-integration-missing-import.mjs", + "rollup-inline", + {}, + ), + ).rejects.toThrowError( + expect.objectContaining({ + code: "MISSING_EXPORT", + }), + ); + }); }); // --- Utils --- From 995dc9f94a1164b6791a802b63af29317f24e2c4 Mon Sep 17 00:00:00 2001 From: Brendan Dahl Date: Wed, 23 Apr 2025 18:57:49 +0000 Subject: [PATCH 3/8] Remove fs. --- src/plugin/runtime/imports.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/plugin/runtime/imports.ts b/src/plugin/runtime/imports.ts index e618ac2..753739a 100644 --- a/src/plugin/runtime/imports.ts +++ b/src/plugin/runtime/imports.ts @@ -5,7 +5,6 @@ import { genString, genImport, } from "knitwork"; -import fs from "node:fs"; import path from "node:path"; import { resolveModulePath } from "exsolve"; From 69036f5d42a4b35de3beab4bae0aed59f5d01ba9 Mon Sep 17 00:00:00 2001 From: Brendan Dahl Date: Wed, 23 Apr 2025 21:16:47 +0000 Subject: [PATCH 4/8] missing test file --- test/fixture/esm-integration-missing-import.mjs | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 test/fixture/esm-integration-missing-import.mjs diff --git a/test/fixture/esm-integration-missing-import.mjs b/test/fixture/esm-integration-missing-import.mjs new file mode 100644 index 0000000..c890d0b --- /dev/null +++ b/test/fixture/esm-integration-missing-import.mjs @@ -0,0 +1,8 @@ +import { badImportName } from "@fixture/wasm/add-esmi.wasm"; + +export function test() { + if (badImportName(1) !== 42) { + return "FALED: sum"; + } + return "OK"; +} From bf9762a2a2e00b59e255f8c8fedfe54315d350d2 Mon Sep 17 00:00:00 2001 From: Brendan Dahl Date: Tue, 29 Apr 2025 22:27:36 +0000 Subject: [PATCH 5/8] feat: support treeshaking Wasm modules Use binaryen's wasm-metadce to perform treeshaking on Wasm modules. resolves #57 --- .github/workflows/ci.yml | 2 + examples/build.mjs | 1 + examples/treeshake.wasm | Bin 0 -> 82 bytes examples/treeshake.wat | 8 +++ package.json | 4 +- pnpm-lock.yaml | 27 ++++++++ src/plugin/index.ts | 41 ++++++++++-- src/plugin/runtime/binding.ts | 4 +- src/plugin/runtime/treeshake.ts | 62 ++++++++++++++++++ src/plugin/shared.ts | 14 ++++ test/fixture/treeshake.mjs | 8 +++ .../node_modules/@fixture/wasm/treeshake.wasm | Bin 0 -> 82 bytes test/plugin.test.ts | 33 ++++++++++ 13 files changed, 195 insertions(+), 9 deletions(-) create mode 100644 examples/treeshake.wasm create mode 100644 examples/treeshake.wat create mode 100644 src/plugin/runtime/treeshake.ts create mode 100644 test/fixture/treeshake.mjs create mode 100644 test/node_modules/@fixture/wasm/treeshake.wasm diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5390de2..7ac1286 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,8 @@ jobs: - uses: actions/checkout@v4 - run: npm i -g --force corepack - run: corepack enable + if: ${{ matrix.os == 'ubuntu-latest' }} + - run: sudo apt install -y binaryen - uses: actions/setup-node@v4 with: node-version: 22 diff --git a/examples/build.mjs b/examples/build.mjs index 77278bd..2058d1b 100644 --- a/examples/build.mjs +++ b/examples/build.mjs @@ -32,3 +32,4 @@ process.chdir(fileURLToPath(new URL(".", import.meta.url))); await compile("sum"); await compile("rand"); await compileWat("add-esmi"); +await compileWat("treeshake"); diff --git a/examples/treeshake.wasm b/examples/treeshake.wasm new file mode 100644 index 0000000000000000000000000000000000000000..3f202ac374ce2c0daaff5baf11299307800bc3e9 GIT binary patch literal 82 zcmZQbEY4+QU|?WmWlUgTtY>CsVqjpGW#UdN%}XxH%+K@BOJ!hy^Fqq=85p_vnOGPc dwYWJL9N+)l_O+Iqfrlk8F*lWo6{wey0RYKF5>EgC literal 0 HcmV?d00001 diff --git a/examples/treeshake.wat b/examples/treeshake.wat new file mode 100644 index 0000000..ca290f5 --- /dev/null +++ b/examples/treeshake.wat @@ -0,0 +1,8 @@ +(module + (func (export "functionOne") (result i32) + i32.const 42 + ) + (func (export "functionTwo") (result i32) + i32.const 0xDEADBEEF + ) +) diff --git a/package.json b/package.json index b51d5a4..4f16e9b 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,9 @@ "mlly": "^1.7.4", "pathe": "^2.0.3", "pkg-types": "^1.3.1", - "unplugin": "^2.2.0" + "tmp": "^0.2.3", + "unplugin": "^2.2.0", + "which": "^5.0.0" }, "devDependencies": { "@rollup/plugin-node-resolve": "^16.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b7f4b3..f1a5f8f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,9 +32,15 @@ importers: pkg-types: specifier: ^1.3.1 version: 1.3.1 + tmp: + specifier: ^0.2.3 + version: 0.2.3 unplugin: specifier: ^2.2.0 version: 2.2.0 + which: + specifier: ^5.0.0 + version: 5.0.0 devDependencies: '@rollup/plugin-node-resolve': specifier: ^16.0.0 @@ -1673,6 +1679,10 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isexe@3.1.1: + resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} + engines: {node: '>=16'} + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -2433,6 +2443,10 @@ packages: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} + tmp@0.2.3: + resolution: {integrity: sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==} + engines: {node: '>=14.14'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -2601,6 +2615,11 @@ packages: engines: {node: '>= 8'} hasBin: true + which@5.0.0: + resolution: {integrity: sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -4190,6 +4209,8 @@ snapshots: isexe@2.0.0: {} + isexe@3.1.1: {} + istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -4930,6 +4951,8 @@ snapshots: tinyspy@3.0.2: {} + tmp@0.2.3: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -5118,6 +5141,10 @@ snapshots: dependencies: isexe: 2.0.0 + which@5.0.0: + dependencies: + isexe: 3.1.1 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 diff --git a/src/plugin/index.ts b/src/plugin/index.ts index e43ffef..294ec79 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -5,6 +5,7 @@ import type { RenderedChunk, Plugin as RollupPlugin } from "rollup"; import { createUnplugin } from "unplugin"; import { parseWasm } from "../tools"; import { getWasmESMBinding, getWasmModuleBinding } from "./runtime/binding"; +import { treeshake, filterExports } from "./runtime/treeshake"; import { getPluginUtils } from "./runtime/utils"; import { sha1, @@ -139,8 +140,30 @@ const unplugin = createUnplugin((opts) => { }; }, renderChunk(code: string, chunk: RenderedChunk) { - if (!opts.esmImport) { - return; + if (chunk.type === "chunk" && opts.treeshake) { + for (const [id, module] of Object.entries(chunk.modules)) { + if (!WASM_ID_RE.test(id)) { + continue; + } + // Find the asset and tree shake it. + let assetKey; + for (const [key, value] of Object.entries(assets)) { + if (value.id === id) { + assetKey = key; + break; + } + } + if (!assetKey) { + throw new Error("Could not find asset."); + } + const asset = assets[assetKey]; + const buffer = treeshake( + asset.source, + filterExports(asset.exports, module.removedExports), + opts, + ); + asset.source = buffer; + } } if ( @@ -160,10 +183,16 @@ const unplugin = createUnplugin((opts) => { if (!asset) { return; } - const nestedLevel = - chunk.fileName.split("/").filter(Boolean /* handle // */).length - 1; - const relativeId = - (nestedLevel ? "../".repeat(nestedLevel) : "./") + asset.name; + let relativeId; + if (opts.esmImport) { + const nestedLevel = + chunk.fileName.split("/").filter(Boolean /* handle // */).length - + 1; + relativeId = + (nestedLevel ? "../".repeat(nestedLevel) : "./") + asset.name; + } else { + relativeId = asset.source.toString("base64"); + } return { relativeId, asset, diff --git a/src/plugin/runtime/binding.ts b/src/plugin/runtime/binding.ts index 3b15034..8b80cd8 100644 --- a/src/plugin/runtime/binding.ts +++ b/src/plugin/runtime/binding.ts @@ -34,7 +34,7 @@ export default _mod; ` : /* js */ ` import { base64ToUint8Array } from "${UMWASM_HELPERS_ID}"; -const _data = base64ToUint8Array("${asset.source.toString("base64")}"); +const _data = base64ToUint8Array("${UNWASM_EXTERNAL_PREFIX}${asset.name}"); const _mod = new WebAssembly.Module(_data); export default _mod; `; @@ -60,7 +60,7 @@ import { base64ToUint8Array } from "${UMWASM_HELPERS_ID}"; ${importsCode} function _instantiate(imports = _imports) { - const _data = base64ToUint8Array("${asset.source.toString("base64")}") + const _data = base64ToUint8Array("${UNWASM_EXTERNAL_PREFIX}${asset.name}") return WebAssembly.instantiate(_data, imports) } `; } diff --git a/src/plugin/runtime/treeshake.ts b/src/plugin/runtime/treeshake.ts new file mode 100644 index 0000000..c979ef9 --- /dev/null +++ b/src/plugin/runtime/treeshake.ts @@ -0,0 +1,62 @@ +import fs from "node:fs"; +import tmp from "tmp"; +import which from "which"; +import { execSync } from "node:child_process"; +import { UnwasmPluginOptions } from "../shared"; + +export function treeshake( + source: Buffer, + exports: string[], + opts: UnwasmPluginOptions, +): Buffer { + const wasmMetaDcePath = opts.wasmMetaDCE ?? which.sync("wasm-metadce"); + // Create the wasm-metadce graph. + const graph = [ + { + name: "outside", + reaches: [...exports], + root: true, + }, + ]; + for (const exportName of exports) { + graph.push({ + name: exportName, + export: exportName, + }); + } + let output; + const graphFile = tmp.fileSync({ postfix: ".json" }); + const wasmFile = tmp.fileSync({ postfix: ".wasm" }); + const outputFile = tmp.fileSync({ postfix: ".wasm" }); + try { + fs.writeFileSync(graphFile.name, JSON.stringify(graph)); + fs.writeFileSync(wasmFile.name, source); + execSync( + `${wasmMetaDcePath} ${wasmFile.name} --graph-file ${graphFile.name} -o ${outputFile.name}`, + ); + output = fs.readFileSync(outputFile.name); + } finally { + graphFile.removeCallback(); + wasmFile.removeCallback(); + outputFile.removeCallback(); + } + return output; +} + +/** + * Removes elements from one array that are present in another array. + * + * @param exports - The array of strings to filter. + * @param removedExports - The array of strings to remove from the exports array. + * @returns A new array containing elements from exports with removedExports removed. + */ +export function filterExports(exports: string[], removedExports: string[]) { + // Create a Set from removedExports for efficient lookup. + const removedExportsSet = new Set(removedExports); + const filteredExports = exports.filter((exportItem) => { + // Keep the item if it's NOT in the removedExportsSet. + return !removedExportsSet.has(exportItem); + }); + + return filteredExports; +} diff --git a/src/plugin/shared.ts b/src/plugin/shared.ts index 53fa69e..a74d766 100644 --- a/src/plugin/shared.ts +++ b/src/plugin/shared.ts @@ -16,6 +16,20 @@ export interface UnwasmPluginOptions { * @default false */ lazy?: boolean; + + /** + * Enable treeshaking of Wasm files using Binaryen's wasm-metadce. + * + * @default false + */ + treeshake?: boolean; + + /** + * Path to wasm-metadce binary. Defaults to `which wasm-metadce` from the + * system. + * + */ + wasmMetaDCE?: string; } export type WasmAsset = { diff --git a/test/fixture/treeshake.mjs b/test/fixture/treeshake.mjs new file mode 100644 index 0000000..7202e64 --- /dev/null +++ b/test/fixture/treeshake.mjs @@ -0,0 +1,8 @@ +import { functionOne } from "@fixture/wasm/treeshake.wasm"; + +export function test() { + if (functionOne(1) !== 42) { + return "FAILED: call export"; + } + return "OK"; +} diff --git a/test/node_modules/@fixture/wasm/treeshake.wasm b/test/node_modules/@fixture/wasm/treeshake.wasm new file mode 100644 index 0000000000000000000000000000000000000000..3f202ac374ce2c0daaff5baf11299307800bc3e9 GIT binary patch literal 82 zcmZQbEY4+QU|?WmWlUgTtY>CsVqjpGW#UdN%}XxH%+K@BOJ!hy^Fqq=85p_vnOGPc dwYWJL9N+)l_O+Iqfrlk8F*lWo6{wey0RYKF5>EgC literal 0 HcmV?d00001 diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 715ce49..813fded 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -75,6 +75,30 @@ describe("plugin:rollup", () => { }), ); }); + + it("treeshake", async () => { + const { output } = await _rollupBuild( + "fixture/treeshake.mjs", + "rollup-inline", + { + treeshake: true, + }, + ); + const code = output[0].code; + // Check that the unused export 'functionTwo' is removed from the Wasm + // module. + const wasm = getBase64WasmModule(code); + expect(wasm).toBeTruthy(); + const module = await WebAssembly.compile(Buffer.from(wasm, "base64")); + const exports = WebAssembly.Module.exports(module); + expect(exports.length).toEqual(1); + expect(exports[0].name).toEqual("functionOne"); + // Ensure that it still runs after dce. + const mod = await evalModule(code, { + url: r("fixture/treeshake.mjs"), + }); + expect(mod.test()).toBe("OK"); + }); }); // --- Utils --- @@ -115,3 +139,12 @@ export default { await mf.dispose(); return res; } + +function getBase64WasmModule(code: string) { + const start = code.indexOf('base64ToUint8Array("'); + if (start === -1) { + return false; + } + const end = code.indexOf('")', start); + return code.slice(start + 20, end); +} From 25df4524e01a12e9787e4b16e85d36bceeac5fd8 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Mon, 18 Aug 2025 21:30:44 +0200 Subject: [PATCH 6/8] update --- package.json | 4 +-- pnpm-lock.yaml | 27 ----------------- src/plugin/runtime/treeshake.ts | 53 ++++++++++++++++++++++----------- src/plugin/shared.ts | 10 +++---- 4 files changed, 40 insertions(+), 54 deletions(-) diff --git a/package.json b/package.json index 0242e38..c57cf87 100644 --- a/package.json +++ b/package.json @@ -54,9 +54,7 @@ "mlly": "^1.7.4", "pathe": "^2.0.3", "pkg-types": "^2.2.0", - "tmp": "^0.2.5", - "unplugin": "^2.3.6", - "which": "^5.0.0" + "unplugin": "^2.3.6" }, "devDependencies": { "@prisma/client": "^6.14.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6704e14..2dd9144 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,15 +37,9 @@ importers: pkg-types: specifier: ^2.2.0 version: 2.2.0 - tmp: - specifier: ^0.2.5 - version: 0.2.5 unplugin: specifier: ^2.3.6 version: 2.3.6 - which: - specifier: ^5.0.0 - version: 5.0.0 devDependencies: '@prisma/client': specifier: ^6.14.0 @@ -1606,10 +1600,6 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - isexe@3.1.1: - resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} - engines: {node: '>=16'} - istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -2278,10 +2268,6 @@ packages: resolution: {integrity: sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==} engines: {node: '>=14.0.0'} - tmp@0.2.5: - resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} - engines: {node: '>=14.14'} - to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -2438,11 +2424,6 @@ packages: engines: {node: '>= 8'} hasBin: true - which@5.0.0: - resolution: {integrity: sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==} - engines: {node: ^18.17.0 || >=20.5.0} - hasBin: true - why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -3921,8 +3902,6 @@ snapshots: isexe@2.0.0: {} - isexe@3.1.1: {} - istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -4594,8 +4573,6 @@ snapshots: tinyspy@4.0.3: {} - tmp@0.2.5: {} - to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -4778,10 +4755,6 @@ snapshots: dependencies: isexe: 2.0.0 - which@5.0.0: - dependencies: - isexe: 3.1.1 - why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 diff --git a/src/plugin/runtime/treeshake.ts b/src/plugin/runtime/treeshake.ts index c979ef9..be5135c 100644 --- a/src/plugin/runtime/treeshake.ts +++ b/src/plugin/runtime/treeshake.ts @@ -1,46 +1,63 @@ -import fs from "node:fs"; -import tmp from "tmp"; -import which from "which"; +import fs, { mkdirSync } from "node:fs"; import { execSync } from "node:child_process"; import { UnwasmPluginOptions } from "../shared"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; export function treeshake( source: Buffer, exports: string[], opts: UnwasmPluginOptions, ): Buffer { - const wasmMetaDcePath = opts.wasmMetaDCE ?? which.sync("wasm-metadce"); // Create the wasm-metadce graph. const graph = [ { name: "outside", - reaches: [...exports], root: true, + reaches: [...exports], }, - ]; + ] as { name: string; root?: boolean; reaches?: string[]; export?: string }[]; for (const exportName of exports) { graph.push({ name: exportName, export: exportName, }); } - let output; - const graphFile = tmp.fileSync({ postfix: ".json" }); - const wasmFile = tmp.fileSync({ postfix: ".wasm" }); - const outputFile = tmp.fileSync({ postfix: ".wasm" }); + + // Wasm Meta DCE binary + const wasmMetaDCEBin = opts.commands?.wasmMetaDCE || "wasm-metadce"; + + // Temporary files + const tmpDir = join( + tmpdir(), + "unwasm-" + Math.random().toString(36).slice(2), + ); + const graphFile = join(tmpDir, "graph.json"); + const wasmFile = join(tmpDir, "input.wasm"); + const outputFile = join(tmpDir, "output.wasm"); + mkdirSync(tmpDir, { recursive: true }); + fs.writeFileSync(graphFile, JSON.stringify(graph)); + fs.writeFileSync(wasmFile, source); + + // Execute the wasm-metadce command try { - fs.writeFileSync(graphFile.name, JSON.stringify(graph)); - fs.writeFileSync(wasmFile.name, source); execSync( - `${wasmMetaDcePath} ${wasmFile.name} --graph-file ${graphFile.name} -o ${outputFile.name}`, + `${wasmMetaDCEBin} ${wasmFile} --graph-file ${graphFile} -o ${outputFile}`, + { stdio: "ignore" }, + ); + return fs.readFileSync(outputFile); + } catch (error) { + throw new Error( + [ + error instanceof Error ? error.message : String(error), + `Hint: Make sure "wasm-metadce" (part of binaryen) is installed and on your PATH,`, + `or set commands.wasmMetaDCE option to the full path of the executable.`, + ].join(" "), + { cause: error }, ); - output = fs.readFileSync(outputFile.name); } finally { - graphFile.removeCallback(); - wasmFile.removeCallback(); - outputFile.removeCallback(); + fs.rmSync(tmpDir, { force: true, recursive: true }); } - return output; } /** diff --git a/src/plugin/shared.ts b/src/plugin/shared.ts index a74d766..1c876f4 100644 --- a/src/plugin/shared.ts +++ b/src/plugin/shared.ts @@ -24,12 +24,10 @@ export interface UnwasmPluginOptions { */ treeshake?: boolean; - /** - * Path to wasm-metadce binary. Defaults to `which wasm-metadce` from the - * system. - * - */ - wasmMetaDCE?: string; + commands?: { + /** Path to wasm-metadce binary. Defaults to `wasm-metadce` from the system. */ + wasmMetaDCE?: string; + }; } export type WasmAsset = { From 78f7dd3e45699947726f709e30efd418172925fe Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Mon, 18 Aug 2025 21:37:31 +0200 Subject: [PATCH 7/8] fix ci --- .github/workflows/ci.yml | 1 + test/plugin.test.ts | 12 +++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef173c4..5f55342 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,7 @@ jobs: - run: corepack enable if: ${{ matrix.os == 'ubuntu-latest' }} - run: sudo apt install -y binaryen + if: ${{ matrix.os == 'ubuntu-latest' }} - uses: actions/setup-node@v4 with: node-version: 22 diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 813fded..2938e45 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -6,6 +6,7 @@ import { evalModule } from "mlly"; import { nodeResolve as rollupNodeResolve } from "@rollup/plugin-node-resolve"; import { rollup } from "rollup"; import { UnwasmPluginOptions, rollup as unwasmRollup } from "../src/plugin"; +import { execSync } from "node:child_process"; const r = (p: string) => fileURLToPath(new URL(p, import.meta.url)); @@ -76,7 +77,16 @@ describe("plugin:rollup", () => { ); }); - it("treeshake", async () => { + // const hasBin = + let wasmMetaDCEBinExists: boolean; + try { + execSync("wasm-metadce --version"); + wasmMetaDCEBinExists = true; + } catch { + wasmMetaDCEBinExists = false; + } + + it.skipIf(!wasmMetaDCEBinExists)("treeshake", async () => { const { output } = await _rollupBuild( "fixture/treeshake.mjs", "rollup-inline", From f0363e4529551c0fd20966c50ea16d7005c52592 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Tue, 19 Aug 2025 08:32:58 +0200 Subject: [PATCH 8/8] fix issue in ci --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f55342..709f514 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,6 @@ jobs: - uses: actions/checkout@v5 - run: npm i -g --force corepack - run: corepack enable - if: ${{ matrix.os == 'ubuntu-latest' }} - run: sudo apt install -y binaryen if: ${{ matrix.os == 'ubuntu-latest' }} - uses: actions/setup-node@v4