mirror of
https://github.com/nodejs/node.git
synced 2025-08-16 06:08:50 +02:00
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:
parent
2960a59540
commit
e85964610c
57 changed files with 2045 additions and 95 deletions
366
lib/internal/modules/customization_hooks.js
Normal file
366
lib/internal/modules/customization_hooks.js
Normal file
|
@ -0,0 +1,366 @@
|
|||
'use strict';
|
||||
|
||||
const {
|
||||
ArrayPrototypeFindIndex,
|
||||
ArrayPrototypePush,
|
||||
ArrayPrototypeSplice,
|
||||
ObjectFreeze,
|
||||
StringPrototypeStartsWith,
|
||||
Symbol,
|
||||
} = primordials;
|
||||
const {
|
||||
isAnyArrayBuffer,
|
||||
isArrayBufferView,
|
||||
} = require('internal/util/types');
|
||||
|
||||
const { BuiltinModule } = require('internal/bootstrap/realm');
|
||||
const {
|
||||
ERR_INVALID_RETURN_PROPERTY_VALUE,
|
||||
} = require('internal/errors').codes;
|
||||
const { validateFunction } = require('internal/validators');
|
||||
const { isAbsolute } = require('path');
|
||||
const { pathToFileURL, fileURLToPath } = require('internal/url');
|
||||
|
||||
let debug = require('internal/util/debuglog').debuglog('module_hooks', (fn) => {
|
||||
debug = fn;
|
||||
});
|
||||
|
||||
/** @typedef {import('internal/modules/cjs/loader.js').Module} Module */
|
||||
/**
|
||||
* @typedef {(specifier: string, context: ModuleResolveContext, nextResolve: ResolveHook)
|
||||
* => ModuleResolveResult} ResolveHook
|
||||
* @typedef {(url: string, context: ModuleLoadContext, nextLoad: LoadHook)
|
||||
* => ModuleLoadResult} LoadHook
|
||||
*/
|
||||
|
||||
// Use arrays for better insertion and iteration performance, we don't care
|
||||
// about deletion performance as much.
|
||||
const resolveHooks = [];
|
||||
const loadHooks = [];
|
||||
const hookId = Symbol('kModuleHooksIdKey');
|
||||
let nextHookId = 0;
|
||||
|
||||
class ModuleHooks {
|
||||
/**
|
||||
* @param {ResolveHook|undefined} resolve User-provided hook.
|
||||
* @param {LoadHook|undefined} load User-provided hook.
|
||||
*/
|
||||
constructor(resolve, load) {
|
||||
this[hookId] = Symbol(`module-hook-${nextHookId++}`);
|
||||
// Always initialize all hooks, if it's unspecified it'll be an owned undefined.
|
||||
this.resolve = resolve;
|
||||
this.load = load;
|
||||
|
||||
if (resolve) {
|
||||
ArrayPrototypePush(resolveHooks, this);
|
||||
}
|
||||
if (load) {
|
||||
ArrayPrototypePush(loadHooks, this);
|
||||
}
|
||||
|
||||
ObjectFreeze(this);
|
||||
}
|
||||
// TODO(joyeecheung): we may want methods that allow disabling/enabling temporarily
|
||||
// which just sets the item in the array to undefined temporarily.
|
||||
// TODO(joyeecheung): this can be the [Symbol.dispose] implementation to pair with
|
||||
// `using` when the explicit resource management proposal is shipped by V8.
|
||||
/**
|
||||
* Deregister the hook instance.
|
||||
*/
|
||||
deregister() {
|
||||
const id = this[hookId];
|
||||
let index = ArrayPrototypeFindIndex(resolveHooks, (hook) => hook[hookId] === id);
|
||||
if (index !== -1) {
|
||||
ArrayPrototypeSplice(resolveHooks, index, 1);
|
||||
}
|
||||
index = ArrayPrototypeFindIndex(loadHooks, (hook) => hook[hookId] === id);
|
||||
if (index !== -1) {
|
||||
ArrayPrototypeSplice(loadHooks, index, 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* TODO(joyeecheung): taken an optional description?
|
||||
* @param {{ resolve?: ResolveHook, load?: LoadHook }} hooks User-provided hooks
|
||||
* @returns {ModuleHooks}
|
||||
*/
|
||||
function registerHooks(hooks) {
|
||||
const { resolve, load } = hooks;
|
||||
if (resolve) {
|
||||
validateFunction(resolve, 'hooks.resolve');
|
||||
}
|
||||
if (load) {
|
||||
validateFunction(load, 'hooks.load');
|
||||
}
|
||||
return new ModuleHooks(resolve, load);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} filename
|
||||
* @returns {string}
|
||||
*/
|
||||
function convertCJSFilenameToURL(filename) {
|
||||
if (!filename) { return filename; }
|
||||
const builtinId = BuiltinModule.normalizeRequirableId(filename);
|
||||
if (builtinId) {
|
||||
return `node:${builtinId}`;
|
||||
}
|
||||
// Handle the case where filename is neither a path, nor a built-in id,
|
||||
// which is possible via monkey-patching.
|
||||
if (isAbsolute(filename)) {
|
||||
return pathToFileURL(filename).href;
|
||||
}
|
||||
return filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @returns {string}
|
||||
*/
|
||||
function convertURLToCJSFilename(url) {
|
||||
if (!url) { return url; }
|
||||
const builtinId = BuiltinModule.normalizeRequirableId(url);
|
||||
if (builtinId) {
|
||||
return builtinId;
|
||||
}
|
||||
if (StringPrototypeStartsWith(url, 'file://')) {
|
||||
return fileURLToPath(url);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a list of hooks into a function that can be used to do an operation through
|
||||
* a chain of hooks. If any of the hook returns without calling the next hook, it
|
||||
* must return shortCircuit: true to stop the chain from continuing to avoid
|
||||
* forgetting to invoke the next hook by mistake.
|
||||
* @param {ModuleHooks[]} hooks A list of hooks whose last argument is `nextHook`.
|
||||
* @param {'load'|'resolve'} name Name of the hook in ModuleHooks.
|
||||
* @param {Function} defaultStep The default step in the chain.
|
||||
* @param {Function} validate A function that validates and sanitize the result returned by the chain.
|
||||
* @returns {Function}
|
||||
*/
|
||||
function buildHooks(hooks, name, defaultStep, validate) {
|
||||
let lastRunIndex = hooks.length;
|
||||
function wrapHook(index, userHook, next) {
|
||||
return function wrappedHook(...args) {
|
||||
lastRunIndex = index;
|
||||
const hookResult = userHook(...args, next);
|
||||
if (lastRunIndex > 0 && lastRunIndex === index && !hookResult.shortCircuit) {
|
||||
throw new ERR_INVALID_RETURN_PROPERTY_VALUE('true', name, 'shortCircuit',
|
||||
hookResult.shortCircuit);
|
||||
}
|
||||
return validate(...args, hookResult);
|
||||
};
|
||||
}
|
||||
const chain = [wrapHook(0, defaultStep)];
|
||||
for (let i = 0; i < hooks.length; ++i) {
|
||||
const wrappedHook = wrapHook(i + 1, hooks[i][name], chain[i]);
|
||||
ArrayPrototypePush(chain, wrappedHook);
|
||||
}
|
||||
return chain[chain.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {object} ModuleResolveResult
|
||||
* @property {string} url Resolved URL of the module.
|
||||
* @property {string|undefined} format Format of the module.
|
||||
* @property {ImportAttributes|undefined} importAttributes Import attributes for the request.
|
||||
* @property {boolean|undefined} shortCircuit Whether the next hook has been skipped.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Validate the result returned by a chain of resolve hook.
|
||||
* @param {string} specifier Specifier passed into the hooks.
|
||||
* @param {ModuleResolveContext} context Context passed into the hooks.
|
||||
* @param {ModuleResolveResult} result Result produced by resolve hooks.
|
||||
* @returns {ModuleResolveResult}
|
||||
*/
|
||||
function validateResolve(specifier, context, result) {
|
||||
const { url, format, importAttributes } = result;
|
||||
if (typeof url !== 'string') {
|
||||
throw new ERR_INVALID_RETURN_PROPERTY_VALUE(
|
||||
'a URL string',
|
||||
'resolve',
|
||||
'url',
|
||||
url,
|
||||
);
|
||||
}
|
||||
|
||||
if (format && typeof format !== 'string') {
|
||||
throw new ERR_INVALID_RETURN_PROPERTY_VALUE(
|
||||
'a string',
|
||||
'resolve',
|
||||
'format',
|
||||
format,
|
||||
);
|
||||
}
|
||||
|
||||
if (importAttributes && typeof importAttributes !== 'object') {
|
||||
throw new ERR_INVALID_RETURN_PROPERTY_VALUE(
|
||||
'an object',
|
||||
'resolve',
|
||||
'importAttributes',
|
||||
importAttributes,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
__proto__: null,
|
||||
url,
|
||||
format,
|
||||
importAttributes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {object} ModuleLoadResult
|
||||
* @property {string|undefined} format Format of the loaded module.
|
||||
* @property {string|ArrayBuffer|TypedArray} source Source code of the module.
|
||||
* @property {boolean|undefined} shortCircuit Whether the next hook has been skipped.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Validate the result returned by a chain of resolve hook.
|
||||
* @param {string} url URL passed into the hooks.
|
||||
* @param {ModuleLoadContext} context Context passed into the hooks.
|
||||
* @param {ModuleLoadResult} result Result produced by load hooks.
|
||||
* @returns {ModuleLoadResult}
|
||||
*/
|
||||
function validateLoad(url, context, result) {
|
||||
const { source, format } = result;
|
||||
// To align with module.register(), the load hooks are still invoked for
|
||||
// the builtins even though the default load step only provides null as source,
|
||||
// and any source content for builtins provided by the user hooks are ignored.
|
||||
if (!StringPrototypeStartsWith(url, 'node:') &&
|
||||
typeof result.source !== 'string' &&
|
||||
!isAnyArrayBuffer(source) &&
|
||||
!isArrayBufferView(source)) {
|
||||
throw new ERR_INVALID_RETURN_PROPERTY_VALUE(
|
||||
'a string, an ArrayBuffer, or a TypedArray',
|
||||
'load',
|
||||
'source',
|
||||
source,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof format !== 'string' && format !== undefined) {
|
||||
throw new ERR_INVALID_RETURN_PROPERTY_VALUE(
|
||||
'a string',
|
||||
'load',
|
||||
'format',
|
||||
format,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
__proto__: null,
|
||||
format,
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
class ModuleResolveContext {
|
||||
/**
|
||||
* Context for the resolve hook.
|
||||
* @param {string|undefined} parentURL Parent URL.
|
||||
* @param {ImportAttributes|undefined} importAttributes Import attributes.
|
||||
* @param {string[]} conditions Conditions.
|
||||
*/
|
||||
constructor(parentURL, importAttributes, conditions) {
|
||||
this.parentURL = parentURL;
|
||||
this.importAttributes = importAttributes;
|
||||
this.conditions = conditions;
|
||||
// TODO(joyeecheung): a field to differentiate between require and import?
|
||||
}
|
||||
};
|
||||
|
||||
class ModuleLoadContext {
|
||||
/**
|
||||
* Context for the load hook.
|
||||
* @param {string|undefined} format URL.
|
||||
* @param {ImportAttributes|undefined} importAttributes Import attributes.
|
||||
* @param {string[]} conditions Conditions.
|
||||
*/
|
||||
constructor(format, importAttributes, conditions) {
|
||||
this.format = format;
|
||||
this.importAttributes = importAttributes;
|
||||
this.conditions = conditions;
|
||||
}
|
||||
};
|
||||
|
||||
let decoder;
|
||||
/**
|
||||
* Load module source for a url, through a hooks chain if it exists.
|
||||
* @param {string} url
|
||||
* @param {string|undefined} originalFormat
|
||||
* @param {ImportAttributes|undefined} importAttributes
|
||||
* @param {string[]} conditions
|
||||
* @param {(url: string, context: ModuleLoadContext) => ModuleLoadResult} defaultLoad
|
||||
* @returns {ModuleLoadResult}
|
||||
*/
|
||||
function loadWithHooks(url, originalFormat, importAttributes, conditions, defaultLoad) {
|
||||
debug('loadWithHooks', url, originalFormat);
|
||||
const context = new ModuleLoadContext(originalFormat, importAttributes, conditions);
|
||||
if (loadHooks.length === 0) {
|
||||
return defaultLoad(url, context);
|
||||
}
|
||||
|
||||
const runner = buildHooks(loadHooks, 'load', defaultLoad, validateLoad);
|
||||
|
||||
const result = runner(url, context);
|
||||
const { source, format } = result;
|
||||
if (!isAnyArrayBuffer(source) && !isArrayBufferView(source)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
switch (format) {
|
||||
// Text formats:
|
||||
case undefined:
|
||||
case 'module':
|
||||
case 'commonjs':
|
||||
case 'json':
|
||||
case 'module-typescript':
|
||||
case 'commonjs-typescript':
|
||||
case 'typescript': {
|
||||
decoder ??= new (require('internal/encoding').TextDecoder)();
|
||||
result.source = decoder.decode(source);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve module request to a url, through a hooks chain if it exists.
|
||||
* @param {string} specifier
|
||||
* @param {string|undefined} parentURL
|
||||
* @param {ImportAttributes|undefined} importAttributes
|
||||
* @param {string[]} conditions
|
||||
* @param {(specifier: string, context: ModuleResolveContext) => ModuleResolveResult} defaultResolve
|
||||
* @returns {ModuleResolveResult}
|
||||
*/
|
||||
function resolveWithHooks(specifier, parentURL, importAttributes, conditions, defaultResolve) {
|
||||
debug('resolveWithHooks', specifier, parentURL, importAttributes);
|
||||
const context = new ModuleResolveContext(parentURL, importAttributes, conditions);
|
||||
if (resolveHooks.length === 0) {
|
||||
return defaultResolve(specifier, context);
|
||||
}
|
||||
|
||||
const runner = buildHooks(resolveHooks, 'resolve', defaultResolve, validateResolve);
|
||||
|
||||
return runner(specifier, context);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
convertCJSFilenameToURL,
|
||||
convertURLToCJSFilename,
|
||||
loadHooks,
|
||||
loadWithHooks,
|
||||
registerHooks,
|
||||
resolveHooks,
|
||||
resolveWithHooks,
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue