Skip to content

Commit 875c1b3

Browse files
mandarinisxzz
andauthored
feat(dts): add cjsReexport option to eliminate dual module type hazard (#856)
Co-authored-by: Kevin Deng <sxzz@sxzz.moe>
1 parent 512926d commit 875c1b3

File tree

6 files changed

+112
-5
lines changed

6 files changed

+112
-5
lines changed

dts.snapshot.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@
127127
"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}",
128128
"DepsPlugin": "declare function DepsPlugin(_: ResolvedConfig, _: TsdownBundle): Plugin",
129129
"DevtoolsOptions": "interface DevtoolsOptions extends NonNullable<InputOptions['devtools']> {\n ui?: boolean | Partial<StartOptions>\n clean?: boolean\n}",
130+
"DtsOptions": "interface DtsOptions extends Options$1 {\n cjsReexport?: boolean\n}",
130131
"ExeOptions": "interface ExeOptions extends ExeExtensionOptions {\n seaConfig?: Omit<SeaConfig, 'main' | 'output' | 'mainFormat'>\n fileName?: string | ((_: RolldownChunk) => string)\n outDir?: string\n}",
131132
"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 bin?: boolean | string | Record<string, string>\n}",
132133
"Format": "type Format = ModuleFormat",

src/build.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -289,14 +289,14 @@ async function buildSingle(
289289
}
290290

291291
const configs: BuildOptions[] = [buildOptions]
292-
if (format === 'cjs' && dts) {
292+
if (format === 'cjs' && dts && (!isDualFormat || !dts.cjsReexport)) {
293293
configs.push(
294294
await getBuildOptions(
295295
config,
296296
format,
297297
configFiles,
298298
bundle,
299-
true,
299+
true, // cjsDts
300300
isDualFormat,
301301
),
302302
)
@@ -308,6 +308,7 @@ async function buildSingle(
308308
async function postBuild() {
309309
await copy(config)
310310
await buildExe(config, chunks)
311+
311312
if (!hasBuilt) {
312313
await done(bundle)
313314
}

src/config/types.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,27 @@ import type {
4444
OutputOptions,
4545
TreeshakingOptions,
4646
} from 'rolldown'
47-
import type { Options as DtsOptions } from 'rolldown-plugin-dts'
47+
import type { Options as RolldownPluginDtsOptions } from 'rolldown-plugin-dts'
4848
import type { Options as UnusedOptions } from 'unplugin-unused'
4949

50+
export interface DtsOptions extends RolldownPluginDtsOptions {
51+
/**
52+
* When building dual ESM+CJS formats, generate a `.d.cts` re-export stub
53+
* instead of running a full second TypeScript compilation pass.
54+
*
55+
* The stub re-exports everything from the corresponding `.d.mts` file,
56+
* ensuring CJS and ESM consumers share the same type declarations. This
57+
* eliminates the TypeScript "dual module hazard" where separate `.d.cts`
58+
* and `.d.mts` declarations cause `TS2352` ("neither type sufficiently
59+
* overlaps") errors when casting between types derived from the same class.
60+
*
61+
* Only applies when building both `esm` and `cjs` formats simultaneously.
62+
*
63+
* @default false
64+
*/
65+
cjsReexport?: boolean
66+
}
67+
5068
export type Sourcemap = boolean | 'inline' | 'hidden'
5169
export type Format = ModuleFormat
5270
export type NormalizedFormat = InternalModuleFormat
@@ -83,7 +101,6 @@ export type {
83101
CopyOptionsFn,
84102
DepsConfig,
85103
DevtoolsOptions,
86-
DtsOptions,
87104
ExeOptions,
88105
ExportsOptions,
89106
NoExternalFn,

src/features/rolldown.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
type Plugin,
1212
type RolldownPluginOption,
1313
} from 'rolldown'
14+
import { filename_js_to_dts, RE_JS } from 'rolldown-plugin-dts/internal'
1415
import { importGlobPlugin } from 'rolldown/experimental'
1516
import pkg from '../../package.json' with { type: 'json' }
1617
import { mergeUserOptions } from '../config/options.ts'
@@ -117,9 +118,10 @@ async function resolveInputOptions(
117118

118119
if (dts) {
119120
const { dts: dtsPlugin } = await import('rolldown-plugin-dts')
121+
const { cjsReexport: _, ...dtsPluginOptions } = dts
120122
const options: DtsOptions = {
121123
tsconfig,
122-
...dts,
124+
...dtsPluginOptions,
123125
}
124126

125127
if (format === 'es') {
@@ -132,6 +134,8 @@ async function resolveInputOptions(
132134
cjsDefault,
133135
}),
134136
)
137+
} else if (dts.cjsReexport && isDualFormat) {
138+
plugins.push(CjsDtsReexportPlugin())
135139
}
136140
}
137141
let cssPostPlugins: Plugin[] | undefined
@@ -339,6 +343,31 @@ function handlePluginInspect(plugins: RolldownPluginOption) {
339343
}
340344
}
341345

346+
export function CjsDtsReexportPlugin(): Plugin {
347+
return {
348+
name: 'tsdown:cjs-dts-reexport',
349+
generateBundle(_options, bundle) {
350+
for (const chunk of Object.values(bundle)) {
351+
if (chunk.type !== 'chunk') continue
352+
353+
if (!chunk.fileName.endsWith('.cjs') && !chunk.fileName.endsWith('.js'))
354+
continue
355+
356+
const dMtsBasename = path.basename(
357+
chunk.fileName.replace(RE_JS, '.d.mts'),
358+
)
359+
const content = `export * from './${dMtsBasename}'\n`
360+
361+
this.emitFile({
362+
type: 'prebuilt-chunk',
363+
fileName: filename_js_to_dts(chunk.fileName),
364+
code: content,
365+
})
366+
}
367+
},
368+
}
369+
}
370+
342371
export function CssGuardPlugin(): Plugin {
343372
return {
344373
name: 'tsdown:css-guard',
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
## folder/index.cjs
2+
3+
```cjs
4+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
5+
//#region index.ts
6+
function hello() {
7+
console.log("Hello!");
8+
}
9+
//#endregion
10+
exports.hello = hello;
11+
12+
```
13+
14+
## folder/index.d.cts
15+
16+
```cts
17+
export * from './index.d.mts'
18+
19+
```
20+
21+
## folder/index.d.mts
22+
23+
```mts
24+
//#region index.d.ts
25+
declare function hello(): void;
26+
//#endregion
27+
export { hello };
28+
```
29+
30+
## folder/index.mjs
31+
32+
```mjs
33+
//#region index.ts
34+
function hello() {
35+
console.log("Hello!");
36+
}
37+
//#endregion
38+
export { hello };
39+
40+
```

tests/e2e.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,25 @@ test('cjs default', async (context) => {
9898
})
9999
})
100100

101+
test('cjs dts reexport', async (context) => {
102+
const files = {
103+
'index.ts': `export function hello(): void {
104+
console.log('Hello!')
105+
}`,
106+
}
107+
await testBuild({
108+
context,
109+
files,
110+
options: {
111+
entry: {
112+
'folder/index': 'index.ts',
113+
},
114+
format: ['esm', 'cjs'],
115+
dts: { cjsReexport: true },
116+
},
117+
})
118+
})
119+
101120
test('fixed extension', async (context) => {
102121
const files = {
103122
'index.ts': `export default 10`,

0 commit comments

Comments
 (0)