-
Notifications
You must be signed in to change notification settings - Fork 29.6k
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
module: resolve and instantiate loader pipeline hooks #15445
Conversation
@guybedford what might it look like to extend on the hooks available, such that a modified source-file could be stored in the cache? I'm interested in how we'll continue providing test-coverage for |
@bcoe one option might be to add a separate 'fetch' hook to such a loader object alongside the 'resolve' here which could then add instrumentation. Happy to prototype it if there's interest. |
@guybedford I'm very interested. The built-in coverage in V8 doesn't seem to quite be over the finish line yet, so this would be a great transitional step for continuing to support test-coverage with Istanbul. |
@guybedford @bmeck any chance we could also allow you to attach a loader in a way similar to |
@bcoe something like import module from 'module';
import { customResolve, customFetch } from 'custom-loader';
module.registerHook('resolve', customResolve);
module.registerHook('fetch', customFetch);
// now kick off the dynamic import hooking the main application tree load
import('./entry-point.mjs'); This was actually roughly what I had for the original API here, but after discussion with @bmeck we adopted a flag rather here to avoid some of the above issues. I'm still open to either approach. |
lib/internal/errors.js
Outdated
@@ -287,6 +287,8 @@ E('ERR_TRANSFORM_WITH_LENGTH_0', | |||
E('ERR_UNESCAPED_CHARACTERS', | |||
(name) => `${name} contains unescaped characters`); | |||
E('ERR_UNKNOWN_ENCODING', 'Unknown encoding: %s'); | |||
E('ERR_UNKNOWN_FILE_EXTENSION', 'Uknown file extension: %s'); |
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.
typo: s/Uknown/Unknown
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.
Maybe this could print out the file too?
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.
Sure, instead of just outputting the extension we can output the full path.
lib/internal/errors.js
Outdated
@@ -287,6 +287,8 @@ E('ERR_TRANSFORM_WITH_LENGTH_0', | |||
E('ERR_UNESCAPED_CHARACTERS', | |||
(name) => `${name} contains unescaped characters`); | |||
E('ERR_UNKNOWN_ENCODING', 'Unknown encoding: %s'); | |||
E('ERR_UNKNOWN_FILE_EXTENSION', 'Uknown file extension: %s'); | |||
E('ERR_UNKNOWN_LOADER', 'Uknown module loader: %s'); |
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.
here too :-)
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.
The message here seems a bit vague - what does this mean?
src/node.cc
Outdated
@@ -3950,6 +3955,8 @@ static void CheckIfAllowedInEnv(const char* exe, bool is_env, | |||
"--no-warnings", | |||
"--napi-modules", | |||
"--expose-http2", | |||
"--experimental-modules", | |||
"--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.
Perhaps --module-loader
would be more descriptive?
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.
@jasnell what might it be confused by?
READONLY_BOOLEAN_PROPERTY("experimentalModules"); | ||
if (!config_userland_loader.empty()) { |
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.
thank you for using node_config
to expose this :-)
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.
That is all @bmeck!
Awesome to see this @guybedford ... left a few initial review comments but will need to play with this a bit more before signing off. Thank you for working on this |
I'd like to leave things as declarative instead of API based if possible for now. As for a fetch transform, I have several ideas on how to accomplish similar ideas without introducing that transform:
These can be worked through in the browser if done carefully; I am not convinced we should introduce fetch hooks in Node without creating a story for network backed modules and security of them. I also would like some involvement from browser people who are creating multiple different execution workers like ServiceWorker in order to do these tasks. For code coverage I know V8 now has that instrumented in the codegen, so perhaps using that and adding missing features would be easier over time than trying to have hooks. Especially since we don't have a composition mechanism for these hooks currently. This PR looks good even though I think we have more than enough hook area already and should not expand the scope of the PR. Overall, I think minimal hooks is the best approach in the beginning as I still think these hooks will change as we use them / won't look exactly the same by the time they are released.
|
I'm not sure when this is targeted to land, but this seems very early. I would hope this doesn't end up in Node core at least until the ecosystem has had some time to settle down and explore alternatives and prove that this particular API shape is the best, and that the use cases it addresses are actually important enough to add such a powerful, fragmentation-encouraging primitive which cannot be taken back. General API comment: please never return or accept URL objects; see https://url.spec.whatwg.org/#url-apis-elsewhere . On the actual capabilities here, I think the resolve hook is straightforward enough (although may end up mismatching browsers if they need something more declarative, as is currently the thinking). The loader object is much scarier and is nowhere near what we would currently contemplate exposing in browsers (as essentially we are trying to stick with Source Text Module Records, so any hook I can imagine would be just a fetch hook, i.e. service workers). |
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.
left some comments :)
lib/internal/errors.js
Outdated
@@ -287,6 +287,8 @@ E('ERR_TRANSFORM_WITH_LENGTH_0', | |||
E('ERR_UNESCAPED_CHARACTERS', | |||
(name) => `${name} contains unescaped characters`); | |||
E('ERR_UNKNOWN_ENCODING', 'Unknown encoding: %s'); | |||
E('ERR_UNKNOWN_FILE_EXTENSION', 'Uknown file extension: %s'); | |||
E('ERR_UNKNOWN_LOADER', 'Uknown module loader: %s'); |
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.
The message here seems a bit vague - what does this mean?
lib/internal/errors.js
Outdated
@@ -287,6 +287,8 @@ E('ERR_TRANSFORM_WITH_LENGTH_0', | |||
E('ERR_UNESCAPED_CHARACTERS', | |||
(name) => `${name} contains unescaped characters`); | |||
E('ERR_UNKNOWN_ENCODING', 'Unknown encoding: %s'); | |||
E('ERR_UNKNOWN_FILE_EXTENSION', 'Uknown file extension: %s'); |
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.
Maybe this could print out the file too?
lib/internal/loader/ModuleRequest.js
Outdated
|
||
// Strategy for loading a standard JavaScript module | ||
loaders.set('esm', async (url) => { | ||
const source = `${await asyncReadFile(url)}`; |
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.
What happens if the readFile throws?
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.
- The loader fails to produce a ModuleWrap
- The module job it is placed in produces an error (cached as per spec): https://github.com/nodejs/node/pull/15445/files/db6eb0069cfc94d00b647525a47ddc73dba9ccd8#diff-e6f2798544dae1f6ec863621e745489aR93
- All imports (current and in future) of that absolute url fail due to the job failing: https://github.com/nodejs/node/pull/15445/files/db6eb0069cfc94d00b647525a47ddc73dba9ccd8#diff-e6f2798544dae1f6ec863621e745489aR101
lib/internal/loader/ModuleRequest.js
Outdated
return { url, loader: 'esm' }; | ||
case '.json': | ||
return { url, loader: 'cjs' }; | ||
case '.node': |
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.
Aren't .node
files native addon C++?
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.
they use the CJS interop
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.
It might be nice to add a binary
format now - suggestions welcome for how to load a file as just a Node binary, throwing otherwise.
lib/internal/loader/ModuleRequest.js
Outdated
case '.js': | ||
return { url, loader: 'cjs' }; | ||
default: | ||
throw new errors.Error('ERR_UNKNOWN_FILE_EXTENSION', ext); |
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.
Shouldn't this default to 'cjs'
? (Though I suppose this is not required for importing CJS via ESM...)
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.
This is for future proofing, just like #15365 was talking about.
src/node.cc
Outdated
@@ -3950,6 +3955,8 @@ static void CheckIfAllowedInEnv(const char* exe, bool is_env, | |||
"--no-warnings", | |||
"--napi-modules", | |||
"--expose-http2", | |||
"--experimental-modules", | |||
"--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.
@jasnell what might it be confused by?
Thanks so much for all feedback so far. I've updated the description above to match the latest updates:
|
lib/module.js
Outdated
@@ -490,6 +490,10 @@ function tryModuleLoad(module, filename) { | |||
} | |||
} | |||
|
|||
Module.import = function(request, parent) { | |||
return ESMLoader.import(request, parent); | |||
}; |
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.
Why not make this Module.prototype.import
so you can get rid of the parent
parameter (and use this
instead)?
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.
there eventually could be multiple loaders / I would like to see this fn removed when import()
lands
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.
Module.import
doesn't appear to be used anywhere else in this PR or usage examples, is it dead code?
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.
used as stop gap til import()
in [email protected] lands
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.
used as stop gap til import() in [email protected] lands
Is there a rough idea of when that'll be?
Would Module.import
be intended as public consumable API?
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.
Sometime in Node@9
no, i'd remove it once 6.2 lands.
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 guard this by checking for experimentalModules
Ok, so @bcoe asked me to chime in: My primary use case for a custom loader is mocks. I currently maintain require-inject which fiddles with the cache to allow tests to replace modules with their own code. How it does this is not something I'd want to see replicated as it's very much a hack, but the functionality is something I don't want to lose with ESM. It looks like a loader API + An interpreter flag might be workable (because these are tests) but would be awkward, and some version of |
I'd assume |
lib/internal/loader/Loader.js
Outdated
parentURLOrString = new URL(parentURLOrString); | ||
} else if (parentURLOrString instanceof URL === false) { | ||
throw new errors.TypeError('ERR_INVALID_ARG_TYPE', | ||
'parentURLOrString', 'URL'); |
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.
This doesn't really address Domenic's concern of a mutable URL
object being passed around. Instead you can do if (typeof parentURLOrString !== 'string' && !(parentURLOrString instanceof URL)) throw ...; const url = new URL(parentURLOrString);
.
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.
Or just do ToString on any incoming argument like every ES and web API do.
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 think the Node code precedent here is to use template strings to coerce:
`${url}`
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.
This resolve isn't exposed publicly, it's the this.resolver
call just below that represents the hookable API. Will add a guard to that.
This becomes a bit awkward for writing testing tools like This is a similar concern I have with trying to facilitate test-coverage functionality using V8's built-in coverage. Which is even more of a Rube Goldberg machine, as you need to spawn a process with tldr; it's a lot easier if there's a programatic way hook into this functionality in a vanilla Node process . |
I don't have a ton of experience with all that. I know that using tools like AVA, which forks processes, I was able to enable |
nyc already does a lot of this, passing around state so that subprocesses can when spawned have their My concern is that this flow is already very complex and bug prone. As it stands right now the new module system makes writing a development tool like this even more difficult. EDIT: tldr; it feels like there's an opportunity to make the API for building features like this less painful, since the loader implementation is a greenfield right now. So, I don't see it as a question of whether it's possible, it's a question of can we reduce the burden placed on folks developing tools for Node.js. |
You can use the
This seems like we can improve that workflow rather than making a more complex hook system.
This is exactly why I want to work on a minimal API and iterate rather than trying to recreate the old workflows. If we focus on specific aspects instead of trying to solve all the use cases at once I think we will not suffer the previous problems with standardizing loaders. |
That's a really thoughtful point of view; mainly I just want to make sure we have examples of how we'd facilitate use-cases like dependency injection, coverage, and transpilation, before ES modules aren't behind a flag... I think if we can nail these three categories of common developer tools, we'd know the API changes are looking really healthy. |
@bcoe Dependency injection won't work like in other languages where it can change over time due to idempotency of ESM for any given specifier. The spec does not allow things like: await import('thingFactory') != await import('thingFactory'); You can pass in data via query params as needed and that will give you a clean local module map that can change what I'm not going to block unflagging on transpilation as they can be done ahead of time. However, various workflows like needing a way to get coverage is certainly blocking. If that is done ahead of time and works I would not see it as a blocker. |
lib/module.js
Outdated
@@ -490,6 +490,10 @@ function tryModuleLoad(module, filename) { | |||
} | |||
} | |||
|
|||
Module.import = function(request, parent) { | |||
return ESMLoader.import(request, parent); | |||
}; |
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 guard this by checking for experimentalModules
The test added here has started failing on Raspberry Pi devices. https://ci.nodejs.org/job/node-test-binary-arm/10851/RUN_SUBSET=4,label=pi1-raspbian-wheezy/console not ok 249 es-module/test-esm-addon
---
duration_ms: 2.65
severity: fail
stack: |-
(node:29865) ExperimentalWarning: The ESM module loader is experimental.
Error: module ../addons/hello-world/build/Release/binding.node not found
at module.exports (internal/loader/search.js:14:12)
at exports.resolve (internal/loader/ModuleRequest.js:91:13)
at Loader.resolve (internal/loader/Loader.js:50:40)
at Loader.getModuleJob (internal/loader/Loader.js:78:40)
at ModuleWrap.module.link (internal/loader/ModuleJob.js:27:25)
at linked (internal/loader/ModuleJob.js:25:19)
at <anonymous> |
Oh, wait, I'm totally wrong, it had CI, even passing CI. (I searched for OK, so wonder what changed to cause the rash of failures all of a sudden... |
@bmeck where would be a good place to start (or continue) a discussion about |
Talking with @refack in IRC. The issue is indeed with this change but CI didn't catch it because of a bug in Makefile that caused the test to be omitted on CI. Most relevant bit:
|
Ref: #16155 (comment) |
For reference the |
Proposed fix in #16174 |
This enables a --loader flag for Node, which can provide custom "resolve" and "dynamicInstantiate" methods for custom ES module loading. In the process, module providers have been converted from classes into functions and the module APIs have been made to pass URL strings over objects. PR-URL: #15445 Reviewed-By: Bradley Farias <[email protected]> Reviewed-By: James M Snell <[email protected]> Reviewed-By: Jeremiah Senkpiel <[email protected]> Reviewed-By: Michaël Zasso <[email protected]> Reviewed-By: Timothy Gu <[email protected]>
This enables a --loader flag for Node, which can provide custom "resolve" and "dynamicInstantiate" methods for custom ES module loading. In the process, module providers have been converted from classes into functions and the module APIs have been made to pass URL strings over objects. PR-URL: #15445 Reviewed-By: Bradley Farias <[email protected]> Reviewed-By: James M Snell <[email protected]> Reviewed-By: Jeremiah Senkpiel <[email protected]> Reviewed-By: Michaël Zasso <[email protected]> Reviewed-By: Timothy Gu <[email protected]>
Add `"dynamic"` to the list of supported `format`s returned by a custom resolve hook. Add a table describing the meaning of each `format`. Refs: nodejs#15445
Notable Changes: * crypto: - expose ECDH class #8188 * http2: - http2 is now exposed by defualt without the need for a flag #15685 - a new environment varible NODE\_NO\_HTTP2 has been added to allow userland http2 to be required #15685 - support has been added for generic `Duplex` streams #16269 * module: - resolve and instantiate loader pipeline hooks have been added to the ESM lifecycle #15445 * zlib: - CVE-2017-14919 - In zlib v1.2.9, a change was made that causes an error to be raised when a raw deflate stream is initialized with windowBits set to 8. On some versions this crashes Node and you cannot recover from it, while on some versions it throws an exception. Node.js will now gracefully set windowBits to 9 replicating the legacy behavior to avoid a DOS vector. https://github.com/nodejs-private/node-private/pull/95 PR-URL: https://github.com/nodejs-private/node-private/pull/98
Notable Changes: * crypto: - expose ECDH class nodejs/node#8188 * http2: - http2 is now exposed by defualt without the need for a flag nodejs/node#15685 - a new environment varible NODE\_NO\_HTTP2 has been added to allow userland http2 to be required nodejs/node#15685 - support has been added for generic `Duplex` streams nodejs/node#16269 * module: - resolve and instantiate loader pipeline hooks have been added to the ESM lifecycle nodejs/node#15445 * zlib: - CVE-2017-14919 - In zlib v1.2.9, a change was made that causes an error to be raised when a raw deflate stream is initialized with windowBits set to 8. On some versions this crashes Node and you cannot recover from it, while on some versions it throws an exception. Node.js will now gracefully set windowBits to 9 replicating the legacy behavior to avoid a DOS vector. nodejs-private/node-private#95 PR-URL: nodejs-private/node-private#98
Add `"dynamic"` to the list of supported `format`s returned by a custom resolve hook. Add a table describing the meaning of each `format`. PR-URL: #16375 Refs: #15445 Reviewed-By: Vse Mozhet Byt <[email protected]> Reviewed-By: Yuta Hiroto <[email protected]> Reviewed-By: Ruben Bridgewater <[email protected]> Reviewed-By: Michaël Zasso <[email protected]>
Notable Changes: * crypto: - expose ECDH class nodejs/node#8188 * http2: - http2 is now exposed by defualt without the need for a flag nodejs/node#15685 - a new environment varible NODE\_NO\_HTTP2 has been added to allow userland http2 to be required nodejs/node#15685 - support has been added for generic `Duplex` streams nodejs/node#16269 * module: - resolve and instantiate loader pipeline hooks have been added to the ESM lifecycle nodejs/node#15445 * zlib: - CVE-2017-14919 - In zlib v1.2.9, a change was made that causes an error to be raised when a raw deflate stream is initialized with windowBits set to 8. On some versions this crashes Node and you cannot recover from it, while on some versions it throws an exception. Node.js will now gracefully set windowBits to 9 replicating the legacy behavior to avoid a DOS vector. nodejs-private/node-private#95 PR-URL: nodejs-private/node-private#98
Add `"dynamic"` to the list of supported `format`s returned by a custom resolve hook. Add a table describing the meaning of each `format`. PR-URL: #16375 Refs: #15445 Reviewed-By: Vse Mozhet Byt <[email protected]> Reviewed-By: Yuta Hiroto <[email protected]> Reviewed-By: Ruben Bridgewater <[email protected]> Reviewed-By: Michaël Zasso <[email protected]>
Add `"dynamic"` to the list of supported `format`s returned by a custom resolve hook. Add a table describing the meaning of each `format`. PR-URL: #16375 Refs: #15445 Reviewed-By: Vse Mozhet Byt <[email protected]> Reviewed-By: Yuta Hiroto <[email protected]> Reviewed-By: Ruben Bridgewater <[email protected]> Reviewed-By: Michaël Zasso <[email protected]>
Add `"dynamic"` to the list of supported `format`s returned by a custom resolve hook. Add a table describing the meaning of each `format`. PR-URL: #16375 Refs: #15445 Reviewed-By: Vse Mozhet Byt <[email protected]> Reviewed-By: Yuta Hiroto <[email protected]> Reviewed-By: Ruben Bridgewater <[email protected]> Reviewed-By: Michaël Zasso <[email protected]>
Add `"dynamic"` to the list of supported `format`s returned by a custom resolve hook. Add a table describing the meaning of each `format`. PR-URL: #16375 Refs: #15445 Reviewed-By: Vse Mozhet Byt <[email protected]> Reviewed-By: Yuta Hiroto <[email protected]> Reviewed-By: Ruben Bridgewater <[email protected]> Reviewed-By: Michaël Zasso <[email protected]>
This PR provides an implementation of a hooking mechanism worked on with some early feedback from @bmeck.
Usage
The es module loading pipeline is hooked via a
--loader
flag to Node, which specifies a file containing a loader definition.For example usage:
.js
) as es modules is provided at https://github.com/guybedford/node/blob/resolve-hook-rebased/test/fixtures/es-module-loaders/js-loader.mjs.Motivation
This work is built to follow principles of extensibility - allowing users (framework and platform developers for this feature) to customize their own hooks for their use case needs. The core ecosystem conventions for loading modules in NodeJS will remain the ones that NodeJS sets itself by the ecosystem popularity due to interop requirements.
The goal here though is that through having the ability for users to experiment with loader features will allow the most useful and popular features to potentially be merged back into NodeJS core to extend the ecosystem conventions. So roughly the extensible manifesto thinking.
I believe enabling these use cases is important to avoid possible ecosystem fragmentation through other means.
The examples above show common use cases, and these hooks are also sufficient to implement the package.json
"module"
property resolution concept using a loader (nodejs/node-eps#60).Architecture
The hook architecture here is inspired by learnings from the WhatWG loader work, while aiming to cut down the scope of hooking to a bare minimum API surface area, as can be seen from the examples.
The loader itself is given control of module resolution, of the form:
Where the
"format"
returned is"esm"
for the ES module loader,"cjs"
for the traditional cjs loader,"builtin"
to refer to a NodeJS core module,"json"
for json and"addon"
for a Node binary addon. In future this could be extended for new module formats (ie wasm, binary ast).This resolver follows exactly what has always been the resolve hook in the module loader specifications, except instead of just returning a
string
URL, it returns the URL along with the format. Other information could also possibly be returned on this object in future including for example integrity metadata.When the format returned is
"dynamic"
, then we delegate loading to adynamicInstantiate
hook, hooked in the same way asresolve
:This API is designed to enable the above example use cases, while ensuring that loader internals are kept private, to ensure that what is hooked is only what is intended to be hooked. For example, none of
ModuleWrap
is exposed.Feedback very welcome!
Checklist
make -j4 test
(UNIX), orvcbuild test
(Windows) passesAffected core subsystem(s)
esmodules