Skip to content

module: expose module format by module loader #57777

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

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
51 changes: 51 additions & 0 deletions doc/api/module.md
Original file line number Diff line number Diff line change
Expand Up @@ -1556,6 +1556,56 @@ Running `node --import 'data:text/javascript,import { register } from "node:modu
or `node --import ./import-map-sync-hooks.js main.js`
should print `some module!`.

## Module Hooks Reflection

<!-- YAML
added: REPLACEME
-->

> Stability: 1.1 - Active development

### `module.loadModule(specifier, parentURL[, options])`

<!-- YAML
added: REPLACEME
-->

* `specifier` {string} The URL of the module to load.
* `parentURL` {string} The module importing this one.
* `options` {Object} Optional
* `importAttributes` {Object} An object whose key-value pairs represent the
attributes for the module to import.
* Returns: {Object}
* `format` {string} The resolved format of the module.
* `source` {string|ArrayBuffer|TypedArray|null} The source for Node.js to evaluate.
* `url` {string} The resolved URL of the module.

Request to load a module using the current module hooks. This does not
evaluate the module, it only returns the resolved URL and source code.
This is useful for determining the format of a module and its source code.

This is the recommended way to detect a module format rather than referring
to the `package.json` file or the file extension. The `format` property
is one of the values listed in the [`module.kModuleFormats`][].

### `module.kModuleFormats`

<!-- YAML
added: REPLACEME
-->

* Returns: {Object} An object with the following properties:
* `addon` {string} Only present when the `--experimental-addon-modules` flag is enabled.
* `builtin` {string}
* `commonjs` {string}
* `json` {string}
* `module` {string}
* `wasm` {string} Only present when the `--experimental-wasm-modules` flag is
enabled.

The `kModuleFormats` property is an object that enumerates the module formats
supported as a final format returned by the `load` hook.

## Source map v3 support

<!-- YAML
Expand Down Expand Up @@ -1778,6 +1828,7 @@ returned object contains the following keys:
[`module.enableCompileCache()`]: #moduleenablecompilecachecachedir
[`module.flushCompileCache()`]: #moduleflushcompilecache
[`module.getCompileCacheDir()`]: #modulegetcompilecachedir
[`module.kModuleFormats`]: #modulekmoduleformats
[`module`]: #the-module-object
[`os.tmpdir()`]: os.md#ostmpdir
[`registerHooks`]: #moduleregisterhooksoptions
Expand Down
19 changes: 19 additions & 0 deletions lib/internal/modules/esm/formats.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict';

const {
ObjectFreeze,
RegExpPrototypeExec,
} = primordials;

Expand All @@ -20,12 +21,26 @@ const extensionFormatMap = {
'.mjs': 'module',
};

/**
* @type {Record<string, string>}
* Allowed final module formats returned in a load hook of a module loader.
*/
const kModuleFormats = {
__proto__: null,
commonjs: 'commonjs',
module: 'module',
json: 'json',
builtin: 'builtin',
};

if (experimentalWasmModules) {
extensionFormatMap['.wasm'] = 'wasm';
kModuleFormats.wasm = 'wasm';
}

if (experimentalAddonModules) {
extensionFormatMap['.node'] = 'addon';
kModuleFormats.addon = 'addon';
}

if (getOptionValue('--experimental-strip-types')) {
Expand All @@ -34,6 +49,9 @@ if (getOptionValue('--experimental-strip-types')) {
extensionFormatMap['.cts'] = 'commonjs-typescript';
}

ObjectFreeze(extensionFormatMap);
ObjectFreeze(kModuleFormats);

/**
* @param {string} mime
* @returns {string | null}
Expand Down Expand Up @@ -68,6 +86,7 @@ function getFormatOfExtensionlessFile(url) {
}

module.exports = {
kModuleFormats,
extensionFormatMap,
getFormatOfExtensionlessFile,
mimeToFormat,
Expand Down
32 changes: 32 additions & 0 deletions lib/internal/modules/esm/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const {
let defaultResolve, defaultLoad, defaultLoadSync, importMetaInitializer;

const { tracingChannel } = require('diagnostics_channel');
const { validateObject } = require('internal/validators');
const onImport = tracingChannel('module.import');

let debug = require('internal/util/debuglog').debuglog('esm', (fn) => {
Expand Down Expand Up @@ -1061,9 +1062,40 @@ function register(specifier, parentURL = undefined, options) {
);
}

async function loadModule(specifier, parentURL, options = kEmptyObject) {
specifier = `${specifier}`;
parentURL = `${parentURL}`;

validateObject(options, 'options');
const { importAttributes = { __proto__: null } } = options;
validateObject(importAttributes, 'options.importAttributes');

const loader = getOrInitializeCascadedLoader();
const { url, format: resolvedFormat } = await loader.resolve(specifier, parentURL, importAttributes);
const result = await loader.load(url, { format: resolvedFormat, importAttributes });
const { source } = result;
let { format: finalFormat } = result;

// Translate internal formats to public ones.
if (finalFormat === 'commonjs-typescript') {
finalFormat = 'commonjs';
}
if (finalFormat === 'module-typescript') {
finalFormat = 'module';
}

return {
__proto__: null,
url,
format: finalFormat,
source,
};
}

module.exports = {
createModuleLoader,
getHooksProxy,
getOrInitializeCascadedLoader,
register,
loadModule,
};
7 changes: 6 additions & 1 deletion lib/module.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const {
setSourceMapsSupport,
} = require('internal/source_map/source_map_cache');
const { Module } = require('internal/modules/cjs/loader');
const { register } = require('internal/modules/esm/loader');
const { register, loadModule } = require('internal/modules/esm/loader');
const {
SourceMap,
} = require('internal/source_map/source_map');
Expand All @@ -20,6 +20,7 @@ const {
findPackageJSON,
} = require('internal/modules/package_json_reader');
const { stripTypeScriptTypes } = require('internal/modules/typescript');
const { kModuleFormats } = require('internal/modules/esm/formats');

Module.register = register;
Module.constants = constants;
Expand All @@ -29,6 +30,10 @@ Module.flushCompileCache = flushCompileCache;
Module.getCompileCacheDir = getCompileCacheDir;
Module.stripTypeScriptTypes = stripTypeScriptTypes;

// Module reflection APIs
Module.loadModule = loadModule;
Module.kModuleFormats = kModuleFormats;

// SourceMap APIs
Module.findSourceMap = findSourceMap;
Module.SourceMap = SourceMap;
Expand Down
5 changes: 5 additions & 0 deletions test/fixtures/typescript/legacy-module/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Legacy TypeScript Module

When `tsconfig.json` is set to `module: "node16"` or any `node*`, the TypeScript compiler will
produce the output in the format by the extension (e.g. `.cts` or `.mts`), or set by the
`package.json#type` field, regardless of the syntax of the original source code.
3 changes: 3 additions & 0 deletions test/fixtures/typescript/legacy-module/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"type": "commonjs"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const foo: string = 'Hello, TypeScript!';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const foo: string = 'Hello, TypeScript!';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const foo: string = 'Hello, TypeScript!';
24 changes: 24 additions & 0 deletions test/parallel/test-module-loadmodule-no-typescript.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Flags: --no-experimental-strip-types

