dnt — the easiest way to publish a hybrid npm module for ESM and CommonJS
Though browsers and JavaScript have come a long way, writing and publishing JavaScript modules is still painful. To maximize adoption, your module should support CommonJS and ESM, JavaScript with TypeScript declarations, and work in Deno, Node.js, and web browsers. To achieve that, many resort to complex release pipelines or maintaining two copies of code with slightly different module syntax.
What if you could write your module once with modern tooling like TypeScript and transform it to support all use cases?
dnt
— Deno to Node transform
dnt
is a build tool that transforms Deno
modules into Node.js/npm-compatible packages. Not only that, the transformed
package:
- supports both CommonJS and ESM,
- can work in Node.js, Deno, browers,
- runs tests in both CommonJS and ESM,
- supports TypeScript and JavaScript
How does it work? At a high level:
- Transforms Deno code to Node.js compatible TypeScript code
- Rewrites Deno’s extensioned module specifiers to ones compatible with Node.js module resolution.
- Injects shims for any Deno namespaces APIs detected, as well as other globals which can be configured.
- Rewrites remote imports from Skypack or esm.sh as bare specifier imports and adds them to the package.json as dependencies.
- Other remote imports are downloaded and included in the package.
- Type checks the transformed TypeScript code with tsc.
- Writes out the package as a set of ESM, CommonJS and TypeScript type declaration files along with a package.json.
- Runs the final output in Node.js through a test runner which supports the
Deno.test()
API.
You can develop and test all your code in Deno and TypeScript. When it’s time to
publish, you can use dnt
to export it to Node.js/npm-compatible format.
Let’s run through an example with my module, is-42
. (You can also
view the final source code here.)
Write, transform, publish
We’ve created a simple and totally real module that tests whether a variable is
the number 42. The main logic will be in mod.ts
:
// mod.ts
export function is42(num: number): boolean {
return num === 42;
}
We’ll write some tests in mod_test.ts
:
// mod_test.ts
import { assertEquals } from "https://deno.land/[email protected]/testing/asserts.ts";
import { is42 } from "./mod.ts";
Deno.test("42 should return true", () => {
assertEquals(true, is42(42));
});
Deno.test("1 should return false", () => {
assertEquals(false, is42(1));
});
We can run the tests without any additional configuration with
deno test
:
$ deno test
Check file:///Users/andyjiang/Developer/deno/is-42/mod_test.ts
running 2 tests from ./mod_test.ts
42 should return true ... ok (13ms)
1 should return false ... ok (7ms)
ok | 2 passed | 0 failed (142ms)
Finally, let’s also add a LICENSE
and README.md
file in the directory root,
because it’s a real module:
That’s it!
Let’s transform this to an npm package by creating the build script,
build_npm.ts
:
import { build, emptyDir } from "https://deno.land/x/[email protected]/mod.ts";
await emptyDir("./npm");
await build({
entryPoints: ["./mod.ts"],
outDir: "./npm",
shims: {
deno: true,
},
package: {
name: "is-42",
version: Deno.args[0],
description:
"Boolean function that returns whether or not parameter is the number 42",
license: "MIT",
repository: {
type: "git",
url: "git+https://github.com/lambtron/is-42.git",
},
bugs: {
url: "https://github.com/lambtron/is-42/issues",
},
},
postBuild() {
Deno.copyFileSync("LICENSE", "npm/LICENSE");
Deno.copyFileSync("README.md", "npm/README.md");
},
});
This script creates a new npm
folder as the output directory, where it outputs
an entire npm package from your module.
In the build()
options, we set the
entry file, output directory, shims, and all the context needed to build out a
package.json
file.
In the postBuild()
function, we include filesystem operations to copy our
LICENSE
and README.md
files accordingly.
Let’s run build_npm.ts
script with the version as a parameter:
$ deno run -A build_npm.ts 0.0.1
[dnt] Transforming...
[dnt] Running npm install...
added 6 packages, and audited 7 packages in 2s
found 0 vulnerabilities
[dnt] Building project...
[dnt] Type checking ESM...
[dnt] Emitting ESM package...
[dnt] Emitting script package...
[dnt] Running post build action...
[dnt] Running tests...
> test
> node test_runner.js
Running tests in ./script/mod_test.js...
test 42 should return true ... ok
test 1 should return false ... ok
Running tests in ./esm/mod_test.js...
test 42 should return true ... ok
test 1 should return false ... ok
[dnt] Complete!
If you’re following along, your directory should have a new subdirectory npm
,
in it contains your transformed npm package (which supports CJS and ESM) as well
as tests in both formats.
Not only are tests generated for CJS and ESM, they’re also run using both Deno and Node, so you can be confident your code runs in both runtimes.
Now, publishing your CommonJS/ESM compatible npm package is as simple as:
$ npm publish /npm
Check out the published package on npm.
With dnt
transforming your module to support CommonJS and ES Modules,
maintaining your module is easier, as your code base is smaller.
Automate with GitHub Actions
To make it easier to publish everytime we tag a release, we can use
GitHub Actions with dnt
. Note that the
below is an extremely simplified version, but should get you started in the
right direction.
Create a .github/workflows/action.yml
directory and file, which will perform
the following steps anytime a new release is tagged and published:
- check out the repo
- parse the release version
- setup Deno
- run the
build_npm.ts
script with release version number - setup Node and npm with an npm auth token
- publish with
npm publish npm/
name: Publish to registry
on:
release:
types: [published]
jobs:
publish_to_npm:
name: Publish to npm
runs-on: ubuntu-latest
steps:
- name: Checkout is-42
uses: actions/checkout@v3
- name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- name: Setup Deno
uses: denoland/setup-deno@v1
with:
deno-version: v1.x
- name: Build npm package
run: deno run -A build_npm.ts $RELEASE_VERSION
- name: Setup Node/npm
uses: actions/setup-node@v3
with:
node-version: 18
registry-url: "https://registry.npmjs.org"
scope: "@lambtron"
- name: Publish to npm
run: npm publish npm/ --access=public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }}
Note that you will need to
create a Classic Token (type Automation) from npmjs.com
and save that as a
GitHub Actions secret
as NPM_AUTH_TOKEN
.
Now, everytime you publish a new tagged release, it will trigger this action and publish your module to npm.
For more details on using GitHub Actions with dnt
,
check out the documentation.
What’s next?
Writing software should be productive, simple, and fun. It shouldn’t also include managing complex build pipelines or intricate code bases to support the widest user base.
And while we believe ESM is the future,
we recognize that many npm modules still use CommonJS. Module authors
unfortunately bear the brunt of needing to support both CommonJS and ESM. So we
like abstractions that make creating and publishing software simpler, such as
dnt
.