Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

esm: --experimental-wasm-modules integration support #27659

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 31 additions & 5 deletions doc/api/esm.md
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,30 @@ node --experimental-modules index.mjs # fails
node --experimental-modules --experimental-json-modules index.mjs # works
```

## Experimental Wasm Modules

Importing Web Assembly modules is supported under the
`--experimental-wasm-modules` flag, allowing any `.wasm` files to be
imported as normal modules while also supporting their module imports.

This integration is in line with the
[ES Module Integration Proposal for Web Assembly][].

For example, an `index.mjs` containing:

```js
import * as M from './module.wasm';
console.log(M);
```

executed under:

```bash
node --experimental-modules --experimental-wasm-modules index.mjs
```

would provide the exports interface for the instantiation of `module.wasm`.

## Experimental Loader hooks

**Note: This API is currently being redesigned and will still change.**
Expand Down Expand Up @@ -484,11 +508,12 @@ module. This can be one of the following:

| `format` | Description |
| --- | --- |
| `'module'` | Load a standard JavaScript module |
| `'commonjs'` | Load a Node.js CommonJS module |
| `'builtin'` | Load a Node.js builtin module |
| `'json'` | Load a JSON file |
| `'commonjs'` | Load a Node.js CommonJS module |
| `'dynamic'` | Use a [dynamic instantiate hook][] |
| `'json'` | Load a JSON file |
| `'module'` | Load a standard JavaScript module |
| `'wasm'` | Load a WebAssembly module |

For example, a dummy loader to load JavaScript restricted to browser resolution
rules with only JS file extension and Node.js builtin modules support could
Expand Down Expand Up @@ -585,8 +610,8 @@ format for that resolved URL given by the **ESM_FORMAT** routine.

The _"module"_ format is returned for an ECMAScript Module, while the
_"commonjs"_ format is used to indicate loading through the legacy
CommonJS loader. Additional formats such as _"wasm"_ or _"addon"_ can be
extended in future updates.
CommonJS loader. Additional formats such as _"addon"_ can be extended in future
updates.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's one additional mention of the non-existent --entry-type CLI flag 7 lines below this one. Is this the right PR to remove or update that material?


In the following algorithms, all subroutine errors are propagated as errors
of these top-level routines.
Expand Down Expand Up @@ -739,5 +764,6 @@ success!
[ECMAScript-modules implementation]: https://github.com/nodejs/modules/blob/master/doc/plan-for-new-modules-implementation.md
[Node.js EP for ES Modules]: https://github.com/nodejs/node-eps/blob/master/002-es-modules.md
[WHATWG JSON modules]: https://github.com/whatwg/html/issues/4315
[ES Module Integration Proposal for Web Assembly]: https://github.com/webassembly/esm-integration
[dynamic instantiate hook]: #esm_dynamic_instantiate_hook
[the official standard format]: https://tc39.github.io/ecma262/#sec-modules
2 changes: 1 addition & 1 deletion lib/internal/modules/cjs/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -658,7 +658,7 @@ Module.prototype.load = function(filename) {
url,
new ModuleJob(ESMLoader, url, async () => {
return createDynamicModule(
['default'], url, (reflect) => {
[], ['default'], url, (reflect) => {
reflect.exports.default.set(exports);
});
})
Expand Down
18 changes: 12 additions & 6 deletions lib/internal/modules/esm/create_dynamic_module.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
'use strict';

const { ArrayPrototype } = primordials;
const { ArrayPrototype, JSON, Object } = primordials;

const debug = require('internal/util/debuglog').debuglog('esm');

const createDynamicModule = (exports, url = '', evaluate) => {
const createDynamicModule = (imports, exports, url = '', evaluate) => {
debug('creating ESM facade for %s with exports: %j', url, exports);
const names = ArrayPrototype.map(exports, (name) => `${name}`);

const source = `
${ArrayPrototype.join(ArrayPrototype.map(imports, (impt, index) =>
`import * as $import_${index} from ${JSON.stringify(impt)};
import.meta.imports[${JSON.stringify(impt)}] = $import_${index};`), '\n')
}
${ArrayPrototype.join(ArrayPrototype.map(names, (name) =>
`let $${name};
export { $${name} as ${name} };
Expand All @@ -22,19 +26,21 @@ import.meta.done();
`;
const { ModuleWrap, callbackMap } = internalBinding('module_wrap');
const m = new ModuleWrap(source, `${url}`);
m.link(() => 0);
m.instantiate();

const readyfns = new Set();
const reflect = {
namespace: m.namespace(),
exports: {},
exports: Object.create(null),
onReady: (cb) => { readyfns.add(cb); },
};

if (imports.length)
reflect.imports = Object.create(null);

callbackMap.set(m, {
initializeImportMeta: (meta, wrap) => {
meta.exports = reflect.exports;
if (reflect.imports)
meta.imports = reflect.imports;
meta.done = () => {
evaluate(reflect);
reflect.onReady = (cb) => cb(reflect);
Expand Down
21 changes: 7 additions & 14 deletions lib/internal/modules/esm/default_resolve.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,14 @@ const preserveSymlinks = getOptionValue('--preserve-symlinks');
const preserveSymlinksMain = getOptionValue('--preserve-symlinks-main');
const experimentalJsonModules = getOptionValue('--experimental-json-modules');
const typeFlag = getOptionValue('--input-type');

const experimentalWasmModules = getOptionValue('--experimental-wasm-modules');
const { resolve: moduleWrapResolve,
getPackageType } = internalBinding('module_wrap');
const { pathToFileURL, fileURLToPath } = require('internal/url');
const { ERR_INPUT_TYPE_NOT_ALLOWED,
ERR_UNKNOWN_FILE_EXTENSION } = require('internal/errors').codes;

const {
Object,
SafeMap
} = primordials;
const { SafeMap } = primordials;

const realpathCache = new SafeMap();

Expand All @@ -44,15 +41,11 @@ const legacyExtensionFormatMap = {
'.node': 'commonjs'
};

if (experimentalJsonModules) {
// This is a total hack
Object.assign(extensionFormatMap, {
'.json': 'json'
});
Object.assign(legacyExtensionFormatMap, {
'.json': 'json'
});
}
if (experimentalWasmModules)
extensionFormatMap['.wasm'] = legacyExtensionFormatMap['.wasm'] = 'wasm';

if (experimentalJsonModules)
extensionFormatMap['.json'] = legacyExtensionFormatMap['.json'] = 'json';

function resolve(specifier, parentURL) {
if (NativeModule.canBeRequiredByUsers(specifier)) {
Expand Down
2 changes: 1 addition & 1 deletion lib/internal/modules/esm/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ class Loader {
loaderInstance = async (url) => {
debug(`Translating dynamic ${url}`);
const { exports, execute } = await this._dynamicInstantiate(url);
return createDynamicModule(exports, url, (reflect) => {
return createDynamicModule([], exports, url, (reflect) => {
debug(`Loading dynamic ${url}`);
execute(reflect.exports);
});
Expand Down
41 changes: 34 additions & 7 deletions lib/internal/modules/esm/translators.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
'use strict';

/* global WebAssembly */

const {
JSON,
Object,
SafeMap,
StringPrototype,
JSON
StringPrototype
} = primordials;

const { NativeModule } = require('internal/bootstrap/loaders');
Expand Down Expand Up @@ -72,11 +75,11 @@ translators.set('commonjs', async function commonjsStrategy(url, isMain) {
];
if (module && module.loaded) {
const exports = module.exports;
return createDynamicModule(['default'], url, (reflect) => {
return createDynamicModule([], ['default'], url, (reflect) => {
reflect.exports.default.set(exports);
});
}
return createDynamicModule(['default'], url, () => {
return createDynamicModule([], ['default'], url, () => {
debug(`Loading CJSModule ${url}`);
// We don't care about the return val of _load here because Module#load
// will handle it for us by checking the loader registry and filling the
Expand All @@ -97,7 +100,7 @@ translators.set('builtin', async function builtinStrategy(url) {
}
module.compileForPublicLoader(true);
return createDynamicModule(
[...module.exportKeys, 'default'], url, (reflect) => {
[], [...module.exportKeys, 'default'], url, (reflect) => {
debug(`Loading BuiltinModule ${url}`);
module.reflect = reflect;
for (const key of module.exportKeys)
Expand All @@ -116,7 +119,7 @@ translators.set('json', async function jsonStrategy(url) {
let module = CJSModule._cache[modulePath];
if (module && module.loaded) {
const exports = module.exports;
return createDynamicModule(['default'], url, (reflect) => {
return createDynamicModule([], ['default'], url, (reflect) => {
reflect.exports.default.set(exports);
});
}
Expand All @@ -136,8 +139,32 @@ translators.set('json', async function jsonStrategy(url) {
throw err;
}
CJSModule._cache[modulePath] = module;
return createDynamicModule(['default'], url, (reflect) => {
return createDynamicModule([], ['default'], url, (reflect) => {
debug(`Parsing JSONModule ${url}`);
reflect.exports.default.set(module.exports);
});
});

// Strategy for loading a wasm module
translators.set('wasm', async function(url) {
const pathname = fileURLToPath(url);
const buffer = await readFileAsync(pathname);
debug(`Translating WASMModule ${url}`);
let compiled;
try {
compiled = await WebAssembly.compile(buffer);
} catch (err) {
err.message = pathname + ': ' + err.message;
throw err;
}

const imports =
WebAssembly.Module.imports(compiled).map(({ module }) => module);
const exports = WebAssembly.Module.exports(compiled).map(({ name }) => name);

return createDynamicModule(imports, exports, url, (reflect) => {
const { exports } = new WebAssembly.Instance(compiled, reflect.imports);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In a cycle-closing edge, will reflect.imports usually be an empty object? If so, this will be basically the right semantics.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the unexecuted cycle edge, reflect.imports would contain all the imported module namespaces, with their named exports, but all of those named exports would be undefined. Function exports would be supported in cycles here though just like ES modules.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually cycles would throw for any accesses to uninitialized let bindings on the namespaces, since these would be in TDZ.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To summarize:

  • Unexecuted cycle edge with function exports: fine - function is defined
  • Unexecuted cycle edge with let binding: throws - TDZ error

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And specifically these errors are only thrown if the WASM module actually attempts to access the given binding.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, so in particular, Wasm exports should always act like const bindings, and all imports are accessed during Instantiate (in the ESM evaluate phase). Does this implementation do that?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep! reflect.imports is namespace objects so the imports are accessed directly.

for (const expt of Object.keys(exports))
reflect.exports[expt].set(exports[expt]);
});
});
9 changes: 9 additions & 0 deletions src/node_options.cc
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,11 @@ void EnvironmentOptions::CheckOptions(std::vector<std::string>* errors) {
"--experimental-modules be enabled");
}

if (experimental_wasm_modules && !experimental_modules) {
errors->push_back("--experimental-wasm-modules requires "
"--experimental-modules be enabled");
}

if (!es_module_specifier_resolution.empty()) {
if (!experimental_modules) {
errors->push_back("--es-module-specifier-resolution requires "
Expand Down Expand Up @@ -274,6 +279,10 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
"experimental ES Module support and caching modules",
&EnvironmentOptions::experimental_modules,
kAllowedInEnvironment);
AddOption("--experimental-wasm-modules",
"experimental ES Module support for webassembly modules",
&EnvironmentOptions::experimental_wasm_modules,
kAllowedInEnvironment);
guybedford marked this conversation as resolved.
Show resolved Hide resolved
AddOption("--experimental-policy",
"use the specified file as a "
"security policy",
Expand Down
1 change: 1 addition & 0 deletions src/node_options.h
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ class EnvironmentOptions : public Options {
bool experimental_json_modules = false;
bool experimental_modules = false;
std::string es_module_specifier_resolution;
bool experimental_wasm_modules = false;
std::string module_type;
std::string experimental_policy;
bool experimental_repl_await = false;
Expand Down
15 changes: 15 additions & 0 deletions test/es-module/test-esm-wasm.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Flags: --experimental-modules --experimental-wasm-modules
import '../common/index.mjs';
import { add, addImported } from '../fixtures/es-modules/simple.wasm';
import { state } from '../fixtures/es-modules/wasm-dep.mjs';
import { strictEqual } from 'assert';

strictEqual(state, 'WASM Start Executed');

strictEqual(add(10, 20), 30);

strictEqual(addImported(0), 42);

strictEqual(state, 'WASM JS Function Executed');

strictEqual(addImported(1), 43);
Binary file added test/fixtures/es-modules/simple.wasm
Binary file not shown.
23 changes: 23 additions & 0 deletions test/fixtures/es-modules/simple.wat
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
;; Compiled using the WebAssembly Tootkit (https://github.com/WebAssembly/wabt)
;; $ wat2wasm simple.wat -o simple.wasm

(module
guybedford marked this conversation as resolved.
Show resolved Hide resolved
(import "./wasm-dep.mjs" "jsFn" (func $jsFn (result i32)))
(import "./wasm-dep.mjs" "jsInitFn" (func $jsInitFn))
(export "add" (func $add))
(export "addImported" (func $addImported))
(start $startFn)
(func $startFn
call $jsInitFn
)
(func $add (param $a i32) (param $b i32) (result i32)
local.get $a
local.get $b
i32.add
)
(func $addImported (param $a i32) (result i32)
local.get $a
call $jsFn
i32.add
)
)
13 changes: 13 additions & 0 deletions test/fixtures/es-modules/wasm-dep.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { strictEqual } from 'assert';

export function jsFn () {
state = 'WASM JS Function Executed';
return 42;
}

export let state = 'JS Function Executed';

export function jsInitFn () {
strictEqual(state, 'JS Function Executed');
state = 'WASM Start Executed';
}