'use strict';

require('../common');
const test = require('node:test');
const assert = require('node:assert');
const { pathToFileURL } = require('node:url');
const fixtures = require('../common/fixtures');
const { loadModule } = require('node:module');

const parentURL = pathToFileURL(__filename);

test('should reject a TypeScript module', async () => {
const fileUrl = fixtures.fileURL('typescript/legacy-module/test-module-export.ts');
await assert.rejects(
async () => {
await loadModule(fileUrl, parentURL);
},
{
code: 'ERR_UNKNOWN_FILE_EXTENSION',
}
);
});
49 changes: 49 additions & 0 deletions test/parallel/test-module-loadmodule-typescript.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Flags: --experimental-strip-types

'use strict';

require('../common');
const test = require('node:test');
const assert = require('node:assert');
const { pathToFileURL } = require('node:url');
const fixtures = require('../common/fixtures');
const { loadModule, kModuleFormats } = require('node:module');

const parentURL = pathToFileURL(__filename);

test('should load a TypeScript module source by package.json type', async () => {
// Even if the .ts file contains module syntax, it should be loaded as a CommonJS module
// because the package.json type is set to "commonjs".

const fileUrl = fixtures.fileURL('typescript/legacy-module/test-module-export.ts');
const { url, format, source } = await loadModule(fileUrl, parentURL);
assert.strictEqual(format, kModuleFormats.commonjs);
assert.strictEqual(url, fileUrl.href);

// Built-in TypeScript loader loads the source.
assert.ok(Buffer.isBuffer(source));
});

test('should load a TypeScript cts module source by extension', async () => {
// By extension, .cts files should be loaded as CommonJS modules.

const fileUrl = fixtures.fileURL('typescript/legacy-module/test-module-export.cts');
const { url, format, source } = await loadModule(fileUrl, parentURL);
assert.strictEqual(format, kModuleFormats.commonjs);
assert.strictEqual(url, fileUrl.href);

// Built-in TypeScript loader loads the source.
assert.ok(Buffer.isBuffer(source));
});

test('should load a TypeScript mts module source by extension', async () => {
// By extension, .mts files should be loaded as ES modules.

const fileUrl = fixtures.fileURL('typescript/legacy-module/test-module-export.mts');
const { url, format, source } = await loadModule(fileUrl, parentURL);
assert.strictEqual(format, kModuleFormats.module);
assert.strictEqual(url, fileUrl.href);

// Built-in TypeScript loader loads the source.
assert.ok(Buffer.isBuffer(source));
});
36 changes: 36 additions & 0 deletions test/parallel/test-module-loadmodule.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
'use strict';

require('../common');
const test = require('node:test');
const assert = require('node:assert');
const { pathToFileURL } = require('node:url');
const fixtures = require('../common/fixtures');
const { loadModule, kModuleFormats } = require('node:module');

const parentURL = pathToFileURL(__filename);

test('kModuleFormats is a frozen object', () => {
assert.ok(typeof kModuleFormats === 'object');
assert.ok(Object.isFrozen(kModuleFormats));
});

test('should throw if the module is not found', async () => {
await assert.rejects(
async () => {
await loadModule('nonexistent-module', parentURL);
},
{
code: 'ERR_MODULE_NOT_FOUND',
}
);
});

test('should load a module', async () => {
const fileUrl = fixtures.fileURL('es-modules/cjs.js');
const { url, format, source } = await loadModule(fileUrl, parentURL);
assert.strictEqual(format, kModuleFormats.commonjs);
assert.strictEqual(url, fileUrl.href);

// `source` is null and the final builtin loader will read the file.
assert.strictEqual(source, null);
});
Loading