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
Integrate ESM loading of commands & hooks #160
Conversation
src/config/plugin.ts
Outdated
let m | ||
try { | ||
m = require(p) | ||
m = isESM ? await ModuleLoader.importDynamic(p) : require(p) |
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.
Can you call ModuleLoader.load(this, p)
since that has the importDynamic/require logic?
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.
Both load and loadWithData pass the string through ModuleLoader.resolvePaths
This require.resolve line (require.resolve(path.join(this.commandsDir, ...id.split(':')))
) uses this.commandDir
https://github.com/oclif/core/pull/160/files/28566d746b4ec5b0d8be3ef2ba5d7808d213452d#diff-0edcef2f1da996cde64784a30d76043fbe58fb9352f6b0189e1d135b868ee8f1L184
In ModuleLoader.resolvePaths config.root
is used:
filePath = isESM ? require.resolve(path.join(config.root, modulePath))
However this could be a solution (tests pass with this below):
let m
try {
const p = path.join(this.pjson.oclif.commands as string, ...id.split(':'))
const {isESM, module, filePath} = await ModuleLoader.loadWithData(this, p)
this._debug(isESM ? '(import)' : '(require)', filePath)
m = module
} catch (error) {
if (!opts.must && error.code === 'MODULE_NOT_FOUND') return
throw error
}
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.
A further note is that since error.code
is being checked in the catch statement that it should be verified that the code is the same for require and dynamic import. My assumption is that may not be the case.
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.
@amphro It turns out in a CommonJS environment that both require and dynamic import return an error code of MODULE_NOT_FOUND
.
It is interesting to note that in an ESM environment dynamic import has an error code of ERR_MODULE_NOT_FOUND
and require through module.createRequire returns MODULE_NOT_FOUND
.
I did one more check from my ESM Oclif CLI and indeed ERR_MODULE_NOT_FOUND
is returned for dynamic import internally in @oclif/core
, so the error.code check should be changed to:
if (!opts.must && (error.code === 'MODULE_NOT_FOUND' || error.code === 'ERR_MODULE_NOT_FOUND')) return
This is the only location in @oclif/core
that checks the error code, but this is something to keep in mind for other Oclif plugins.
Another possible solution since all loading of commands / hooks / help class will be going through ModuleLoader is to add a try / catch to ModuleLoader.load
and ModuleLoader.loadWithData
and throw a custom Error that is the same for all execution environments.
A reason that this might be a good idea is that error messages displayed to users / developers will be the same for both require / dynamic import in either a CJS or ESM environment and any code in Oclif can use a consistent error.code
.
A final note.. If the proposed solution with Module.loadWithData
is used in plugin.ts / findCommand then ModuleLoader.importDynamic
should be removed.
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 have to say I'm certainly shying away from any custom error wrapping of the loading process. MODULE_NOT_FOUND
/ ERR_MODULE_NOT_FOUND
is one of many errors that can be thrown; IE various errors in the code being loaded itself should be posted.
So, I have updated the PR with what I think is best.
For reference I have implemented my own dual Node / browser module loading code as an ESM Node module that is similar to what is being discussed here (has test cases / test framework):
https://github.com/typhonjs-node-utils/loader-module
*/ | ||
const _importDynamic = new Function('modulePath', 'return import(modulePath)') // eslint-disable-line no-new-func | ||
|
||
export default class ModuleLoader { |
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 thought your original PR had tests? Is this worth unit testing?
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 were no added tests. I'm certainly willing to submit tests as well.
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.
At the bare minimum, can you add a new fixture for a esm and a main test for it here.
I've tried this branch on a couple of oclif/core cli's that we have and things look good. I haven't tested on a distributed cli with plugins installed but @oclif/plugin-plugins hasn't been updated yet to use core anyways. I'm good to merge this once I get your responses to my comments. |
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 like the changes! I liked the idea of ModuleLoader throwing a custom error for module not found, but I don't really care. Catching both is totally fine. In the case of a custom error, I definitely wouldn't try to wrap every type of error either. You could also put a comment why the two types of errors.
After you get the tests in, I'll merge this in. |
I suppose that is true regarding only handling the two module not found error codes then passing through everything else. I'm thinking a custom CLIError is the way to go. I'll put it in This is what I'm thinking: import {CLIError} from './cli'
import {OclifError} from '../../interfaces'
export class ModuleLoadError extends CLIError implements OclifError {
oclif!: { exit: number }
code = 'MODULE_NOT_FOUND'
constructor(message: string) {
super(`[MODULE_NOT_FOUND] ${message}`, {exit: 1})
this.name = 'ModuleLoadError'
}
render(): string {
return ''
}
} I do think having a custom error makes a lot of sense for Oclif because the runtime is only Node / V8 and it provides a concise and unified error stack leading back to ModuleLoader with one defined error for require or dynamic import when the module is not found. The errors printed out are quite a bit different and verbose in the case of require versus dynamic import and it easily could confuse someone not accustomed to knowing what to expect in advance. The require error lists the stack leading back to ModuleLoader, but the dynamic import error has an internal Node stack that gives no indication of where the error actually occurred. This is what the error message will be like (paths substituted):
for dynamic import: |
Ya, I think that looks good. How come you empty out the render?
Why is that? Wouldn't creating the new CLIError create a new stack trace from the module loader class? |
Just took the initial blueprint from ExitError, so I'll remove it and let CLIError do its thing (render).
The new ModuleLoadError gives a reasonable stack trace from ModuleLoader as shown above. I meant the actual stock ESM dynamic import error is not the greatest; below is the normal error:
|
@amphro This PR should be good to merge now. I added ModuleLoadError to handle the CJS & ESM loader variance with the module not found case and added reasonably thorough unit tests for ModuleLoader in |
Love the changes. Nice work on this. |
Greets. This is the first of three PRs regarding adding ESM support to Oclif v2.
This initial PR adds ESM loading of commands and hooks. I have tested it with my ESM Oclif middleware / CLI tool in addition to all tests passing in the repo.
The easiest way for me to proceed with working on the Help subsystem adding ESM help class loading support and async handling is for this PR to be merged first as it depends on the ModuleLoader addition.
Let me know your thoughts @amphro.