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: add experimental support for addon modules #55844

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
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
19 changes: 17 additions & 2 deletions doc/api/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,11 @@ Otherwise, the file is loaded using the CommonJS module loader. See

When loading, the [ES module loader][Modules loaders] loads the program
entry point, the `node` command will accept as input only files with `.js`,
`.mjs`, or `.cjs` extensions; and with `.wasm` extensions when
[`--experimental-wasm-modules`][] is enabled.
`.mjs`, or `.cjs` extensions. With the following flags, additional file
extensions are enabled:

* [`--experimental-wasm-modules`][] for files with `.wasm` extension.
* [`--experimental-addon-modules`][] for files with `.node` extension.

## Options

Expand Down Expand Up @@ -880,6 +883,16 @@ and `"` are usable.
It is possible to run code containing inline types by passing
[`--experimental-strip-types`][].

### `--experimental-addon-modules`

<!-- YAML
added: REPLACEME
-->

> Stability: 1.0 - Early development
Enable experimental import support for `.node` addons.

### `--experimental-eventsource`

<!-- YAML
Expand Down Expand Up @@ -3043,6 +3056,7 @@ one is included in the list below.
* `--enable-source-maps`
* `--entry-url`
* `--experimental-abortcontroller`
* `--experimental-addon-modules`
* `--experimental-detect-module`
* `--experimental-eventsource`
* `--experimental-import-meta-resolve`
Expand Down Expand Up @@ -3612,6 +3626,7 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
[`--diagnostic-dir`]: #--diagnostic-dirdirectory
[`--env-file-if-exists`]: #--env-file-if-existsconfig
[`--env-file`]: #--env-fileconfig
[`--experimental-addon-modules`]: #--experimental-addon-modules
[`--experimental-sea-config`]: single-executable-applications.md#generating-single-executable-preparation-blobs
[`--experimental-strip-types`]: #--experimental-strip-types
[`--experimental-wasm-modules`]: #--experimental-wasm-modules
Expand Down
19 changes: 11 additions & 8 deletions doc/api/esm.md
Original file line number Diff line number Diff line change
Expand Up @@ -1045,18 +1045,21 @@ _isImports_, _conditions_)
> 5. If `--experimental-wasm-modules` is enabled and _url_ ends in
> _".wasm"_, then
> 1. Return _"wasm"_.
> 6. Let _packageURL_ be the result of **LOOKUP\_PACKAGE\_SCOPE**(_url_).
> 7. Let _pjson_ be the result of **READ\_PACKAGE\_JSON**(_packageURL_).
> 8. Let _packageType_ be **null**.
> 9. If _pjson?.type_ is _"module"_ or _"commonjs"_, then
> 1. Set _packageType_ to _pjson.type_.
> 10. If _url_ ends in _".js"_, then
> 6. If `--experimental-addon-modules` is enabled and _url_ ends in
> _".node"_, then
> 1. Return _"addon"_.
> 7. Let _packageURL_ be the result of **LOOKUP\_PACKAGE\_SCOPE**(_url_).
> 8. Let _pjson_ be the result of **READ\_PACKAGE\_JSON**(_packageURL_).
> 9. Let _packageType_ be **null**.
> 10. If _pjson?.type_ is _"module"_ or _"commonjs"_, then
> 1. Set _packageType_ to _pjson.type_.
> 11. If _url_ ends in _".js"_, then
> 1. If _packageType_ is not **null**, then
> 1. Return _packageType_.
> 2. If the result of **DETECT\_MODULE\_SYNTAX**(_source_) is true, then
> 1. Return _"module"_.
> 3. Return _"commonjs"_.
> 11. If _url_ does not have any extension, then
> 12. If _url_ does not have any extension, then
> 1. If _packageType_ is _"module"_ and `--experimental-wasm-modules` is
> enabled and the file at _url_ contains the header for a WebAssembly
> module, then
Expand All @@ -1066,7 +1069,7 @@ _isImports_, _conditions_)
> 3. If the result of **DETECT\_MODULE\_SYNTAX**(_source_) is true, then
> 1. Return _"module"_.
> 4. Return _"commonjs"_.
> 12. Return **undefined** (will throw during load phase).
> 13. Return **undefined** (will throw during load phase).

**LOOKUP\_PACKAGE\_SCOPE**(_url_)

Expand Down
1 change: 1 addition & 0 deletions doc/api/module.md
Original file line number Diff line number Diff line change
Expand Up @@ -1161,6 +1161,7 @@ The final value of `format` must be one of the following:

