Skip to content

Commit 4a3e748

Browse files
committed
Use pkg-entry-points for import discovery
1 parent 05677dc commit 4a3e748

8 files changed

Lines changed: 57 additions & 292 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ const results = await getPackageStats('react-dom@19.2.0', {
4141

4242
`importPoint` only accepts an exact value returned by
4343
`getPackageImportPoints()` for that package version. This keeps generated build
44-
entries limited to package-declared public imports.
44+
entries limited to discovered package imports while respecting `exports`
45+
encapsulation when it is present.
4546

4647
##### Building local packages (beta)
4748

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@
104104
"oxc-walker": "^0.5.2",
105105
"p-queue": "^9.3.1",
106106
"pify": "^6.1.0",
107+
"pkg-entry-points": "^1.1.2",
107108
"postcss": "^8.4.0",
108109
"postcss-loader": "^8.1.0",
109110
"rimraf": "^6.1.0",

src/utils/importPoints.utils.ts

Lines changed: 35 additions & 289 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,32 @@
11
import fs from 'node:fs/promises'
2-
import path from 'node:path'
3-
import type { Dirent } from 'node:fs'
2+
import { getPackageEntryPoints } from 'pkg-entry-points'
43
import { throwIfAborted } from './common.utils.js'
54

6-
type PackageJSON = {
7-
exports?: unknown
8-
}
9-
10-
const ACTIVE_IMPORT_CONDITIONS = new Set([
11-
'browser',
12-
'default',
13-
'import',
14-
'module',
15-
'production',
16-
'svelte',
17-
'webpack',
18-
])
19-
20-
function isRecord(value: unknown): value is Record<string, unknown> {
21-
return typeof value === 'object' && value !== null
22-
}
23-
24-
function getImportTargets(value: unknown): string[] {
25-
if (typeof value === 'string') {
26-
return [value]
27-
}
28-
29-
if (Array.isArray(value)) {
30-
return value.flatMap(getImportTargets)
31-
}
32-
33-
if (!isRecord(value)) {
34-
return []
35-
}
36-
37-
for (const [condition, target] of Object.entries(value)) {
38-
if (!ACTIVE_IMPORT_CONDITIONS.has(condition)) {
39-
continue
40-
}
41-
42-
const targets = getImportTargets(target)
43-
if (targets.length) {
44-
return targets
45-
}
46-
}
5+
function withAbortSignal(signal: AbortSignal) {
6+
return new Proxy(fs, {
7+
get(target, property, receiver) {
8+
const member = Reflect.get(target, property, receiver)
9+
if (typeof member !== 'function') {
10+
return member
11+
}
4712

48-
return []
13+
return async (...args: unknown[]) => {
14+
throwIfAborted(signal)
15+
const result = await Reflect.apply(member, target, args)
16+
throwIfAborted(signal)
17+
return result
18+
}
19+
},
20+
}) as typeof fs
4921
}
5022

5123
function isBundlableTarget(target: string) {
24+
const pathname = target.split(/[?#]/, 1)[0]
5225
return (
53-
target.startsWith('./') &&
54-
!target.endsWith('.d.ts') &&
55-
!target.endsWith('.d.mts') &&
56-
!target.endsWith('.d.cts') &&
57-
!target.endsWith('.json')
26+
!pathname.endsWith('.d.ts') &&
27+
!pathname.endsWith('.d.mts') &&
28+
!pathname.endsWith('.d.cts') &&
29+
!pathname.endsWith('.json')
5830
)
5931
}
6032

@@ -64,252 +36,26 @@ function toImportPoint(packageName: string, subpath: string) {
6436
: `${packageName}/${subpath.replace(/^\.\//, '')}`
6537
}
6638

67-
function matchesSubpathPattern(subpath: string, pattern: string) {
68-
const wildcardIndex = pattern.indexOf('*')
69-
if (wildcardIndex === -1) {
70-
return subpath === pattern
71-
}
72-
73-
const prefix = pattern.slice(0, wildcardIndex)
74-
const suffix = pattern.slice(wildcardIndex + 1)
75-
return (
76-
subpath.startsWith(prefix) &&
77-
subpath.endsWith(suffix) &&
78-
subpath.length >= prefix.length + suffix.length
79-
)
80-
}
81-
82-
function selectedExportKey(subpath: string, exportKeys: string[]) {
83-
if (exportKeys.includes(subpath)) {
84-
return subpath
85-
}
86-
87-
return exportKeys
88-
.filter(key => key.includes('*') && matchesSubpathPattern(subpath, key))
89-
.sort((left, right) => {
90-
const prefixDifference = right.indexOf('*') - left.indexOf('*')
91-
return prefixDifference || right.length - left.length
92-
})[0]
93-
}
94-
95-
function toSubpath(packageName: string, importPoint: string) {
96-
return importPoint === packageName
97-
? '.'
98-
: `./${importPoint.slice(packageName.length + 1)}`
99-
}
100-
101-
function escapeRegex(value: string) {
102-
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
103-
}
104-
105-
function makeTargetPatternRegex(targetPattern: string) {
106-
const parts = targetPattern.split('*')
107-
let source = `^${escapeRegex(parts[0])}`
108-
109-
if (parts.length > 1) {
110-
source += `(.+?)${escapeRegex(parts[1])}`
111-
for (const part of parts.slice(2)) {
112-
source += `\\1${escapeRegex(part)}`
113-
}
114-
}
115-
116-
return new RegExp(`${source}$`)
117-
}
118-
119-
async function collectFiles(
120-
directory: string,
121-
packagePath: string,
122-
signal?: AbortSignal,
123-
): Promise<string[]> {
124-
throwIfAborted(signal)
125-
126-
let entries: Dirent[]
127-
try {
128-
entries = await fs.readdir(directory, { withFileTypes: true })
129-
} catch (error) {
130-
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
131-
return []
132-
}
133-
throw error
134-
}
135-
136-
const files = await Promise.all(
137-
entries.map(async entry => {
138-
throwIfAborted(signal)
139-
const entryPath = path.join(directory, entry.name)
140-
141-
if (entry.isDirectory()) {
142-
return collectFiles(entryPath, packagePath, signal)
143-
}
144-
145-
return entry.isFile()
146-
? [path.relative(packagePath, entryPath).split(path.sep).join('/')]
147-
: []
148-
}),
149-
)
150-
151-
return files.flat()
152-
}
153-
154-
async function targetExists(packagePath: string, target: string) {
155-
const packageRoot = path.resolve(packagePath)
156-
const targetPath = path.resolve(packagePath, target)
157-
if (
158-
targetPath !== packageRoot &&
159-
!targetPath.startsWith(`${packageRoot}${path.sep}`)
160-
) {
161-
return false
162-
}
163-
164-
try {
165-
await fs.access(targetPath)
166-
return true
167-
} catch {
168-
return false
169-
}
170-
}
171-
172-
async function expandPatternImportPoints(
173-
packageName: string,
174-
packagePath: string,
175-
subpathPattern: string,
176-
targetPattern: string,
177-
signal?: AbortSignal,
178-
) {
179-
if (
180-
!subpathPattern.includes('*') ||
181-
!targetPattern.includes('*') ||
182-
!isBundlableTarget(targetPattern)
183-
) {
184-
return []
185-
}
186-
187-
const relativeTargetPattern = targetPattern.slice(2)
188-
const staticPrefix = relativeTargetPattern.slice(
189-
0,
190-
relativeTargetPattern.indexOf('*'),
191-
)
192-
const scanDirectory = path.resolve(
193-
packagePath,
194-
path.posix.dirname(`${staticPrefix}__candidate__`),
195-
)
196-
const packageRoot = path.resolve(packagePath)
197-
198-
if (
199-
scanDirectory !== packageRoot &&
200-
!scanDirectory.startsWith(`${packageRoot}${path.sep}`)
201-
) {
202-
return []
203-
}
204-
205-
const matcher = makeTargetPatternRegex(relativeTargetPattern)
206-
const files = await collectFiles(scanDirectory, packageRoot, signal)
207-
208-
return files.flatMap(file => {
209-
if (!isBundlableTarget(`./${file}`)) {
210-
return []
211-
}
212-
213-
const match = file.match(matcher)
214-
if (!match?.[1]) {
215-
return []
216-
}
217-
218-
return [
219-
toImportPoint(packageName, subpathPattern.replaceAll('*', match[1])),
220-
]
221-
})
222-
}
223-
224-
async function getSubpathImportPoints(
225-
packageName: string,
226-
packagePath: string,
227-
subpath: string,
228-
exportValue: unknown,
229-
signal?: AbortSignal,
230-
) {
231-
const targets = getImportTargets(exportValue)
232-
233-
const resolvedTargets = await Promise.all(
234-
targets.map(async target => {
235-
throwIfAborted(signal)
236-
237-
if (subpath.includes('*')) {
238-
return expandPatternImportPoints(
239-
packageName,
240-
packagePath,
241-
subpath,
242-
target,
243-
signal,
244-
)
245-
}
246-
247-
return isBundlableTarget(target) &&
248-
(await targetExists(packagePath, target))
249-
? [toImportPoint(packageName, subpath)]
250-
: []
251-
}),
252-
)
253-
254-
return resolvedTargets.find(importPoints => importPoints.length) ?? []
255-
}
256-
25739
export async function getImportPointsFromPackage(
25840
packageName: string,
25941
packagePath: string,
26042
signal?: AbortSignal,
26143
) {
26244
throwIfAborted(signal)
263-
const packageJson = JSON.parse(
264-
await fs.readFile(path.join(packagePath, 'package.json'), 'utf8'),
265-
) as PackageJSON
266-
throwIfAborted(signal)
267-
268-
if (packageJson.exports === undefined) {
269-
return [packageName]
270-
}
271-
272-
const exportsField = packageJson.exports
273-
const subpathEntries = isRecord(exportsField)
274-
? Object.entries(exportsField).filter(([key]) => key.startsWith('.'))
275-
: []
276-
277-
const entries: Array<[string, unknown]> = subpathEntries.length
278-
? subpathEntries
279-
: [['.', exportsField]]
280-
281-
const exportKeys = entries.map(([subpath]) => subpath)
282-
const resolvedEntries = await Promise.all(
283-
entries.map(async ([subpath, exportValue]) => {
284-
if (subpath !== '.' && !subpath.startsWith('./')) {
285-
return { importPoints: [], subpath }
286-
}
287-
288-
return {
289-
importPoints: await getSubpathImportPoints(
290-
packageName,
291-
packagePath,
292-
subpath,
293-
exportValue,
294-
signal,
295-
),
296-
subpath,
297-
}
298-
}),
299-
)
300-
const importPoints = new Set(
301-
resolvedEntries.flatMap(({ importPoints: candidates, subpath }) =>
302-
candidates.filter(
303-
importPoint =>
304-
selectedExportKey(toSubpath(packageName, importPoint), exportKeys) ===
305-
subpath,
306-
),
307-
),
45+
const entryPoints = await getPackageEntryPoints(
46+
packagePath,
47+
signal ? withAbortSignal(signal) : fs,
30848
)
49+
throwIfAborted(signal)
30950

310-
return [...importPoints].sort((left, right) => {
311-
if (left === packageName) return -1
312-
if (right === packageName) return 1
313-
return left.localeCompare(right)
314-
})
51+
return Object.entries(entryPoints)
52+
.filter(([, mappings]) =>
53+
mappings.some(([, target]) => isBundlableTarget(target)),
54+
)
55+
.map(([subpath]) => toImportPoint(packageName, subpath))
56+
.sort((left, right) => {
57+
if (left === packageName) return -1
58+
if (right === packageName) return 1
59+
return left.localeCompare(right)
60+
})
31561
}

tests/fast/import-points.utils.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ describe('getImportPointsFromPackage', () => {
1212
'import-points-fixture/feature',
1313
'import-points-fixture/features/alpha',
1414
'import-points-fixture/features/beta',
15+
'import-points-fixture/runtime-specific',
1516
])
1617
})
1718

@@ -31,14 +32,17 @@ describe('getImportPointsFromPackage', () => {
3132
)
3233
})
3334

34-
test('returns the package root when exports metadata is absent', async () => {
35+
test('lists importable files when exports metadata is absent', async () => {
3536
const legacyFixturePath = path.resolve(
3637
__dirname,
3738
'../fixtures/basic/simple-esm',
3839
)
3940

4041
await expect(
4142
getImportPointsFromPackage('simple-esm-fixture', legacyFixturePath),
42-
).resolves.toEqual(['simple-esm-fixture'])
43+
).resolves.toEqual([
44+
'simple-esm-fixture',
45+
'simple-esm-fixture/src/index.js',
46+
])
4347
})
4448
})

0 commit comments

Comments
 (0)