Skip to content

Commit 6f91a89

Browse files
feat: correct module systems by file extension. addresses microsoft/TypeScript#51990, microsoft/TypeScript#27957,
microsoft/TypeScript#50985
1 parent bfd59a9 commit 6f91a89

22 files changed

+276
-31
lines changed

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
Node.js tool for creating a TypeScript dual package.
88

9-
Early stages of development. Inspired by https://github.com/microsoft/TypeScript/issues/49462.
9+
Inspired by https://github.com/microsoft/TypeScript/issues/49462.
1010

1111
## Requirements
1212

@@ -79,8 +79,8 @@ Options:
7979

8080
## Gotchas
8181

82-
* Unfortunately, TypeScript doesn't really build [dual packages](https://nodejs.org/api/packages.html#dual-commonjses-module-packages) very well in regards to preserving module system by file extension. For instance, it will **always** create CJS exports when `--module commonjs` is used, _even on files with an `.mts` extension_, which is contrary to [how Node determines module systems](https://nodejs.org/api/packages.html#determining-module-system). The `tsc` compiler is fundamentally broken in this regard. One reference issue is https://github.com/microsoft/TypeScript/issues/54573. If you use `.mts` extensions to enforce an ESM module system, this will break in the corresponding dual CJS build. There is no way to fix this until TypeScript fixes their compiler.
82+
These are definitely edge cases, and would only really come up if your project mixes file extensions. For example, if you have `.ts` files combined with `.mts`, and/or `.cts`. For most project, things should just work as expected.
8383

