From 35e599b3d0c25442e0b15c4e45a5b9cf59f6489e Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Thu, 10 Jul 2025 06:54:24 -0700 Subject: [PATCH] esm: js-string Wasm builtins in ESM Integration PR-URL: https://github.com/nodejs/node/pull/59020 Reviewed-By: Yagiz Nizipli Reviewed-By: James M Snell Reviewed-By: Chengzhong Wu --- doc/api/esm.md | 69 +++++++++++++ lib/internal/modules/esm/translators.js | 15 ++- test/es-module/test-esm-wasm.mjs | 91 ++++++++++++++++++ .../invalid-export-name-wasm-js.wasm | Bin 0 -> 64 bytes .../invalid-export-name-wasm-js.wat | 7 ++ .../es-modules/invalid-export-name.wasm | Bin 0 -> 61 bytes .../es-modules/invalid-export-name.wat | 7 ++ .../es-modules/invalid-import-module.wasm | Bin 0 -> 94 bytes .../es-modules/invalid-import-module.wat | 8 ++ .../invalid-import-name-wasm-js.wasm | Bin 0 -> 94 bytes .../invalid-import-name-wasm-js.wat | 8 ++ .../es-modules/invalid-import-name.wasm | Bin 0 -> 91 bytes .../es-modules/invalid-import-name.wat | 8 ++ .../es-modules/js-string-builtins.wasm | Bin 0 -> 325 bytes .../es-modules/js-string-builtins.wat | 29 ++++++ 15 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/es-modules/invalid-export-name-wasm-js.wasm create mode 100644 test/fixtures/es-modules/invalid-export-name-wasm-js.wat create mode 100644 test/fixtures/es-modules/invalid-export-name.wasm create mode 100644 test/fixtures/es-modules/invalid-export-name.wat create mode 100644 test/fixtures/es-modules/invalid-import-module.wasm create mode 100644 test/fixtures/es-modules/invalid-import-module.wat create mode 100644 test/fixtures/es-modules/invalid-import-name-wasm-js.wasm create mode 100644 test/fixtures/es-modules/invalid-import-name-wasm-js.wat create mode 100644 test/fixtures/es-modules/invalid-import-name.wasm create mode 100644 test/fixtures/es-modules/invalid-import-name.wat create mode 100644 test/fixtures/es-modules/js-string-builtins.wasm create mode 100644 test/fixtures/es-modules/js-string-builtins.wat diff --git a/doc/api/esm.md b/doc/api/esm.md index d1b2d786b38534..2b7b90545a35e5 100644 --- a/doc/api/esm.md +++ b/doc/api/esm.md @@ -764,6 +764,74 @@ const dynamicLibrary = await import.source('./library.wasm'); const instance = await WebAssembly.instantiate(dynamicLibrary, importObject); ``` +### JavaScript String Builtins + + + +When importing WebAssembly modules, the +[WebAssembly JS String Builtins Proposal][] is automatically enabled through the +ESM Integration. This allows WebAssembly modules to directly use efficient +compile-time string builtins from the `wasm:js-string` namespace. + +For example, the following Wasm module exports a string `getLength` function using +the `wasm:js-string` `length` builtin: + +```text +(module + ;; Compile-time import of the string length builtin. + (import "wasm:js-string" "length" (func $string_length (param externref) (result i32))) + + ;; Define getLength, taking a JS value parameter assumed to be a string, + ;; calling string length on it and returning the result. + (func $getLength (param $str externref) (result i32) + local.get $str + call $string_length + ) + + ;; Export the getLength function. + (export "getLength" (func $get_length)) +) +``` + +```js +import { getLength } from './string-len.wasm'; +getLength('foo'); // Returns 3. +``` + +Wasm builtins are compile-time imports that are linked during module compilation +rather than during instantiation. They do not behave like normal module graph +imports and they cannot be inspected via `WebAssembly.Module.imports(mod)` +or virtualized unless recompiling the module using the direct +`WebAssembly.compile` API with string builtins disabled. + +Importing a module in the source phase before it has been instantiated will also +use the compile-time builtins automatically: + +```js +import source mod from './string-len.wasm'; +const { exports: { getLength } } = await WebAssembly.instantiate(mod, {}); +getLength('foo'); // Also returns 3. +``` + +### Reserved Wasm Namespaces + + + +When importing WebAssembly modules through the ESM Integration, they cannot use +import module names or import/export names that start with reserved prefixes: + +* `wasm-js:` - reserved in all module import names, module names and export + names. +* `wasm:` - reserved in module import names and export names (imported module + names are allowed in order to support future builtin polyfills). + +Importing a module using the above reserved names will throw a +`WebAssembly.LinkError`. + ## Top-level `await` @@ -1206,6 +1274,7 @@ resolution for ESM specifiers is [commonjs-extension-resolution-loader][]. [Source Phase Imports]: https://github.com/tc39/proposal-source-phase-imports [Terminology]: #terminology [URL]: https://url.spec.whatwg.org/ +[WebAssembly JS String Builtins Proposal]: https://github.com/WebAssembly/js-string-builtins [`"exports"`]: packages.md#exports [`"type"`]: packages.md#type [`--input-type`]: cli.md#--input-typetype diff --git a/lib/internal/modules/esm/translators.js b/lib/internal/modules/esm/translators.js index 82c727909e8cc1..e837f2d1ff380b 100644 --- a/lib/internal/modules/esm/translators.js +++ b/lib/internal/modules/esm/translators.js @@ -506,7 +506,10 @@ translators.set('wasm', async function(url, source) { // TODO(joyeecheung): implement a translator that just uses // compiled = new WebAssembly.Module(source) to compile it // synchronously. - compiled = await WebAssembly.compile(source); + compiled = await WebAssembly.compile(source, { + // The ESM Integration auto-enables Wasm JS builtins by default when available. + builtins: ['js-string'], + }); } catch (err) { err.message = errPath(url) + ': ' + err.message; throw err; @@ -518,6 +521,13 @@ translators.set('wasm', async function(url, source) { if (impt.kind === 'global') { ArrayPrototypePush(wasmGlobalImports, impt); } + // Prefix reservations per https://webassembly.github.io/esm-integration/js-api/index.html#parse-a-webassembly-module. + if (impt.module.startsWith('wasm-js:')) { + throw new WebAssembly.LinkError(`Invalid Wasm import "${impt.module}" in ${url}`); + } + if (impt.name.startsWith('wasm:') || impt.name.startsWith('wasm-js:')) { + throw new WebAssembly.LinkError(`Invalid Wasm import name "${impt.module}" in ${url}`); + } importsList.add(impt.module); } @@ -527,6 +537,9 @@ translators.set('wasm', async function(url, source) { if (expt.kind === 'global') { wasmGlobalExports.add(expt.name); } + if (expt.name.startsWith('wasm:') || expt.name.startsWith('wasm-js:')) { + throw new WebAssembly.LinkError(`Invalid Wasm export name "${expt.name}" in ${url}`); + } exportsList.add(expt.name); } diff --git a/test/es-module/test-esm-wasm.mjs b/test/es-module/test-esm-wasm.mjs index 5a3101fc7594f6..86aa347c357551 100644 --- a/test/es-module/test-esm-wasm.mjs +++ b/test/es-module/test-esm-wasm.mjs @@ -403,4 +403,95 @@ describe('ESM: WASM modules', { concurrency: !process.env.TEST_PARALLEL }, () => strictEqual(stdout, ''); notStrictEqual(code, 0); }); + + it('should reject wasm: import names', async () => { + const { code, stderr, stdout } = await spawnPromisified(execPath, [ + '--no-warnings', + '--experimental-wasm-modules', + '--input-type=module', + '--eval', + `import(${JSON.stringify(fixtures.fileURL('es-modules/invalid-import-name.wasm'))})`, + ]); + + match(stderr, /Invalid Wasm import name/); + strictEqual(stdout, ''); + notStrictEqual(code, 0); + }); + + it('should reject wasm-js: import names', async () => { + const { code, stderr, stdout } = await spawnPromisified(execPath, [ + '--no-warnings', + '--experimental-wasm-modules', + '--input-type=module', + '--eval', + `import(${JSON.stringify(fixtures.fileURL('es-modules/invalid-import-name-wasm-js.wasm'))})`, + ]); + + match(stderr, /Invalid Wasm import name/); + strictEqual(stdout, ''); + notStrictEqual(code, 0); + }); + + it('should reject wasm-js: import module names', async () => { + const { code, stderr, stdout } = await spawnPromisified(execPath, [ + '--no-warnings', + '--experimental-wasm-modules', + '--input-type=module', + '--eval', + `import(${JSON.stringify(fixtures.fileURL('es-modules/invalid-import-module.wasm'))})`, + ]); + + match(stderr, /Invalid Wasm import/); + strictEqual(stdout, ''); + notStrictEqual(code, 0); + }); + + it('should reject wasm: export names', async () => { + const { code, stderr, stdout } = await spawnPromisified(execPath, [ + '--no-warnings', + '--experimental-wasm-modules', + '--input-type=module', + '--eval', + `import(${JSON.stringify(fixtures.fileURL('es-modules/invalid-export-name.wasm'))})`, + ]); + + match(stderr, /Invalid Wasm export/); + strictEqual(stdout, ''); + notStrictEqual(code, 0); + }); + + it('should reject wasm-js: export names', async () => { + const { code, stderr, stdout } = await spawnPromisified(execPath, [ + '--no-warnings', + '--experimental-wasm-modules', + '--input-type=module', + '--eval', + `import(${JSON.stringify(fixtures.fileURL('es-modules/invalid-export-name-wasm-js.wasm'))})`, + ]); + + match(stderr, /Invalid Wasm export/); + strictEqual(stdout, ''); + notStrictEqual(code, 0); + }); + + it('should support js-string builtins', async () => { + const { code, stderr, stdout } = await spawnPromisified(execPath, [ + '--no-warnings', + '--experimental-wasm-modules', + '--input-type=module', + '--eval', + [ + 'import { strictEqual } from "node:assert";', + `import * as wasmExports from ${JSON.stringify(fixtures.fileURL('es-modules/js-string-builtins.wasm'))};`, + 'strictEqual(wasmExports.getLength("hello"), 5);', + 'strictEqual(wasmExports.concatStrings("hello", " world"), "hello world");', + 'strictEqual(wasmExports.compareStrings("test", "test"), 1);', + 'strictEqual(wasmExports.compareStrings("test", "different"), 0);', + ].join('\n'), + ]); + + strictEqual(stderr, ''); + strictEqual(stdout, ''); + strictEqual(code, 0); + }); }); diff --git a/test/fixtures/es-modules/invalid-export-name-wasm-js.wasm b/test/fixtures/es-modules/invalid-export-name-wasm-js.wasm new file mode 100644 index 0000000000000000000000000000000000000000..a6b9a7f7c5ad57d8347003d77940f6ec99dadf26 GIT binary patch literal 64 zcmZQbEY4+QU|?WmWlUgTtY>CoWMCI&t+>OW#*M7=47TYFmSOkvM@MmaWn9- SCoWMCIy{zCFW$NFfeejF|sf?YH>60vE(J@ PrZTcKGO(1S7MB13J$4H? literal 0 HcmV?d00001 diff --git a/test/fixtures/es-modules/invalid-export-name.wat b/test/fixtures/es-modules/invalid-export-name.wat new file mode 100644 index 00000000000000..ef99fef9cfa52f --- /dev/null +++ b/test/fixtures/es-modules/invalid-export-name.wat @@ -0,0 +1,7 @@ +;; Test WASM module with invalid export name starting with 'wasm:' +(module + (func $test (result i32) + i32.const 42 + ) + (export "wasm:invalid" (func $test)) +) \ No newline at end of file diff --git a/test/fixtures/es-modules/invalid-import-module.wasm b/test/fixtures/es-modules/invalid-import-module.wasm new file mode 100644 index 0000000000000000000000000000000000000000..ead151ac0c84ad5a2504c1f7752a08e14b989a0e GIT binary patch literal 94 zcmZQbEY4+QU|?WmWlUgTtY?y71GvMW#*M7=47U@l%y7yFfcGPF*2}oFhY2Y iTx^Ui3<3{zCFW$NFfcGPF*2}oKx7#h ix!4$47z7x&8Dv@V5_3}-#h4g)p-Me-3-XIfAPNDJ%oP;? literal 0 HcmV?d00001 diff --git a/test/fixtures/es-modules/invalid-import-name-wasm-js.wat b/test/fixtures/es-modules/invalid-import-name-wasm-js.wat new file mode 100644 index 00000000000000..cb4d3eaf162818 --- /dev/null +++ b/test/fixtures/es-modules/invalid-import-name-wasm-js.wat @@ -0,0 +1,8 @@ +;; Test WASM module with invalid import name starting with 'wasm-js:' +(module + (import "test" "wasm-js:invalid" (func $invalidImport (result i32))) + (export "test" (func $test)) + (func $test (result i32) + call $invalidImport + ) +) \ No newline at end of file diff --git a/test/fixtures/es-modules/invalid-import-name.wasm b/test/fixtures/es-modules/invalid-import-name.wasm new file mode 100644 index 0000000000000000000000000000000000000000..3c631418294584fecbe13fc122c28dbab5670525 GIT binary patch literal 91 zcmZQbEY4+QU|?WmWlUgTtY;EsWGP84F5xK$id$vol_ln6rZ6xtGchu-b3mjR7`fOO fSr`NuxEW+w@)C1X8O4|wc%e!?a|`l|N+1dWJZlsM literal 0 HcmV?d00001 diff --git a/test/fixtures/es-modules/invalid-import-name.wat b/test/fixtures/es-modules/invalid-import-name.wat new file mode 100644 index 00000000000000..1aae87aaed4840 --- /dev/null +++ b/test/fixtures/es-modules/invalid-import-name.wat @@ -0,0 +1,8 @@ +;; Test WASM module with invalid import name starting with 'wasm:' +(module + (import "test" "wasm:invalid" (func $invalidImport (result i32))) + (export "test" (func $test)) + (func $test (result i32) + call $invalidImport + ) +) \ No newline at end of file diff --git a/test/fixtures/es-modules/js-string-builtins.wasm b/test/fixtures/es-modules/js-string-builtins.wasm new file mode 100644 index 0000000000000000000000000000000000000000..b4c08587dd08e715fa2a79fead8fc46143d949d2 GIT binary patch literal 325 zcmZ9GF%tnX6omKXe2#F;aBfPR5i{xQH%ZBuiCj+Q#U4x}B$^V12y^ m*0XTp2)>iej}7&6