Skip to content

Commit ba0d9b8

Browse files
committed
esm: js-string Wasm builtins in ESM Integration
PR-URL: nodejs#59020 Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com> Reviewed-By: James M Snell <jasnell@gmail.com> Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
1 parent f981495 commit ba0d9b8

15 files changed

+396
-1
lines changed

doc/api/esm.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -663,6 +663,74 @@ node --experimental-wasm-modules index.mjs
663663
664664
would provide the exports interface for the instantiation of `module.wasm`.
665665
666+
### JavaScript String Builtins
667+
668+
<!-- YAML
669+
added: REPLACEME
670+
-->
671+
672+
When importing WebAssembly modules, the
673+
[WebAssembly JS String Builtins Proposal][] is automatically enabled through the
674+
ESM Integration. This allows WebAssembly modules to directly use efficient
675+
compile-time string builtins from the `wasm:js-string` namespace.
676+
677+
For example, the following Wasm module exports a string `getLength` function using
678+
the `wasm:js-string` `length` builtin:
679+
680+
```text
681+
(module
682+
;; Compile-time import of the string length builtin.
683+
(import "wasm:js-string" "length" (func $string_length (param externref) (result i32)))
684+
685+
;; Define getLength, taking a JS value parameter assumed to be a string,
686+
;; calling string length on it and returning the result.
687+
(func $getLength (param $str externref) (result i32)
688+
local.get $str
689+
call $string_length
690+
)
691+
692+
;; Export the getLength function.
693+
(export "getLength" (func $get_length))
694+
)
695+
```
696+
697+
```js
698+
import { getLength } from './string-len.wasm';
699+
getLength('foo'); // Returns 3.
700+
```
701+
702+
Wasm builtins are compile-time imports that are linked during module compilation
703+
rather than during instantiation. They do not behave like normal module graph
704+
imports and they cannot be inspected via `WebAssembly.Module.imports(mod)`
705+
or virtualized unless recompiling the module using the direct
706+
`WebAssembly.compile` API with string builtins disabled.
707+
708+
Importing a module in the source phase before it has been instantiated will also
709+
use the compile-time builtins automatically:
710+
711+
```js
712+
import source mod from './string-len.wasm';
713+
const { exports: { getLength } } = await WebAssembly.instantiate(mod, {});
714+
getLength('foo'); // Also returns 3.
715+
```
716+
717+
### Reserved Wasm Namespaces
718+
719+
<!-- YAML
720+
added: REPLACEME
721+
-->
722+
723+
When importing WebAssembly modules through the ESM Integration, they cannot use
724+
import module names or import/export names that start with reserved prefixes:
725+
726+
* `wasm-js:` - reserved in all module import names, module names and export
727+
names.
728+
* `wasm:` - reserved in module import names and export names (imported module
729+
names are allowed in order to support future builtin polyfills).
730+
731+
Importing a module using the above reserved names will throw a
732+
`WebAssembly.LinkError`.
733+
666734
<i id="esm_experimental_top_level_await"></i>
667735
668736
## Top-level `await`
@@ -1101,6 +1169,7 @@ resolution for ESM specifiers is [commonjs-extension-resolution-loader][].
11011169
[Node.js Module Resolution And Loading Algorithm]: #resolution-algorithm-specification
11021170
[Terminology]: #terminology
11031171
[URL]: https://url.spec.whatwg.org/
1172+
[WebAssembly JS String Builtins Proposal]: https://github.com/WebAssembly/js-string-builtins
11041173
[`"exports"`]: packages.md#exports
11051174
[`"type"`]: packages.md#type
11061175
[`--experimental-default-type`]: cli.md#--experimental-default-typetype

lib/internal/modules/esm/translators.js

