esm: js-string Wasm builtins in ESM Integration

PR-URL: https://github.com/nodejs/node/pull/59020
Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
This commit is contained in:
Guy Bedford 2025-07-10 06:54:24 -07:00
parent edd66d0130
commit 35e599b3d0
15 changed files with 241 additions and 1 deletions

View file

@ -764,6 +764,74 @@ const dynamicLibrary = await import.source('./library.wasm');
const instance = await WebAssembly.instantiate(dynamicLibrary, importObject);
```
### JavaScript String Builtins
<!-- YAML
added: REPLACEME
-->
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
<!-- YAML
added: REPLACEME
-->
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`.
<i id="esm_experimental_top_level_await"></i>
## 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

View file

@ -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);
}

View file

@ -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);
});
});

Binary file not shown.

View file

@ -0,0 +1,7 @@
;; Test WASM module with invalid export name starting with 'wasm-js:'
(module
(func $test (result i32)
i32.const 42
)
(export "wasm-js:invalid" (func $test))
)

Binary file not shown.

View file

@ -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))
)

Binary file not shown.

View file

@ -0,0 +1,8 @@
;; Test WASM module with invalid import module name starting with 'wasm-js:'
(module
(import "wasm-js:invalid" "test" (func $invalidImport (result i32)))
(export "test" (func $test))
(func $test (result i32)
call $invalidImport
)
)

Binary file not shown.

View file

@ -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
)
)

Binary file not shown.

View file

@ -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
)
)

Binary file not shown.

View file

@ -0,0 +1,29 @@
;; Test WASM module using js-string builtins
(module
;; Import js-string builtins with correct signatures
(import "wasm:js-string" "length" (func $string_length (param externref) (result i32)))
(import "wasm:js-string" "concat" (func $string_concat (param externref externref) (result (ref extern))))
(import "wasm:js-string" "equals" (func $string_equals (param externref externref) (result i32)))
;; Export functions that use the builtins
(export "getLength" (func $get_length))
(export "concatStrings" (func $concat_strings))
(export "compareStrings" (func $compare_strings))
(func $get_length (param $str externref) (result i32)
local.get $str
call $string_length
)
(func $concat_strings (param $str1 externref) (param $str2 externref) (result (ref extern))
local.get $str1
local.get $str2
call $string_concat
)
(func $compare_strings (param $str1 externref) (param $str2 externref) (result i32)
local.get $str1
local.get $str2
call $string_equals
)
)