Skip to content
Open
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
49 changes: 49 additions & 0 deletions src/features/deps.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { fsRemove } from '../utils/fs.ts'
import {
DepsPlugin,
getTypesPackageName,
Expand Down Expand Up @@ -146,6 +150,51 @@ describe('DepsPlugin', () => {
})
expect(resolve).toHaveBeenCalledTimes(1)
})

it('caches the exports check per build', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'tsdown-deps-cache-'))
const packageJsonPath = path.join(dir, 'package.json')
try {
await writeFile(packageJsonPath, JSON.stringify({ name: 'my-dep' }))

const plugin = DepsPlugin(
{
pkg: { dependencies: { 'my-dep': '^1.0.0' } },
deps: resolveDepsConfig({ deps: { resolveDepSubpath: true } }),
} as any,
{ inlinedDeps: new Map() } as any,
)
const handler = (plugin.resolveId as any).handler
const buildStart = plugin.buildStart as () => void
const resolve = vi.fn(() =>
Promise.resolve({
id: '/project/node_modules/my-dep/sub/index.js',
packageJsonPath,
}),
)
const resolveSub = () =>
handler.call({ resolve }, 'my-dep/sub', '/project/src/index.ts', {})

await expect(resolveSub()).resolves.toMatchObject({
id: 'my-dep/sub/index.js',
})

// within the same build the first read wins
await writeFile(
packageJsonPath,
JSON.stringify({ name: 'my-dep', exports: './index.js' }),
)
await expect(resolveSub()).resolves.toMatchObject({
id: 'my-dep/sub/index.js',
})

// a rebuild in watch mode must pick up the new `exports` field
buildStart()
await expect(resolveSub()).resolves.toMatchObject({ id: 'my-dep/sub' })
} finally {
await fsRemove(dir)
}
})
})

describe('DepsPlugin with neverBundle: true', () => {
Expand Down
45 changes: 34 additions & 11 deletions src/features/deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,15 @@ export function DepsPlugin(
tsdownBundle: TsdownBundle,
): Plugin {
const deps = pkg && Array.from(getProductionDeps(pkg))
// Keyed by package.json path. Every subpath import of a package reads the
// same file, so read it once per build.
const hasExportsCache: HasExportsCache = new Map()

return {
name: 'tsdown:deps',
buildStart() {
hasExportsCache.clear()
},
resolveId: {
filter: [include(and(id(/^[^.]/), importerId(/./)))],
async handler(id, importer, extraOptions) {
Expand Down Expand Up @@ -383,7 +389,8 @@ export function DepsPlugin(
}
if (RE_PACKAGE_SPECIFIER.test(id)) {
const resolvedDep =
shouldResolveDepSubpath && (await resolveDepSubpath(id, resolve))
shouldResolveDepSubpath &&
(await resolveDepSubpath(id, resolve, hasExportsCache))
// eslint-disable-next-line unicorn/prefer-logical-operator-over-ternary
return resolvedDep ? [true, resolvedDep] : true
}
Expand All @@ -396,7 +403,8 @@ export function DepsPlugin(
if (deps) {
if (deps.includes(id) || deps.some((dep) => id.startsWith(`${dep}/`))) {
const resolvedDep =
shouldResolveDepSubpath && (await resolveDepSubpath(id, resolve))
shouldResolveDepSubpath &&
(await resolveDepSubpath(id, resolve, hasExportsCache))
// eslint-disable-next-line unicorn/prefer-logical-operator-over-ternary
return resolvedDep ? [true, resolvedDep] : true
}
Expand Down Expand Up @@ -497,7 +505,24 @@ export function getTypesPackageName(id: string): string | undefined {
return `@types/${name.replace(/^@/, '').replace('/', '__')}`
}

async function resolveDepSubpath(id: string, resolve: ResolveFn) {
// `undefined` when the package.json can't be read or parsed
type HasExportsCache = Map<string, Promise<boolean | undefined>>

async function readHasExports(
packageJsonPath: string,
): Promise<boolean | undefined> {
try {
return !!JSON.parse(await readFile(packageJsonPath, 'utf8')).exports
} catch {
return undefined
}
}

async function resolveDepSubpath(
id: string,
resolve: ResolveFn,
hasExportsCache: HasExportsCache,
) {
const parts = id.split('/')
// ignore scope
if (parts[0][0] === '@') parts.shift()
Expand All @@ -507,15 +532,13 @@ async function resolveDepSubpath(id: string, resolve: ResolveFn) {
const resolved = await resolve()
if (!resolved?.packageJsonPath) return

let pkgJson: Record<string, any>
try {
pkgJson = JSON.parse(await readFile(resolved.packageJsonPath, 'utf8'))
} catch {
return
let hasExports = hasExportsCache.get(resolved.packageJsonPath)
if (!hasExports) {
hasExports = readHasExports(resolved.packageJsonPath)
hasExportsCache.set(resolved.packageJsonPath, hasExports)
}

// no `exports` field
if (pkgJson.exports) return
// only rewrite packages that have a readable package.json without `exports`
if ((await hasExports) !== false) return

const parsed = parseNodeModulesPath(resolved.id)
if (!parsed) return
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
## index.mjs

```mjs
import { lt } from "my-dep/functions/lt.js";
import { a } from "with-exports/a";
import { gt } from "my-dep/functions/gt.js";
import { b } from "with-exports/b";
export { a, b, gt, lt };

```
38 changes: 38 additions & 0 deletions tests/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1486,6 +1486,44 @@ describe('resolve dep subpath without exports field', () => {

expect(fileMap['index.mjs']).toContain('my-dep/folder/index.js')
})

test('each package is checked for exports separately', async (context) => {
const { fileMap } = await testBuild({
context,
files: {
'node_modules/my-dep/package.json': JSON.stringify({
name: 'my-dep',
main: 'index.js',
}),
'node_modules/my-dep/functions/lt.js': `export const lt = () => {}`,
'node_modules/my-dep/functions/gt.js': `export const gt = () => {}`,
'node_modules/with-exports/package.json': JSON.stringify({
name: 'with-exports',
exports: { './*': './dist/*.js' },
}),
'node_modules/with-exports/dist/a.js': `export const a = 1`,
'node_modules/with-exports/dist/b.js': `export const b = 2`,
'index.ts': `export * from './one'
export * from './two'`,
'one.ts': `export { lt } from 'my-dep/functions/lt'
export { a } from 'with-exports/a'`,
'two.ts': `export { gt } from 'my-dep/functions/gt'
export { b } from 'with-exports/b'`,
'package.json': JSON.stringify({
name: 'test-pkg',
dependencies: { 'my-dep': '^1.0.0', 'with-exports': '^1.0.0' },
}),
},
options: {
deps: { resolveDepSubpath: true },
},
})

expect(fileMap['index.mjs']).toContain('my-dep/functions/lt.js')
expect(fileMap['index.mjs']).toContain('my-dep/functions/gt.js')
expect(fileMap['index.mjs']).toMatch(/from ["']with-exports\/a["']/)
expect(fileMap['index.mjs']).toMatch(/from ["']with-exports\/b["']/)
})
})

describe('neverBundle: true', () => {
Expand Down
Loading