esm: --experimental-wasm-modules integration support by guybedford · Pull Request #27659 · nodejs/node · GitHub
Skip to content
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
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);

Copy link
Copy Markdown

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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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.

Copy link
Copy Markdown

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
Copy Markdown
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);
Comment thread
guybedford marked this conversation as resolved.
Outdated
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
Comment thread
guybedford marked this conversation as resolved.
Outdated
(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