From 75c4b0e5c01f9f939997e1e24bf04d3255ce1c9a Mon Sep 17 00:00:00 2001 From: Avi Vahl Date: Sun, 30 Jul 2023 19:04:26 +0300 Subject: [PATCH] feat(resolve)!: support "exports" field --- .../commonjs/test/npm-packages.nodespec.ts | 6 +- packages/resolve/src/request-resolver.ts | 96 ++++- packages/resolve/src/types.ts | 18 +- .../resolve/test/request-resolver.spec.ts | 377 +++++++++++++++--- 4 files changed, 417 insertions(+), 80 deletions(-) diff --git a/packages/commonjs/test/npm-packages.nodespec.ts b/packages/commonjs/test/npm-packages.nodespec.ts index b07fc6c2..61bca01c 100644 --- a/packages/commonjs/test/npm-packages.nodespec.ts +++ b/packages/commonjs/test/npm-packages.nodespec.ts @@ -36,7 +36,7 @@ describe('commonjs module system - integration with existing npm packages', func it('evaluates postcss successfully', () => { const { requireFrom, requireCache } = createCjsModuleSystem({ fs, - resolver: createRequestResolver({ fs, target: 'node' }), + resolver: createRequestResolver({ fs, conditions: ['node', 'require'] }), }); requireCache.set('path', { filename: 'path', id: 'path', exports: path, children: [] }); requireCache.set('url', { filename: 'url', id: 'url', exports: url, children: [] }); @@ -60,7 +60,7 @@ describe('commonjs module system - integration with existing npm packages', func it('evaluates mocha successfully', () => { const { requireFrom, requireCache } = createCjsModuleSystem({ fs, - resolver: createRequestResolver({ fs, target: 'node' }), + resolver: createRequestResolver({ fs, conditions: ['node', 'require'] }), }); requireCache.set('path', { filename: 'path', id: 'path', exports: path, children: [] }); requireCache.set('stream', { filename: 'stream', id: 'stream', exports: stream, children: [] }); @@ -77,7 +77,7 @@ describe('commonjs module system - integration with existing npm packages', func it('evaluates sass successfully', () => { const { requireFrom, requireCache } = createCjsModuleSystem({ fs, - resolver: createRequestResolver({ fs, target: 'node' }), + resolver: createRequestResolver({ fs, conditions: ['node', 'require'] }), }); requireCache.set('fs', { filename: 'fs', id: 'fs', exports: fs, children: [] }); requireCache.set('path', { filename: 'path', id: 'path', exports: path, children: [] }); diff --git a/packages/resolve/src/request-resolver.ts b/packages/resolve/src/request-resolver.ts index b77d0666..af731a9b 100644 --- a/packages/resolve/src/request-resolver.ts +++ b/packages/resolve/src/request-resolver.ts @@ -1,9 +1,9 @@ import type { PackageJson } from 'type-fest'; -import type { RequestResolver, IRequestResolverOptions, IResolvedPackageJson, IResolutionOutput } from './types.js'; +import type { IRequestResolverOptions, IResolutionOutput, IResolvedPackageJson, RequestResolver } from './types.js'; -const defaultTarget = 'browser'; const defaultPackageRoots = ['node_modules']; const defaultExtensions = ['.js', '.json']; +const defaultConditions = ['browser', 'import', 'require']; const isRelative = (request: string) => request === '.' || request === '..' || request.startsWith('./') || request.startsWith('../'); const PACKAGE_JSON = 'package.json'; @@ -14,13 +14,16 @@ export function createRequestResolver(options: IRequestResolverOptions): Request fs: { statSync, readFileSync, realpathSync, dirname, join, resolve, isAbsolute }, packageRoots = defaultPackageRoots, extensions = defaultExtensions, - target = defaultTarget, - moduleField = target === 'browser', + conditions = defaultConditions, resolvedPacakgesCache = new Map(), alias = {}, fallback = {}, } = options; + const exportConditions = new Set(conditions); + const targetsBrowser = exportConditions.has('browser'); + const targetsEsm = exportConditions.has('import'); + const loadPackageJsonFromCached = wrapWithCache(loadPackageJsonFrom, resolvedPacakgesCache); const remapUsingAlias = createRequestRemapper(alias); const remapUsingFallback = createRequestRemapper(fallback); @@ -39,7 +42,7 @@ export function createRequestResolver(options: IRequestResolverOptions): Request if (!statSyncSafe(resolvedFilePath)?.isFile()) { continue; } - if (target === 'browser') { + if (targetsBrowser) { const toPackageJson = findUpPackageJson(dirname(resolvedFilePath)); if (toPackageJson) { visitedPaths.add(toPackageJson.filePath); @@ -73,7 +76,7 @@ export function createRequestResolver(options: IRequestResolverOptions): Request yield requestAlias; } - if (!emittedCandidate && target === 'browser') { + if (!emittedCandidate && targetsBrowser) { const fromPackageJson = findUpPackageJson(contextPath); if (fromPackageJson) { visitedPaths.add(fromPackageJson.filePath); @@ -133,6 +136,7 @@ export function createRequestResolver(options: IRequestResolverOptions): Request if (resolvedPackageJson !== undefined) { visitedPaths.add(resolvedPackageJson.filePath); } + const mainPath = resolvedPackageJson?.mainPath; if (mainPath !== undefined) { @@ -147,6 +151,19 @@ export function createRequestResolver(options: IRequestResolverOptions): Request if (!statSyncSafe(packagesPath)?.isDirectory()) { continue; } + const [packageName, innerPath] = parsePackageSpecifier(request); + if (!packageName.length || (packageName.startsWith('@') && !packageName.includes('/'))) { + return; + } + const packageDirectoryPath = join(packagesPath, packageName); + const resolvedPackageJson = loadPackageJsonFromCached(packageDirectoryPath); + if (resolvedPackageJson !== undefined) { + visitedPaths.add(resolvedPackageJson.filePath); + } + if (resolvedPackageJson?.exports !== undefined) { + yield* resolveExportConditions(packageDirectoryPath, resolvedPackageJson.exports[innerPath]); + return; + } const requestInPackages = join(packagesPath, request); yield* fileRequestPaths(requestInPackages); yield* directoryRequestPaths(requestInPackages, visitedPaths); @@ -181,7 +198,7 @@ export function createRequestResolver(options: IRequestResolverOptions): Request const { browser } = packageJson; let browserMappings: Record | undefined = undefined; - if (target === 'browser' && typeof browser === 'object' && browser !== null) { + if (targetsBrowser && typeof browser === 'object' && browser !== null) { browserMappings = Object.create(null) as Record; for (const [from, to] of Object.entries(browser)) { const resolvedFrom = isRelative(from) ? resolveRelative(join(directoryPath, from)) : from; @@ -199,9 +216,30 @@ export function createRequestResolver(options: IRequestResolverOptions): Request directoryPath, mainPath, browserMappings, + exports: desugarifyExportsField(packageJson.exports), }; } + function* resolveExportConditions(directoryPath: string, exportedValue?: PackageJson.Exports): Generator { + if (exportedValue === null || exportedValue === undefined) { + return; + } else if (typeof exportedValue === 'string') { + yield join(directoryPath, exportedValue); + } else if (typeof exportedValue === 'object') { + if (Array.isArray(exportedValue)) { + for (const arrayItem of exportedValue) { + yield* resolveExportConditions(directoryPath, arrayItem); + } + return; + } + for (const [key, value] of Object.entries(exportedValue)) { + if (key === 'default' || exportConditions.has(key)) { + yield* resolveExportConditions(directoryPath, value); + } + } + } + } + function resolveRemappedRequest(directoryPath: string, to: string | false): string | false | undefined { if (to === false) { return to; @@ -249,9 +287,9 @@ export function createRequestResolver(options: IRequestResolverOptions): Request } function packageJsonTarget({ main, browser, module: moduleFieldValue }: PackageJson): string | undefined { - if (target === 'browser' && typeof browser === 'string') { + if (targetsBrowser && typeof browser === 'string') { return browser; - } else if (moduleField && typeof moduleFieldValue === 'string') { + } else if (targetsEsm && typeof moduleFieldValue === 'string') { return moduleFieldValue; } return typeof main === 'string' ? main : undefined; @@ -345,3 +383,43 @@ interface TracedErrorConstructor extends ErrorConstructor { stackTraceLimit?: number; } declare let Error: TracedErrorConstructor; + +/** + * @example parsePackageSpecifier('react-dom') === ['react-dom', "."] + * @example parsePackageSpecifier('react-dom/client') === ['react-dom', './client'] + * @example parsePackageSpecifier('@stylable/core') === ['@stylable/core', "./core"] + * @example parsePackageSpecifier('@stylable/core/dist/some-file') === ['@stylable/core', './dist/some-file'] + */ +function parsePackageSpecifier(specifier: string): readonly [packageName: string, pathInPackage: string] { + const firstSlashIdx = specifier.indexOf('/'); + if (firstSlashIdx === -1) { + return [specifier, '.']; + } + const isScopedPackage = specifier.startsWith('@'); + if (isScopedPackage) { + const secondSlashIdx = specifier.indexOf('/', firstSlashIdx + 1); + return secondSlashIdx === -1 + ? [specifier, '.'] + : [specifier.slice(0, secondSlashIdx), '.' + specifier.slice(secondSlashIdx)]; + } else { + return [specifier.slice(0, firstSlashIdx), '.' + specifier.slice(firstSlashIdx)]; + } +} + +function desugarifyExportsField(exports: PackageJson.Exports | undefined): PackageJson.ExportConditions | undefined { + if (exports === undefined || exports === null) { + return undefined; + } + if (typeof exports === 'string' || Array.isArray(exports)) { + return { '.': exports }; + } else { + for (const key of Object.keys(exports)) { + if (key === '.' || key.startsWith('./')) { + return exports; + } else { + return { '.': exports }; + } + } + return exports; + } +} diff --git a/packages/resolve/src/types.ts b/packages/resolve/src/types.ts index 74958f36..cfa88df7 100644 --- a/packages/resolve/src/types.ts +++ b/packages/resolve/src/types.ts @@ -1,3 +1,5 @@ +import { PackageJson } from 'type-fest'; + export interface IRequestResolverOptions { /** * File system to use when resolving requests. @@ -19,10 +21,12 @@ export interface IRequestResolverOptions { extensions?: string[]; /** - * Whether to prefer the 'browser' field or 'main' field - * in `package.json`. + * Package export conditions to try resolving the request with. + * + * @default ['browser', 'import', 'require'] + * @see https://nodejs.org/api/packages.html#conditional-exports */ - target?: 'node' | 'browser'; + conditions?: string[]; /** * Cache for resolved packages. Map keys are directoryPaths. @@ -43,13 +47,6 @@ export interface IRequestResolverOptions { * Original request is attempted before fallback. */ fallback?: Record; - - /** - * Support the "module" field. Picked up over "main". - * - * @default true when "target" is set to "browser" - */ - moduleField?: boolean; } export interface IResolutionOutput { @@ -105,4 +102,5 @@ export interface IResolvedPackageJson { browserMappings?: { [from: string]: string | false; }; + exports?: PackageJson.ExportConditions; } diff --git a/packages/resolve/test/request-resolver.spec.ts b/packages/resolve/test/request-resolver.spec.ts index eb561d8e..839f7526 100644 --- a/packages/resolve/test/request-resolver.spec.ts +++ b/packages/resolve/test/request-resolver.spec.ts @@ -328,19 +328,6 @@ describe('request resolver', () => { }); describe('browser/module fields (string)', () => { - it('prefers "browser" over "main" and "module" when loading a package.json', () => { - const fs = createMemoryFs({ - lodash: { - 'package.json': stringifyPackageJson({ main: 'entry.js', module: 'entry.js', browser: './browser.js' }), - 'entry.js': EMPTY, - 'browser.js': EMPTY, - }, - }); - const resolveRequest = createRequestResolver({ fs }); - - expect(resolveRequest('/', './lodash')).to.be.resolvedTo('/lodash/browser.js'); - }); - it('uses "browser" if "main" and "module" were not defined', () => { const fs = createMemoryFs({ lodash: { @@ -353,36 +340,22 @@ describe('request resolver', () => { expect(resolveRequest('/', './lodash')).to.be.resolvedTo('/lodash/file.js'); }); - it('prefers "main" when resolution "target" is set to "node"', () => { - const fs = createMemoryFs({ - lodash: { - 'package.json': stringifyPackageJson({ main: 'entry.js', browser: './browser.js', module: './browser.js' }), - 'entry.js': EMPTY, - 'browser.js': EMPTY, - }, - }); - const resolveRequest = createRequestResolver({ fs, target: 'node' }); - - expect(resolveRequest('/', './lodash')).to.be.resolvedTo('/lodash/entry.js'); - }); - - it('prefers "browser" when resolution "target" is set to "browser" (also default)', () => { + it('uses "module" if "main" and "browser" were not defined', () => { const fs = createMemoryFs({ lodash: { - 'package.json': stringifyPackageJson({ main: 'entry.js', browser: './browser.js' }), - 'entry.js': EMPTY, - 'browser.js': EMPTY, + 'package.json': stringifyPackageJson({ module: 'file.js' }), + 'file.js': EMPTY, }, }); - const resolveRequest = createRequestResolver({ fs, target: 'browser' }); + const resolveRequest = createRequestResolver({ fs }); - expect(resolveRequest('/', './lodash')).to.be.resolvedTo('/lodash/browser.js'); + expect(resolveRequest('/', './lodash')).to.be.resolvedTo('/lodash/file.js'); }); it('prefers "module" over "main"', () => { const fs = createMemoryFs({ lodash: { - 'package.json': stringifyPackageJson({ main: 'entry.js', module: './browser.js' }), + 'package.json': stringifyPackageJson({ main: 'entry.js', module: 'browser.js' }), 'entry.js': EMPTY, 'browser.js': EMPTY, }, @@ -392,68 +365,56 @@ describe('request resolver', () => { expect(resolveRequest('/', './lodash')).to.be.resolvedTo('/lodash/browser.js'); }); - it('prefers "module" over "main" when "target" is set to "browser"', () => { + it('prefers "browser" over "main" and "module"', () => { const fs = createMemoryFs({ lodash: { - 'package.json': stringifyPackageJson({ main: 'entry.js', module: './browser.js' }), + 'package.json': stringifyPackageJson({ main: 'entry.js', module: 'entry.js', browser: './browser.js' }), 'entry.js': EMPTY, 'browser.js': EMPTY, }, }); - const resolveRequest = createRequestResolver({ fs, target: 'browser' }); + const resolveRequest = createRequestResolver({ fs }); expect(resolveRequest('/', './lodash')).to.be.resolvedTo('/lodash/browser.js'); }); - it('prefers "main" over "module" when "target" is set to "node"', () => { + it('prefers "main" over "module" and "browser" when conditions only include "node"', () => { const fs = createMemoryFs({ lodash: { - 'package.json': stringifyPackageJson({ main: 'entry.js', module: './browser.js' }), + 'package.json': stringifyPackageJson({ main: 'entry.js', browser: './browser.js', module: './browser.js' }), 'entry.js': EMPTY, 'browser.js': EMPTY, }, }); - const resolveRequest = createRequestResolver({ fs, target: 'node' }); + const resolveRequest = createRequestResolver({ fs, conditions: ['node'] }); expect(resolveRequest('/', './lodash')).to.be.resolvedTo('/lodash/entry.js'); }); - it('prefers "module" over "main" when "moduleField" is set to true and "target" is "node"', () => { + it('prefers "browser" over "main" and "module" when conditions only include "browser"', () => { const fs = createMemoryFs({ lodash: { - 'package.json': stringifyPackageJson({ main: 'entry.js', module: './browser.js' }), + 'package.json': stringifyPackageJson({ main: 'entry.js', browser: './browser.js', module: 'entry.js' }), 'entry.js': EMPTY, 'browser.js': EMPTY, }, }); - const resolveRequest = createRequestResolver({ fs, moduleField: true, target: 'node' }); + const resolveRequest = createRequestResolver({ fs, conditions: ['browser'] }); expect(resolveRequest('/', './lodash')).to.be.resolvedTo('/lodash/browser.js'); }); - it('prefers "main" over "module" when "moduleField" is set to false', () => { + it('prefers "module" over "main" when conditions only include "import"', () => { const fs = createMemoryFs({ lodash: { - 'package.json': stringifyPackageJson({ main: 'entry.js', module: './browser.js' }), + 'package.json': stringifyPackageJson({ main: 'entry.js', module: './browser.js', browser: 'entry.js' }), 'entry.js': EMPTY, 'browser.js': EMPTY, }, }); - const resolveRequest = createRequestResolver({ fs, moduleField: false }); + const resolveRequest = createRequestResolver({ fs, conditions: ['import'] }); - expect(resolveRequest('/', './lodash')).to.be.resolvedTo('/lodash/entry.js'); - }); - - it('uses "module" if "main" and "browser" were not defined', () => { - const fs = createMemoryFs({ - lodash: { - 'package.json': stringifyPackageJson({ module: 'file.js' }), - 'file.js': EMPTY, - }, - }); - const resolveRequest = createRequestResolver({ fs }); - - expect(resolveRequest('/', './lodash')).to.be.resolvedTo('/lodash/file.js'); + expect(resolveRequest('/', './lodash')).to.be.resolvedTo('/lodash/browser.js'); }); it('resolves "browser" which points to a folder with an index file', () => { @@ -570,6 +531,306 @@ describe('request resolver', () => { }); }); + describe('exports field', () => { + it('treats empty object as nothing can be imported', () => { + const fs = createMemoryFs({ + node_modules: { + lodash: { + 'package.json': stringifyPackageJson({ exports: {} }), + 'index.js': EMPTY, + 'entry.js': EMPTY, + }, + }, + }); + const resolveRequest = createRequestResolver({ fs }); + + expect(resolveRequest('/', 'lodash')).to.be.resolvedTo(undefined); + expect(resolveRequest('/', 'lodash/entry.js')).to.be.resolvedTo(undefined); + expect(resolveRequest('/', 'lodash/package.json')).to.be.resolvedTo(undefined); + }); + + it('treats dot as package root', () => { + const fs = createMemoryFs({ + node_modules: { + lodash: { + 'package.json': stringifyPackageJson({ exports: { '.': './entry.js' } }), + 'entry.js': EMPTY, + }, + }, + }); + const resolveRequest = createRequestResolver({ fs }); + + expect(resolveRequest('/', 'lodash')).to.be.resolvedTo('/node_modules/lodash/entry.js'); + expect(resolveRequest('/', 'lodash/entry.js')).to.be.resolvedTo(undefined); + }); + + it('follows mappings to files', () => { + const fs = createMemoryFs({ + node_modules: { + lodash: { + 'package.json': stringifyPackageJson({ exports: { './anything': './f/file.js' } }), + f: { + 'file.js': EMPTY, + }, + }, + }, + }); + const resolveRequest = createRequestResolver({ fs }); + + expect(resolveRequest('/', 'lodash/anything')).to.be.resolvedTo('/node_modules/lodash/f/file.js'); + expect(resolveRequest('/', 'lodash/anything.js')).to.be.resolvedTo(undefined); + expect(resolveRequest('/', 'lodash/f/anything.js')).to.be.resolvedTo(undefined); + expect(resolveRequest('/', 'lodash/f/file.js')).to.be.resolvedTo(undefined); + }); + + describe('conditions', () => { + it('supports the "default" condition', () => { + const fs = createMemoryFs({ + node_modules: { + lodash: { + 'package.json': stringifyPackageJson({ exports: { '.': { default: './entry.js' } } }), + 'entry.js': EMPTY, + }, + }, + }); + const resolveRequest = createRequestResolver({ fs }); + + expect(resolveRequest('/', 'lodash')).to.be.resolvedTo('/node_modules/lodash/entry.js'); + expect(resolveRequest('/', 'lodash/entry.js')).to.be.resolvedTo(undefined); + }); + + it('picks up "browser", "import" and "require" coniditions', () => { + const fs = createMemoryFs({ + node_modules: { + esm: { + 'package.json': stringifyPackageJson({ exports: { '.': { import: './entry.mjs' } } }), + 'entry.mjs': EMPTY, + }, + cjs: { + 'package.json': stringifyPackageJson({ exports: { '.': { require: './entry.js' } } }), + 'entry.js': EMPTY, + }, + web: { + 'package.json': stringifyPackageJson({ exports: { '.': { browser: './entry.mjs' } } }), + 'entry.mjs': EMPTY, + }, + }, + }); + const resolveRequest = createRequestResolver({ fs }); + + expect(resolveRequest('/', 'esm')).to.be.resolvedTo('/node_modules/esm/entry.mjs'); + expect(resolveRequest('/', 'cjs')).to.be.resolvedTo('/node_modules/cjs/entry.js'); + expect(resolveRequest('/', 'web')).to.be.resolvedTo('/node_modules/web/entry.mjs'); + }); + + it('supports nested conditions', () => { + const fs = createMemoryFs({ + node_modules: { + lodash: { + 'package.json': stringifyPackageJson({ + exports: { + '.': { + node: { import: './entry.mjs', require: './entry.cjs' }, + browser: { import: './entry.browser.mjs', require: './entry.browser.cjs' }, + }, + }, + }), + 'entry.cjs': EMPTY, + 'entry.mjs': EMPTY, + 'entry.browser.cjs': EMPTY, + 'entry.browser.mjs': EMPTY, + }, + }, + }); + const resolveRequest = createRequestResolver({ fs }); + + expect(resolveRequest('/', 'lodash')).to.be.resolvedTo('/node_modules/lodash/entry.browser.mjs'); + }); + + it('ignores any other condition by default', () => { + const fs = createMemoryFs({ + node_modules: { + with_types: { + 'package.json': stringifyPackageJson({ + exports: { '.': { types: './entry.d.ts', require: './entry.js' } }, + }), + 'entry.js': EMPTY, + 'entry.d.ts': EMPTY, + }, + styling_lib: { + 'package.json': stringifyPackageJson({ + exports: { '.': { browser: { css: './style.css' } } }, + }), + 'style.css': EMPTY, + }, + }, + }); + const resolveRequest = createRequestResolver({ fs }); + + expect(resolveRequest('/', 'with_types')).to.be.resolvedTo('/node_modules/with_types/entry.js'); + expect(resolveRequest('/', 'styling_lib')).to.be.resolvedTo(undefined); + }); + + it('allows specifying custom conditions', () => { + const fs = createMemoryFs({ + node_modules: { + with_types: { + 'package.json': stringifyPackageJson({ + exports: { '.': { types: './entry.d.ts', require: './entry.js' } }, + }), + 'entry.js': EMPTY, + 'entry.d.ts': EMPTY, + }, + }, + }); + const resolveRequest = createRequestResolver({ fs, conditions: ['types'] }); + + expect(resolveRequest('/', 'with_types')).to.be.resolvedTo('/node_modules/with_types/entry.d.ts'); + }); + + it('respects order of conditions', () => { + const fs = createMemoryFs({ + node_modules: { + dual: { + 'package.json': stringifyPackageJson({ + exports: { '.': { import: './entry.mjs', require: './entry.js' } }, + }), + 'entry.js': EMPTY, + 'entry.mjs': EMPTY, + }, + dual_reversed: { + 'package.json': stringifyPackageJson({ + exports: { '.': { require: './entry.js', import: './entry.mjs' } }, + }), + 'entry.js': EMPTY, + 'entry.mjs': EMPTY, + }, + }, + }); + const resolveRequest = createRequestResolver({ fs }); + + expect(resolveRequest('/', 'dual')).to.be.resolvedTo('/node_modules/dual/entry.mjs'); + expect(resolveRequest('/', 'dual_reversed')).to.be.resolvedTo('/node_modules/dual_reversed/entry.js'); + }); + }); + + describe('syntactic sugar', () => { + it('treats string-value "exports" field as { ".": "the-string" }', () => { + const fs = createMemoryFs({ + node_modules: { + lodash: { + 'package.json': stringifyPackageJson({ exports: './entry.js' }), + 'entry.js': EMPTY, + }, + }, + }); + const resolveRequest = createRequestResolver({ fs }); + + expect(resolveRequest('/', 'lodash')).to.be.resolvedTo('/node_modules/lodash/entry.js'); + }); + + it('treats root conditions as { "." : { ...conditions } }', () => { + const fs = createMemoryFs({ + node_modules: { + lodash: { + 'package.json': stringifyPackageJson({ exports: { browser: './entry.browser.js', default: 'entry.js' } }), + 'entry.js': EMPTY, + 'entry.browser.js': EMPTY, + }, + }, + }); + const resolveRequest = createRequestResolver({ fs }); + + expect(resolveRequest('/', 'lodash')).to.be.resolvedTo('/node_modules/lodash/entry.browser.js'); + }); + + it('treats Array-value "exports" field as { ".": [...] }', () => { + const fs = createMemoryFs({ + node_modules: { + lodash: { + 'package.json': stringifyPackageJson({ exports: ['./entry.js'] }), + 'entry.js': EMPTY, + }, + no_entry: { + 'package.json': stringifyPackageJson({ exports: ['./entry.js'] }), + }, + }, + }); + const resolveRequest = createRequestResolver({ fs }); + + expect(resolveRequest('/', 'lodash')).to.be.resolvedTo('/node_modules/lodash/entry.js'); + expect(resolveRequest('/', 'no_entry')).to.be.resolvedTo(undefined); + }); + }); + + describe('no cjs resolution leakage', () => { + it('does not append extensions to export targets', () => { + const fs = createMemoryFs({ + node_modules: { + lodash: { + 'package.json': stringifyPackageJson({ exports: { '.': './entry' } }), + 'entry.js': EMPTY, + }, + }, + }); + const resolveRequest = createRequestResolver({ fs }); + + expect(resolveRequest('/', 'lodash')).to.be.resolvedTo(undefined); + }); + + it('does not allow mapping to folder root (no index.js appending)', () => { + const fs = createMemoryFs({ + node_modules: { + lodash: { + 'package.json': stringifyPackageJson({ exports: { '.': './f' } }), + f: { + 'index.js': EMPTY, + }, + }, + }, + }); + const resolveRequest = createRequestResolver({ fs }); + + expect(resolveRequest('/', 'lodash')).to.be.resolvedTo(undefined); + }); + }); + + describe('edge cases', () => { + it('treats "exports": null as not defined (matches node behavior)', () => { + const fs = createMemoryFs({ + node_modules: { + lodash: { + 'package.json': stringifyPackageJson({ exports: null }), + 'entry.js': EMPTY, + }, + }, + }); + const resolveRequest = createRequestResolver({ fs }); + + expect(resolveRequest('/', 'lodash/entry.js')).to.be.resolvedTo('/node_modules/lodash/entry.js'); + }); + + it('resolves "/" only if ./ is allowed', () => { + const fs = createMemoryFs({ + node_modules: { + disallowed: { + 'package.json': stringifyPackageJson({ exports: { '.': './entry.js' } }), + 'entry.js': EMPTY, + }, + allowed: { + 'package.json': stringifyPackageJson({ exports: { '.': './entry.js', './': './entry.js' } }), + 'entry.js': EMPTY, + }, + }, + }); + const resolveRequest = createRequestResolver({ fs }); + + expect(resolveRequest('/', 'disallowed/')).to.be.resolvedTo(undefined); + expect(resolveRequest('/', 'allowed/')).to.be.resolvedTo('/node_modules/allowed/entry.js'); + }); + }); + }); + describe('alias', () => { it('remaps package requests to other package requests', () => { const fs = createMemoryFs({