84-
* If targeting a dual CJS build, and you are using [top level `await`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await#top_level_await), you will most likely encounter the compilation error `error TS1378: Top-level 'await' expressions are only allowed when the 'module' option is set to 'es2022', 'esnext', 'system', 'node16', or 'nodenext', and the 'target' option is set to 'es2017' or higher.` during the CJS build. This is because `duel` creates a temporary `tsconfig.json` from your original and necessarily overwrites the `--module` and `--moduleResolution` based on the provided `--target-ext`. There is no workaround other than to **not** use top level await if you want a dual build.
84+
* Unfortunately, TypeScript doesn't really build [dual packages](https://nodejs.org/api/packages.html#dual-commonjses-module-packages) very well in regards to preserving module system by file extension. For instance, there doesn't appear to be a way to convert an arbitrary `.ts` file into another module system, _while also preserving the module system of `.mts` and `.cts` files_. In my opinion, the `tsc` compiler is fundamentally broken in this regard, and at best is enforcing usage patterns it shouldn't. If you want to see one of my extended rants on this, check out this [comment](https://github.com/microsoft/TypeScript/pull/50985#issuecomment-1656991606). This is only mentioned for transparency, `duel` will correct for this and produce files with the module system you would expect based on the files extension, so that it works with [how Node.js determines module systems](https://nodejs.org/api/packages.html#determining-module-system).
8585

8686
* If doing an `import type` across module systems, i.e. from `.mts` into `.cts`, or vice versa, you might encounter the compilation error ``error TS1452: 'resolution-mode' assertions are only supported when `moduleResolution` is `node16` or `nodenext`.``. This is a [known issue](https://github.com/microsoft/TypeScript/issues/49055) and TypeScript currently suggests installing the nightly build, i.e. `npm i typescript@next`.

package-lock.json

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

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@knighted/duel",
3-
"version": "1.0.0-alpha.3",
3+
"version": "1.0.0-alpha.4",
44
"description": "TypeScript dual packages.",
55
"type": "module",
66
"main": "dist",
@@ -44,7 +44,7 @@
4444
"url": "https://github.com/knightedcodemonkey/duel/issues"
4545
},
4646
"peerDependencies": {
47-
"typescript": "^5.0.0"
47+
"typescript": ">=4.0.0"
4848
},
4949
"devDependencies": {
5050
"babel-dual-package": "^1.0.0-rc.5",

src/duel.js

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
#!/usr/bin/env node
22

33
import { argv, cwd } from 'node:process'
4-
import { join } from 'node:path'
4+
import { join, relative } from 'node:path'
55
import { spawnSync } from 'node:child_process'
6-
import { writeFile, rm } from 'node:fs/promises'
6+
import { writeFile, copyFile, rm } from 'node:fs/promises'
77
import { randomBytes } from 'node:crypto'
88
import { performance } from 'node:perf_hooks'
99

@@ -13,7 +13,6 @@ import { specifier } from '@knighted/specifier'
1313
import { init } from './init.js'
1414
import { getRealPathAsFileUrl, logError, log } from './util.js'
1515

16-
// TypeScript is defined as a peer dependency.
1716
const tsc = join(cwd(), 'node_modules', '.bin', 'tsc')
1817
const runBuild = project => {
1918
const { status, error } = spawnSync(tsc, ['-p', project], { stdio: 'inherit' })
@@ -42,7 +41,7 @@ const duel = async args => {
4241
const ctx = await init(args)
4342

4443
if (ctx) {
45-
const { projectDir, tsconfig, targetExt, configPath } = ctx
44+
const { projectDir, tsconfig, targetExt, configPath, absoluteOutDir } = ctx
4645
const startTime = performance.now()
4746

4847
log('Starting primary build...\n')
@@ -55,13 +54,15 @@ const duel = async args => {
5554
const { outDir } = tsconfig.compilerOptions
5655
const dualConfigPath = join(projectDir, `tsconfig.${hex}.json`)
5756
const dualOutDir = isCjsBuild ? join(outDir, 'cjs') : join(outDir, 'mjs')
57+
// Using structuredClone() would require node >= 17.0.0
5858
const tsconfigDual = {
5959
...tsconfig,
6060
compilerOptions: {
6161
...tsconfig.compilerOptions,
6262
outDir: dualOutDir,
63-
module: isCjsBuild ? 'CommonJS' : 'NodeNext',
64-
moduleResolution: isCjsBuild ? 'Node' : 'NodeNext',
63+
module: isCjsBuild ? 'CommonJS' : 'ESNext',
64+
// Best way to make this work given how tsc works
65+
moduleResolution: 'Node',
6566
},
6667
}
6768

@@ -71,7 +72,8 @@ const duel = async args => {
7172
await rm(dualConfigPath, { force: true })
7273

7374
if (success) {
74-
const filenames = await glob(`${join(projectDir, dualOutDir)}/**/*{.js,.d.ts}`, {
75+
const absoluteDualOutDir = join(projectDir, dualOutDir)
76+
const filenames = await glob(`${absoluteDualOutDir}/**/*{.js,.d.ts}`, {
7577
ignore: 'node_modules/**',
7678
})
7779

@@ -95,6 +97,55 @@ const duel = async args => {
9597
await rm(filename, { force: true })
9698
}
9799

100+
/**
101+
* This is a fix for tsc compiler which doesn't seem to support
102+
* converting an arbitrary `.ts` file, into another module system,
103+
* while also preserving the module systems of `.mts` and `.cts` files.
104+
*
105+
* Hopefully it can be removed when TS updates their supported options,
106+
* or at least how the combination of `--module` and `--moduleResolution`
107+
* currently work.
108+
*
109+
* @see https://github.com/microsoft/TypeScript/pull/50985#issuecomment-1656991606
110+
*/
111+
if (isCjsBuild) {
112+
const mjsFiles = await glob(`${absoluteOutDir}/**/*.mjs`, {
113+
ignore: ['node_modules/**', `${absoluteDualOutDir}/**`],
114+
})
115+
116+
for (const filename of mjsFiles) {
117+
const relativeFn = relative(absoluteOutDir, filename)
118+
119+
await copyFile(filename, join(absoluteDualOutDir, relativeFn))
120+
}
121+
} else {
122+
const cjsFiles = await glob(`${absoluteOutDir}/**/*.cjs`, {
123+
ignore: ['node_modules/**', `${absoluteDualOutDir}/**`],
124+
})
125+
126+
for (const filename of cjsFiles) {
127+
const relativeFn = relative(absoluteOutDir, filename)
128+
129+
await copyFile(filename, join(absoluteDualOutDir, relativeFn))
130+
}
131+
132+
/**
133+
* Now copy the good .mjs files from the dual out dir
134+
* to the original out dir, but build the file path
135+
* from the original out dir to distinguish from the
136+
* dual build .mjs files.
137+
*/
138+
const mjsFiles = await glob(`${absoluteOutDir}/**/*.mjs`, {
139+
ignore: ['node_modules/**', `${absoluteDualOutDir}/**`],
140+
})
141+
142+
for (const filename of mjsFiles) {
143+
const relativeFn = relative(absoluteOutDir, filename)
144+
145+
await copyFile(join(absoluteDualOutDir, relativeFn), filename)
146+
}
147+
}
148+
98149
log(
99150
`Successfully created a dual ${targetExt
100151
.replace('.', '')
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"version": "0.0.0",
3+
"type": "commonjs",
4+
"exports": {
5+
".": {
6+
"import": {
7+
"types": "./dist/mjs/index.d.mts",
8+
"default": "./dist/index.mjs"
9+
},
10+
"require": {
11+
"types": "./dist/index.d.cts",
12+
"default": "./dist/index.cjs"
13+
},
14+
"default": "./dist/index.cjs"
15+
},
16+
"./package.json": "./package.json"
17+
}
18+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
interface ESM {
2+
esm: boolean;
3+
}
4+
5+
export const esm: ESM = {
6+
esm: true
7+
}
8+
9+
export type { ESM }

test/__fixtures__/project/src/folder/module.ts renamed to test/__fixtures__/cjsProject/src/folder/module.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,5 @@ const mod: Mod = {
1616
}
1717
}
1818

19-
export { mod }
19+
export { mod, cjs }
2020
export type { Mod }
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { mod } from "./folder/module.js"
2+
import { cjs } from './cjs.cjs'
3+
4+
import type { Mod } from "./folder/module.js"
5+
import type { CJS } from "./cjs.cjs"
6+
7+
interface User {
8+
name: string;
9+
id: number;
10+
mod: Mod;
11+
esm: any;
12+
cjs: CJS;
13+
}
14+
15+
class UserAccount {
16+
name: string;
17+
id: number;
18+
mod: Mod;
19+
esm: any;
20+
cjs: CJS;
21+
22+
constructor(name: string, id: number, mod: Mod, esm: any, cjs: CJS) {
23+
this.name = name;
24+
this.id = id;
25+
this.mod = mod;
26+
this.esm = esm;
27+
this.cjs = cjs;
28+
}
29+
}
30+
31+
const getUser = async () => {
32+
const { esm } = await import('./esm.mjs')
33+
34+
return new UserAccount("Murphy", 1, mod, esm, cjs)
35+
}
36+
37+
export type { User }
38+
39+
export { getUser }
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ESNext",
4+
"module": "CommonJS",
5+
"moduleResolution": "Node",
6+
"declaration": true,
7+
"strict": false,
8+
"outDir": "dist",
9+
"lib": ["ES2015"]
10+
},
11+
"include": ["src"]
12+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
//import * as cjs from './dist/cjs/cjs.cjs'
2+
3+
require('./dist/cjs/cjs.cjs')
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
import type { ESM } from './esm.mjs' assert { 'resolution-mode': 'import' };
3+
4+
interface CJS {
5+
cjs: boolean,
6+
esm: ESM;
7+
}
8+
9+
const func = async () => {
10+
const { esm } = await import('./esm.mjs')
11+
12+
const cjs: CJS = {
13+
cjs: true,
14+
esm
15+
}
16+
17+
return cjs
18+
}
19+
20+
export { func }
21+
22+
export type { CJS }
23+
*/
24+
25+
import MagicString from "magic-string"
26+
27+
interface CJS {
28+
cjs: boolean;
29+
magic: MagicString;
30+
}
31+
32+
const cjs: CJS = {
33+
cjs: true,
34+
magic: new MagicString('magic')
35+
}
36+
37+
export { cjs }
38+
39+
export type { CJS }
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
interface ESM {
2+
esm: boolean;
3+
}
4+
5+
export const esm: ESM = {
6+
esm: true
7+
}
8+
9+
export type { ESM }
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
interface ESM {
2+
esm: boolean;
3+
}
4+
5+
export const esm: ESM = {
6+
esm: true
7+
}
8+
9+
export type { ESM }
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import MagicString from "magic-string";
2+
import { cjs } from "../cjs.cjs";
3+
4+
import type { CJS } from "../cjs.cjs";
5+
6+
interface Mod {
7+
prop: string;
8+
cjs: CJS
9+
}
10+
11+
const mod: Mod = {
12+
prop: 'foobar',
13+
cjs: {
14+
cjs: true,
15+
magic: new MagicString('module')
16+
}
17+
}
18+
19+
export { mod, cjs }
20+
export type { Mod }

test/__fixtures__/project/tsconfig.json renamed to test/__fixtures__/esmProject/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,5 @@
88
"outDir": "dist",
99
"lib": ["ES2015"]
1010
},
11-
"include": ["src/*.ts"]
11+
"include": ["src"]
1212
}

0 commit comments

Comments
 (0)