Skip to content
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

fix(support): support ESM packages & cjs file type #26

Merged
merged 4 commits into from
Aug 26, 2021
Merged

Conversation

aaarichter
Copy link

@aaarichter aaarichter commented Jul 21, 2021

Problems in current nilo version

  1. nilo requires access to the npm package's package.json. If the package declares the exports section but misses to export its package.json, then nilo will receive the error ERR_PACKAGE_PATH_NOT_EXPORTED
  2. if an npm package or a project is type="module", nilo was not able to load any files due to
    • project.loadInterfaceFiles('object-graph') is generally called without specific file type
    • nilo wouldn't know what file type to look for (js, mjs, cjs)
  3. cjs file type is not supported

Changes

  • supports ESM packages (type="module") (with or without exports package section)
  • supports packages with mjs/js files (with or without exports package section)
  • supports cjs filetype

@aaarichter aaarichter force-pushed the fix-file-support branch 2 times, most recently from 87e9d87 to 4354e57 Compare July 21, 2021 14:33
@aaarichter aaarichter changed the title fix(support): support ESM packages & cjs file type' fix(support): support ESM packages & cjs file type Jul 21, 2021
@aaarichter aaarichter requested a review from jkrems July 21, 2021 14:34
@aaarichter
Copy link
Author

hey @jkrems & @aotarola - how are you ?
Would you mind having a look at this PR?
Much appreciated :octocat:

Comment on lines 26 to 27
env:
NODE_OPTIONS: ${{ matrix.node-options }}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Should this be in the section below, not in the node 10 section?

Out of curiosity, why separate 10 out instead of including it in the matrix below?

Copy link
Author

@aaarichter aaarichter Jul 23, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For node 10 we can run the tests with and without experimental support flag. For Node 12 onwards, Github runs the latest node builds, which all have native support for ESM. For them the NODE_OPTIONS flags make no sense. That's why I separated the node 10 build to avoid having an additional build for every other node version

@dbushong
Copy link
Member

dbushong commented Jul 21, 2021

The import code is (and has always been) particularly tricksy; if you would be willing to write a few sentences on the details of what changed in lib/project.js (and why and how), it would help a lot with the review (and with future contributions) (either inline in comments or narratively here)

Copy link
Author

@aaarichter aaarichter left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hey @dbushong . I added some more information on the change to the PR description as well as comment to the code.

Thank you for reviewing it 🍰

Comment on lines +84 to +91
function directRequire(id, cwd) {
if (!path.extname(id)) {
id = `${id}.js`;
}
id = path.join(cwd, !isLocalRef(id) ? 'node_modules' : '', id);

// eslint-disable-next-line import/no-dynamic-require
return require(id);
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dbushong this the fallback code that is able to load files when this.require usage fails e.g for ERR_PACKAGE_PATH_NOT_EXPORTED (in this.import() and this.requireOrNull()


const localFiles = await this.cachedGlob(
`{modules,lib}/*/${basename}.{js,mjs}`
`{modules,lib}/*/${basename}${!extension ? patterns : ''}`
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

supports extension when calling project.loadInterfaceFiles('object-graph.js')

Comment on lines +229 to +234
for (const ext of extensions) {
res = await this.importOrNull(`${specifier}.${ext}`);
if (res !== null) {
break;
}
}
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

iterates over possible extensions - on the first successful import/require it breaks. so if you have mjs and js source files, it picks mjs over js. for js and cjs it would try loading js first

);
if (moduleNamespace === null) return null;
/** @type {({default: *}|{default: string}|null|undefined)[]} */
const moduleNamespaces = await Promise.all(
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kind of decoupled the logic of require/import and building the interface file array. makes it easier to debug

// handle ERR_PACKAGE_PATH_NOT_EXPORTED for modules with "exports" section (Node 13/14+)
if (e.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED') {
try {
return { default: directRequire(id, this.root) };
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if exports lacks the file declaration require directly. see https://nodejs.org/dist/latest-v14.x/docs/api/packages.html#packages_main_entry_point_export

return { default: directRequire(id, this.root) };
} catch (err) {
// eslint-disable-next-line no-ex-assign
e = err;
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if direct require fails for example with MODULE_NOT_FOUND or ERR_REQUIRE_ESM I'm re-assigning the error, so later code or importOrNull code will handle the errors.

@aaarichter aaarichter requested a review from dbushong July 30, 2021 14:06
Copy link
Member

@dbushong dbushong left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the delay. LGTM - make sure to test it against a real project (one with esm, one with commonjs, maybe one with both?)

Comment on lines +99 to +100
if (!isModule) {
return ['mjs', 'js'];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: At a glance this seems backwards - why are we looking for mjs files if type isn't module? (and vice-versa) Is it because otherwise they'd be found automatically?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so normally the loadInterfaceFiles() method is usually called without any file extension - eg. await project.loadInterfaceFiles('open-graph'). Till now loading local mjs files (if they existed) used to work due the globbing https://github.com/groupon/nilo/blob/main/lib/project.js#L123-L125. By supporting type="module" and also workaround incomplete exports npm package settings, it became necessary to append the file type in order to be able to load the file. Otherwise you will likely get the ERR_REQUIRE_ESM error.

I set the order of those file types is ESM type first, normal type next.

lib/project.js Outdated
async _getLocalTargets(basename) {
const extension = path.extname(basename);
const isModule = this.packageJson.type === 'module';
const patterns = `.{${getExtensions(isModule).join()}}`;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: would prefer being explicit about the join character, to avoid people having to look up the quirk that it defaults to ,

Suggested change
const patterns = `.{${getExtensions(isModule).join()}}`;
const patterns = `.{${getExtensions(isModule).join(',')}}`;

lib/project.js Outdated
await this.importOrNull(specifier)
);
if (moduleNamespace === null) return null;
/** @type {({default: *}|{default: string}|null|undefined)[]} */
Copy link
Member

@dbushong dbushong Jul 30, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/** @type {({default: *}|{default: string}|null|undefined)[]} */
/** @type {({ default: unknown } | null | undefined)[]} */

nitpick (optional): I think unknown is what you want in this case. It's like any or *, but with the guarantee that you won't try to do anything with the contents.

lib/project.js Outdated
}

/**
* @param {string} id
* @returns {Promise<unknown>}
* @returns {Promise<{default: *}|{default: string}>}
Copy link
Member

@dbushong dbushong Jul 30, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick (optional):

Suggested change
* @returns {Promise<{default: *}|{default: string}>}
* @returns {Promise<{ default: unknown }>}

@aotarola
Copy link
Member

Tested it and works, thanks!

@aotarola aotarola merged commit f87a114 into main Aug 26, 2021
@aaarichter aaarichter deleted the fix-file-support branch September 19, 2021 15:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants