Skip to content

Commit e1ee16d

Browse files
committed
module: expose module format by module loader
Module loaders may change a requested module format. It is not safe to determine a `.js` format by `package.json#type` as well. Expose a helper function to frameworks that load js files to avoid relying on `import`/`require` failover-and-retry.
1 parent 8456a12 commit e1ee16d

12 files changed

+228
-1
lines changed

doc/api/module.md

+51
Original file line numberDiff line numberDiff line change
@@ -1556,6 +1556,56 @@ Running `node --import 'data:text/javascript,import { register } from "node:modu
15561556
or `node --import ./import-map-sync-hooks.js main.js`
15571557
should print `some module!`.
15581558
1559+
## Module Hooks Reflection
1560+
1561+
<!-- YAML
1562+
added: REPLACEME
1563+
-->
1564+
1565+
> Stability: 1.1 - Active development
1566+
1567+
### `module.loadModule(specifier, parentURL[, options])`
1568+
1569+
<!-- YAML
1570+
added: REPLACEME
1571+
-->
1572+
1573+
* `specifier` {string} The URL of the module to load.
1574+
* `parentURL` {string} The module importing this one.
1575+
* `options` {Object} Optional
1576+
* `importAttributes` {Object} An object whose key-value pairs represent the
1577+
attributes for the module to import.
1578+
* Returns: {Object}
1579+
* `format` {string} The resolved format of the module.
1580+
* `source` {string|ArrayBuffer|TypedArray|null} The source for Node.js to evaluate.
1581+
* `url` {string} The resolved URL of the module.
1582+
1583+
Request to load a module using the current module hooks. This does not
1584+
evaluate the module, it only returns the resolved URL and source code.
1585+
This is useful for determining the format of a module and its source code.
1586+
1587+
This is the recommended way to detect a module format rather than referring
1588+
to the `package.json` file or the file extension. The `format` property
1589+
is one of the values listed in the [`module.kModuleFormats`][].
1590+
1591+
### `module.kModuleFormats`
1592+
1593+
<!-- YAML
1594+
added: REPLACEME
1595+
-->
1596+
1597+
* Returns: {Object} An object with the following properties:
1598+
* `addon` {string} Only present when the `--experimental-addon-modules` flag is enabled.
1599+
* `builtin` {string}
1600+
* `commonjs` {string}
1601+
* `json` {string}
1602+
* `module` {string}
1603+
* `wasm` {string} Only present when the `--experimental-wasm-modules` flag is
1604+
enabled.
1605+
1606+
The `kModuleFormats` property is an object that enumerates the module formats
1607+
supported as a final format returned by the `load` hook.
1608+
15591609
## Source map v3 support
15601610
15611611
<!-- YAML
@@ -1778,6 +1828,7 @@ returned object contains the following keys:
17781828
[`module.enableCompileCache()`]: #moduleenablecompilecachecachedir
17791829
[`module.flushCompileCache()`]: #moduleflushcompilecache
17801830
[`module.getCompileCacheDir()`]: #modulegetcompilecachedir
1831+
[`module.kModuleFormats`]: #modulekmoduleformats
17811832
[`module`]: #the-module-object
17821833
[`os.tmpdir()`]: os.md#ostmpdir
17831834
[`registerHooks`]: #moduleregisterhooksoptions

lib/internal/modules/esm/formats.js

+19
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use strict';
22

33
const {
4+
ObjectFreeze,
45
RegExpPrototypeExec,
56
} = primordials;
67

@@ -20,12 +21,26 @@ const extensionFormatMap = {
2021
'.mjs': 'module',
2122
};
2223

24+
/**
25+
* @type {Record<string, string>}
26+
* Allowed final module formats returned in a load hook of a module loader.
27+
*/
28+
const kModuleFormats = {
29+
__proto__: null,
30+
commonjs: 'commonjs',
31+
module: 'module',
32+
json: 'json',
33+
builtin: 'builtin',
34+
};
35+
2336
if (experimentalWasmModules) {
2437
extensionFormatMap['.wasm'] = 'wasm';
38+
kModuleFormats.wasm = 'wasm';
2539
}
2640

2741
if (experimentalAddonModules) {
2842
extensionFormatMap['.node'] = 'addon';
43+
kModuleFormats.addon = 'addon';
2944
}
3045

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

52+
ObjectFreeze(extensionFormatMap);
53+
ObjectFreeze(kModuleFormats);
54+
3755
/**
3856
* @param {string} mime
3957
* @returns {string | null}
@@ -68,6 +86,7 @@ function getFormatOfExtensionlessFile(url) {
6886
}
6987

7088
module.exports = {
89+
kModuleFormats,
7190
extensionFormatMap,
7291
getFormatOfExtensionlessFile,
7392
mimeToFormat,

lib/internal/modules/esm/loader.js

+32
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ const {
5858
let defaultResolve, defaultLoad, defaultLoadSync, importMetaInitializer;
5959

6060
const { tracingChannel } = require('diagnostics_channel');
61+
const { validateObject } = require('internal/validators');
6162
const onImport = tracingChannel('module.import');
6263

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

1065+
async function loadModule(specifier, parentURL, options = kEmptyObject) {
1066+
specifier = `${specifier}`;
1067+
parentURL = `${parentURL}`;
1068+
1069+
validateObject(options, 'options');
1070+
const { importAttributes = { __proto__: null } } = options;
1071+
validateObject(importAttributes, 'options.importAttributes');
1072+
1073+
const loader = getOrInitializeCascadedLoader();
1074+
const { url, format: resolvedFormat } = await loader.resolve(specifier, parentURL, importAttributes);
1075+
const result = await loader.load(url, { format: resolvedFormat, importAttributes });
1076+
const { source } = result;
1077+
let { format: finalFormat } = result;
1078+
1079+
// Translate internal formats to public ones.
1080+
if (finalFormat === 'commonjs-typescript') {
1081+
finalFormat = 'commonjs';
1082+
}
1083+
if (finalFormat === 'module-typescript') {
1084+
finalFormat = 'module';
1085+
}
1086+
1087+
return {
1088+
__proto__: null,
1089+
url,
1090+
format: finalFormat,
1091+
source,
1092+
};
1093+
}
1094+
10641095
module.exports = {
10651096
createModuleLoader,
10661097
getHooksProxy,
10671098
getOrInitializeCascadedLoader,
10681099
register,
1100+
loadModule,
10691101
};

lib/module.js

+6-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const {
66
setSourceMapsSupport,
77
} = require('internal/source_map/source_map_cache');
88
const { Module } = require('internal/modules/cjs/loader');
9-
const { register } = require('internal/modules/esm/loader');
9+
const { register, loadModule } = require('internal/modules/esm/loader');
1010
const {
1111
SourceMap,
1212
} = require('internal/source_map/source_map');
@@ -20,6 +20,7 @@ const {
2020
findPackageJSON,
2121
} = require('internal/modules/package_json_reader');
2222
const { stripTypeScriptTypes } = require('internal/modules/typescript');
23+
const { kModuleFormats } = require('internal/modules/esm/formats');
2324

2425
Module.register = register;
2526
Module.constants = constants;
@@ -29,6 +30,10 @@ Module.flushCompileCache = flushCompileCache;
2930
Module.getCompileCacheDir = getCompileCacheDir;
3031
Module.stripTypeScriptTypes = stripTypeScriptTypes;
3132

33+
// Module reflection APIs
34+
Module.loadModule = loadModule;
35+
Module.kModuleFormats = kModuleFormats;
36+
3237
// SourceMap APIs
3338
Module.findSourceMap = findSourceMap;
3439
Module.SourceMap = SourceMap;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Legacy TypeScript Module
2+
3+
When `tsconfig.json` is set to `module: "node16"` or any `node*`, the TypeScript compiler will
4+
produce the output in the format by the extension (e.g. `.cts` or `.mts`), or set by the
5+
`package.json#type` field, regardless of the syntax of the original source code.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"type": "commonjs"
3+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const foo: string = 'Hello, TypeScript!';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const foo: string = 'Hello, TypeScript!';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const foo: string = 'Hello, TypeScript!';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Flags: --no-experimental-strip-types
2+
3+
'use strict';
4+
5+
require('../common');
6+
const test = require('node:test');
7+
const assert = require('node:assert');
8+
const { pathToFileURL } = require('node:url');
9+
const fixtures = require('../common/fixtures');
10+
const { loadModule } = require('node:module');
11+
12+
const parentURL = pathToFileURL(__filename);
13+
14+
test('should reject a TypeScript module', async () => {
15+
const fileUrl = fixtures.fileURL('typescript/legacy-module/test-module-export.ts');
16+
await assert.rejects(
17+
async () => {
18+
await loadModule(fileUrl, parentURL);
19+
},
20+
{
21+
code: 'ERR_UNKNOWN_FILE_EXTENSION',
22+
}
23+
);
24+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Flags: --experimental-strip-types
2+
3+
'use strict';
4+
5+
require('../common');
6+
const test = require('node:test');
7+
const assert = require('node:assert');
8+
const { pathToFileURL } = require('node:url');
9+
const fixtures = require('../common/fixtures');
10+
const { loadModule, kModuleFormats } = require('node:module');
11+
12+
const parentURL = pathToFileURL(__filename);
13+
14+
test('should load a TypeScript module source by package.json type', async () => {
15+
// Even if the .ts file contains module syntax, it should be loaded as a CommonJS module
16+
// because the package.json type is set to "commonjs".
17+
18+
const fileUrl = fixtures.fileURL('typescript/legacy-module/test-module-export.ts');
19+
const { url, format, source } = await loadModule(fileUrl, parentURL);
20+
assert.strictEqual(format, kModuleFormats.commonjs);
21+
assert.strictEqual(url, fileUrl.href);
22+
23+
// Built-in TypeScript loader loads the source.
24+
assert.ok(Buffer.isBuffer(source));
25+
});
26+
27+
test('should load a TypeScript cts module source by extension', async () => {
28+
// By extension, .cts files should be loaded as CommonJS modules.
29+
30+
const fileUrl = fixtures.fileURL('typescript/legacy-module/test-module-export.cts');
31+
const { url, format, source } = await loadModule(fileUrl, parentURL);
32+
assert.strictEqual(format, kModuleFormats.commonjs);
33+
assert.strictEqual(url, fileUrl.href);
34+
35+
// Built-in TypeScript loader loads the source.
36+
assert.ok(Buffer.isBuffer(source));
37+
});
38+
39+
test('should load a TypeScript mts module source by extension', async () => {
40+
// By extension, .mts files should be loaded as ES modules.
41+
42+
const fileUrl = fixtures.fileURL('typescript/legacy-module/test-module-export.mts');
43+
const { url, format, source } = await loadModule(fileUrl, parentURL);
44+
assert.strictEqual(format, kModuleFormats.module);
45+
assert.strictEqual(url, fileUrl.href);
46+
47+
// Built-in TypeScript loader loads the source.
48+
assert.ok(Buffer.isBuffer(source));
49+
});
+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
'use strict';
2+
3+
require('../common');
4+
const test = require('node:test');
5+
const assert = require('node:assert');
6+
const { pathToFileURL } = require('node:url');
7+
const fixtures = require('../common/fixtures');
8+
const { loadModule, kModuleFormats } = require('node:module');
9+
10+
const parentURL = pathToFileURL(__filename);
11+
12+
test('kModuleFormats is a frozen object', () => {
13+
assert.ok(typeof kModuleFormats === 'object');
14+
assert.ok(Object.isFrozen(kModuleFormats));
15+
});
16+
17+
test('should throw if the module is not found', async () => {
18+
await assert.rejects(
19+
async () => {
20+
await loadModule('nonexistent-module', parentURL);
21+
},
22+
{
23+
code: 'ERR_MODULE_NOT_FOUND',
24+
}
25+
);
26+
});
27+
28+
test('should load a module', async () => {
29+
const fileUrl = fixtures.fileURL('es-modules/cjs.js');
30+
const { url, format, source } = await loadModule(fileUrl, parentURL);
31+
assert.strictEqual(format, kModuleFormats.commonjs);
32+
assert.strictEqual(url, fileUrl.href);
33+
34+
// source is null and the final builtin loader will read the file.
35+
assert.deepStrictEqual(source, null);
36+
});

0 commit comments

Comments
 (0)