Skip to content

Commit bae4d23

Browse files
committed
feat(dts): add cjsReexport option to eliminate dual module type hazard
1 parent 475df0c commit bae4d23

File tree

5 files changed

+120
-13
lines changed

5 files changed

+120
-13
lines changed

src/build.ts

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { writeFile } from 'node:fs/promises'
2+
import path from 'node:path'
13
import { bold, green } from 'ansis'
24
import { clearRequireCache } from 'import-without-cache'
35
import {
@@ -290,16 +292,18 @@ async function buildSingle(
290292

291293
const configs: BuildOptions[] = [buildOptions]
292294
if (format === 'cjs' && dts) {
293-
configs.push(
294-
await getBuildOptions(
295-
config,
296-
format,
297-
configFiles,
298-
bundle,
299-
true,
300-
isDualFormat,
301-
),
302-
)
295+
if (!isDualFormat || !dts.cjsReexport) {
296+
configs.push(
297+
await getBuildOptions(
298+
config,
299+
format,
300+
configFiles,
301+
bundle,
302+
true,
303+
isDualFormat,
304+
),
305+
)
306+
}
303307
}
304308

305309
return configs
@@ -308,6 +312,11 @@ async function buildSingle(
308312
async function postBuild() {
309313
await copy(config)
310314
await buildExe(config, chunks)
315+
316+
if (format === 'cjs' && dts && dts.cjsReexport && isDualFormat) {
317+
await writeCjsDtsReexports(chunks, outDir, config.write !== false)
318+
}
319+
311320
if (!hasBuilt) {
312321
await done(bundle)
313322
}
@@ -319,3 +328,27 @@ async function buildSingle(
319328
ab = executeOnSuccess(config)
320329
}
321330
}
331+
332+
async function writeCjsDtsReexports(
333+
chunks: RolldownChunk[],
334+
outDir: string,
335+
write: boolean,
336+
): Promise<void> {
337+
for (const chunk of chunks) {
338+
if (chunk.type !== 'chunk') continue
339+
340+
// Match CJS JS output files: .cjs (fixed extension) or .js (non-fixed)
341+
const match = chunk.fileName.match(/^(.*)\.(cjs|js)$/)
342+
if (!match) continue
343+
344+
const baseName = match[1]
345+
const dCtsName =
346+
match[2] === 'cjs' ? `${baseName}.d.cts` : `${baseName}.d.ts`
347+
const dMtsBasename = path.basename(`${baseName}.d.mts`)
348+
const content = `export * from './${dMtsBasename}'\n`
349+
350+
if (write) {
351+
await writeFile(path.join(outDir, dCtsName), content)
352+
}
353+
}
354+
}

src/config/types.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,25 @@ 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'
48+
49+
export interface DtsOptions extends RolldownPluginDtsOptions {
50+
/**
51+
* When building dual ESM+CJS formats, generate a `.d.cts` re-export stub
52+
* instead of running a full second TypeScript compilation pass.
53+
*
54+
* The stub re-exports everything from the corresponding `.d.mts` file,
55+
* ensuring CJS and ESM consumers share the same type declarations. This
56+
* eliminates the TypeScript "dual module hazard" where separate `.d.cts`
57+
* and `.d.mts` declarations cause `TS2352` ("neither type sufficiently
58+
* overlaps") errors when casting between types derived from the same class.
59+
*
60+
* Only applies when building both `esm` and `cjs` formats simultaneously.
61+
*
62+
* @default false
63+
*/
64+
cjsReexport?: boolean
65+
}
4866
import type { Options as UnusedOptions } from 'unplugin-unused'
4967

5068
export type Sourcemap = boolean | 'inline' | 'hidden'
@@ -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: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,10 @@ async function resolveInputOptions(
117117

118118
if (dts) {
119119
const { dts: dtsPlugin } = await import('rolldown-plugin-dts')
120+
const { cjsReexport: _, ...dtsPluginOptions } = dts
120121
const options: DtsOptions = {
121122
tsconfig,
122-
...dts,
123+
...dtsPluginOptions,
123124
}
124125

125126
if (format === 'es') {
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
## 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+
## index.d.cts
15+
16+
```cts
17+
export * from './index.d.mts'
18+
19+
```
20+
21+
## index.d.mts
22+
23+
```mts
24+
//#region index.d.ts
25+
declare function hello(): void;
26+
//#endregion
27+
export { hello };
28+
```
29+
30+
## 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: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,22 @@ 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+
format: ['esm', 'cjs'],
112+
dts: { cjsReexport: true },
113+
},
114+
})
115+
})
116+
101117
test('fixed extension', async (context) => {
102118
const files = {
103119
'index.ts': `export default 10`,

0 commit comments

Comments
 (0)