-
-
Notifications
You must be signed in to change notification settings - Fork 33.5k
Allow for multiple --loader flags #18914
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
Changes from all commits
6e9e023
0d5badf
64fa4d8
e88059f
b03d27d
3bd6e7c
e2a89b9
4f974f0
4906860
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -106,26 +106,45 @@ fs.readFile('./foo.txt', (err, body) => { | |
<!-- type=misc --> | ||
|
||
To customize the default module resolution, loader hooks can optionally be | ||
provided via a `--loader ./loader-name.mjs` argument to Node. | ||
provided via a `--loader ./loader-name.mjs` argument to Node.js. This argument | ||
can be passed multiple times to compose loaders like | ||
`--loader ./loader-coverage.mjs --loader ./loader-mocking.mjs`. The last loader | ||
must explicitly call to the parent loader in order to provide compose behavior. | ||
|
||
|
||
When hooks are used they only apply to ES module loading and not to any | ||
CommonJS modules loaded. | ||
|
||
All loaders are created by invoking the default export of their module as a | ||
|
||
function. The parameters given to the function are a single object with | ||
properties to call the `resolve` and `dynamicInstantiate` hooks of the parent | ||
loader. The default loader has a `resolve` hook and a function that throws for | ||
the value of `dynamicInstantiate`. | ||
|
||
|
||
### Resolve hook | ||
|
||
The resolve hook returns the resolved file URL and module format for a | ||
given module specifier and parent file URL: | ||
|
||
```js | ||
// example loader that treats all files within the current working directory as | ||
// ECMAScript Modules | ||
const baseURL = new URL('file://'); | ||
baseURL.pathname = `${process.cwd()}/`; | ||
|
||
export async function resolve(specifier, | ||
parentModuleURL = baseURL, | ||
defaultResolver) { | ||
export default function(parent) { | ||
return { | ||
url: new URL(specifier, parentModuleURL).href, | ||
format: 'esm' | ||
async resolve(specifier, | ||
|
||
parentModuleURL = baseURL) { | ||
const location = new URL(specifier, parentModuleURL); | ||
if (locations.host === baseURL.host && | ||
location.pathname.startsWith(baseURL.pathname)) { | ||
return { | ||
url: location.href, | ||
format: 'esm' | ||
}; | ||
} | ||
return parent.resolve(specifier, parentModuleURL); | ||
} | ||
|
||
}; | ||
} | ||
``` | ||
|
@@ -164,28 +183,35 @@ const JS_EXTENSIONS = new Set(['.js', '.mjs']); | |
const baseURL = new URL('file://'); | ||
baseURL.pathname = `${process.cwd()}/`; | ||
|
||
export function resolve(specifier, parentModuleURL = baseURL, defaultResolve) { | ||
if (builtins.includes(specifier)) { | ||
return { | ||
url: specifier, | ||
format: 'builtin' | ||
}; | ||
} | ||
if (/^\.{0,2}[/]/.test(specifier) !== true && !specifier.startsWith('file:')) { | ||
// For node_modules support: | ||
// return defaultResolve(specifier, parentModuleURL); | ||
throw new Error( | ||
`imports must begin with '/', './', or '../'; '${specifier}' does not`); | ||
} | ||
const resolved = new URL(specifier, parentModuleURL); | ||
const ext = path.extname(resolved.pathname); | ||
if (!JS_EXTENSIONS.has(ext)) { | ||
throw new Error( | ||
`Cannot load file with non-JavaScript file extension ${ext}.`); | ||
} | ||
export default function(parent) { | ||
return { | ||
url: resolved.href, | ||
format: 'esm' | ||
resolve(specifier, parentModuleURL = baseURL) { | ||
if (builtins.includes(specifier)) { | ||
return { | ||
url: specifier, | ||
format: 'builtin' | ||
}; | ||
} | ||
if (/^\.{0,2}[/]/.test(specifier) !== true && | ||
!specifier.startsWith('file:')) { | ||
// For node_modules support: | ||
// return parent.resolve(specifier, parentModuleURL); | ||
throw new Error( | ||
`imports must begin with '/', './', or '../'; '${ | ||
specifier | ||
}' does not`); | ||
} | ||
const resolved = new URL(specifier, parentModuleURL); | ||
const ext = path.extname(resolved.pathname); | ||
if (!JS_EXTENSIONS.has(ext)) { | ||
throw new Error( | ||
`Cannot load file with non-JavaScript file extension ${ext}.`); | ||
} | ||
return { | ||
url: resolved.href, | ||
format: 'esm' | ||
}; | ||
} | ||
}; | ||
} | ||
``` | ||
|
@@ -207,12 +233,25 @@ This hook is called only for modules that return `format: "dynamic"` from | |
the `resolve` hook. | ||
|
||
```js | ||
export async function dynamicInstantiate(url) { | ||
// example loader that can generate modules for .txt files | ||
// that resolved to a 'dynamic' format | ||
import fs from 'fs'; | ||
import util from 'util'; | ||
export default function(parent) { | ||
return { | ||
exports: ['customExportName'], | ||
execute: (exports) => { | ||
// get and set functions provided for pre-allocated export names | ||
exports.customExportName.set('value'); | ||
async dynamicInstantiate(url) { | ||
const location = new URL(url); | ||
if (location.pathname.slice(-4) === '.txt') { | ||
const text = String(await util.promisify(fs.readFile)(location)); | ||
return { | ||
exports: ['text'], | ||
execute: (exports) => { | ||
// get and set functions provided for pre-allocated export names | ||
exports.text.set(text); | ||
} | ||
}; | ||
} | ||
return parent.dynamicInstantiate(url); | ||
} | ||
}; | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,14 +2,28 @@ | |
|
||
const { | ||
setImportModuleDynamicallyCallback, | ||
setInitializeImportMetaObjectCallback | ||
setInitializeImportMetaObjectCallback, | ||
} = internalBinding('module_wrap'); | ||
|
||
const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; | ||
const hasOwnProperty = Function.call.bind(Object.prototype.hasOwnProperty); | ||
const apply = Reflect.apply; | ||
|
||
const errors = require('internal/errors'); | ||
const { getURLFromFilePath } = require('internal/url'); | ||
const Loader = require('internal/loader/Loader'); | ||
const path = require('path'); | ||
const { URL } = require('url'); | ||
|
||
// fires a getter or reads the value off a descriptor | ||
|
||
function grabPropertyOffDescriptor(object, descriptor) { | ||
if (hasOwnProperty(descriptor, 'value')) { | ||
return descriptor.value; | ||
} else { | ||
return apply(descriptor.get, object, []); | ||
} | ||
} | ||
|
||
function normalizeReferrerURL(referrer) { | ||
if (typeof referrer === 'string' && path.isAbsolute(referrer)) { | ||
return getURLFromFilePath(referrer).href; | ||
|
@@ -23,25 +37,81 @@ function initializeImportMetaObject(wrap, meta) { | |
|
||
let loaderResolve; | ||
exports.loaderPromise = new Promise((resolve, reject) => { | ||
loaderResolve = resolve; | ||
loaderResolve = (v) => { | ||
resolve(v); | ||
}; | ||
}); | ||
|
||
exports.ESMLoader = undefined; | ||
|
||
exports.setup = function() { | ||
setInitializeImportMetaObjectCallback(initializeImportMetaObject); | ||
|
||
let ESMLoader = new Loader(); | ||
const RuntimeLoader = new Loader(); | ||
const loaderPromise = (async () => { | ||
const userLoader = process.binding('config').userLoader; | ||
if (userLoader) { | ||
const hooks = await ESMLoader.import( | ||
userLoader, getURLFromFilePath(`${process.cwd()}/`).href); | ||
ESMLoader = new Loader(); | ||
ESMLoader.hook(hooks); | ||
exports.ESMLoader = ESMLoader; | ||
const { userLoaders } = process.binding('config'); | ||
if (userLoaders) { | ||
const BootstrapLoader = new Loader(); | ||
exports.ESMLoader = BootstrapLoader; | ||
let resolve = (url, referrer) => { | ||
return require('internal/loader/DefaultResolve')(url, referrer); | ||
}; | ||
let dynamicInstantiate = (url) => { | ||
throw new errors.Error('ERR_MISSING_DYNAMIC_INSTANTIATE_HOOK'); | ||
}; | ||
for (var i = 0; i < userLoaders.length; i++) { | ||
const loaderSpecifier = userLoaders[i]; | ||
const { default: factory } = await BootstrapLoader.import( | ||
loaderSpecifier); | ||
const cachedResolve = resolve; | ||
const cachedDynamicInstantiate = dynamicInstantiate; | ||
const next = factory({ | ||
__proto__: null, | ||
resolve: Object.setPrototypeOf(async (url, referrer) => { | ||
const ret = await cachedResolve(url, referrer); | ||
return { | ||
__proto__: null, | ||
url: `${ret.url}`, | ||
format: `${ret.format}`, | ||
}; | ||
}, null), | ||
dynamicInstantiate: Object.setPrototypeOf(async (url) => { | ||
const ret = await cachedDynamicInstantiate(url); | ||
return { | ||
__proto__: null, | ||
exports: ret.exports, | ||
execute: ret.execute, | ||
}; | ||
}, null), | ||
}); | ||
|
||
const resolveDesc = getOwnPropertyDescriptor(next, 'resolve'); | ||
if (resolveDesc !== undefined) { | ||
resolve = grabPropertyOffDescriptor(next, resolveDesc); | ||
if (typeof resolve !== 'function') { | ||
throw new errors.TypeError('ERR_LOADER_HOOK_BAD_TYPE', | ||
'resolve', 'function'); | ||
} | ||
} | ||
const dynamicInstantiateDesc = getOwnPropertyDescriptor( | ||
next, | ||
'dynamicInstantiate'); | ||
if (dynamicInstantiateDesc !== undefined) { | ||
dynamicInstantiate = grabPropertyOffDescriptor( | ||
next, | ||
dynamicInstantiateDesc); | ||
if (typeof dynamicInstantiate !== 'function') { | ||
throw new errors.TypeError('ERR_LOADER_HOOK_BAD_TYPE', | ||
'dynamicInstantiate', 'function'); | ||
} | ||
} | ||
} | ||
RuntimeLoader.hook({ | ||
resolve, | ||
dynamicInstantiate | ||
}); | ||
} | ||
return ESMLoader; | ||
exports.ESMLoader = RuntimeLoader; | ||
return RuntimeLoader; | ||
})(); | ||
loaderResolve(loaderPromise); | ||
|
||
|
@@ -50,5 +120,5 @@ exports.setup = function() { | |
return loader.import(specifier, normalizeReferrerURL(referrer)); | ||
}); | ||
|
||
exports.ESMLoader = ESMLoader; | ||
exports.RuntimeLoader = RuntimeLoader; | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
/* eslint-disable node-core/required-modules */ | ||
export default import.meta.url; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
/* eslint-disable node-core/required-modules */ | ||
import url from './esm-export-url.mjs?x=y'; | ||
process.stdout.write(url); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
/* eslint-disable node-core/required-modules */ | ||
import url from 'url'; | ||
|
||
export default ({ | ||
resolve: parentResolve | ||
}) => ({ | ||
async resolve(specifier, parentModuleURL) { | ||
const parentResolved = await parentResolve(specifier, parentModuleURL); | ||
const request = new url.URL(parentResolved.url); | ||
request.hash = '#hash'; | ||
return { | ||
url: request.href, | ||
format: parentResolved.format | ||
}; | ||
} | ||
}); | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this mean that if any loader forgets to call to the previous loader, it will break the chain?
If so, that seems like a pit of failure; could we make this a pit of success by forcing loaders that want to break the chain to explicitly do so?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
perhaps if a hook returns
null
we automatically walk up the chain?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I actually like the current design - it pretty much mimics the behavior of middleware in
koa
, which is kinda similar.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@devsnek
== null
, probably :-)@weswigham the difference I see here is that if a middleware breaks the contract, your entire request likely fails, because nothing further proceeds; however, depending on what the loaders do, node might silently do the wrong thing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should definitely not just check for undefined. We should require full responsibility to handle all cases and avoid polymorphic return types. We could add something more complicated to model having defaults, but I'm more curious about a code example of this idea of the current design being a pit of failure.
Since the parameters are not sufficient to produce a valid return value, loaders must do something to return a proper value. Is there a real world use case where you are not breaking the chain but are also not fundamentally always calling the parent to do useful things?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fwiw I agree that all of the use cases where "being able to explicitly call the parent loader" are valuable, including "not calling the parent loader" - however, I think it's important that the default, when no explicit choice is made, should be "call the parent loader".
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@ljharb the isn't really a way to explicitly say you are not choosing your return value. We can only make well known return values, which I would want to be the same type as the other return values.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@ljharb to clarify. I am not really seeing the case where you want to default to the parent, but do not want to take any action using the result of the parent as the common case. Most loaders want to redirect behaviors or mutate what would be loaded. The only way to know what would be loaded is to query the parent.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure that's correct - If a given loader wasn't made with a loader pipeline in mind (ie, never explicitly calls the parent), how am I even sure the loader is capable of yielding output another loader can usefully use?
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Like
return null
is also probably more cryptic thanreturn parentResolve()
.