Lines changed: 169 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
const {
44
ArrayPrototypePush,
5+
BigInt,
56
Boolean,
67
FunctionPrototypeCall,
78
JSONParse,
@@ -12,13 +13,17 @@ const {
1213
SafeMap,
1314
SafeSet,
1415
SafeWeakMap,
16+
StringFromCharCode,
17+
StringFromCodePoint,
1518
StringPrototypeIncludes,
1619
StringPrototypeReplaceAll,
1720
StringPrototypeSlice,
1821
StringPrototypeStartsWith,
1922
globalThis: { WebAssembly },
2023
} = primordials;
2124

25+
const { Buffer: { from: BufferFrom } } = require('buffer');
26+
2227
const {
2328
compileFunctionForCJSLoader,
2429
} = internalBinding('contextify');
@@ -455,6 +460,17 @@ translators.set('wasm', async function(url, source) {
455460
if (impt.kind === 'global') {
456461
ArrayPrototypePush(wasmGlobalImports, impt);
457462
}
463+
// Prefix reservations per https://webassembly.github.io/esm-integration/js-api/index.html#parse-a-webassembly-module.
464+
if (impt.module.startsWith('wasm-js:')) {
465+
throw new WebAssembly.LinkError(`Invalid Wasm import "${impt.module}" in ${url}`);
466+
}
467+
// wasm:js-string polyfill is being applied
468+
if (impt.module === 'wasm:js-string') {
469+
continue;
470+
}
471+
if (impt.name.startsWith('wasm:') || impt.name.startsWith('wasm-js:')) {
472+
throw new WebAssembly.LinkError(`Invalid Wasm import name "${impt.module}" in ${url}`);
473+
}
458474
importsList.add(impt.module);
459475
}
460476

@@ -464,6 +480,9 @@ translators.set('wasm', async function(url, source) {
464480
if (expt.kind === 'global') {
465481
wasmGlobalExports.add(expt.name);
466482
}
483+
if (expt.name.startsWith('wasm:') || expt.name.startsWith('wasm-js:')) {
484+
throw new WebAssembly.LinkError(`Invalid Wasm export name "${expt.name}" in ${url}`);
485+
}
467486
exportsList.add(expt.name);
468487
}
469488

@@ -486,9 +505,14 @@ translators.set('wasm', async function(url, source) {
486505
reflect.imports[impt] = wrappedModule;
487506
}
488507
}
508+
489509
// In cycles importing unexecuted Wasm, wasmInstance will be undefined, which will fail during
490510
// instantiation, since all bindings will be in the Temporal Deadzone (TDZ).
491-
const { exports } = new WebAssembly.Instance(compiled, reflect.imports);
511+
const { exports } = new WebAssembly.Instance(compiled, {
512+
...reflect.imports,
513+
// Provide a polyfill for js string builtins
514+
'wasm:js-string': wasmJSStringBuiltinsPolyfill,
515+
});
492516
wasmInstances.set(module.getNamespace(), exports);
493517
for (const expt of exportsList) {
494518
let val = exports[expt];
@@ -524,3 +548,147 @@ translators.set('module-typescript', function(url, source) {
524548
debug(`Translating TypeScript ${url}`);
525549
return FunctionPrototypeCall(translators.get('module'), this, url, code, false);
526550
});
551+
552+
// Helper binary:
553+
// (module
554+
// (type $array_i16 (array (mut i16)))
555+
// (func $createArrayMutI16 (param $size i32) (result anyref)
556+
// (local.get $size)
557+
// (array.new_default $array_i16)
558+
// )
559+
// (func $arrayLength (param $arr arrayref) (result i32)
560+
// (local.get $arr)
561+
// (array.len)
562+
// )
563+
// (func $arraySet (param $arr (ref null $array_i16)) (param $index i32) (param $value i32)
564+
// (local.get $arr)
565+
// (local.get $index)
566+
// (local.get $value)
567+
// (array.set $array_i16)
568+
// )
569+
// (func $arrayGet (param $arr (ref null $array_i16)) (param $index i32) (result i32)
570+
// (local.get $arr)
571+
// (local.get $index)
572+
// (array.get_u $array_i16)
573+
// )
574+
// (export "createArrayMutI16" (func $createArrayMutI16))
575+
// (export "arrayLength" (func $arrayLength))
576+
// (export "arraySet" (func $arraySet))
577+
// (export "arrayGet" (func $arrayGet))
578+
// )
579+
let helperExports;
580+
function loadHelperBinary() {
581+
if (!helperExports) {
582+
const module = new WebAssembly.Module(BufferFrom('AGFzbQEAAAABHAVedwFgAX8BbmABagF/YANjAH9/AGACYwB/AX8DBQQBAgMEBz' +
583+
'kEEWNyZWF0ZUFycmF5TXV0STE2AAALYXJyYXlMZW5ndGgAAQhhcnJheVNldAACCGFycmF5R2V0AAMKJgQHACAA+wcACwYAIAD7DwsLACAAIAE' +
584+
'gAvsOAAsJACAAIAH7DQALAH8EbmFtZQE1BAARY3JlYXRlQXJyYXlNdXRJMTYBC2FycmF5TGVuZ3RoAghhcnJheVNldAMIYXJyYXlHZXQCMwQA' +
585+
'AQAEc2l6ZQEBAANhcnICAwADYXJyAQVpbmRleAIFdmFsdWUDAgADYXJyAQVpbmRleAQMAQAJYXJyYXlfaTE2', 'base64'));
586+
({ exports: helperExports } = new WebAssembly.Instance(module));
587+
}
588+
}
589+
590+
function throwIfNotString(a) {
591+
if (typeof a !== 'string') {
592+
throw new WebAssembly.RuntimeError();
593+
}
594+
}
595+
596+
const wasmJSStringBuiltinsPolyfill = {
597+
test: (string) => {
598+
if (string === null || typeof string !== 'string') {
599+
return 0;
600+
}
601+
return 1;
602+
},
603+
cast: (string) => {
604+
throwIfNotString(string);
605+
return string;
606+
},
607+
fromCharCodeArray: (array, arrayStart, arrayCount) => {
608+
loadHelperBinary();
609+
arrayStart >>>= 0;
610+
arrayCount >>>= 0;
611+
const length = helperExports.arrayLength(array);
612+
if (BigInt(arrayStart) + BigInt(arrayCount) > BigInt(length)) {
613+
throw new WebAssembly.RuntimeError();
614+
}
615+
let result = '';
616+
for (let i = arrayStart; i < arrayStart + arrayCount; i++) {
617+
result += StringFromCharCode(helperExports.arrayGet(array, i));
618+
}
619+
return result;
620+
},
621+
intoCharCodeArray: (string, arr, arrayStart) => {
622+
loadHelperBinary();
623+
arrayStart >>>= 0;
624+
throwIfNotString(string);
625+
const arrLength = helperExports.arrayLength(arr);
626+
const stringLength = string.length;
627+
if (BigInt(arrayStart) + BigInt(stringLength) > BigInt(arrLength)) {
628+
throw new WebAssembly.RuntimeError();
629+
}
630+
for (let i = 0; i < stringLength; i++) {
631+
helperExports.arraySet(arr, arrayStart + i, string[i].charCodeAt(0));
632+
}
633+
return stringLength;
634+
},
635+
fromCharCode: (charCode) => {
636+
charCode >>>= 0;
637+
return StringFromCharCode(charCode);
638+
},
639+
fromCodePoint: (codePoint) => {
640+
codePoint >>>= 0;
641+
return StringFromCodePoint(codePoint);
642+
},
643+
charCodeAt: (string, stringIndex) => {
644+
stringIndex >>>= 0;
645+
throwIfNotString(string);
646+
if (stringIndex >= string.length) {
647+
throw new WebAssembly.RuntimeError();
648+
}
649+
return string.charCodeAt(stringIndex);
650+
},
651+
codePointAt: (string, stringIndex) => {
652+
stringIndex >>>= 0;
653+
throwIfNotString(string);
654+
if (stringIndex >= string.length) {
655+
throw new WebAssembly.RuntimeError();
656+
}
657+
return string.codePointAt(stringIndex);
658+
},
659+
length: (string) => {
660+
throwIfNotString(string);
661+
return string.length;
662+
},
663+
concat: (stringA, stringB) => {
664+
throwIfNotString(stringA);
665+
throwIfNotString(stringB);
666+
return stringA + stringB;
667+
},
668+
substring: (string, startIndex, endIndex) => {
669+
startIndex >>>= 0;
670+
endIndex >>>= 0;
671+
throwIfNotString(string);
672+
if (startIndex > string.length || endIndex > string.length || endIndex < startIndex) {
673+
return '';
674+
}
675+
return string.substring(startIndex, endIndex);
676+
},
677+
equals: (stringA, stringB) => {
678+
if (stringA !== null) {
679+
throwIfNotString(stringA);
680+
}
681+
if (stringB !== null) {
682+
throwIfNotString(stringB);
683+
}
684+
return stringA === stringB;
685+
},
686+
compare: (stringA, stringB) => {
687+
throwIfNotString(stringA);
688+
throwIfNotString(stringB);
689+
if (stringA < stringB) {
690+
return -1;
691+
}
692+
return stringA === stringB ? 0 : 1;
693+
},
694+
};

test/es-module/test-esm-wasm.mjs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,4 +410,95 @@ describe('ESM: WASM modules', { concurrency: !process.env.TEST_PARALLEL }, () =>
410410
strictEqual(stdout, '');
411411
notStrictEqual(code, 0);
412412
});
413+
414+
it('should reject wasm: import names', async () => {
415+
const { code, stderr, stdout } = await spawnPromisified(execPath, [
416+
'--no-warnings',
417+
'--experimental-wasm-modules',
418+
'--input-type=module',
419+
'--eval',
420+
`import(${JSON.stringify(fixtures.fileURL('es-modules/invalid-import-name.wasm'))})`,
421+
]);
422+
423+
match(stderr, /Invalid Wasm import name/);
424+
strictEqual(stdout, '');
425+
notStrictEqual(code, 0);
426+
});
427+
428+
it('should reject wasm-js: import names', async () => {
429+
const { code, stderr, stdout } = await spawnPromisified(execPath, [
430+
'--no-warnings',
431+
'--experimental-wasm-modules',
432+
'--input-type=module',
433+
'--eval',
434+
`import(${JSON.stringify(fixtures.fileURL('es-modules/invalid-import-name-wasm-js.wasm'))})`,
435+
]);
436+
437+
match(stderr, /Invalid Wasm import name/);
438+
strictEqual(stdout, '');
439+
notStrictEqual(code, 0);
440+
});
441+
442+
it('should reject wasm-js: import module names', async () => {
443+
const { code, stderr, stdout } = await spawnPromisified(execPath, [
444+
'--no-warnings',
445+
'--experimental-wasm-modules',
446+
'--input-type=module',
447+
'--eval',
448+
`import(${JSON.stringify(fixtures.fileURL('es-modules/invalid-import-module.wasm'))})`,
449+
]);
450+
451+
match(stderr, /Invalid Wasm import/);
452+
strictEqual(stdout, '');
453+
notStrictEqual(code, 0);
454+
});
455+
456+
it('should reject wasm: export names', async () => {
457+
const { code, stderr, stdout } = await spawnPromisified(execPath, [
458+
'--no-warnings',
459+
'--experimental-wasm-modules',
460+
'--input-type=module',
461+
'--eval',
462+
`import(${JSON.stringify(fixtures.fileURL('es-modules/invalid-export-name.wasm'))})`,
463+
]);
464+
465+
match(stderr, /Invalid Wasm export/);
466+
strictEqual(stdout, '');
467+
notStrictEqual(code, 0);
468+
});
469+
470+
it('should reject wasm-js: export names', async () => {
471+
const { code, stderr, stdout } = await spawnPromisified(execPath, [
472+
'--no-warnings',
473+
'--experimental-wasm-modules',
474+
'--input-type=module',
475+
'--eval',
476+
`import(${JSON.stringify(fixtures.fileURL('es-modules/invalid-export-name-wasm-js.wasm'))})`,
477+
]);
478+
479+
match(stderr, /Invalid Wasm export/);
480+
strictEqual(stdout, '');
481+
notStrictEqual(code, 0);
482+
});
483+
484+
it('should support js-string builtins', async () => {
485+
const { code, stderr, stdout } = await spawnPromisified(execPath, [
486+
'--no-warnings',
487+
'--experimental-wasm-modules',
488+
'--input-type=module',
489+
'--eval',
490+
[
491+
'import { strictEqual } from "node:assert";',
492+
`import * as wasmExports from ${JSON.stringify(fixtures.fileURL('es-modules/js-string-builtins.wasm'))};`,
493+
'strictEqual(wasmExports.getLength("hello"), 5);',
494+
'strictEqual(wasmExports.concatStrings("hello", " world"), "hello world");',
495+
'strictEqual(wasmExports.compareStrings("test", "test"), 1);',
496+
'strictEqual(wasmExports.compareStrings("test", "different"), 0);',
497+
].join('\n'),
498+
]);
499+
500+
strictEqual(stderr, '');
501+
strictEqual(stdout, '');
502+
strictEqual(code, 0);
503+
});
413504
});
64 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)