Skip to content

Commit 29cbf78

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

File tree

5 files changed

+109
-4
lines changed

5 files changed

+109
-4
lines changed

src/build.ts

Lines changed: 32 additions & 1 deletion
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 {
@@ -289,7 +291,7 @@ async function buildSingle(
289291
}
290292

291293
const configs: BuildOptions[] = [buildOptions]
292-
if (format === 'cjs' && dts) {
294+
if (format === 'cjs' && dts && (!isDualFormat || !dts.cjsReexport)) {
293295
configs.push(
294296
await getBuildOptions(
295297
config,
@@ -308,6 +310,11 @@ async function buildSingle(
308310
async function postBuild() {
309311
await copy(config)
310312
await buildExe(config, chunks)
313+
314+
if (format === 'cjs' && dts && dts.cjsReexport && isDualFormat) {
315+
await writeCjsDtsReexports(chunks, outDir, config.write !== false)
316+
}
317+
311318
if (!hasBuilt) {
312319
await done(bundle)
313320
}
@@ -319,3 +326,27 @@ async function buildSingle(
319326
ab = executeOnSuccess(config)
320327
}
321328
}
329+
330+
async function writeCjsDtsReexports(
331+
chunks: RolldownChunk[],
332+
outDir: string,
333+
write: boolean,
334+
): Promise<void> {
335+
for (const chunk of chunks) {
336+
if (chunk.type !== 'chunk') continue
337+
338+
// Match CJS JS output files: .cjs (fixed extension) or .js (non-fixed)
339+
const match = chunk.fileName.match(/^(.*)\.(cjs|js)$/)
340+
if (!match) continue
341+
342+
const baseName = match[1]
343+
const dCtsName =
344+
match[2] === 'cjs' ? `${baseName}.d.cts` : `${baseName}.d.ts`
345+
const dMtsBasename = path.basename(`${baseName}.d.mts`)
346+
const content = `export * from './${dMtsBasename}'\n`
347+
348+
if (write) {
349+
await writeFile(path.join(outDir, dCtsName), content)
350+
}
351+
}
352+
}

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: 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)