Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-var-requires */
// Requires a local package directory that exposes its entry point only through
// the package.json `exports` field (no `main`), so the target file is found
// solely via exports resolution.
const pkg = require('./local-pkg')

module.exports = { pkg }
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = { name: 'browser' }
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = { name: 'fallback' }
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = { name: 'main' }
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "local-pkg",
"version": "1.0.0",
"exports": {
".": {
"browser": "./lib/browser.js",
"node": "./lib/main.js",
"default": "./lib/fallback.js"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = { name: 'config' }
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-var-requires */
// Exercises Node.js subpath imports (the package.json `imports` field):
// - `#config` -> a relative file within the package
// - `#internal/bar` -> a wildcard-mapped relative file
// - `#dep` -> an external package (lodash)
const config = require('#config')
const bar = require('#internal/bar')
const dep = require('#dep')

module.exports = { config, bar, dep }
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"name": "subpath-imports-fixture",
"version": "1.0.0",
"main": "entrypoint.js",
"imports": {
"#config": "./config.js",
"#internal/*": "./src/internal/*.js",
"#dep": "lodash"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = { name: 'bar' }
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,50 @@ describe('dependency-parser - parser()', () => {
// parsing must not throw a DependencyParseError.
expect(dependencies.map(d => d.filePath)).not.toContain(toAbsolutePath('native.node'))
})

it('should resolve package.json subpath #imports', async () => {
// Node.js subpath imports (the package.json `imports` field, referenced
// via `#`-prefixed specifiers) must be discovered and bundled
// automatically, without a manual `include`.
const toAbsolutePath = (...filepath: string[]) => fixt.abspath('subpath-imports', ...filepath)
const parser = new Parser({
supportedNpmModules: defaultNpmModules,
restricted: false,
})

const { dependencies } = await parser.parse(toAbsolutePath('entrypoint.js'))
const filePaths = dependencies.map(d => d.filePath)

// Both a direct relative target (#config) and a wildcard-mapped target
// (#internal/bar) are bundled.
expect(filePaths).toContain(toAbsolutePath('config.js'))
expect(filePaths).toContain(toAbsolutePath('src', 'internal', 'bar.js'))

// The raw `#`-specifiers must never leak into the dependency set, and the
// external target (#dep -> lodash) is recorded as external, not bundled.
expect(filePaths.some(filePath => filePath.includes('#'))).toBe(false)
expect(filePaths.some(filePath => filePath.includes('lodash'))).toBe(false)
})

it('should resolve a package.json exports field', async () => {
// The imported package exposes its entry point only through a conditional
// `exports` map (no `main`), so the target file is discovered solely via
// exports resolution. The `node` condition is selected: the earlier,
// non-matching `browser` condition is skipped and the `default` fallback
// is not reached.
const toAbsolutePath = (...filepath: string[]) => fixt.abspath('package-exports', ...filepath)
const parser = new Parser({
supportedNpmModules: defaultNpmModules,
restricted: false,
})

const { dependencies } = await parser.parse(toAbsolutePath('entrypoint.js'))
const filePaths = dependencies.map(d => d.filePath)

expect(filePaths).toContain(toAbsolutePath('local-pkg', 'lib', 'main.js'))
expect(filePaths).not.toContain(toAbsolutePath('local-pkg', 'lib', 'browser.js'))
expect(filePaths).not.toContain(toAbsolutePath('local-pkg', 'lib', 'fallback.js'))
})
})

describe('restricted mode', () => {
Expand Down Expand Up @@ -641,6 +685,40 @@ describe('dependency-parser - parser()', () => {
])
})

it('should resolve relative subpath #imports', async () => {
// In restricted mode the nearest package.json is not bundled, so the
// `imports` map is unavailable at runtime (a documented limitation), but
// dependency resolution must still discover the mapped target files
// and must not crash.
const toAbsolutePath = (...filepath: string[]) => fixt.abspath('subpath-imports', ...filepath)
const parser = new Parser({
supportedNpmModules: defaultNpmModules,
restricted: true,
})

const { dependencies } = await parser.parse(toAbsolutePath('entrypoint.js'))
const filePaths = dependencies.map(d => d.filePath)

expect(filePaths).toContain(toAbsolutePath('config.js'))
expect(filePaths).toContain(toAbsolutePath('src', 'internal', 'bar.js'))
expect(filePaths.some(filePath => filePath.includes('#'))).toBe(false)
})

it('should resolve a package.json exports field', async () => {
const toAbsolutePath = (...filepath: string[]) => fixt.abspath('package-exports', ...filepath)
const parser = new Parser({
supportedNpmModules: defaultNpmModules,
restricted: true,
})

const { dependencies } = await parser.parse(toAbsolutePath('entrypoint.js'))
const filePaths = dependencies.map(d => d.filePath)

expect(filePaths).toContain(toAbsolutePath('local-pkg', 'lib', 'main.js'))
expect(filePaths).not.toContain(toAbsolutePath('local-pkg', 'lib', 'browser.js'))
expect(filePaths).not.toContain(toAbsolutePath('local-pkg', 'lib', 'fallback.js'))
})

it('should report a missing entrypoint file', async () => {
const missingEntrypoint = fixt.abspath('does-not-exist.js')
expect.assertions(1)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -404,4 +404,93 @@ describe('package.json file', () => {
expect(paths[0].target.path).toBe('./index.js')
})
})

describe('resolveImportPath', () => {
const importConditions = ['import', 'node', 'module-sync', 'default']
const requireConditions = ['require', 'node', 'module-sync', 'default']

it('resolves a plain-string relative target', () => {
const testFile = PackageJsonFile.make('/pkg/package.json', {
name: 'foo',
version: '1.0.0',
imports: {
'#config': './config.js',
},
})

const { paths } = testFile.resolveImportPath('#config', importConditions)

expect(paths).toHaveLength(1)
expect(paths[0].target.path).toBe('./config.js')
})

it('resolves a conditional target differently for import and require', () => {
const testFile = PackageJsonFile.make('/pkg/package.json', {
name: 'foo',
version: '1.0.0',
imports: {
'#dep': {
import: './dep.mjs',
require: './dep.cjs',
default: './dep.js',
},
} as any,
})

const importResult = testFile.resolveImportPath('#dep', importConditions)
expect(importResult.paths).toHaveLength(1)
expect(importResult.paths[0].target.path).toBe('./dep.mjs')

const requireResult = testFile.resolveImportPath('#dep', requireConditions)
expect(requireResult.paths).toHaveLength(1)
expect(requireResult.paths[0].target.path).toBe('./dep.cjs')
})

it('resolves a wildcard target', () => {
const testFile = PackageJsonFile.make('/pkg/package.json', {
name: 'foo',
version: '1.0.0',
imports: {
'#internal/*': './src/internal/*.js',
},
})

const { paths } = testFile.resolveImportPath('#internal/foo', importConditions)

expect(paths).toHaveLength(1)
expect(paths[0].target.path).toBe('./src/internal/foo.js')
})

it('returns a bare/external target verbatim', () => {
// Import maps may point at an external package rather than a file within
// the declaring package; the target is returned unchanged so the resolver
// can treat it as an external (or workspace-neighbor) specifier.
const testFile = PackageJsonFile.make('/pkg/package.json', {
name: 'foo',
version: '1.0.0',
imports: {
'#dep': 'lodash',
},
})

const { paths } = testFile.resolveImportPath('#dep', importConditions)

expect(paths).toHaveLength(1)
expect(paths[0].target.path).toBe('lodash')
})

it('returns no paths for an unmatched specifier', () => {
const testFile = PackageJsonFile.make('/pkg/package.json', {
name: 'foo',
version: '1.0.0',
imports: {
'#config': './config.js',
},
})

const { paths } = testFile.resolveImportPath('#missing', importConditions)

expect(paths).toHaveLength(0)
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,14 @@ export class PackageJsonFile {
return !!this.jsonFile.data.exports
}

hasImports (): boolean {
return !!this.jsonFile.data.imports
}

resolveExportPath (exportPath: string, conditions: ConditionKey[]): ResolveResult {
const resolver = PathResolver.createFromPaths(
this.basePath,
this.#resolveExports(this.jsonFile.data.exports ?? {}, conditions),
this.#resolveConditionalTargets(this.jsonFile.data.exports ?? {}, conditions),
)

// Normalize the export path to the canonical subpath form used by
Expand All @@ -107,16 +111,44 @@ export class PackageJsonFile {
return resolver.resolve(exportPath)
}

#resolveExports (exports: Exports, conditions: ConditionKey[]): Record<string, string[]> {
if (typeof exports === 'string') {
/**
* Resolves a Node.js subpath import specifier (a `#`-prefixed specifier such
* as `#utils/foo`) against the package's `imports` map.
*
* Unlike `resolveExportPath`, no subpath normalization is needed: `imports`
* keys and the specifier are both `#`-prefixed and matched verbatim. Relative
* targets in the result resolve against `basePath`; bare targets are external
* package (or workspace neighbor) specifiers.
*
* See https://nodejs.org/api/packages.html#subpath-imports
*/
resolveImportPath (importPath: string, conditions: ConditionKey[]): ResolveResult {
const resolver = PathResolver.createFromPaths(
this.basePath,
this.#resolveConditionalTargets(this.jsonFile.data.imports ?? {}, conditions),
)

return resolver.resolve(importPath)
}

// Resolves the conditional targets of an `exports` or `imports` map into a
// flat `subpath -> [target]` mapping. Both fields share the same target shape
// (string | null | array | nested conditions), so the same logic applies.
#resolveConditionalTargets (
map: Exports,
conditions: ConditionKey[],
): Record<string, string[]> {
// A plain-string `exports` shorthand maps the root subpath to that target.
// (`imports` is never a plain string, but sharing the branch is harmless.)
if (typeof map === 'string') {
return {
'.': [exports],
'.': [map],
}
}

const resolved: Record<string, string[]> = {}

for (const [from, target] of Object.entries(exports)) {
for (const [from, target] of Object.entries(map)) {
const resolvedTarget = this.#resolveExportTarget(target, conditions)
if (resolvedTarget !== undefined) {
resolved[from] = [resolvedTarget]
Expand Down
Loading
Loading