Skip to content

Commit c51502b

Browse files
authored
fix(deps): resolve subpath extensions for packages without exports field (#863)
1 parent bb6decb commit c51502b

File tree

11 files changed

+178
-72
lines changed

11 files changed

+178
-72
lines changed

dts.snapshot.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@
103103
"ShebangPlugin": "declare function ShebangPlugin(_: Logger, _: string, _: string, _: boolean): Plugin",
104104
"WatchPlugin": "declare function WatchPlugin(_: string[], _: TsdownBundle): Plugin",
105105
"#exports": [
106-
"DepPlugin",
106+
"DepsPlugin",
107107
"NodeProtocolPlugin",
108108
"ReportPlugin",
109109
"ShebangPlugin",
@@ -124,8 +124,8 @@
124124
"CopyEntry": "interface CopyEntry {\n from: string | string[]\n to?: string\n flatten?: boolean\n verbose?: boolean\n rename?: string | ((_: string, _: string, _: string) => string)\n}",
125125
"CopyOptions": "type CopyOptions = Arrayable<string | CopyEntry>",
126126
"CopyOptionsFn": "type CopyOptionsFn = (_: ResolvedConfig) => Awaitable<CopyOptions>",
127-
"DepPlugin": "declare function DepPlugin(_: ResolvedConfig, _: TsdownBundle): Plugin",
128127
"DepsConfig": "interface DepsConfig {\n neverBundle?: ExternalOption\n alwaysBundle?: Arrayable<string | RegExp> | NoExternalFn\n onlyBundle?: Arrayable<string | RegExp> | false\n onlyAllowBundle?: Arrayable<string | RegExp> | false\n skipNodeModulesBundle?: boolean\n}",
128+
"DepsPlugin": "declare function DepsPlugin(_: ResolvedConfig, _: TsdownBundle): Plugin",
129129
"DevtoolsOptions": "interface DevtoolsOptions extends NonNullable<InputOptions['devtools']> {\n ui?: boolean | Partial<StartOptions>\n clean?: boolean\n}",
130130
"ExeOptions": "interface ExeOptions extends ExeExtensionOptions {\n seaConfig?: Omit<SeaConfig, 'main' | 'output' | 'mainFormat'>\n fileName?: string | ((_: RolldownChunk) => string)\n outDir?: string\n}",
131131
"ExportsOptions": "interface ExportsOptions {\n devExports?: boolean | string\n packageJson?: boolean\n all?: boolean\n exclude?: (RegExp | string)[]\n legacy?: boolean\n customExports?: Record<string, any> | ((_: Record<string, any>, _: { pkg: PackageJson; chunks: ChunksByFormat; isPublish: boolean }) => Awaitable<Record<string, any>>)\n inlinedDependencies?: boolean\n}",

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,6 @@
142142
},
143143
"devDependencies": {
144144
"@arethetypeswrong/core": "catalog:peer",
145-
"@publint/pack": "catalog:dev",
146145
"@sxzz/eslint-config": "catalog:dev",
147146
"@sxzz/prettier-config": "catalog:dev",
148147
"@sxzz/test-utils": "catalog:dev",

pnpm-lock.yaml

Lines changed: 0 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ catalogs:
1212
'@clack/prompts': ^1.1.0
1313
giget: ^3.1.2
1414
dev:
15-
'@publint/pack': ^0.1.4
1615
'@sxzz/eslint-config': ^7.8.3
1716
'@sxzz/prettier-config': ^2.3.1
1817
'@sxzz/test-utils': ^0.5.15

src/features/deps.ts

Lines changed: 103 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,9 @@ import type { TsdownBundle } from '../utils/chunks.ts'
1818
import type { Logger } from '../utils/logger.ts'
1919
import type { Arrayable } from '../utils/types.ts'
2020
import type { PackageJson } from 'pkg-types'
21-
import type {
22-
ExternalOption,
23-
Plugin,
24-
PluginContext,
25-
ResolveIdExtraOptions,
26-
} from 'rolldown'
21+
import type { ExternalOption, Plugin, ResolvedId } from 'rolldown'
2722

28-
const debug = createDebug('tsdown:dep')
23+
const debug = createDebug('tsdown:deps')
2924

3025
export type NoExternalFn = (
3126
id: string,
@@ -162,37 +157,7 @@ export function resolveDepsConfig(
162157
}
163158
}
164159

165-
async function parseBundledDep(
166-
moduleId: string,
167-
): Promise<{ name: string; pkgName: string; version: string } | undefined> {
168-
const slashed = slash(moduleId)
169-
const lastNmIdx = slashed.lastIndexOf('/node_modules/')
170-
if (lastNmIdx === -1) return
171-
172-
const afterNm = slashed.slice(lastNmIdx + 14 /* '/node_modules/'.length */)
173-
const parts = afterNm.split('/')
174-
175-
let name: string
176-
if (parts[0][0] === '@') {
177-
name = `${parts[0]}/${parts[1]}`
178-
} else {
179-
name = parts[0]
180-
}
181-
182-
const root = slashed.slice(
183-
0,
184-
lastNmIdx + 14 /* '/node_modules/'.length */ + name.length,
185-
)
186-
187-
try {
188-
const json = JSON.parse(
189-
await readFile(path.join(root, 'package.json'), 'utf8'),
190-
)
191-
return { name, pkgName: json.name, version: json.version }
192-
} catch {}
193-
}
194-
195-
export function DepPlugin(
160+
export function DepsPlugin(
196161
{
197162
pkg,
198163
deps: { alwaysBundle, onlyBundle, skipNodeModulesBundle },
@@ -204,28 +169,40 @@ export function DepPlugin(
204169
const deps = pkg && Array.from(getProductionDeps(pkg))
205170

206171
return {
207-
name: 'tsdown:external',
172+
name: 'tsdown:deps',
208173
resolveId: {
209174
filter: [include(and(id(/^[^.]/), importerId(/./)))],
210175
async handler(id, importer, extraOptions) {
211176
if (extraOptions.isEntry) return
212177
typeAssert(importer)
213178

214-
const shouldExternal = await externalStrategy(
215-
this,
216-
id,
217-
importer,
218-
extraOptions,
219-
)
179+
const resolved = await this.resolve(id, importer, {
180+
...extraOptions,
181+
skipSelf: true,
182+
})
183+
let shouldExternal = await externalStrategy(id, importer, resolved)
184+
if (Array.isArray(shouldExternal)) {
185+
debug('custom resolved id for %o -> %o', id, shouldExternal[1])
186+
id = shouldExternal[1]
187+
shouldExternal = shouldExternal[0]
188+
}
220189
const nodeBuiltinModule = isBuiltin(id)
190+
const moduleSideEffects = nodeBuiltinModule ? false : undefined
221191

222192
debug('shouldExternal: %o = %o', id, shouldExternal)
223193

224194
if (shouldExternal === true || shouldExternal === 'absolute') {
225195
return {
226196
id,
227197
external: shouldExternal,
228-
moduleSideEffects: nodeBuiltinModule ? false : undefined,
198+
moduleSideEffects,
199+
}
200+
}
201+
202+
if (resolved) {
203+
return {
204+
...resolved,
205+
moduleSideEffects,
229206
}
230207
}
231208
},
@@ -311,35 +288,34 @@ export function DepPlugin(
311288

312289
/**
313290
* - `true`: always external
291+
* - `[true, resolvedId]`: external with custom resolved ID
314292
* - `false`: skip, let other plugins handle it
315293
* - `'absolute'`: external as absolute path
316294
* - `'no-external'`: skip, but mark as non-external for inlineOnly check
317295
*/
318296
async function externalStrategy(
319-
context: PluginContext,
320297
id: string,
321298
importer: string | undefined,
322-
extraOptions: ResolveIdExtraOptions,
323-
): Promise<boolean | 'absolute' | 'no-external'> {
299+
resolved: ResolvedId | null,
300+
): Promise<boolean | [true, string] | 'absolute' | 'no-external'> {
324301
if (id === shimFile) return false
325302

326303
if (alwaysBundle?.(id, importer)) {
327304
return 'no-external'
328305
}
329306

330-
if (skipNodeModulesBundle) {
331-
const resolved = await context.resolve(id, importer, extraOptions)
332-
if (
333-
resolved &&
334-
(resolved.external || RE_NODE_MODULES.test(resolved.id))
335-
) {
336-
return true
337-
}
307+
if (
308+
skipNodeModulesBundle &&
309+
resolved &&
310+
(resolved.external || RE_NODE_MODULES.test(resolved.id))
311+
) {
312+
return true
338313
}
339314

340315
if (deps) {
341316
if (deps.includes(id) || deps.some((dep) => id.startsWith(`${dep}/`))) {
342-
return true
317+
const resolvedDep = await resolveDepPath(id, resolved)
318+
return resolvedDep ? [true, resolvedDep] : true
343319
}
344320

345321
if (importer && RE_DTS.test(importer) && !id.startsWith('@types/')) {
@@ -354,6 +330,75 @@ export function DepPlugin(
354330
}
355331
}
356332

333+
function parseDepPath(
334+
id: string,
335+
): [name: string, subpath: string, root: string] | undefined {
336+
const slashed = slash(id)
337+
const lastNmIdx = slashed.lastIndexOf('/node_modules/')
338+
if (lastNmIdx === -1) return
339+
340+
const afterNm = slashed.slice(lastNmIdx + 14 /* '/node_modules/'.length */)
341+
const parts = afterNm.split('/')
342+
343+
let name: string
344+
if (parts[0][0] === '@') {
345+
name = `${parts[0]}/${parts[1]}`
346+
} else {
347+
name = parts[0]
348+
}
349+
350+
const root = slashed.slice(
351+
0,
352+
lastNmIdx + 14 /* '/node_modules/'.length */ + name.length,
353+
)
354+
355+
return [name, afterNm.slice(name.length), root]
356+
}
357+
358+
async function parseBundledDep(
359+
moduleId: string,
360+
): Promise<{ name: string; pkgName: string; version: string } | undefined> {
361+
const parsed = parseDepPath(moduleId)
362+
if (!parsed) return
363+
364+
const [name, , root] = parsed
365+
366+
try {
367+
const json = JSON.parse(
368+
await readFile(path.join(root, 'package.json'), 'utf8'),
369+
)
370+
return { name, pkgName: json.name, version: json.version }
371+
} catch {}
372+
}
373+
374+
async function resolveDepPath(id: string, resolved: ResolvedId | null) {
375+
if (!resolved?.packageJsonPath) return
376+
377+
const parts = id.split('/')
378+
// ignore scope
379+
if (parts[0][0] === '@') parts.shift()
380+
// ignore no subpath or file imports
381+
if (parts.length === 1 || parts.at(-1)!.includes('.')) return
382+
383+
let pkgJson: Record<string, any>
384+
try {
385+
pkgJson = JSON.parse(await readFile(resolved.packageJsonPath, 'utf8'))
386+
} catch {
387+
return
388+
}
389+
390+
// no `exports` field
391+
if (pkgJson.exports) return
392+
393+
const parsed = parseDepPath(resolved.id)
394+
if (!parsed) return
395+
396+
const result = parsed[0] + parsed[1]
397+
if (result === id) return
398+
399+
return result
400+
}
401+
357402
/*
358403
* Production deps should be excluded from the bundle
359404
*/

src/features/rolldown.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import pkg from '../../package.json' with { type: 'json' }
1616
import { mergeUserOptions } from '../config/options.ts'
1717
import { importWithError, pkgExists } from '../utils/general.ts'
1818
import { LogLevels } from '../utils/logger.ts'
19-
import { DepPlugin } from './deps.ts'
19+
import { DepsPlugin } from './deps.ts'
2020
import { NodeProtocolPlugin } from './node-protocol.ts'
2121
import { resolveChunkAddon, resolveChunkFilename } from './output.ts'
2222
import { ReportPlugin } from './report.ts'
@@ -112,7 +112,7 @@ async function resolveInputOptions(
112112
}
113113

114114
if (config.pkg || config.deps.skipNodeModulesBundle) {
115-
plugins.push(DepPlugin(config, bundle))
115+
plugins.push(DepsPlugin(config, bundle))
116116
}
117117

118118
if (dts) {

src/plugins.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export { DepPlugin } from './features/deps.ts'
1+
export { DepsPlugin } from './features/deps.ts'
22
export { NodeProtocolPlugin } from './features/node-protocol.ts'
33
export { ReportPlugin } from './features/report.ts'
44
export { ShebangPlugin } from './features/shebang.ts'
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
## index.mjs
2+
3+
```mjs
4+
import { lt } from "my-dep/functions/lt.js";
5+
export { lt };
6+
7+
```
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
## index.mjs
2+
3+
```mjs
4+
import { folder } from "my-dep/folder/index.js";
5+
export { folder };
6+
7+
```

tests/e2e.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -897,6 +897,62 @@ test('failOnWarn', async (context) => {
897897
).rejects.toThrow('Module not found')
898898
})
899899

900+
describe('resolve dep subpath without exports field', () => {
901+
test('dep/file should resolve to dep/file.js', async (context) => {
902+
const node_modules = {
903+
'node_modules/my-dep/package.json': JSON.stringify({
904+
name: 'my-dep',
905+
version: '1.0.0',
906+
main: 'index.js',
907+
}),
908+
'node_modules/my-dep/index.js': `export const main = 1`,
909+
'node_modules/my-dep/functions/lt.js': `export const lt = () => {}`,
910+
}
911+
912+
const { fileMap } = await testBuild({
913+
context,
914+
files: {
915+
...node_modules,
916+
'index.ts': `export { lt } from 'my-dep/functions/lt'`,
917+
'package.json': JSON.stringify({
918+
name: 'test-pkg',
919+
version: '1.0.0',
920+
dependencies: { 'my-dep': '^1.0.0' },
921+
}),
922+
},
923+
})
924+
925+
expect(fileMap['index.mjs']).toContain('my-dep/functions/lt.js')
926+
})
927+
928+
test('dep/folder should resolve to dep/folder/index.js', async (context) => {
929+
const node_modules = {
930+
'node_modules/my-dep/package.json': JSON.stringify({
931+
name: 'my-dep',
932+
version: '1.0.0',
933+
main: 'index.js',
934+
}),
935+
'node_modules/my-dep/index.js': `export const main = 1`,
936+
'node_modules/my-dep/folder/index.js': `export const folder = 42`,
937+
}
938+
939+
const { fileMap } = await testBuild({
940+
context,
941+
files: {
942+
...node_modules,
943+
'index.ts': `export { folder } from 'my-dep/folder'`,
944+
'package.json': JSON.stringify({
945+
name: 'test-pkg',
946+
version: '1.0.0',
947+
dependencies: { 'my-dep': '^1.0.0' },
948+
}),
949+
},
950+
})
951+
952+
expect(fileMap['index.mjs']).toContain('my-dep/folder/index.js')
953+
})
954+
})
955+
900956
test('.node file bundle', async (context) => {
901957
const files = {
902958
'index.ts': `

0 commit comments

Comments
 (0)