Skip to content

Commit

Permalink
refactor: switch from standard-things/esm to native
Browse files Browse the repository at this point in the history
BREAKING CHANGE: now if you want to use `.mjs` files, you should run
with `NODE_OPTIONS=--experimental-modules` on Node 12.x+.  Also, native
ES module loading is NOT compatible with coffeescript/register

Fixes: #11
  • Loading branch information
dbushong committed Dec 9, 2019
1 parent 5bcdfbe commit cd5f669
Show file tree
Hide file tree
Showing 7 changed files with 161 additions and 66 deletions.
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
/node_modules
/tmp
/lib/esm/import.js
40 changes: 40 additions & 0 deletions lib/esm/import.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* Copyright (c) 2019, Groupon
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
*
* Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* Neither the name of GROUPON nor the names of its contributors may be
* used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
* IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
* TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
* PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
* TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
* LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
'use strict';

// put this in its own file so that it can barf on require if needed

/**
* @param {string} id
* @returns {Promise<any>}
*/
module.exports = id => import(id);
48 changes: 48 additions & 0 deletions lib/esm/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2019, Groupon
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
*
* Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* Neither the name of GROUPON nor the names of its contributors may be
* used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
* IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
* TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
* PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
* TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
* LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/

'use strict';

const debug = require('debug')('esm');

/** @type {(id: string) => Promise<any>} */
let importESM;
try {
importESM = require('./import');
} catch (err) {
debug('failed to load esm import: ', err);
importESM = () => Promise.reject(err);
}

exports.importESM = importESM;
exports.supportsESM = () =>
importESM('../../package.json').then(() => true, () => false);
65 changes: 27 additions & 38 deletions lib/project.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,10 @@
const path = require('path');
const Module = require('module');
const util = require('util');
const { URL } = require('url');

const debug = require('debug')('nilo:project');
// @ts-ignore
const esm = require('esm');
// @ts-ignore
const globCallback = require('glob');

const glob = util.promisify(globCallback);
Expand All @@ -48,27 +47,18 @@ const glob = util.promisify(globCallback);
const rawModulePaths = Module['_nodeModulePaths'];
const getModulePaths = /** @type {(from: string) => string[]} */ (rawModulePaths);

const { importESM } = require('./esm');

/**
* @typedef {import('./typedefs').InterfaceFile} InterfaceFile
* @param {string} reSTR
*/
function escapeRE(reSTR) {
return reSTR.replace(/[$^*()+\[\]{}\\|.?]/g, '\\$&');
}

/**
* @param {object} esmResult
* @param {string} specifier
* @typedef {import('./typedefs').InterfaceFile} InterfaceFile
*/
function guessNamespace(esmResult, specifier) {
if (specifier.endsWith('.mjs')) return esmResult;

// "Heuristic" to figure out if it kinda looks like a namespace
if (
esmResult !== null &&
typeof esmResult === 'object' &&
'default' in esmResult
)
return esmResult;

return { default: esmResult };
}

