Skip to content

Commit

Permalink
esm: add experimental support for addon modules
Browse files Browse the repository at this point in the history
  • Loading branch information
legendecas committed Dec 16, 2024
1 parent c39875a commit 16777e3
Show file tree
Hide file tree
Showing 20 changed files with 312 additions and 22 deletions.
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` } |
| `'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';
}

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
97 changes: 85 additions & 12 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 @@ -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;
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,39 @@ 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.

Check warning on line 349 in lib/internal/modules/esm/translators.js

View workflow job for this annotation

GitHub Actions / lint-js-and-md

@param "isMain" does not match an existing function parameter
* @param {string} format
*/
function cjsPreparseModuleExports(filename, source, isMain, format) {
let module = CJSModule._cache[filename];
if (module && module[kModuleExportNames] !== undefined) {
function cjsPreparseModuleExports(filename, source) {
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));

Check failure on line 359 in lib/internal/modules/esm/translators.js

View workflow job for this annotation

GitHub Actions / lint-js-and-md

'format' is not defined
Expand Down Expand Up @@ -462,6 +516,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());
15 changes: 15 additions & 0 deletions test/addons/esm-package-dependent/test-require.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 CJS loader.
*/

require(`esm-package/${common.buildType}`)
.then((mod) => {
assert.strictEqual(mod.hello(), 'world');
})
.then(common.mustCall());
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

0 comments on commit 16777e3

Please sign in to comment.