@knighted/duel
Version:
TypeScript dual packages.
155 lines (107 loc) • 8.65 kB
Markdown
# [`@knighted/duel`](https://www.npmjs.com/package/@knighted/duel)

[](https://codecov.io/gh/knightedcodemonkey/duel)
[](https://www.npmjs.com/package/@knighted/duel)
Tool for building a Node.js [dual package](https://nodejs.org/api/packages.html#dual-commonjses-module-packages) with TypeScript. Supports CommonJS and ES module projects.
> [!NOTE]
> I wish this tool were unnecessary, but dual emit was declared out of scope by the TypeScript team, so `duel` exists to fill that gap.
## Features
- Bidirectional ESM ↔️ CJS dual builds inferred from the package.json `type`.
- Correctly preserves module systems for `.mts` and `.cts` file extensions.
- No extra configuration files needed, uses `package.json` and `tsconfig.json` files.
- Transforms the [differences between ES modules and CommonJS](https://nodejs.org/api/esm.html#differences-between-es-modules-and-commonjs).
- Works with monorepos.
## Requirements
- Node >= 22.21.1 (<23) or >= 24 (<25)
## Example
First, install this package to create the `duel` executable inside your `node_modules/.bin` directory.
```console
npm i @knighted/duel --save-dev
```
Then, given a `package.json` that defines `"type": "module"` and a `tsconfig.json` file that looks something like the following:
```json
{
"compilerOptions": {
"declaration": true,
"module": "NodeNext",
"outDir": "dist"
},
"include": ["src"]
}
```
You can create an ES module build for the project defined by the above configuration, **and also a dual CJS build** by defining the following npm run script in your `package.json`:
```json
"scripts": {
"build": "duel"
}
```
And then running it:
```console
npm run build
```
If everything worked, you should have an ESM build inside of `dist` and a CJS build inside of `dist/cjs`. You can manually update your [`exports`](https://nodejs.org/api/packages.html#exports) to match the build output, or run `duel --exports <mode>` to generate them automatically (see [docs/exports.md](docs/exports.md)).
It should work similarly for a CJS-first project. Except, your package.json file would use `"type": "commonjs"` and the dual build directory is in `dist/esm`.
> [!IMPORTANT]
> This works best if your CJS-first project uses file extensions in _relative_ specifiers. That is acceptable in CJS and [required in ESM](https://nodejs.org/api/esm.html#import-specifiers). `duel` does not rewrite bare specifiers or remap relative specifiers to directory indexes.
> [!TIP]
> `duel` creates a hash-named temp workspace (`.duel-cache/_duel_<hash>_`) inside your project during a build. The `_duel_<hash>_` temp directory is removed on success/failure unless `DUEL_KEEP_TEMP=1` is set. The `.duel-cache/` folder itself (which also holds incremental caches) is not automatically deleted—add it to your `.gitignore`. If a temp folder is ever left behind (e.g., abrupt kill), it is safe to delete.
### Build orientation
`duel` infers the primary vs dual build orientation from your `package.json` `type`:
- `"type": "module"` → primary ESM, dual CJS
- `"type": "commonjs"` → primary CJS, dual ESM
### Output directories
If you prefer to have both builds in directories inside of your defined `outDir`, you can use the `--dirs` option.
```json
"scripts": {
"build": "duel --dirs"
}
```
Assuming an `outDir` of `dist`, running the above will create `dist/esm` and `dist/cjs` directories.
### Module transforms
`tsc` is asymmetric: `import.meta` globals fail in a CJS-targeted build, but CommonJS globals like `__filename`/`__dirname` pass when targeting ESM, causing runtime errors in the compiled output. See [TypeScript#58658](https://github.com/microsoft/TypeScript/issues/58658). Use `--mode` to mitigate:
- `--mode globals` [rewrites module globals](https://github.com/knightedcodemonkey/module/blob/main/docs/globals-only.md#rewrites-at-a-glance).
- `--mode full` adds syntax lowering _in addition to_ the globals rewrite. TS sources use globals-only transformation before `tsc` to keep declaration emit correct, while JS/JSX and the dual CJS rewrite path are fully lowered. See the [mode matrix](docs/mode-matrix.md) for details.
```json
"scripts": {
"build": "duel --mode globals"
}
```
```json
"scripts": {
"build": "duel --mode full"
}
```
When `--mode` is enabled, `duel` copies sources and runs [`@knighted/module`](https://github.com/knightedcodemonkey/module) **before** `tsc`, so TypeScript sees already-mitigated sources. That pre-`tsc` step is globals-only for `--mode globals` and full lowering for `--mode full`.
### Dual package hazards
Mixed `import`/`require` of the same dual package (especially when conditional exports differ) can create two module instances. `duel` exposes the detector from `@knighted/module`:
- `--detect-dual-package-hazard [off|warn|error]` (default `warn`): emit diagnostics; `error` exits non-zero.
- `--dual-package-hazard-allowlist <pkg>[,<pkg>...]`: comma-separated packages to ignore for hazard reporting (e.g., `react`).
- `--dual-package-hazard-scope [file|project]` (default `file`): per-file checks or a project-wide pre-pass that aggregates package usage across all compiled sources before building.
Project scope is helpful in monorepos or hoisted installs where hazards surface only when looking across files.
## Options
These are the CLI options `duel` supports to work alongside your project's `tsconfig.json` settings.
- `--project, -p` The path to the project's configuration file. Defaults to `tsconfig.json`.
- `--pkg-dir, -k` The directory to start looking for a package.json file. Defaults to `--project` dir.
- `--mode` Optional shorthand for the module transform mode: `none` (default), `globals` (globals-only), `full` (globals + full syntax lowering).
- `--dirs, -d` Outputs both builds to directories inside of `outDir`. Defaults to `false`.
- `--exports, -e` Generate `package.json` `exports` from build output. Values: `wildcard` | `dir` | `name`.
- `--exports-config` Provide a JSON file with `{ "entries": ["./dist/index.js", ...], "main": "./dist/index.js" }` to limit which outputs become exports.
- `--exports-validate` Dry-run exports generation/validation without writing package.json; combine with `--exports` or `--exports-config` to emit after validation.
- `--rewrite-policy [safe|warn|skip]` Control how specifier rewrites behave when a matching target is missing (`safe` warns and skips, `warn` rewrites and warns, `skip` leaves specifiers untouched).
- `--detect-dual-package-hazard, -H [off|warn|error]` Flag mixed import/require usage of dual packages; `error` exits non-zero. If project-scope checks lack file paths, or file-scope checks return pathless diagnostics, Duel falls back to file-scope reporting during transforms so diagnostics include locations.
- `--dual-package-hazard-allowlist <pkg>[,<pkg>...]` Comma-separated packages to ignore when reporting dual package hazards (e.g., `react`).
- `--dual-package-hazard-scope [file|project]` Run hazard checks per file (default) or aggregate across the project.
- `--copy-mode [sources|full]` Temp copy strategy. `sources` (default) copies only files participating in the build (plus configs); `full` mirrors the previous whole-project copy.
- `--verbose, -V` Verbose logging.
- `--help, -h` Print the help text.
> [!NOTE]
> Exports keys are extensionless by design; the target `import`/`require`/`types` entries keep explicit file extensions so Node resolution remains deterministic.
You can run `duel --help` to get the same info.
## Notes
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 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).
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.
## Documentation
- [docs/faq.md](docs/faq.md)
- [docs/exports.md](docs/exports.md)
- [docs/migrate-v2-v3.md](docs/migrate-v2-v3.md)
- [docs/migrate-v3-v4.md](docs/v4-migration.md)