module: implement module.registerHooks()

PR-URL: https://github.com/nodejs/node/pull/55698
Reviewed-By: Geoffrey Booth <webadmin@geoffreybooth.com>
Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
Reviewed-By: Guy Bedford <guybedford@gmail.com>
This commit is contained in:
Joyee Cheung 2024-10-29 16:08:12 +01:00 committed by Node.js GitHub Bot
parent 2960a59540
commit e85964610c
57 changed files with 2045 additions and 95 deletions

View file

@ -102,6 +102,7 @@ const kIsCachedByESMLoader = Symbol('kIsCachedByESMLoader');
const kRequiredModuleSymbol = Symbol('kRequiredModuleSymbol');
const kIsExecuting = Symbol('kIsExecuting');
const kURL = Symbol('kURL');
const kFormat = Symbol('kFormat');
// Set first due to cycle with ESM loader functions.
@ -112,6 +113,9 @@ module.exports = {
kModuleCircularVisited,
initializeCJS,
Module,
findLongestRegisteredExtension,
resolveForCJSWithHooks,
loadSourceForCJSWithHooks: loadSource,
wrapSafe,
wrapModuleLoad,
kIsMainSymbol,
@ -157,6 +161,15 @@ const {
stripBOM,
toRealPath,
} = require('internal/modules/helpers');
const {
convertCJSFilenameToURL,
convertURLToCJSFilename,
loadHooks,
loadWithHooks,
registerHooks,
resolveHooks,
resolveWithHooks,
} = require('internal/modules/customization_hooks');
const { stripTypeScriptModuleTypes } = require('internal/modules/typescript');
const packageJsonReader = require('internal/modules/package_json_reader');
const { getOptionValue, getEmbedderOptions } = require('internal/options');
@ -173,6 +186,7 @@ const {
ERR_REQUIRE_CYCLE_MODULE,
ERR_REQUIRE_ESM,
ERR_UNKNOWN_BUILTIN_MODULE,
ERR_UNKNOWN_MODULE_FORMAT,
},
setArrowMessage,
} = require('internal/errors');
@ -585,7 +599,7 @@ function trySelfParentPath(parent) {
* @param {string} parentPath The path of the parent module
* @param {string} request The module request to resolve
*/
function trySelf(parentPath, request) {
function trySelf(parentPath, request, conditions) {
if (!parentPath) { return false; }
const pkg = packageJsonReader.getNearestParentPackageJSON(parentPath);
@ -606,7 +620,7 @@ function trySelf(parentPath, request) {
const { packageExportsResolve } = require('internal/modules/esm/resolve');
return finalizeEsmResolution(packageExportsResolve(
pathToFileURL(pkg.path), expansion, pkg.data,
pathToFileURL(parentPath), getCjsConditions()), parentPath, pkg.path);
pathToFileURL(parentPath), conditions), parentPath, pkg.path);
} catch (e) {
if (e.code === 'ERR_MODULE_NOT_FOUND') {
throw createEsmNotFoundErr(request, pkg.path);
@ -627,7 +641,7 @@ const EXPORTS_PATTERN = /^((?:@[^/\\%]+\/)?[^./\\%][^/\\%]*)(\/.*)?$/;
* @param {string} nmPath The path to the module.
* @param {string} request The request for the module.
*/
function resolveExports(nmPath, request) {
function resolveExports(nmPath, request, conditions) {
// The implementation's behavior is meant to mirror resolution in ESM.
const { 1: name, 2: expansion = '' } =
RegExpPrototypeExec(EXPORTS_PATTERN, request) || kEmptyObject;
@ -639,7 +653,7 @@ function resolveExports(nmPath, request) {
const { packageExportsResolve } = require('internal/modules/esm/resolve');
return finalizeEsmResolution(packageExportsResolve(
pathToFileURL(pkgPath + '/package.json'), '.' + expansion, pkg, null,
getCjsConditions()), null, pkgPath);
conditions), null, pkgPath);
} catch (e) {
if (e.code === 'ERR_MODULE_NOT_FOUND') {
throw createEsmNotFoundErr(request, pkgPath + '/package.json');
@ -681,7 +695,7 @@ function getDefaultExtensions() {
* @param {boolean} isMain Whether the request is the main app entry point
* @returns {string | false}
*/
Module._findPath = function(request, paths, isMain) {
Module._findPath = function(request, paths, isMain, conditions = getCjsConditions()) {
const absoluteRequest = path.isAbsolute(request);
if (absoluteRequest) {
paths = [''];
@ -736,7 +750,7 @@ Module._findPath = function(request, paths, isMain) {
}
if (!absoluteRequest) {
const exportsResolved = resolveExports(curPath, request);
const exportsResolved = resolveExports(curPath, request, conditions);
if (exportsResolved) {
return exportsResolved;
}
@ -1017,6 +1031,153 @@ function getExportsForCircularRequire(module) {
return module.exports;
}
/**
* Resolve a module request for CommonJS, invoking hooks from module.registerHooks()
* if necessary.
* @param {string} specifier
* @param {Module|undefined} parent
* @param {boolean} isMain
* @returns {{url?: string, format?: string, parentURL?: string, filename: string}}
*/
function resolveForCJSWithHooks(specifier, parent, isMain) {
let defaultResolvedURL;
let defaultResolvedFilename;
let format;
function defaultResolveImpl(specifier, parent, isMain, options) {
// For backwards compatibility, when encountering requests starting with node:,
// throw ERR_UNKNOWN_BUILTIN_MODULE on failure or return the normalized ID on success
// without going into Module._resolveFilename.
let normalized;
if (StringPrototypeStartsWith(specifier, 'node:')) {
normalized = BuiltinModule.normalizeRequirableId(specifier);
if (!normalized) {
throw new ERR_UNKNOWN_BUILTIN_MODULE(specifier);
}
defaultResolvedURL = specifier;
format = 'builtin';
return normalized;
}
return Module._resolveFilename(specifier, parent, isMain, options).toString();
}
// Fast path: no hooks, just return simple results.
if (!resolveHooks.length) {
const filename = defaultResolveImpl(specifier, parent, isMain);
return { __proto__: null, url: defaultResolvedURL, filename, format };
}
// Slow path: has hooks, do the URL conversions and invoke hooks with contexts.
let parentURL;
if (parent) {
if (!parent[kURL] && parent.filename) {
parent[kURL] = convertCJSFilenameToURL(parent.filename);
}
parentURL = parent[kURL];
}
// This is used as the last nextResolve for the resolve hooks.
function defaultResolve(specifier, context) {
// TODO(joyeecheung): parent and isMain should be part of context, then we
// no longer need to use a different defaultResolve for every resolution.
defaultResolvedFilename = defaultResolveImpl(specifier, parent, isMain, {
__proto__: null,
conditions: context.conditions,
});
defaultResolvedURL = convertCJSFilenameToURL(defaultResolvedFilename);
return { __proto__: null, url: defaultResolvedURL };
}
const resolveResult = resolveWithHooks(specifier, parentURL, /* importAttributes */ undefined,
getCjsConditions(), defaultResolve);
const { url } = resolveResult;
format = resolveResult.format;
let filename;
if (url === defaultResolvedURL) { // Not overridden, skip the re-conversion.
filename = defaultResolvedFilename;
} else {
filename = convertURLToCJSFilename(url);
}
return { __proto__: null, url, format, filename, parentURL };
}
/**
* @typedef {import('internal/modules/customization_hooks').ModuleLoadContext} ModuleLoadContext;
* @typedef {import('internal/modules/customization_hooks').ModuleLoadResult} ModuleLoadResult;
*/
/**
* Load the source code of a module based on format.
* @param {string} filename Filename of the module.
* @param {string|undefined|null} format Format of the module.
* @returns {string|null}
*/
function defaultLoadImpl(filename, format) {
switch (format) {
case undefined:
case null:
case 'module':
case 'commonjs':
case 'json':
case 'module-typescript':
case 'commonjs-typescript':
case 'typescript': {
return fs.readFileSync(filename, 'utf8');
}
case 'builtin':
return null;
default:
// URL is not necessarily necessary/available - convert it on the spot for errors.
throw new ERR_UNKNOWN_MODULE_FORMAT(format, convertCJSFilenameToURL(filename));
}
}
/**
* Construct a last nextLoad() for load hooks invoked for the CJS loader.
* @param {string} url URL passed from the hook.
* @param {string} filename Filename inferred from the URL.
* @returns {(url: string, context: ModuleLoadContext) => ModuleLoadResult}
*/
function getDefaultLoad(url, filename) {
return function defaultLoad(urlFromHook, context) {
// If the url is the same as the original one, save the conversion.
const isLoadingOriginalModule = (urlFromHook === url);
const filenameFromHook = isLoadingOriginalModule ? filename : convertURLToCJSFilename(url);
const source = defaultLoadImpl(filenameFromHook, context.format);
// Format from context is directly returned, because format detection should only be
// done after the entire load chain is completed.
return { source, format: context.format };
};
}
/**
* Load a specified builtin module, invoking load hooks if necessary.
* @param {string} id The module ID (without the node: prefix)
* @param {string} url The module URL (with the node: prefix)
* @param {string} format Format from resolution.
* @returns {any} If there are no load hooks or the load hooks do not override the format of the
* builtin, load and return the exports of the builtin. Otherwise, return undefined.
*/
function loadBuiltinWithHooks(id, url, format) {
if (loadHooks.length) {
url ??= `node:${id}`;
// TODO(joyeecheung): do we really want to invoke the load hook for the builtins?
const loadResult = loadWithHooks(url, format || 'builtin', /* importAttributes */ undefined,
getCjsConditions(), getDefaultLoad(url, id));
if (loadResult.format && loadResult.format !== 'builtin') {
return undefined; // Format has been overridden, return undefined for the caller to continue loading.
}
}
// No hooks or the hooks have not overridden the format. Load it as a builtin module and return the
// exports.
const mod = loadBuiltinModule(id);
return mod.exports;
}
/**
* Load a module from cache if it exists, otherwise create a new module instance.
* 1. If a module already exists in the cache: return its exports object.
@ -1051,19 +1212,18 @@ Module._load = function(request, parent, isMain) {
}
}
const { url, format, filename } = resolveForCJSWithHooks(request, parent, isMain);
// For backwards compatibility, if the request itself starts with node:, load it before checking
// Module._cache. Otherwise, load it after the check.
if (StringPrototypeStartsWith(request, 'node:')) {
// Slice 'node:' prefix
const id = StringPrototypeSlice(request, 5);
if (!BuiltinModule.canBeRequiredByUsers(id)) {
throw new ERR_UNKNOWN_BUILTIN_MODULE(request);
const result = loadBuiltinWithHooks(filename, url, format);
if (result) {
return result;
}
const module = loadBuiltinModule(id, request);
return module.exports;
// The format of the builtin has been overridden by user hooks. Continue loading.
}
const filename = Module._resolveFilename(request, parent, isMain);
const cachedModule = Module._cache[filename];
if (cachedModule !== undefined) {
updateChildren(parent, cachedModule, true);
@ -1088,8 +1248,11 @@ Module._load = function(request, parent, isMain) {
}
if (BuiltinModule.canBeRequiredWithoutScheme(filename)) {
const mod = loadBuiltinModule(filename, request);
return mod.exports;
const result = loadBuiltinWithHooks(filename, url, format);
if (result) {
return result;
}
// The format of the builtin has been overridden by user hooks. Continue loading.
}
// Don't call updateChildren(), Module constructor already does.
@ -1108,6 +1271,10 @@ Module._load = function(request, parent, isMain) {
reportModuleToWatchMode(filename);
Module._cache[filename] = module;
module[kIsCachedByESMLoader] = false;
// If there are resolve hooks, carry the context information into the
// load hooks for the module keyed by the (potentially customized) filename.
module[kURL] = url;
module[kFormat] = format;
}
if (parent !== undefined) {
@ -1150,11 +1317,13 @@ Module._load = function(request, parent, isMain) {
* @param {ResolveFilenameOptions} options Options object
* @typedef {object} ResolveFilenameOptions
* @property {string[]} paths Paths to search for modules in
* @property {string[]} conditions Conditions used for resolution.
*/
Module._resolveFilename = function(request, parent, isMain, options) {
if (BuiltinModule.normalizeRequirableId(request)) {
return request;
}
const conditions = (options?.conditions) || getCjsConditions();
let paths;
@ -1200,7 +1369,7 @@ Module._resolveFilename = function(request, parent, isMain, options) {
try {
const { packageImportsResolve } = require('internal/modules/esm/resolve');
return finalizeEsmResolution(
packageImportsResolve(request, pathToFileURL(parentPath), getCjsConditions()),
packageImportsResolve(request, pathToFileURL(parentPath), conditions),
parentPath,
pkg.path,
);
@ -1215,7 +1384,7 @@ Module._resolveFilename = function(request, parent, isMain, options) {
// Try module self resolution first
const parentPath = trySelfParentPath(parent);
const selfResolved = trySelf(parentPath, request);
const selfResolved = trySelf(parentPath, request, conditions);
if (selfResolved) {
const cacheKey = request + '\x00' +
(paths.length === 1 ? paths[0] : ArrayPrototypeJoin(paths, '\x00'));
@ -1224,7 +1393,7 @@ Module._resolveFilename = function(request, parent, isMain, options) {
}
// Look up the filename first, since that's the cache key.
const filename = Module._findPath(request, paths, isMain);
const filename = Module._findPath(request, paths, isMain, conditions);
if (filename) { return filename; }
const requireStack = [];
for (let cursor = parent;
@ -1291,8 +1460,8 @@ Module.prototype.load = function(filename) {
debug('load %j for module %j', filename, this.id);
assert(!this.loaded);
this.filename = filename;
this.paths = Module._nodeModulePaths(path.dirname(filename));
this.filename ??= filename;
this.paths ??= Module._nodeModulePaths(path.dirname(filename));
const extension = findLongestRegisteredExtension(filename);
@ -1572,27 +1741,41 @@ Module.prototype._compile = function(content, filename, format) {
};
/**
* Get the source code of a module, using cached ones if it's cached.
* Get the source code of a module, using cached ones if it's cached. This is used
* for TypeScript, JavaScript and JSON loading.
* After this returns, mod[kFormat], mod[kModuleSource] and mod[kURL] will be set.
* @param {Module} mod Module instance whose source is potentially already cached.
* @param {string} filename Absolute path to the file of the module.
* @returns {{source: string, format?: string}}
*/
function loadSource(mod, filename, formatFromNode) {
if (formatFromNode !== undefined) {
if (mod[kFormat] === undefined) {
mod[kFormat] = formatFromNode;
}
const format = mod[kFormat];
let source = mod[kModuleSource];
if (source !== undefined) {
mod[kModuleSource] = undefined;
} else {
// TODO(joyeecheung): we can read a buffer instead to speed up
// compilation.
source = fs.readFileSync(filename, 'utf8');
// If the module was loaded before, just return.
if (mod[kModuleSource] !== undefined) {
return { source: mod[kModuleSource], format: mod[kFormat] };
}
return { source, format };
// Fast path: no hooks, just load it and return.
if (!loadHooks.length) {
const source = defaultLoadImpl(filename, formatFromNode);
return { source, format: formatFromNode };
}
if (mod[kURL] === undefined) {
mod[kURL] = convertCJSFilenameToURL(filename);
}
const loadResult = loadWithHooks(mod[kURL], mod[kFormat], /* importAttributes */ undefined, getCjsConditions(),
getDefaultLoad(mod[kURL], filename));
// Reset the module properties with load hook results.
if (loadResult.format !== undefined) {
mod[kFormat] = loadResult.format;
}
mod[kModuleSource] = loadResult.source;
return { source: mod[kModuleSource], format: mod[kFormat] };
}
/**
@ -1610,7 +1793,6 @@ function loadMTS(mod, filename) {
* @param {Module} module CJS module instance
* @param {string} filename The file path of the module
*/
function loadCTS(module, filename) {
const loadResult = loadSource(module, filename, 'commonjs-typescript');
module._compile(loadResult.source, filename, loadResult.format);
@ -1724,7 +1906,7 @@ Module._extensions['.js'] = function(module, filename) {
* @param {string} filename The file path of the module
*/
Module._extensions['.json'] = function(module, filename) {
const content = fs.readFileSync(filename, 'utf8');
const { source: content } = loadSource(module, filename, 'json');
try {
setOwnProperty(module, 'exports', JSONParse(stripBOM(content)));
@ -1878,3 +2060,4 @@ ObjectDefineProperty(Module.prototype, 'constructor', {
// Backwards compatibility
Module.Module = Module;
Module.registerHooks = registerHooks;