From c4fbab1f0e4826d71760c54fc09416e9302b0791 Mon Sep 17 00:00:00 2001 From: Luke Edwards Date: Fri, 22 Jan 2021 21:23:30 -0800 Subject: [PATCH 1/3] feat: add error codes & target validation; - Closes #3 - Closes #5 --- src/index.js | 46 +++++++++---- test/resolve.js | 180 ++++++++++++++++++++++++------------------------ 2 files changed, 121 insertions(+), 105 deletions(-) diff --git a/src/index.js b/src/index.js index 84e9b7a..d2a691f 100644 --- a/src/index.js +++ b/src/index.js @@ -23,19 +23,36 @@ function loop(exports, keys) { } } +function throws(code, msg) { + let err = Error(msg); + err.code = code; + throw err; +} + /** * @param {string} name The package name * @param {string} entry The target entry, eg "." * @param {number} [condition] Unmatched condition? */ -function bail(name, entry, condition) { - throw new Error( - condition +function missing(name, entry, condition) { + throws('ERR_PACKAGE_PATH_NOT_EXPORTED', condition ? `No known conditions for "${entry}" entry in "${name}" package` : `Missing "${entry}" export in "${name}" package` ); } +/** + * @param {string} name The package name + * @param {string} entry The target entry, eg "." + * @param {string} value The resolved value + * @returns {void|string} + */ +function validate(name, entry, value) { + if (value[0] != '.' || value[1] != '/') throws('ERR_INVALID_PACKAGE_TARGET', `Invalid "${entry}" export in "${name}" package; targets must start with "./"`); + // if (value[value.length - 1] == '/') throws('ERR_UNSUPPORTED_DIR_IMPORT', `Invalid "${entry}" export in "${name}" package; targets must not resolve to a directory`); + return value; +} + /** * @param {string} name the package name * @param {string} entry the target path/import @@ -64,7 +81,7 @@ export function resolve(pkg, entry='.', options={}) { if (target[0] !== '.') target = './' + target; if (typeof exports === 'string') { - return target === '.' ? exports : bail(name, target); + return target === '.' ? exports : missing(name, target); } let allows = new Set(['default', ...conditions]); @@ -79,33 +96,32 @@ export function resolve(pkg, entry='.', options={}) { } if (isSingle) { - return target === '.' - ? loop(exports, allows) || bail(name, target, 1) - : bail(name, target); + if (target !== '.') return missing(name, target); + tmp = loop(exports, allows) || missing(name, target, 1); + return validate(name, target, tmp); } if (tmp = exports[target]) { - return loop(tmp, allows) || bail(name, target, 1); + tmp = loop(tmp, allows) || missing(name, target, 1); + return validate(name, target, tmp); } for (key in exports) { tmp = key[key.length - 1]; if (tmp === '/' && target.startsWith(key)) { - return (tmp = loop(exports[key], allows)) - ? (tmp + target.substring(key.length)) - : bail(name, target, 1); + tmp = loop(exports[key], allows) || missing(name, target, 1); + return validate(name, target, tmp + target.substring(key.length)); } if (tmp === '*' && target.startsWith(key.slice(0, -1))) { // do not trigger if no *content* to inject if (target.substring(key.length - 1).length > 0) { - return (tmp = loop(exports[key], allows)) - ? tmp.replace('*', target.substring(key.length - 1)) - : bail(name, target, 1); + tmp = loop(exports[key], allows) || missing(name, target, 1); + return validate(name, target, tmp.replace('*', target.substring(key.length - 1))); } } } - return bail(name, target); + return missing(name, target); } } diff --git a/test/resolve.js b/test/resolve.js index dd5dc39..ad435b2 100644 --- a/test/resolve.js +++ b/test/resolve.js @@ -28,12 +28,12 @@ resolve('should be a function', () => { resolve('exports=string', () => { let pkg = { "name": "foobar", - "exports": "$string", + "exports": "./$string", }; - pass(pkg, '$string'); - pass(pkg, '$string', '.'); - pass(pkg, '$string', 'foobar'); + pass(pkg, './$string'); + pass(pkg, './$string', '.'); + pass(pkg, './$string', 'foobar'); fail(pkg, './other', 'other'); fail(pkg, './other', 'foobar/other'); @@ -44,14 +44,14 @@ resolve('exports = { self }', () => { let pkg = { "name": "foobar", "exports": { - "import": "$import", - "require": "$require", + "import": "./$import", + "require": "./$require", } }; - pass(pkg, '$import'); - pass(pkg, '$import', '.'); - pass(pkg, '$import', 'foobar'); + pass(pkg, './$import'); + pass(pkg, './$import', '.'); + pass(pkg, './$import', 'foobar'); fail(pkg, './other', 'other'); fail(pkg, './other', 'foobar/other'); @@ -62,13 +62,13 @@ resolve('exports["."] = string', () => { let pkg = { "name": "foobar", "exports": { - ".": "$self", + ".": "./$self", } }; - pass(pkg, '$self'); - pass(pkg, '$self', '.'); - pass(pkg, '$self', 'foobar'); + pass(pkg, './$self'); + pass(pkg, './$self', '.'); + pass(pkg, './$self', 'foobar'); fail(pkg, './other', 'other'); fail(pkg, './other', 'foobar/other'); @@ -80,15 +80,15 @@ resolve('exports["."] = object', () => { "name": "foobar", "exports": { ".": { - "import": "$import", - "require": "$require", + "import": "./$import", + "require": "./$require", } } }; - pass(pkg, '$import'); - pass(pkg, '$import', '.'); - pass(pkg, '$import', 'foobar'); + pass(pkg, './$import'); + pass(pkg, './$import', '.'); + pass(pkg, './$import', 'foobar'); fail(pkg, './other', 'other'); fail(pkg, './other', 'foobar/other'); @@ -99,12 +99,12 @@ resolve('exports["./foo"] = string', () => { let pkg = { "name": "foobar", "exports": { - "./foo": "$import", + "./foo": "./$import", } }; - pass(pkg, '$import', './foo'); - pass(pkg, '$import', 'foobar/foo'); + pass(pkg, './$import', './foo'); + pass(pkg, './$import', 'foobar/foo'); fail(pkg, '.'); fail(pkg, '.', 'foobar'); @@ -116,14 +116,14 @@ resolve('exports["./foo"] = object', () => { "name": "foobar", "exports": { "./foo": { - "import": "$import", - "require": "$require", + "import": "./$import", + "require": "./$require", } } }; - pass(pkg, '$import', './foo'); - pass(pkg, '$import', 'foobar/foo'); + pass(pkg, './$import', './foo'); + pass(pkg, './$import', 'foobar/foo'); fail(pkg, '.'); fail(pkg, '.', 'foobar'); @@ -136,19 +136,19 @@ resolve('nested conditions', () => { "name": "foobar", "exports": { "node": { - "import": "$node.import", - "require": "$node.require" + "import": "./$node.import", + "require": "./$node.require" }, - "default": "$default", + "default": "./$default", } }; - pass(pkg, '$node.import'); - pass(pkg, '$node.import', 'foobar'); + pass(pkg, './$node.import'); + pass(pkg, './$node.import', 'foobar'); // browser => no "node" key - pass(pkg, '$default', '.', { browser: true }); - pass(pkg, '$default', 'foobar', { browser: true }); + pass(pkg, './$default', '.', { browser: true }); + pass(pkg, './$default', 'foobar', { browser: true }); fail(pkg, './hello', './hello'); fail(pkg, './other', 'foobar/other'); @@ -161,22 +161,22 @@ resolve('nested conditions :: subpath', () => { "exports": { "./lite": { "node": { - "import": "$node.import", - "require": "$node.require" + "import": "./$node.import", + "require": "./$node.require" }, "browser": { - "import": "$browser.import", - "require": "$browser.require" + "import": "./$browser.import", + "require": "./$browser.require" }, } } }; - pass(pkg, '$node.import', 'foobar/lite'); - pass(pkg, '$node.require', 'foobar/lite', { require: true }); + pass(pkg, './$node.import', 'foobar/lite'); + pass(pkg, './$node.require', 'foobar/lite', { require: true }); - pass(pkg, '$browser.import', 'foobar/lite', { browser: true }); - pass(pkg, '$browser.require', 'foobar/lite', { browser: true, require: true }); + pass(pkg, './$browser.import', 'foobar/lite', { browser: true }); + pass(pkg, './$browser.require', 'foobar/lite', { browser: true, require: true }); }); resolve('nested conditions :: subpath :: inverse', () => { @@ -185,22 +185,22 @@ resolve('nested conditions :: subpath :: inverse', () => { "exports": { "./lite": { "import": { - "browser": "$browser.import", - "node": "$node.import", + "browser": "./$browser.import", + "node": "./$node.import", }, "require": { - "browser": "$browser.require", - "node": "$node.require", + "browser": "./$browser.require", + "node": "./$node.require", } } } }; - pass(pkg, '$node.import', 'foobar/lite'); - pass(pkg, '$node.require', 'foobar/lite', { require: true }); + pass(pkg, './$node.import', 'foobar/lite'); + pass(pkg, './$node.require', 'foobar/lite', { require: true }); - pass(pkg, '$browser.import', 'foobar/lite', { browser: true }); - pass(pkg, '$browser.require', 'foobar/lite', { browser: true, require: true }); + pass(pkg, './$browser.import', 'foobar/lite', { browser: true }); + pass(pkg, './$browser.require', 'foobar/lite', { browser: true, require: true }); }); // https://nodejs.org/api/packages.html#packages_subpath_folder_mappings @@ -209,17 +209,17 @@ resolve('exports["./"]', () => { "name": "foobar", "exports": { ".": { - "require": "$require", - "import": "$import" + "require": "./$require", + "import": "./$import" }, "./package.json": "./package.json", "./": "./" } }; - pass(pkg, '$import'); - pass(pkg, '$import', 'foobar'); - pass(pkg, '$require', 'foobar', { require: true }); + pass(pkg, './$import'); + pass(pkg, './$import', 'foobar'); + pass(pkg, './$require', 'foobar', { require: true }); pass(pkg, './package.json', 'package.json'); pass(pkg, './package.json', 'foobar/package.json'); @@ -468,29 +468,29 @@ resolve('should handle mixed path/conditions', () => { "exports": { ".": [ { - "import": "$root.import", + "import": "./$root.import", }, - "$root.string" + "./$root.string" ], "./foo": [ { - "require": "$foo.require" + "require": "./$foo.require" }, - "$foo.string" + "./$foo.string" ] } } - pass(pkg, '$root.import'); - pass(pkg, '$root.import', 'foobar'); + pass(pkg, './$root.import'); + pass(pkg, './$root.import', 'foobar'); - pass(pkg, '$foo.string', 'foo'); - pass(pkg, '$foo.string', 'foobar/foo'); - pass(pkg, '$foo.string', './foo'); + pass(pkg, './$foo.string', 'foo'); + pass(pkg, './$foo.string', 'foobar/foo'); + pass(pkg, './$foo.string', './foo'); - pass(pkg, '$foo.require', 'foo', { require: true }); - pass(pkg, '$foo.require', 'foobar/foo', { require: true }); - pass(pkg, '$foo.require', './foo', { require: true }); + pass(pkg, './$foo.require', 'foo', { require: true }); + pass(pkg, './$foo.require', 'foobar/foo', { require: true }); + pass(pkg, './$foo.require', './foo', { require: true }); }); resolve.run(); @@ -499,38 +499,38 @@ resolve.run(); const requires = suite('options.requires', { "exports": { - "require": "$require", - "import": "$import", + "require": "./$require", + "import": "./$import", } }); requires('should ignore "require" keys by default', pkg => { - pass(pkg, '$import'); + pass(pkg, './$import'); }); requires('should use "require" key when defined first', pkg => { - pass(pkg, '$require', '.', { require: true }); + pass(pkg, './$require', '.', { require: true }); }); requires('should ignore "import" key when enabled', () => { let pkg = { "exports": { - "import": "$import", - "require": "$require", + "import": "./$import", + "require": "./$require", } }; - pass(pkg, '$require', '.', { require: true }); - pass(pkg, '$import', '.'); + pass(pkg, './$require', '.', { require: true }); + pass(pkg, './$import', '.'); }); requires('should match "default" if "require" is after', () => { let pkg = { "exports": { - "default": "$default", - "require": "$require", + "default": "./$default", + "require": "./$require", } }; - pass(pkg, '$default', '.', { require: true }); + pass(pkg, './$default', '.', { require: true }); }); requires.run(); @@ -539,29 +539,29 @@ requires.run(); const browser = suite('options.browser', { "exports": { - "browser": "$browser", - "node": "$node", + "browser": "./$browser", + "node": "./$node", } }); browser('should ignore "browser" keys by default', pkg => { - pass(pkg, '$node'); + pass(pkg, './$node'); }); browser('should use "browser" key when defined first', pkg => { - pass(pkg, '$browser', '.', { browser: true }); + pass(pkg, './$browser', '.', { browser: true }); }); browser('should ignore "node" key when enabled', () => { let pkg = { "exports": { - "node": "$node", - "import": "$import", - "browser": "$browser", + "node": "./$node", + "import": "./$import", + "browser": "./$browser", } }; // import defined before browser - pass(pkg, '$import', '.', { browser: true }); + pass(pkg, './$import', '.', { browser: true }); }); browser.run(); @@ -570,22 +570,22 @@ browser.run(); const conditions = suite('options.conditions', { "exports": { - "production": "$prod", - "development": "$dev", - "default": "$default", + "production": "./$prod", + "development": "./$dev", + "default": "./$default", } }); conditions('should ignore unknown conditions by default', pkg => { - pass(pkg, '$default'); + pass(pkg, './$default'); }); conditions('should recognize custom field(s) when specified', pkg => { - pass(pkg, '$dev', '.', { + pass(pkg, './$dev', '.', { conditions: ['development'] }); - pass(pkg, '$prod', '.', { + pass(pkg, './$prod', '.', { conditions: ['development', 'production'] }); }); From 596900dc33806e6ffc1cc06674b20ca1b0d2d6a2 Mon Sep 17 00:00:00 2001 From: Luke Edwards Date: Fri, 22 Jan 2021 21:40:06 -0800 Subject: [PATCH 2/3] chore: add `err.code` assertions --- test/resolve.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/resolve.js b/test/resolve.js index ad435b2..bae603c 100644 --- a/test/resolve.js +++ b/test/resolve.js @@ -13,6 +13,7 @@ function fail(pkg, target, ...args) { assert.unreachable(); } catch (err) { assert.instance(err, Error); + assert.is(err.code, 'ERR_PACKAGE_PATH_NOT_EXPORTED'); assert.is(err.message, `Missing "${target}" export in "${pkg.name}" package`); } } @@ -605,6 +606,7 @@ conditions('should throw an error if no known conditions', ctx => { assert.unreachable(); } catch (err) { assert.instance(err, Error); + assert.is(err.code, 'ERR_PACKAGE_PATH_NOT_EXPORTED'); assert.is(err.message, `No known conditions for "." entry in "hello" package`); } }); From 1bd82bb51dd40a650d1314232f3da6f377da7454 Mon Sep 17 00:00:00 2001 From: Luke Edwards Date: Fri, 22 Jan 2021 21:40:52 -0800 Subject: [PATCH 3/3] chore: add relative-path validation tests --- test/resolve.js | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/test/resolve.js b/test/resolve.js index bae603c..2e01b38 100644 --- a/test/resolve.js +++ b/test/resolve.js @@ -612,3 +612,49 @@ conditions('should throw an error if no known conditions', ctx => { }); conditions.run(); + +// --- + +const validation = suite('validation', { + "name": "foobar", + "exports": { + "browser": "/$require", + "import": "$prod", + "require": "../$require", + } +}); + +validation('throws `ERR_INVALID_PACKAGE_TARGET` error :: "target"', pkg => { + try { + $exports.resolve(pkg); + assert.unreachable(); + } catch (err) { + assert.instance(err, Error); + assert.is(err.code, 'ERR_INVALID_PACKAGE_TARGET'); + assert.is(err.message, `Invalid "." export in "foobar" package; targets must start with "./"`); + } +}); + +validation('throws `ERR_INVALID_PACKAGE_TARGET` error :: "../target"', pkg => { + try { + $exports.resolve(pkg, '.', { require: true }); + assert.unreachable(); + } catch (err) { + assert.instance(err, Error); + assert.is(err.code, 'ERR_INVALID_PACKAGE_TARGET'); + assert.is(err.message, `Invalid "." export in "foobar" package; targets must start with "./"`); + } +}); + +validation('throws `ERR_INVALID_PACKAGE_TARGET` error :: "/target"', pkg => { + try { + $exports.resolve(pkg, '.', { browser: true }); + assert.unreachable(); + } catch (err) { + assert.instance(err, Error); + assert.is(err.code, 'ERR_INVALID_PACKAGE_TARGET'); + assert.is(err.message, `Invalid "." export in "foobar" package; targets must start with "./"`); + } +}); + +validation.run();