| `format` | Description | Acceptable types for `source` returned by `load` |
| ------------ | ------------------------------ | -------------------------------------------------------------------------- |
| `'addon'` | Load a Node.js addon | { `null` } |
legendecas marked this conversation as resolved.
Show resolved Hide resolved
| `'builtin'` | Load a Node.js builtin module | Not applicable |
| `'commonjs'` | Load a Node.js CommonJS module | { [`string`][], [`ArrayBuffer`][], [`TypedArray`][], `null`, `undefined` } |
| `'json'` | Load a JSON file | { [`string`][], [`ArrayBuffer`][], [`TypedArray`][] } |
Expand Down
3 changes: 3 additions & 0 deletions doc/node.1
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,9 @@ Enable Source Map V3 support for stack traces.
.It Fl -entry-url
Interpret the entry point as a URL.
.
.It Fl -experimental-addon-modules
Enable experimental addon module support.
.
.It Fl -experimental-import-meta-resolve
Enable experimental ES modules support for import.meta.resolve().
.
Expand Down
5 changes: 5 additions & 0 deletions lib/internal/modules/esm/formats.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const fsBindings = internalBinding('fs');
const { fs: fsConstants } = internalBinding('constants');

const experimentalWasmModules = getOptionValue('--experimental-wasm-modules');
const experimentalAddonModules = getOptionValue('--experimental-addon-modules');

