Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: --modules option. #47

Merged
merged 6 commits into from
Jun 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ Tool for building a Node.js [dual package](https://nodejs.org/api/packages.html#

- Bidirectional ESM ↔️ CJS dual builds inferred from the package.json `type`.
- Correctly preserves module systems for `.mts` and `.cts` file extensions.
- Resolves the [differences between ES modules and CommonJS](https://nodejs.org/api/esm.html#differences-between-es-modules-and-commonjs).
- Use only one package.json and tsconfig.json.
- 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

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

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

### Module transforms

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.

Note, there is a slight performance penalty since your project needs to be copied first to run the transforms before compiling with `tsc`.

```json
"scripts": {
"build": "duel --modules"
}
```

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

## Options

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

- `--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 the cwd.
- `--modules, -m` Transform module globals for dual build target. Defaults to false.
- `--dirs, -d` Outputs both builds to directories inside of `outDir`. Defaults to `false`.

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

- 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.

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

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

- 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.

## Notes

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

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.
53 changes: 47 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@knighted/duel",
"version": "2.0.0-rc.0",
"version": "2.0.0-rc.1",
"description": "TypeScript dual packages.",
"type": "module",
"main": "dist/esm/duel.js",
Expand All @@ -20,6 +20,8 @@
"scripts": {
"prettier": "prettier -w src/*.js test/*.js",
"lint": "eslint src/*.js test/*.js",
"test:integration": "node --test --test-reporter=spec test/integration.js",
"test:monorepos": "node --test --test-reporter=spec test/monorepos.js",
"test": "c8 --reporter=text --reporter=text-summary --reporter=lcov node --test --test-reporter=spec test/*.js",
"build": "node src/duel.js --dirs",
"prepack": "npm run build"
Expand Down Expand Up @@ -59,11 +61,12 @@
"eslint": "^8.45.0",
"eslint-plugin-n": "^16.0.1",
"prettier": "^3.2.4",
"tsx": "^4.11.2",
"typescript": "^5.5.0-dev.20240525",
"vite": "^5.2.8"
},
"dependencies": {
"@knighted/module": "^1.0.0-alpha.3",
"@knighted/module": "^1.0.0-alpha.4",
"@knighted/specifier": "^2.0.0-rc.1",
"find-up": "^6.3.0",
"glob": "^10.3.3",
Expand Down
53 changes: 33 additions & 20 deletions src/duel.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const duel = async args => {
const ctx = await init(args)

if (ctx) {
const { projectDir, tsconfig, configPath, dirs, pkg } = ctx
const { projectDir, tsconfig, configPath, modules, dirs, pkg } = ctx
const tsc = await findUp(
async dir => {
const tscBin = join(dir, 'node_modules', '.bin', 'tsc')
Expand Down Expand Up @@ -130,22 +130,47 @@ const duel = async args => {
}

if (success) {
const compileFiles = getCompileFiles(tsc, projectDir)
const subDir = join(projectDir, `_${hex}_`)
const dualConfigPath = join(subDir, `tsconfig.${hex}.json`)
const absoluteDualOutDir = join(
projectDir,
isCjsBuild ? join(outDir, 'cjs') : join(outDir, 'esm'),
)
const tsconfigDual = getOverrideTsConfig()
const pkgRename = 'package.json.bak'
let dualConfigPath = join(projectDir, `tsconfig.${hex}.json`)
let errorMsg = ''

// Copy project directory as a subdirectory
await mkdir(subDir)
await Promise.all(
compileFiles.map(file => cp(file, resolve(subDir, relative(projectDir, file)))),
)
if (modules) {
const compileFiles = getCompileFiles(tsc, projectDir)

dualConfigPath = join(subDir, `tsconfig.${hex}.json`)
await mkdir(subDir)
await Promise.all(
compileFiles.map(file =>
cp(file, join(subDir, relative(projectDir, file).replace(/^(\.\.\/)*/, ''))),
),
)

/**
* Transform ambiguous modules for the target dual build.
* @see https://github.com/microsoft/TypeScript/issues/58658
*/
const toTransform = await glob(`${subDir}/**/*{.js,.jsx,.ts,.tsx}`, {
ignore: 'node_modules/**',
})

for (const file of toTransform) {
/**
* Maybe include the option to transform modules implicitly
* (modules: true) so that `exports` are correctly converted
* when targeting a CJS dual build. Depends on @knighted/module
* supporting he `modules` option.
*
* @see https://github.com/microsoft/TypeScript/issues/58658
*/
await transform(file, { out: file, type: isCjsBuild ? 'commonjs' : 'module' })
}
}

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

/**
* Transform ambiguous modules for the target dual build.
* @see https://github.com/microsoft/TypeScript/issues/58658
*/
const toTransform = await glob(`${subDir}/**/*{.js,.jsx,.ts,.tsx}`, {
ignore: 'node_modules/**',
})

for (const file of toTransform) {
await transform(file, { out: file, type: isCjsBuild ? 'commonjs' : 'module' })
}

// Build dual
log('Starting dual build...')
try {
Expand Down
17 changes: 16 additions & 1 deletion src/init.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ const init = async args => {
short: 'k',
default: cwd(),
},
modules: {
type: 'boolean',
short: 'm',
default: false,
},
dirs: {
type: 'boolean',
short: 'd',
Expand Down Expand Up @@ -59,10 +64,19 @@ const init = async args => {
log(
'--pkg-dir, -k [path] \t The directory to start looking for a package.json file. Defaults to cwd.',
)
log(
'--modules, -m \t\t Transform module globals for dual build target. Defaults to false.',
)
log('--dirs, -d \t\t Output both builds to directories inside of outDir. [esm, cjs].')
log('--help, -h \t\t Print this message.')
} else {
const { project, 'target-extension': targetExt, 'pkg-dir': pkgDir, dirs } = parsed
const {
project,
'target-extension': targetExt,
'pkg-dir': pkgDir,
modules,
dirs,
} = parsed
let configPath = resolve(project)
let stats = null
let pkg = null
Expand Down Expand Up @@ -134,6 +148,7 @@ const init = async args => {
return {
pkg,
dirs,
modules,
tsconfig,
projectDir,
configPath,
Expand Down
3 changes: 3 additions & 0 deletions test/__fixtures__/modulesCjs/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"type": "commonjs"
}
15 changes: 15 additions & 0 deletions test/__fixtures__/modulesCjs/src/file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { argv, stdout } from 'node:process'
import { pathToFileURL } from 'node:url'
import { realpath } from 'node:fs/promises'

const detectCalledFromCli = async (path: string) => {
const realPath = await realpath(path)

if (__filename === pathToFileURL(realPath).href) {
stdout.write('invoked as cli')
}
}

detectCalledFromCli(argv[1])

require.resolve(`${__dirname}/other.js`)
1 change: 1 addition & 0 deletions test/__fixtures__/modulesCjs/src/other.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
exports.other = true
11 changes: 11 additions & 0 deletions test/__fixtures__/modulesCjs/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"outDir": "dist",
"strict": true,
},
"include": ["src"],
}
3 changes: 3 additions & 0 deletions test/__fixtures__/modulesEsm/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"type": "module"
}
15 changes: 15 additions & 0 deletions test/__fixtures__/modulesEsm/src/file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { argv, stdout } from 'node:process'
import { pathToFileURL } from 'node:url'
import { realpath } from 'node:fs/promises'

const detectCalledFromCli = async (path: string) => {
const realPath = await realpath(path)

if (import.meta.url === pathToFileURL(realPath).href) {
stdout.write('invoked as cli')
}
}

detectCalledFromCli(argv[1])

import.meta.resolve(`${import.meta.dirname}/other.js`)
1 change: 1 addition & 0 deletions test/__fixtures__/modulesEsm/src/other.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const other = true
11 changes: 11 additions & 0 deletions test/__fixtures__/modulesEsm/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"outDir": "dist",
"strict": true,
},
"include": ["src"],
}
Loading
Loading