tsconfig-to-dual-package v1.2.0
tsconfig-to-dual-package
A Node.js dual package tool for TypeScript.
You can support CommonJS and ESModules in one package via this tool.
This tool add package.json which is { "type": "module" } or { "type": "commonjs" } based on tsconfig's module and outDir option.
You can use this tool with tsc command.
$ tsc -p . && tsc -p ./tsconfig.cjs.json && tsconfig-to-dual-package # add "{ourDir}/package.json"Install
Install with npm:
npm install tsconfig-to-dual-package --save-devRequirements: This tool depended on typescript package for parsing tsconfig.json file.
It means that You need to install typescript as devDependencies in your project.
- PeerDependency:
typescript:*(any version)
- Node.js v16.17.0+
Usage
Usage
$ tsconfig-to-dual-package [Option] <tsconfig.json>
Options
--cwd [String] current working directory. Default: process.cwd()
--debug [Boolean] Enable debug output
--help [Boolean] show help
Examples
# Find tsconfig*.json in cwd and convert to dual package
$ tsconfig-to-dual-package
# Convert specified tsconfig.json to dual package
$ tsconfig-to-dual-package ./config/tsconfig.esm.json ./config/tsconfig.cjs.jsonHow it works
This tool adds package.json to tsconfig's outDir for dual package.
Each generated package.json has type field that is commonjs or module.
You can see example repository in following:
For example, This project package.json is following:
{
"name": "my-package",
"version": "1.0.0",
"type": "module",
"main": "./lib/index.js",
"types": "./lib/index.d.ts",
"module": "./module/index.js",
// Note: Normally same .js extension can not be used as dual package
// but this tool add custom `package.json` to each outDir(=lib/, module/) and resolve it.
"exports": {
".": {
"import": {
"types": "./module/index.d.ts",
"default": "./module/index.js"
},
"require": {
"types": "./lib/index.d.ts",
"default": "./lib/index.js"
},
"default": "./lib/index.js"
}
}
}And, This project has tsconfig.json and tsconfig.cjs.json:
tsconfig.json: for ES Module
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"newLine": "LF",
"outDir": "./module/", // <= Output ESM to `module` directory
"target": "ES2018",
"strict": true,
},
"include": [
"**/*"
]
}tsconfig.cjs.json: for CommonJS
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "Node",
"outDir": "./cjs/" // <= Output CommonJS to `cjs` directory
},
"include": [
"**/*"
]
}Then, You can run tsconfig-to-dual-package after you compile both CommonJS and ES Module with following command:
{
"scripts": {
"build": "tsc -p ./tsconfig.json && tsc -p ./tsconfig.cjs.json && tsconfig-to-dual-package",
}
}tsconfig-to-dual-package command adds package.json to module and cjs directory.
As a result, you can publish both CommonJS and ESModule in a single package. It is called dual package.
- package.json // { "type": "module" }
- index.ts // Node.js treat this as ESModule
- tsconfig.json // output to `module` directory
- tsconfig.cjs.json // output to `cjs` directory
- cjs/
- package.json // { "type": "commonjs" }
- index.js // Node.js treat it as CommonJS module
- module/
- package.json // { "type": "module" }
- index.js // Node.js treat it as ESModuleFor more details, please see Dual CommonJS/ES module packages in Node.js official document.
- Example: tsconfig-to-dual-package-example
Limitation
This tool copy almost fields from package.json to generated {outDir}/package.json.
However, it does not copy main, module, exports, types fields because it points invalid file path.
It defined in OMIT_FIELDS constant.
Used by
- eventmit
- Work on CJS: https://github.com/azu/events-to-async/pull/4
- Work on ESM: https://github.com/azu/eventmit-module-env
- Work on Deno: https://github.com/azu/eventmit-deno-env
- Work on Browser: https://codesandbox.io/s/determined-poitras-yll61f?file=/index.html
- safe-marked
Motivation
- TypeScript disallow to change file extension of generated files from
.tsby Design - Node.js require separate
.mjsand.cjsif you need to get dual package in one package
As a result, TypeScript and Node.js ESM support is conflicting.
It is hard that you can support dual package with same .js extension.
Of course, you can use tsc-multi or Packemon to support dual packages.
However, These are build tools. I want to use TypeScript compiler(tsc) directly.
tsconfig-to-dual-package do not touch TypeScript compiler(tsc) process.
It just put package.json({ "type": "module" } or "{ "type": "commonjs" }) to outDir for each tsconfig.json after tsc compile source codes.
@aduh95 describe the mechanism in https://github.com/nodejs/node/issues/34515#issuecomment-664209714
For reference, the
library-package/package.jsoncontains:{ "name": "library-package", "version": "1.0.0", "main": "./index-cjs.js", "exports": { "import": "./index-esm.js", "require": "./index-cjs.js" }, "type": "module" }Setting
"type": "module"makes Node.js interpret all.jsfiles as ESM, includingindex-cjs.js. When you remove it, all.jsfiles will be interpreted as CJS, includingindex-esm.js. If you want to support both with.jsextension, you should create two subfolders:$ mkdir ./cjs ./esm $ echo '{"type":"commonjs"}' > cjs/package.json $ echo '{"type":"module"}' > esm/package.json $ git mv index-cjs.js cjs/index.js $ git mv index-esm.js esm/index.jsAnd then have your package exports point to those subfolders:
{ "name": "library-package", "version": "1.0.0", "main": "./cjs/index.js", "exports": { "import": "./esm/index.js", "require": "./cjs/index.js" }, "type": "module" }
Also, Node.js documentation describe this behavior as follows
The nearest parent package.json is defined as the first package.json found when searching in the current folder, that folder's parent, and so on up until a node_modules folder or the volume root is reached.
// package.json { "type": "module" }# In same folder as preceding package.json node my-app.js # Runs as ES moduleIf the nearest parent package.json lacks a "type" field, or contains "type": "commonjs", .js files are treated as CommonJS. If the volume root is reached and no package.json is found, .js files are treated as CommonJS.
Pros and Cons
Pros
- You can use TypeScript compiler(
tsc) directly- No additional bundler, transpiler, build tool
Cons
- You need to run
tsconfig-to-dual-packageaftertsccompile - This tool copy
package.jsontooutDir. This approach may affect path finding forpackage.jsonlike read-pkg-up - Dual package hazard - I recommend that you should not use this approach for stateful package.
- For example, a singleton and
instanceofcheck for user-input may cause unexpected behavior. - This Dual package has a risk of loading double(
requireandimportload separate resources). - Very large package may want to prevent loading double package. For example, a large dictionary included package.
- For example, a singleton and
- Dual package is hard to use some API like
__diranme,__filenamewithout transpiler- Normally, you can use
import.meta.urlandnew URL(..., import.meta.url)to get__dirnameand__filenamein ESM. - On the other hands,
import.meta.urlis disallowed syntax in CJS import.metais not defined in CJS__diranameis not defined in ESM- As a result, it is hard to use
__dirnameand__filenamein dual package. - Some package get these via Error stack trace
- bevry/filedirname: Fetch the current file and directory path, no matter your environment (Deno, Node.js, Web Browsers, ESM, CJS)
- fwh1990/this-file: Create dynamic dirname, filename and require method for both ESM and CJS
- JumpLink/cross-dirname: Node.js + Gjs + Deno module that returns the current script dirname. Similar to __dirname but also works in CommonJs and ES modules.
- 🆘 If you know a solution about this problem, please send pull request!
- Normally, you can use
FAQ
What should I do support dual package?
- Example repository: tsconfig-to-dual-package-example
- Pull Request: feat: support dual package by azu · Pull Request #2 · azu/tsconfig-to-dual-package-example
- Steps:
- Install
tsconfig-to-dual-package:npm install --save-dev tsconfig-to-dual-package - Add
"type": "module"to package.json vianpm pkg set type=module - Add
tsconfig.jsonandtsconfig.cjs.json - Create
tsconfig.jsonand set it to usemodule: "esnext" - Create
tsconfig.cjs.jsonand set it to usemodule: "commonjs" - Add
tsconfig-to-dual-packageto build script"build": "tsc -p ./tsconfig.json && tsc -p ./tsconfig.cjs.json && tsconfig-to-dual-package"
- Add
"main"/"types"(for backward compatibility)/"files"/"exports"fields topackage.json"files": ["lib/", "module/"](lib/ = cjs, module/ = esm)"main"/"types"/"exports"
{ "main": "./lib/index.js", "types": "./lib/index.d.ts", "exports": { ".": { "import": { "types": "./module/index.d.ts", "default": "./module/index.js" }, "require": { "types": "./lib/index.d.ts", "default": "./lib/index.js" }, "default": "./module/index.js" }, "./package.json": "./package.json" } } - Check Check Check
- Lint
npx publintis helpful- dependency-check@5 is useful
- Test
- use
ts-node/esminstead ofts-nodefor testing - https://github.com/TypeStrong/ts-node#node-flags-and-other-tools
- use
- Lint
- Publish!
npm publish
- After Check!
- publint
- Load test via require/import
- Install
Is there a migration script?
It is not for everyone, but I wrote a migration script for TypeScript project.
- Migration Script: Convert TypeScript project to Node.js dual package
- This script make almost migration automatic
- Use
npm pkgcommand for changepackage.json - Use tsconfig-to-dual-package to build dual package
- Use eslint-cjs-to-esm to migrate source code to ESM from CJS
- Use publint to check
package.json
Example Result:
Should I migrate to dual package?
- If your package is a library, you should migrate to dual package if possible
- Because dual package reduce interop issues between CJS and ESM
- If your package is just logics, you can move to dual package
- If your package is a Command Line Tool(CLI), you not need to migrate to dual package
- Because CLI is not loaded from
requirefunction - You can move to Pure ESM package
- Because CLI is not loaded from
References
- Dual CommonJS/ES module packages
- Improve documentation on Dual Module Packages · Issue #34515 · nodejs/node
- TypeScript: Documentation - ECMAScript Modules in Node.js
- Why
typesfields at first - Issue: package.json
exportsresolution uses fallback conditions, unlike Node · Issue #50762 · microsoft/TypeScript
- Why
- why is there a package.json export for
package.json? · Issue #1 · tsmodule/tsmodule- Why add
"./package.json": "./package.json" - Special treatment for package.json resolution and exports? · Issue #33460 · nodejs/node
- Why add
- frehner/modern-guide-to-packaging-js-library: A guide to help ensure your JavaScript library is the most compatible, fast, and efficient library you can make.
Related
- publint: Lint your
exportsfield inpackage.json - eslint-cjs-to-esm: help you to migrate CJS to ESM
- isaacs/rimraf: A
rm -rfutil for nodejs: use same approach
Changelog
See Releases page.
Running tests
Install devDependencies and Run npm test:
npm testContributing
Pull requests and stars are always welcome.
For bugs and feature requests, please create an issue.
- Fork it!
- Create your feature branch:
git checkout -b my-new-feature - Commit your changes:
git commit -am 'Add some feature' - Push to the branch:
git push origin my-new-feature - Submit a pull request :D
Author
License
MIT © azu