From a800eb876e2f0c34a4b95e7452a023a1b5834a8f Mon Sep 17 00:00:00 2001 From: Chunpeng Huo Date: Fri, 20 Dec 2024 11:15:57 +1000 Subject: [PATCH] feat: support package.json exports field related to #17 --- lib/index.js | 5 +- lib/package-reader.js | 142 +++++++++++- test/package-reader.spec.js | 439 +++++++++++++++++++++++++++++++++++- 3 files changed, 577 insertions(+), 9 deletions(-) diff --git a/lib/index.js b/lib/index.js index e24079d..3b4d551 100644 --- a/lib/index.js +++ b/lib/index.js @@ -338,8 +338,11 @@ module.exports = class Bundler { }); } + const parsedRequiredBy = requiredBy.map(id => parse(mapId(id, this._paths))); + const isLocalRequire = parsedRequiredBy.findIndex(parsed => parsed.parts[0] === packageName) !== -1; + return this.packageReaderFor(stub || {name: packageName}) - .then(reader => resource ? reader.readResource(resource) : reader.readMain()) + .then(reader => resource ? reader.readResource(resource, isLocalRequire) : reader.readMain()) .then(unit => this.capture(unit)) .catch(err => { error('Resolving failed for module ' + bareId); diff --git a/lib/package-reader.js b/lib/package-reader.js index c5f5b80..9712adb 100644 --- a/lib/package-reader.js +++ b/lib/package-reader.js @@ -36,6 +36,7 @@ module.exports = class PackageReader { this.name = metadata.name; this.version = metadata.version || 'N/A'; this.browserReplacement = _browserReplacement(metadata.browser); + this.exportsReplacement = _exportsReplacement(metadata.exports); return this._main(metadata) // fallback to "index.js" even when it's missing. @@ -65,7 +66,7 @@ module.exports = class PackageReader { .then(() => this._readFile(this.mainPath)); } - readResource(resource) { + readResource(resource, isLocalRequire = false) { return this.ensureMainPath().then(() => { let parts = this.parsedMainId.parts; let len = parts.length; @@ -81,8 +82,48 @@ module.exports = class PackageReader { let fullResource = resParts.join('/'); - const replacement = this.browserReplacement['./' + fullResource] || + let replacement; + + // exports subpath is designed for outside require. + if (!isLocalRequire) { + if (('./' + fullResource) in this.exportsReplacement) { + replacement = this.exportsReplacement['./' + fullResource]; + } else if (('./' + fullResource + '.js') in this.exportsReplacement) { + replacement = this.exportsReplacement['./' + fullResource + '.js']; + } + + if (replacement === null) { + throw new Error(`Resource ${this.name + '/' + resource} is not allowed to be imported (${this.name} package.json exports definition ${JSON.stringify(this.exportsReplacement)}).`); + } + + if (!replacement) { + // Try wildcard replacement + for (const key in this.exportsReplacement) { + const starIndex = key.indexOf('*'); + if (starIndex !== -1) { + const prefix = key.slice(2, starIndex); // remove ./ + const subfix = key.slice(starIndex + 1); + if (fullResource.startsWith(prefix) && fullResource.endsWith(subfix)) { + + const target = this.exportsReplacement[key]; + if (target && target.includes('*')) { + const flexPart = fullResource.slice(prefix.length, fullResource.length - subfix.length); + replacement = target.replace('*', flexPart); + } else { + replacement = target; + } + break; + } + } + } + } + } + + if (!replacement) { + replacement = this.browserReplacement['./' + fullResource] || this.browserReplacement['./' + fullResource + '.js']; + } + if (replacement) { // replacement is always local, remove leading ./ fullResource = replacement.slice(2); @@ -254,13 +295,10 @@ module.exports = class PackageReader { } _main(metadata, dirPath = '') { - // try 1.browser > 2.module > 3.main + // try 1. exports > 2.browser > 3.module > 4.main // the order is to target browser. - // it probably should use different order for electron app - // for electron 1.module > 2.browser > 3.main // note path.join also cleans up leading './'. const mains = []; - if (typeof metadata.dumberForcedMain === 'string') { // dumberForcedMain is not in package.json. // it is the forced main override in dumber config, @@ -269,6 +307,12 @@ module.exports = class PackageReader { // note there is no fallback to other browser/module/main fields. mains.push({field: 'dumberForcedMain', path: path.join(dirPath, metadata.dumberForcedMain)}); } else { + + const exportsMain = _exportsMain(metadata.exports); + if (typeof exportsMain === 'string') { + mains.push({field: 'exports', path: path.join(dirPath, exportsMain)}); + } + if (typeof metadata.browser === 'string') { // use package.json browser field if possible. mains.push({field: 'browser', path: path.join(dirPath, metadata.browser)}); @@ -330,7 +374,10 @@ function _browserReplacement(browser) { // replacement is always local targetModule = './' + targetModule; } - replacement[sourceModule] = targetModule; + // Only replace when sourceModule cannot be resolved to targetModule. + if (!nodejsIds(sourceModule).includes(targetModule)) { + replacement[sourceModule] = targetModule; + } } else { replacement[sourceModule] = false; } @@ -339,6 +386,87 @@ function _browserReplacement(browser) { return replacement; } +function isExportsConditions(obj) { + if (typeof obj !== 'object' || obj === null) return false; + const keys = Object.keys(obj); + return keys.length > 0 && keys[0][0] !== '.'; +} + +function pickCondition(obj) { + // string or null + if (typeof obj !== 'object' || obj === null) return obj; + let env = process.env.NODE_ENV || ''; + if (env === 'undefined') env = ''; + + // use env (NODE_ENV) to support "development" and "production" + for (const condition of ['import', 'module', 'browser', 'require', env, 'default']) { + // Recursive call to support nested conditions. + if (condition && condition in obj) return pickCondition(obj[condition]); + } + + return null; +} + +function _exportsMain(exports) { + // string exports field is alternative main + if (!exports || typeof exports === 'string') return exports; + + if (isExportsConditions(exports)) { + return pickCondition(exports); + } + + if (typeof exports === 'object') { + for (const key in exports) { + if (key === '.' || key !== './') { + return pickCondition(exports[key]); + } + } + } +} + +function _exportsReplacement(exports) { + // string exports field is alternative main, + // leave to the main field replacment + if (!exports || typeof exports === 'string') return {}; + + if (isExportsConditions(exports)) { + // leave to the main field replacment + return {}; + } + + let replacement = {}; + + Object.keys(exports).forEach(key => { + // leave {".": ...} to the main field replacment + if (key === '.') return; + + if (key[0] !== '.') { + throw new Error("Unexpected exports subpath: " + key); + } + + let target = pickCondition(exports[key]); + + let sourceModule = filePathToModuleId(key); + + if (typeof target === 'string') { + let targetModule = filePathToModuleId(target); + if (!targetModule.startsWith('.')) { + // replacement is always local + targetModule = './' + targetModule; + } + + // Only replace when sourceModule cannot be resolved to targetModule. + if (!nodejsIds(sourceModule).includes(targetModule)) { + replacement[sourceModule] = targetModule; + } + } else { + replacement[sourceModule] = null; + } + }); + + return replacement; +} + function filePathToModuleId(filePath) { return parse(filePath.replace(/\\/g, '/')).bareId; } diff --git a/test/package-reader.spec.js b/test/package-reader.spec.js index 415983d..da1c294 100644 --- a/test/package-reader.spec.js +++ b/test/package-reader.spec.js @@ -312,9 +312,321 @@ test('packageReader reads browser "." mapping over module/main field', async t = }); }); +test('packageReader reads exports mapping over module/main field', async t => { + return getReader('foo', { + 'node_modules/foo/package.json': '{"name":"foo", "exports": "./br", "browser": {".": "index"}, "module": "es", "main": "index"}', + 'node_modules/foo/index.js': "lorem", + 'node_modules/foo/es.js': 'es', + 'node_modules/foo/br.js': 'br' + }).then(r => { + return r.readMain().then( + unit => { + t.deepEqual(unit, { + path: 'node_modules/foo/br.js', + contents: 'br', + moduleId: 'foo/br.js', + packageName: 'foo', + packageMainPath: 'br.js', + alias: 'foo', + sourceMap: undefined + }); + + t.equal(r.name, 'foo'); + t.equal(r.mainPath, 'br.js'); + t.deepEqual(r.browserReplacement, {}); + }, + err => { + t.fail(err.message); + } + ); + }); +}); + +test('packageReader reads exports "." mapping over module/main field', async t => { + return getReader('foo', { + 'node_modules/foo/package.json': '{"name":"foo", "exports": {".": "./br"}, "browser": {".": "index"}, "module": "es", "main": "index"}', + 'node_modules/foo/index.js': "lorem", + 'node_modules/foo/es.js': 'es', + 'node_modules/foo/br.js': 'br' + }).then(r => { + return r.readMain().then( + unit => { + t.deepEqual(unit, { + path: 'node_modules/foo/br.js', + contents: 'br', + moduleId: 'foo/br.js', + packageName: 'foo', + packageMainPath: 'br.js', + alias: 'foo', + sourceMap: undefined + }); + + t.equal(r.name, 'foo'); + t.equal(r.mainPath, 'br.js'); + t.deepEqual(r.browserReplacement, {}); + }, + err => { + t.fail(err.message); + } + ); + }); +}); + +test('packageReader reads exports "import" mapping over module/main field', async t => { + return getReader('foo', { + 'node_modules/foo/package.json': '{"name":"foo", "exports": {"import": "./br.js", "module": "./index", "browser": "./index", "require": "./index.js", "default": "./index"}, "browser": {".": "index"}, "module": "es", "main": "index"}', + 'node_modules/foo/index.js': "lorem", + 'node_modules/foo/es.js': 'es', + 'node_modules/foo/br.js': 'br' + }).then(r => { + return r.readMain().then( + unit => { + t.deepEqual(unit, { + path: 'node_modules/foo/br.js', + contents: 'br', + moduleId: 'foo/br.js', + packageName: 'foo', + packageMainPath: 'br.js', + alias: 'foo', + sourceMap: undefined + }); + + t.equal(r.name, 'foo'); + t.equal(r.mainPath, 'br.js'); + t.deepEqual(r.browserReplacement, {}); + }, + err => { + t.fail(err.message); + } + ); + }); +}); + +test('packageReader reads exports "module" mapping', async t => { + return getReader('foo', { + 'node_modules/foo/package.json': '{"name":"foo", "exports": {"module": "./br.js", "browser": "./index", "require": "./index.js", "default": "./index"}, "browser": {".": "index"}, "module": "es", "main": "index"}', + 'node_modules/foo/index.js': "lorem", + 'node_modules/foo/es.js': 'es', + 'node_modules/foo/br.js': 'br' + }).then(r => { + return r.readMain().then( + unit => { + t.deepEqual(unit, { + path: 'node_modules/foo/br.js', + contents: 'br', + moduleId: 'foo/br.js', + packageName: 'foo', + packageMainPath: 'br.js', + alias: 'foo', + sourceMap: undefined + }); + + t.equal(r.name, 'foo'); + t.equal(r.mainPath, 'br.js'); + t.deepEqual(r.browserReplacement, {}); + }, + err => { + t.fail(err.message); + } + ); + }); +}); + +test('packageReader reads exports "browser" mapping', async t => { + return getReader('foo', { + 'node_modules/foo/package.json': '{"name":"foo", "exports": {"browser": "./br.js", "require": "./index.js", "default": "./index"}, "browser": {".": "index"}, "module": "es", "main": "index"}', + 'node_modules/foo/index.js': "lorem", + 'node_modules/foo/es.js': 'es', + 'node_modules/foo/br.js': 'br' + }).then(r => { + return r.readMain().then( + unit => { + t.deepEqual(unit, { + path: 'node_modules/foo/br.js', + contents: 'br', + moduleId: 'foo/br.js', + packageName: 'foo', + packageMainPath: 'br.js', + alias: 'foo', + sourceMap: undefined + }); + + t.equal(r.name, 'foo'); + t.equal(r.mainPath, 'br.js'); + t.deepEqual(r.browserReplacement, {}); + }, + err => { + t.fail(err.message); + } + ); + }); +}); + +test('packageReader reads exports "require" mapping', async t => { + return getReader('foo', { + 'node_modules/foo/package.json': '{"name":"foo", "exports": {"require": "./br.js", "default": "./index"}, "browser": {".": "index"}, "module": "es", "main": "index"}', + 'node_modules/foo/index.js': "lorem", + 'node_modules/foo/es.js': 'es', + 'node_modules/foo/br.js': 'br' + }).then(r => { + return r.readMain().then( + unit => { + t.deepEqual(unit, { + path: 'node_modules/foo/br.js', + contents: 'br', + moduleId: 'foo/br.js', + packageName: 'foo', + packageMainPath: 'br.js', + alias: 'foo', + sourceMap: undefined + }); + + t.equal(r.name, 'foo'); + t.equal(r.mainPath, 'br.js'); + t.deepEqual(r.browserReplacement, {}); + }, + err => { + t.fail(err.message); + } + ); + }); +}); + +test('packageReader reads exports based on NODE_ENV', async t => { + await t.test('packageReader reads exports "development" mapping in development env', async t => { + const oldNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + + return getReader('foo', { + 'node_modules/foo/package.json': '{"name":"foo", "exports": {"development": "./br.js", "production": "./index"}, "browser": {".": "index"}, "module": "es", "main": "index"}', + 'node_modules/foo/index.js': "lorem", + 'node_modules/foo/es.js': 'es', + 'node_modules/foo/br.js': 'br' + }).then(r => { + return r.readMain().then( + unit => { + process.env.NODE_ENV = oldNodeEnv; + t.deepEqual(unit, { + path: 'node_modules/foo/br.js', + contents: 'br', + moduleId: 'foo/br.js', + packageName: 'foo', + packageMainPath: 'br.js', + alias: 'foo', + sourceMap: undefined + }); + + t.equal(r.name, 'foo'); + t.equal(r.mainPath, 'br.js'); + t.deepEqual(r.browserReplacement, {}); + }, + err => { + process.env.NODE_ENV = oldNodeEnv; + t.fail(err.message); + } + ); + }); + }); + + await t.test('packageReader reads exports "production" mapping in development env', async t => { + const oldNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + return getReader('foo', { + 'node_modules/foo/package.json': '{"name":"foo", "exports": {"development": "./index.js", "production": "./br.js"}, "browser": {".": "index"}, "module": "es", "main": "index"}', + 'node_modules/foo/index.js': "lorem", + 'node_modules/foo/es.js': 'es', + 'node_modules/foo/br.js': 'br' + }).then(r => { + return r.readMain().then( + unit => { + process.env.NODE_ENV = oldNodeEnv; + t.deepEqual(unit, { + path: 'node_modules/foo/br.js', + contents: 'br', + moduleId: 'foo/br.js', + packageName: 'foo', + packageMainPath: 'br.js', + alias: 'foo', + sourceMap: undefined + }); + + t.equal(r.name, 'foo'); + t.equal(r.mainPath, 'br.js'); + t.deepEqual(r.browserReplacement, {}); + }, + err => { + process.env.NODE_ENV = oldNodeEnv; + t.fail(err.message); + } + ); + }); + }); +}); + +test('packageReader reads exports "default" mapping', async t => { + return getReader('foo', { + 'node_modules/foo/package.json': '{"name":"foo", "exports": {"default": "./br.js"}, "browser": {".": "index"}, "module": "es", "main": "index"}', + 'node_modules/foo/index.js': "lorem", + 'node_modules/foo/es.js': 'es', + 'node_modules/foo/br.js': 'br' + }).then(r => { + return r.readMain().then( + unit => { + t.deepEqual(unit, { + path: 'node_modules/foo/br.js', + contents: 'br', + moduleId: 'foo/br.js', + packageName: 'foo', + packageMainPath: 'br.js', + alias: 'foo', + sourceMap: undefined + }); + + t.equal(r.name, 'foo'); + t.equal(r.mainPath, 'br.js'); + t.deepEqual(r.browserReplacement, {}); + }, + err => { + t.fail(err.message); + } + ); + }); +}); + +test('packageReader reads exports nested condition mapping', async t => { + return getReader('foo', { + 'node_modules/foo/package.json': '{"name":"foo", "exports": {"default": {"module": "./br.js"}}, "browser": {".": "index"}, "module": "es", "main": "index"}', + 'node_modules/foo/index.js': "lorem", + 'node_modules/foo/es.js': 'es', + 'node_modules/foo/br.js': 'br' + }).then(r => { + return r.readMain().then( + unit => { + t.deepEqual(unit, { + path: 'node_modules/foo/br.js', + contents: 'br', + moduleId: 'foo/br.js', + packageName: 'foo', + packageMainPath: 'br.js', + alias: 'foo', + sourceMap: undefined + }); + + t.equal(r.name, 'foo'); + t.equal(r.mainPath, 'br.js'); + t.deepEqual(r.browserReplacement, {}); + }, + err => { + t.fail(err.message); + } + ); + }); +}); + test('packageReader reads dumberForcedMain over browser/module/main field', async t => { return getReader('foo', { - 'node_modules/foo/package.json': '{"name":"foo", "browser": "br", "module": "es", "main": "index", "dumberForcedMain": "hc"}', + 'node_modules/foo/package.json': '{"name":"foo", "exports": "./br", "browser": "br", "module": "es", "main": "index", "dumberForcedMain": "hc"}', 'node_modules/foo/index.js': "lorem", 'node_modules/foo/es.js': 'es', 'node_modules/foo/br.js': 'br', @@ -1402,3 +1714,128 @@ test('packageReader reads main field main file when module field is broken', asy ); }); }); + +test('packageReader reads exports subpaths in package.json', async t => { + const r = await getReader('foo', { + 'node_modules/foo/package.json': `{ + "name": "foo", + "exports": { + ".": "./index.js", + "./package.json": "./package.json", + "./a": "./a.js", + "./b": "./be.js", + "./c": {"import": "./lib/c.js"}, + "./d/*": "./lib/d/*.js", + "./e": null + } + }`, + 'node_modules/foo/index.js': 'lorem', + 'node_modules/foo/a.js': 'a', + 'node_modules/foo/b.js': 'b', + 'node_modules/foo/be.js': 'be', + 'node_modules/foo/lib/c.js': 'c', + 'node_modules/foo/lib/d/x.js': 'x', + 'node_modules/foo/lib/d/y/z.js': 'yz', + 'node_modules/foo/e.js': 'e', + }); + + + let unit = await r.readMain(); + t.equal(r.name, 'foo'); + t.equal(r.mainPath, 'index.js'); + t.deepEqual(r.exportsReplacement, { + './b': './be.js', + './c': './lib/c.js', + './d/*': './lib/d/*.js', + './e': null + }); + + t.deepEqual(unit, { + path: 'node_modules/foo/index.js', + contents: 'lorem', + moduleId: 'foo/index.js', + packageName: 'foo', + packageMainPath: 'index.js', + alias: 'foo', + sourceMap: undefined + }); + + unit = await r.readResource('a', true); + + t.deepEqual(unit, { + path: 'node_modules/foo/a.js', + contents: 'a', + moduleId: 'foo/a.js', + packageName: 'foo', + packageMainPath: 'index.js', + sourceMap: undefined + }); + + unit = await r.readResource('b'); + + t.deepEqual(unit, { + path: 'node_modules/foo/be.js', + contents: 'be', + moduleId: 'foo/be.js', + packageName: 'foo', + packageMainPath: 'index.js', + alias: 'foo/b', + sourceMap: undefined + }); + + // FIXME local require on b uses module id foo/b + // that conflicts with global require('foo/b') from user code + unit = await r.readResource('b', true); + + t.deepEqual(unit, { + path: 'node_modules/foo/b.js', + contents: 'b', + moduleId: 'foo/b.js', + packageName: 'foo', + packageMainPath: 'index.js', + sourceMap: undefined + }); + + unit = await r.readResource('c'); + + t.deepEqual(unit, { + path: 'node_modules/foo/lib/c.js', + contents: 'c', + moduleId: 'foo/lib/c.js', + packageName: 'foo', + packageMainPath: 'index.js', + alias: 'foo/c', + sourceMap: undefined + }); + + unit = await r.readResource('d/x'); + + t.deepEqual(unit, { + path: 'node_modules/foo/lib/d/x.js', + contents: 'x', + moduleId: 'foo/lib/d/x.js', + packageName: 'foo', + packageMainPath: 'index.js', + alias: 'foo/d/x', + sourceMap: undefined + }); + + unit = await r.readResource('d/y/z'); + + t.deepEqual(unit, { + path: 'node_modules/foo/lib/d/y/z.js', + contents: 'yz', + moduleId: 'foo/lib/d/y/z.js', + packageName: 'foo', + packageMainPath: 'index.js', + alias: 'foo/d/y/z', + sourceMap: undefined + }); + + try { + await r.readResource('e'); + t.fail('should not readResource "e"'); + } catch (err) { + t.equal(err.message, "Resource foo/e is not allowed to be imported (foo package.json exports definition {\"./b\":\"./be.js\",\"./c\":\"./lib/c.js\",\"./d/*\":\"./lib/d/*.js\",\"./e\":null})."); + } +});