const extensionFormatMap = {
'__proto__': null,
Expand All @@ -23,6 +24,10 @@ if (experimentalWasmModules) {
extensionFormatMap['.wasm'] = 'wasm';
}

if (experimentalAddonModules) {
extensionFormatMap['.node'] = 'addon';
Copy link
Member

Choose a reason for hiding this comment

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

I am somewhat surprised to find that there is not a test for the node-addons exports condition...it seems the the files pointed to by node-addons won't get interpreted as addons unless they have the .node extension, though this issue predates this PR and also happens to require().

Copy link
Member Author

@legendecas legendecas Dec 5, 2024

Choose a reason for hiding this comment

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

Taking a step back, I'm wondering if the format name could be renamed as node-addon/node-addons for alignment.

Copy link
Member

Choose a reason for hiding this comment

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

Sure, also I am not even sure if anyone is actually using it, it seems only useful to distinguish "if the current run times can load Node.js style addons" as it currently stands, so may not really be that big of a deal anyway....

Copy link
Member

Choose a reason for hiding this comment

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

I prefer the more concise addon

Copy link
Member

Choose a reason for hiding this comment

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

Actually I think I misread #55844 (comment) - I think for the format name, addon or node-addon would make more sense. node-addons would be a bit weird in the hooks since it's plural.

Copy link
Member Author

@legendecas legendecas Dec 16, 2024

Choose a reason for hiding this comment

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

This loader format enum is specific for Node.js so I'll stick with the concise addon.

Copy link
Member

@joyeecheung joyeecheung Dec 16, 2024

Choose a reason for hiding this comment

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

In any case, the exports condition question is separate - it's more about when a module that doesn't end with .node is pointed to by node-addons, should it be interpreted(translated) into an addon or should it be interpreted based on whatever extension it has - and if it has a different extension (e.g. dylib or dll), what should happen then. As I said in the first comment, it's fine to keep it as is since CJS also only considers .node, but it is a bit strange for the exports condition.

Copy link
Member Author

@legendecas legendecas Dec 16, 2024

Choose a reason for hiding this comment

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

The reality is that the conditional exported entries are been loaded based on their extensions, not their entry types. With require(esm), it is possible to do the following already 👀:

{
  "exports": {
    "import": "./index-require.cjs",
    "require": "./index-module.mjs"
  }
}

}

if (getOptionValue('--experimental-strip-types')) {
extensionFormatMap['.ts'] = 'module-typescript';
extensionFormatMap['.mts'] = 'module-typescript';
Expand Down
3 changes: 3 additions & 0 deletions lib/internal/modules/esm/load.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ async function defaultLoad(url, context = kEmptyObject) {
if (urlInstance.protocol === 'node:') {
source = null;
format ??= 'builtin';
} else if (format === 'addon') {
// Skip loading addon file content. It must be loaded with dlopen from file system.
source = null;
} else if (format !== 'commonjs') {
if (source == null) {
({ responseURL, source } = await getSource(urlInstance, context));
Expand Down
104 changes: 88 additions & 16 deletions lib/internal/modules/esm/translators.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
const {
ArrayPrototypeMap,
ArrayPrototypePush,
Boolean,
FunctionPrototypeCall,
JSONParse,
ObjectKeys,
Expand Down Expand Up @@ -52,6 +51,7 @@ let debug = require('internal/util/debuglog').debuglog('esm', (fn) => {
});
const { emitExperimentalWarning, kEmptyObject, setOwnProperty, isWindows } = require('internal/util');
const {
ERR_INVALID_RETURN_PROPERTY_VALUE,
ERR_UNKNOWN_BUILTIN_MODULE,
} = require('internal/errors').codes;
const { maybeCacheSourceMap } = require('internal/source_map/source_map_cache');
Expand Down Expand Up @@ -185,7 +185,7 @@ function createCJSModuleWrap(url, source, isMain, format, loadCJS = loadCJSModul
// In case the source was not provided by the `load` step, we need fetch it now.
source = stringify(source ?? getSource(new URL(url)).source);

const { exportNames, module } = cjsPreparseModuleExports(filename, source, isMain, format);
const { exportNames, module } = cjsPreparseModuleExports(filename, source, format);
cjsCache.set(url, module);

const wrapperNames = [...exportNames, 'module.exports'];
Expand Down Expand Up @@ -229,6 +229,47 @@ function createCJSModuleWrap(url, source, isMain, format, loadCJS = loadCJSModul
}, module);
}

/**
* Creates a ModuleWrap object for a CommonJS module without source texts.
* @param {string} url - The URL of the module.
* @param {boolean} isMain - Whether the module is the main module.
* @returns {ModuleWrap} The ModuleWrap object for the CommonJS module.
*/
function createCJSNoSourceModuleWrap(url, isMain) {
debug(`Translating CJSModule without source ${url}`);

const filename = urlToFilename(url);

const module = cjsEmplaceModuleCacheEntry(filename);
cjsCache.set(url, module);

if (isMain) {
setOwnProperty(process, 'mainModule', module);
}

// Addon export names are not known until the addon is loaded.
const exportNames = ['default', 'module.exports'];
return new ModuleWrap(url, undefined, exportNames, function evaluationCallback() {
debug(`Loading CJSModule ${url}`);

if (!module.loaded) {
wrapModuleLoad(filename, null, isMain);
}

/** @type {import('./loader').ModuleExports} */
let exports;
legendecas marked this conversation as resolved.
Show resolved Hide resolved
if (module[kModuleExport] !== undefined) {
exports = module[kModuleExport];
module[kModuleExport] = undefined;
} else {
({ exports } = module);
}

this.setExport('default', exports);
this.setExport('module.exports', exports);
}, module);
}

translators.set('commonjs-sync', function requireCommonJS(url, source, isMain) {
initCJSParseSync();

Expand Down Expand Up @@ -280,26 +321,38 @@ translators.set('commonjs', function commonjsStrategy(url, source, isMain) {
return createCJSModuleWrap(url, source, isMain, 'commonjs', cjsLoader);
});

/**
* Get or create an entry in the CJS module cache for the given filename.
* @param {string} filename CJS module filename
* @returns {CJSModule} the cached CJS module entry
*/
function cjsEmplaceModuleCacheEntry(filename, exportNames) {
// TODO: Do we want to keep hitting the user mutable CJS loader here?
let cjsMod = CJSModule._cache[filename];
if (cjsMod) {
return cjsMod;
}

cjsMod = new CJSModule(filename);
cjsMod.filename = filename;
cjsMod.paths = CJSModule._nodeModulePaths(cjsMod.path);
cjsMod[kIsCachedByESMLoader] = true;
CJSModule._cache[filename] = cjsMod;

return cjsMod;
}

/**
* Pre-parses a CommonJS module's exports and re-exports.
* @param {string} filename - The filename of the module.
* @param {string} [source] - The source code of the module.
* @param {boolean} isMain - Whether it is pre-parsing for the entry point.
* @param {string} format
* @param {string} [format]
*/
function cjsPreparseModuleExports(filename, source, isMain, format) {
let module = CJSModule._cache[filename];
if (module && module[kModuleExportNames] !== undefined) {
function cjsPreparseModuleExports(filename, source, format) {
const module = cjsEmplaceModuleCacheEntry(filename);
if (module[kModuleExportNames] !== undefined) {
return { module, exportNames: module[kModuleExportNames] };
}
const loaded = Boolean(module);
if (!loaded) {
module = new CJSModule(filename);
module.filename = filename;
module.paths = CJSModule._nodeModulePaths(module.path);
module[kIsCachedByESMLoader] = true;
CJSModule._cache[filename] = module;
}

if (source === undefined) {
({ source } = loadSourceForCJSWithHooks(module, filename, format));
Expand Down Expand Up @@ -340,7 +393,7 @@ function cjsPreparseModuleExports(filename, source, isMain, format) {

if (format === 'commonjs' ||
(!BuiltinModule.normalizeRequirableId(resolved) && findLongestRegisteredExtension(resolved) === '.js')) {
const { exportNames: reexportNames } = cjsPreparseModuleExports(resolved, undefined, false, format);
const { exportNames: reexportNames } = cjsPreparseModuleExports(resolved, undefined, format);
for (const name of reexportNames) {
exportNames.add(name);
}
Expand Down Expand Up @@ -462,6 +515,25 @@ translators.set('wasm', async function(url, source) {
}).module;
});

// Strategy for loading a addon
translators.set('addon', function translateAddon(url, source, isMain) {
emitExperimentalWarning('Importing addons');

// The addon must be loaded from file system with dlopen. Assert
// the source is null.
if (source !== null) {
throw new ERR_INVALID_RETURN_PROPERTY_VALUE(
'null',
'load',
'source',
source);
}

debug(`Translating addon ${url}`);

return createCJSNoSourceModuleWrap(url, isMain);
});

// Strategy for loading a commonjs TypeScript module
translators.set('commonjs-typescript', function(url, source) {
emitExperimentalWarning('Type Stripping');
Expand Down
4 changes: 4 additions & 0 deletions src/node_options.cc
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,10 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
"Treat the entrypoint as a URL",
&EnvironmentOptions::entry_is_url,
kAllowedInEnvvar);
AddOption("--experimental-addon-modules",
"experimental import support for addons",
&EnvironmentOptions::experimental_addon_modules,
kAllowedInEnvvar);
AddOption("--experimental-abortcontroller", "", NoOp{}, kAllowedInEnvvar);
AddOption("--experimental-eventsource",
"experimental EventSource API",
Expand Down
1 change: 1 addition & 0 deletions src/node_options.h
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ class EnvironmentOptions : public Options {
bool require_module = true;
std::string dns_result_order;
bool enable_source_maps = false;
bool experimental_addon_modules = false;
bool experimental_eventsource = false;
bool experimental_fetch = true;
bool experimental_websocket = true;
Expand Down
1 change: 1 addition & 0 deletions test/addons/esm-package-dependent/node_modules/esm-package

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions test/addons/esm-package-dependent/test-import.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Flags: --experimental-addon-modules
'use strict';
const common = require('../../common');
const assert = require('node:assert');

/**
* Test that the export condition `node-addons` can be used
* with `*.node` files with the ESM loader.
*/

import(`esm-package/${common.buildType}`)
.then((mod) => {
assert.strictEqual(mod.default.hello(), 'world');
})
.then(common.mustCall());
12 changes: 12 additions & 0 deletions test/addons/esm-package-dependent/test-require.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Flags: --experimental-addon-modules
'use strict';
const common = require('../../common');
const assert = require('node:assert');

/**
* Test that the export condition `node-addons` can be used
* with `*.node` files with the CJS loader.
*/

const mod = require(`esm-package/${common.buildType}`);
assert.strictEqual(mod.hello(), 'world');
17 changes: 17 additions & 0 deletions test/addons/esm-package/binding-export-default.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#include <node.h>
#include <uv.h>
#include <v8.h>

static void Method(const v8::FunctionCallbackInfo<v8::Value>& args) {
v8::Isolate* isolate = args.GetIsolate();
args.GetReturnValue().Set(
v8::String::NewFromUtf8(isolate, "hello world").ToLocalChecked());
}

static void InitModule(v8::Local<v8::Object> exports,
v8::Local<v8::Value> module,
v8::Local<v8::Context> context) {
NODE_SET_METHOD(exports, "default", Method);
}

NODE_MODULE_CONTEXT_AWARE(Binding, InitModule)
17 changes: 17 additions & 0 deletions test/addons/esm-package/binding-export-primitive.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#include <node.h>
#include <uv.h>
#include <v8.h>

static void InitModule(v8::Local<v8::Object> exports,
v8::Local<v8::Value> module_val,
v8::Local<v8::Context> context) {
v8::Isolate* isolate = context->GetIsolate();
v8::Local<v8::Object> module = module_val.As<v8::Object>();
module
->Set(context,
v8::String::NewFromUtf8(isolate, "exports").ToLocalChecked(),
v8::String::NewFromUtf8(isolate, "hello world").ToLocalChecked())
.FromJust();
}

NODE_MODULE_CONTEXT_AWARE(Binding, InitModule)
17 changes: 17 additions & 0 deletions test/addons/esm-package/binding.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#include <node.h>
#include <uv.h>
#include <v8.h>

static void Method(const v8::FunctionCallbackInfo<v8::Value>& args) {
v8::Isolate* isolate = args.GetIsolate();
args.GetReturnValue().Set(
v8::String::NewFromUtf8(isolate, "world").ToLocalChecked());
}

static void InitModule(v8::Local<v8::Object> exports,
v8::Local<v8::Value> module,
v8::Local<v8::Context> context) {
NODE_SET_METHOD(exports, "hello", Method);
}

NODE_MODULE_CONTEXT_AWARE(Binding, InitModule)
Loading
Loading