Skip to content

Commit 7aa7a09

Browse files
feat: --modules option. (#47)
* test: add monorepos test.
1 parent 4815dc1 commit 7aa7a09

28 files changed

+442
-37
lines changed

README.md

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ Tool for building a Node.js [dual package](https://nodejs.org/api/packages.html#
1010

1111
- Bidirectional ESM ↔️ CJS dual builds inferred from the package.json `type`.
1212
- Correctly preserves module systems for `.mts` and `.cts` file extensions.
13-
- Resolves the [differences between ES modules and CommonJS](https://nodejs.org/api/esm.html#differences-between-es-modules-and-commonjs).
14-
- Use only one package.json and tsconfig.json.
13+
- No extra configuration files needed, uses `package.json` and `tsconfig.json` files.
14+
- Transforms the [differences between ES modules and CommonJS](https://nodejs.org/api/esm.html#differences-between-es-modules-and-commonjs).
15+
- Works with monorepos.
16+
1517

1618
## Requirements
1719

@@ -68,12 +70,27 @@ If you prefer to have both builds in directories inside of your defined `outDir`
6870

6971
Assuming an `outDir` of `dist`, running the above will create `dist/esm` and `dist/cjs` directories.
7072

73+
### Module transforms
74+
75+
TypeScript will throw compiler errors when using `import.meta` globals while targeting a CommonJS dual build, but _will not_ throw compiler errors when the inverse is true, i.e. using CommonJS globals (`__filename`, `__dirname`, etc.) while targeting an ES module dual build. There is an [open issue](https://github.com/microsoft/TypeScript/issues/58658) regarding this unexpected behavior. You can use the `--modules` option to have the [differences between ES modules and CommonJS](https://nodejs.org/api/esm.html#differences-between-es-modules-and-commonjs) transformed by `duel` prior to running compilation with `tsc` so that there are no compilation or runtime errors.
76+
77+
Note, there is a slight performance penalty since your project needs to be copied first to run the transforms before compiling with `tsc`.
78+
79+
```json
80+
"scripts": {
81+
"build": "duel --modules"
82+
}
83+
```
84+
85+
This feature is still a work in progress regarding transforming `exports` when targeting an ES module build (relies on [`@knighted/module`](https://github.com/knightedcodemonkey/module)).
86+
7187
## Options
7288

7389
The available options are limited, because you should define most of them inside your project's `tsconfig.json` file.
7490

7591
- `--project, -p` The path to the project's configuration file. Defaults to `tsconfig.json`.
7692
- `--pkg-dir, -k` The directory to start looking for a package.json file. Defaults to the cwd.
93+
- `--modules, -m` Transform module globals for dual build target. Defaults to false.
7794
- `--dirs, -d` Outputs both builds to directories inside of `outDir`. Defaults to `false`.
7895

7996
You can run `duel --help` to get the same info. Below is the output of that:
@@ -84,6 +101,7 @@ Usage: duel [options]
84101
Options:
85102
--project, -p [path] Compile the project given the path to its configuration file, or to a folder with a 'tsconfig.json'.
86103
--pkg-dir, -k [path] The directory to start looking for a package.json file. Defaults to cwd.
104+
--modules, -m Transform module globals for dual build target. Defaults to false.
87105
--dirs, -d Output both builds to directories inside of outDir. [esm, cjs].
88106
--help, -h Print this message.
89107
```
@@ -94,12 +112,14 @@ These are definitely edge cases, and would only really come up if your project m
94112

95113
- This is going to work best if your CJS-first project uses file extensions in _relative_ specifiers. This is completely acceptable in CJS projects, and [required in ESM projects](https://nodejs.org/api/esm.html#import-specifiers). This package makes no attempt to rewrite bare specifiers, or remap any relative specifiers to a directory index.
96114

97-
- 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_, without requiring **multiple** package.json files. In my opinion, the `tsc` compiler is fundamentally broken in this regard, and at best is enforcing usage patterns it shouldn't. This is only mentioned for transparency, `duel` will correct for this and produce files with the module system you would expect based on the file's extension, so that it works with [how Node.js determines module systems](https://nodejs.org/api/packages.html#determining-module-system).
115+
- Unfortunately, TypeScript doesn't really build [dual packages](https://nodejs.org/api/packages.html#dual-commonjses-module-packages) very well. One instance of unexpected behavior is when the compiler throws errors for ES module globals when running a dual CJS build, but not for the inverse case, despite both causing runtime errors in Node.js. See the [open issue](https://github.com/microsoft/TypeScript/issues/58658). You can circumvent this with `duel` by using the `--modules` option if your project uses module globals such as `import.meta` properties or `__dirname`, `__filename`, etc. in a CommonJS project.
98116

99117
- 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`.
100118

101119
- If running `duel` with your project's package.json file open in your editor, you may temporarily see the content replaced. This is because `duel` dynamically creates a new package.json using the `type` necessary for the dual build. Your original package.json will be restored after the build completes.
102120

103121
## Notes
104122

105-
As far as I can tell, `duel` is one (if not the only) way to get a correct dual package build using `tsc` with only **one package.json and tsconfig.json file**, _while also preserving module system by file extension_. Basically, how you expect things to work. The Microsoft backed TypeScript team [keep](https://github.com/microsoft/TypeScript/pull/54546) [talking](https://github.com/microsoft/TypeScript/issues/54593) about dual build support, but they continue to [refuse to rewrite specifiers](https://github.com/microsoft/TypeScript/issues/16577).
123+
As far as I can tell, `duel` is one (if not the only) way to get a correct dual package build using `tsc` without requiring multiple `tsconfig.json` files or extra configuration. The Microsoft backed TypeScript team [keep](https://github.com/microsoft/TypeScript/pull/54546) [talking](https://github.com/microsoft/TypeScript/issues/54593) about dual build support, but they continue to [refuse to rewrite specifiers](https://github.com/microsoft/TypeScript/issues/16577).
124+
125+
Fortunately, Node.js has added `--experimental-require-module` so that you can [`require()` ES modules](https://nodejs.org/api/esm.html#require) if they don't use top level await, which sets the stage for possibly no longer requiring dual builds.

package-lock.json

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

package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@knighted/duel",
3-
"version": "2.0.0-rc.0",
3+
"version": "2.0.0-rc.1",
44
"description": "TypeScript dual packages.",
55
"type": "module",
66
"main": "dist/esm/duel.js",
@@ -20,6 +20,8 @@
2020
"scripts": {
2121
"prettier": "prettier -w src/*.js test/*.js",
2222
"lint": "eslint src/*.js test/*.js",
23+
"test:integration": "node --test --test-reporter=spec test/integration.js",
24+
"test:monorepos": "node --test --test-reporter=spec test/monorepos.js",
2325
"test": "c8 --reporter=text --reporter=text-summary --reporter=lcov node --test --test-reporter=spec test/*.js",
2426
"build": "node src/duel.js --dirs",
2527
"prepack": "npm run build"
@@ -59,11 +61,12 @@
5961
"eslint": "^8.45.0",
6062
"eslint-plugin-n": "^16.0.1",
6163
"prettier": "^3.2.4",
64+
"tsx": "^4.11.2",
6265
"typescript": "^5.5.0-dev.20240525",
6366
"vite": "^5.2.8"
6467
},
6568
"dependencies": {
66-
"@knighted/module": "^1.0.0-alpha.3",
69+
"@knighted/module": "^1.0.0-alpha.4",
6770
"@knighted/specifier": "^2.0.0-rc.1",
6871
"find-up": "^6.3.0",
6972
"glob": "^10.3.3",

src/duel.js

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const duel = async args => {
3030
const ctx = await init(args)
3131

3232
if (ctx) {
33-
const { projectDir, tsconfig, configPath, dirs, pkg } = ctx
33+
const { projectDir, tsconfig, configPath, modules, dirs, pkg } = ctx
3434
const tsc = await findUp(
3535
async dir => {
3636
const tscBin = join(dir, 'node_modules', '.bin', 'tsc')
@@ -130,22 +130,47 @@ const duel = async args => {
130130
}
131131

132132
if (success) {
133-
const compileFiles = getCompileFiles(tsc, projectDir)
134133
const subDir = join(projectDir, `_${hex}_`)
135-
const dualConfigPath = join(subDir, `tsconfig.${hex}.json`)
136134
const absoluteDualOutDir = join(
137135
projectDir,
138136
isCjsBuild ? join(outDir, 'cjs') : join(outDir, 'esm'),
139137
)
140138
const tsconfigDual = getOverrideTsConfig()
141139
const pkgRename = 'package.json.bak'
140+
let dualConfigPath = join(projectDir, `tsconfig.${hex}.json`)
142141
let errorMsg = ''
143142

144-
// Copy project directory as a subdirectory
145-
await mkdir(subDir)
146-
await Promise.all(
147-
compileFiles.map(file => cp(file, resolve(subDir, relative(projectDir, file)))),
148-
)
143+
if (modules) {
144+
const compileFiles = getCompileFiles(tsc, projectDir)
145+
146+
dualConfigPath = join(subDir, `tsconfig.${hex}.json`)
147+
await mkdir(subDir)
148+
await Promise.all(
149+
compileFiles.map(file =>
150+
cp(file, join(subDir, relative(projectDir, file).replace(/^(\.\.\/)*/, ''))),
151+
),
152+
)
153+
154+
/**
155+
* Transform ambiguous modules for the target dual build.
156+
* @see https://github.com/microsoft/TypeScript/issues/58658
157+
*/
158+
const toTransform = await glob(`${subDir}/**/*{.js,.jsx,.ts,.tsx}`, {
159+
ignore: 'node_modules/**',
160+
})
161+
162+
for (const file of toTransform) {
163+
/**
164+
* Maybe include the option to transform modules implicitly
165+
* (modules: true) so that `exports` are correctly converted
166+
* when targeting a CJS dual build. Depends on @knighted/module
167+
* supporting he `modules` option.
168+
*
169+
* @see https://github.com/microsoft/TypeScript/issues/58658
170+
*/
171+
await transform(file, { out: file, type: isCjsBuild ? 'commonjs' : 'module' })
172+
}
173+
}
149174

150175
/**
151176
* Create a new package.json with updated `type` field.
@@ -160,18 +185,6 @@ const duel = async args => {
160185
)
161186
await writeFile(dualConfigPath, JSON.stringify(tsconfigDual))
162187

163-
/**
164-
* Transform ambiguous modules for the target dual build.
165-
* @see https://github.com/microsoft/TypeScript/issues/58658
166-
*/
167-
const toTransform = await glob(`${subDir}/**/*{.js,.jsx,.ts,.tsx}`, {
168-
ignore: 'node_modules/**',
169-
})
170-
171-
for (const file of toTransform) {
172-
await transform(file, { out: file, type: isCjsBuild ? 'commonjs' : 'module' })
173-
}
174-
175188
// Build dual
176189
log('Starting dual build...')
177190
try {

src/init.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ const init = async args => {
3030
short: 'k',
3131
default: cwd(),
3232
},
33+
modules: {
34+
type: 'boolean',
35+
short: 'm',
36+
default: false,
37+
},
3338
dirs: {
3439
type: 'boolean',
3540
short: 'd',
@@ -59,10 +64,19 @@ const init = async args => {
5964
log(
6065
'--pkg-dir, -k [path] \t The directory to start looking for a package.json file. Defaults to cwd.',
6166
)
67+
log(
68+
'--modules, -m \t\t Transform module globals for dual build target. Defaults to false.',
69+
)
6270
log('--dirs, -d \t\t Output both builds to directories inside of outDir. [esm, cjs].')
6371
log('--help, -h \t\t Print this message.')
6472
} else {
65-
const { project, 'target-extension': targetExt, 'pkg-dir': pkgDir, dirs } = parsed
73+
const {
74+
project,
75+
'target-extension': targetExt,
76+
'pkg-dir': pkgDir,
77+
modules,
78+
dirs,
79+
} = parsed
6680
let configPath = resolve(project)
6781
let stats = null
6882
let pkg = null
@@ -134,6 +148,7 @@ const init = async args => {
134148
return {
135149
pkg,
136150
dirs,
151+
modules,
137152
tsconfig,
138153
projectDir,
139154
configPath,
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"type": "commonjs"
3+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { argv, stdout } from 'node:process'
2+
import { pathToFileURL } from 'node:url'
3+
import { realpath } from 'node:fs/promises'
4+
5+
const detectCalledFromCli = async (path: string) => {
6+
const realPath = await realpath(path)
7+
8+
if (__filename === pathToFileURL(realPath).href) {
9+
stdout.write('invoked as cli')
10+
}
11+
}
12+
13+
detectCalledFromCli(argv[1])
14+
15+
require.resolve(`${__dirname}/other.js`)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
exports.other = true
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ESNext",
4+
"module": "NodeNext",
5+
"moduleResolution": "NodeNext",
6+
"declaration": true,
7+
"outDir": "dist",
8+
"strict": true,
9+
},
10+
"include": ["src"],
11+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"type": "module"
3+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { argv, stdout } from 'node:process'
2+
import { pathToFileURL } from 'node:url'
3+
import { realpath } from 'node:fs/promises'
4+
5+
const detectCalledFromCli = async (path: string) => {
6+
const realPath = await realpath(path)
7+
8+
if (import.meta.url === pathToFileURL(realPath).href) {
9+
stdout.write('invoked as cli')
10+
}
11+
}
12+
13+
detectCalledFromCli(argv[1])
14+
15+
import.meta.resolve(`${import.meta.dirname}/other.js`)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const other = true
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ESNext",
4+
"module": "NodeNext",
5+
"moduleResolution": "NodeNext",
6+
"declaration": true,
7+
"outDir": "dist",
8+
"strict": true,
9+
},
10+
"include": ["src"],
11+
}

0 commit comments

Comments
 (0)