class Project {
/**
Expand All @@ -91,27 +81,12 @@ class Project {
this._pkgJson = null;
this._globCache = {};
this._globStatCache = {};

this._importESM = esm(this.app, {
cjs: {
cache: false,
esModule: false,
extensions: false,
mutableNamespace: false,
namedExports: false,
paths: false,
vars: false,
dedefault: false,
topLevelReturn: false,
},
mode: 'strict',
});
}

/**
* @param {string} pattern
* @param {object} [options]
* @returns {string[]}
* @returns {Promise<string[]>}
*/
cachedGlob(pattern, options = {}) {
return glob(
Expand Down Expand Up @@ -188,9 +163,22 @@ class Project {
* @param {string} id
* @returns {Promise<unknown>}
*/
import(id) {
const esmResult = this._importESM(id);
return guessNamespace(esmResult, id);
async import(id) {
debug('import', id);
// if it's a package spec, assume we can't load it as a ES module
// for now
if (!/^(\.\.?)?\//.test(id)) return { default: this.require(id) };

try {
return await importESM(
/^\.\.?\//.test(id)
? new URL(id, `file://${this.app.filename}`).toString()
: id
);
} catch (err) {
if (err.message !== 'Not supported') throw err;
return { default: this.require(id) };
}
}

/**
Expand All @@ -204,7 +192,8 @@ class Project {
// Still throw errors unrelated to finding the module
if (e.code !== 'MODULE_NOT_FOUND') throw e;
// Do *not* ignore failing requires of subsequent files
if (!e.message.includes(`'${id}'`)) throw e;
const re = new RegExp(`(^|[\\s'"])${escapeRE(id)}([\\s'"]|$)`);
if (!re.test(e.message)) throw e;

return null;
}
Expand Down
5 changes: 0 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
"dependencies": {
"commander": "^2.19.0",
"debug": "^4.1.1",
"esm": "^3.2.20",
"glob": "^7.1.3"
},
"devDependencies": {
Expand Down
67 changes: 45 additions & 22 deletions test/project.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const mkdirp = require('mkdirp');
const tmp = require('tmp');

const { Project } = require('../');
const { supportsESM } = require('../lib/esm');

const FRAMEWORK_DIR = path.resolve(__dirname, '..');

Expand All @@ -34,7 +35,7 @@ describe('Project', () => {
let project;
/** @type {import('tmp').SynchrounousResult} */
let tmpHandle;
before(() => {
before(async () => {
tmpHandle = tmp.dirSync({ unsafeCleanup: true });
project = new Project(tmpHandle.name, FRAMEWORK_DIR);

Expand All @@ -55,11 +56,13 @@ describe('Project', () => {
module.exports = 'from lib1';
`,
'modules/mod1/everywhere.mjs': `\
};
if (await supportsESM()) {
files['modules/mod1/everywhere.mjs'] = `\
export default 'from mod1';
export const namedExport = 'forwarded';
`,
};
`;
}
const pkgNames = ['@some-scope/pkg1', 'dev-dep1', 'hoisted', 'pkg2'];
for (const pkgName of pkgNames) {
files[`node_modules/${pkgName}/everywhere.js`] = `\
Expand All @@ -83,11 +86,23 @@ module.exports = 'from ${pkgName}';
if (tmpHandle) tmpHandle.removeCallback();
});

afterEach(() => {
// @ts-ignore
project['_globCache'] = {};
// @ts-ignore
project['_globStatCache'] = {};
});

/**
* @param {{ specifier: string, [key: string]: any }[]} expected
* @param {{ specifier: string, [key: string]: any }[]} actual
*/
function sortedEqual(expected, actual) {
// native es modules return a namespace which isn't an ordinary object,
// which makes mocha barf on diffing
for (const a of actual) {
if (a.moduleNamespace) a.moduleNamespace = { ...a.moduleNamespace };
}
assert.deepStrictEqual(
sortBy(actual, 'specifier'),
sortBy(expected, 'specifier')
Expand All @@ -108,15 +123,19 @@ module.exports = 'from ${pkgName}';
moduleNamespace: { default: 'from lib1' },
defaultExport: 'from lib1',
},
{
specifier: './modules/mod1/everywhere.mjs',
group: 'mod1',
moduleNamespace: {
default: 'from mod1',
namedExport: 'forwarded',
},
defaultExport: 'from mod1',
},
...((await supportsESM())
? [
{
specifier: './modules/mod1/everywhere.mjs',
group: 'mod1',
moduleNamespace: {
default: 'from mod1',
namedExport: 'forwarded',
},
defaultExport: 'from mod1',
},
]
: []),
{
specifier: '@some-scope/pkg1/everywhere',
group: 'pkg1',
Expand All @@ -139,15 +158,19 @@ module.exports = 'from ${pkgName}';
moduleNamespace: { default: 'from lib1' },
defaultExport: 'from lib1',
},
{
specifier: './modules/mod1/everywhere.mjs',
group: 'mod1',
moduleNamespace: {
default: 'from mod1',
namedExport: 'forwarded',
},
defaultExport: 'from mod1',
},
...((await supportsESM())
? [
{
specifier: './modules/mod1/everywhere.mjs',
group: 'mod1',
moduleNamespace: {
default: 'from mod1',
namedExport: 'forwarded',
},
defaultExport: 'from mod1',
},
]
: []),
{
specifier: '@some-scope/pkg1/everywhere',
group: 'pkg1',
Expand Down

0 comments on commit cd5f669

Please sign in to comment.