Creating an NPM Package in 2024 (Deno, dnt)
JLarky
Posted on September 23, 2023
Jokey Intro
So, you want to create an npm package? My advice? Don't. It's a lot of work, and the results are often not worth the effort. At the time of me writing this, npm has 2,532,666 packages. By the time you finish this article, it's guaranteed there will be at least 10 more.
On the surface, it sounds simple: write some code in a file and push it to npm. But you can't just push the code; you also need a package.json
filled with all sorts of metadata. You can't merely push the source code either. If you're using TypeScript (and you should be), suddenly you have a build step, bundlers, minifiers, CommonJS, ES Modules — the list goes on.
I ain't reading all that. I'm happy for u tho. Or sorry that happened.
The Best Way to Author an NPM Package is Deno
Deno is an alternative JavaScript runtime that comes with built-in lint, TypeScript, and bundling capabilities. You don't need to be familiar with Deno to follow this tutorial.
For the first step, install Deno. If you're on a Mac or Linux, it's just one command:
curl -fsSL https://deno.land/x/install/install.sh | sh
1. Create a New Project
Create a new folder for your project and run deno init
:
mkdir is-not-bun
cd is-not-bun
deno init
touch README.md LICENSE
Replace is-not-bun
with the name of your package. This tutorial is the text version of this Youtube tutorial and will address the same problem space of determining if a user is running an npm package using Bun (you don't need to know about Bun either).
2. Create your code
Create mod.ts
and write your code:
import { isBun } from "npm:is-bun";
export function isNotBun(): boolean {
return !isBun();
}
export function printIsNotBun() {
if (isNotBun()) {
console.log("No Bun, no problem");
} else {
console.log("Help, I'm trapped in a Bun factory");
}
}
Create cli.ts
and write your CLI:
import { printIsNotBun } from "./mod.ts";
printIsNotBun();
And run it with:
deno run cli.ts
You will see the output:
No Bun, no problem
Notice the difference, because we are using Deno we have to add npm:
prefix when importing npm packages, but the positive is that we don't need to run npm install
, because Deno will automatically download the package from NPM.
3. DNT build script
We are going to use dnt - Deno to Node Transform tool to build the npm package.
I recommend you to checkout other examples of DNT configs:
Create a file _build_npm.ts
and add the following code:
#!/usr/bin/env -S deno run --allow-read --allow-write --allow-net --allow-env --allow-run
// Copyright 2018-2022 the oak authors. All rights reserved. MIT license.
/**
* This is the build script for building npm package.
*
* @module
*/
import { build, emptyDir } from "https://deno.land/x/dnt@0.38.1/mod.ts";
async function start() {
await emptyDir("./npm");
await build({
entryPoints: [
// change me
"./mod.ts",
{
kind: "bin",
name: "is-not-bun",
path: "./cli.ts",
},
],
outDir: "./npm",
shims: {},
test: false,
typeCheck: "both",
compilerOptions: {
importHelpers: false,
sourceMap: true,
target: "ES2021",
lib: ["esnext", "dom", "dom.iterable"],
},
package: {
name: "is-not-bun", // change me
version: Deno.args[0],
description: "Return true if you are running not in Bun.", // change me
license: "MIT",
keywords: ["bun"], // change me
engines: {
node: ">=8.0.0",
},
repository: {
type: "git",
url: "git+https://github.com/JLarky/is-not-bun.git", // change me
},
bugs: {
url: "https://github.com/JLarky/is-not-bun/issues", // change me
},
dependencies: {
"is-bun": "*", // change me
},
devDependencies: {},
},
});
await Deno.copyFile("LICENSE", "npm/LICENSE");
await Deno.copyFile("README.md", "npm/README.md");
}
start();
Make sure to replace is-not-bun
with the name of your project, change your description, keywords, repository, dependencies and entry points.
"./mod.ts"
is the name of the "main"
entry in your package, most of the time that's the only entry point you need. But if you are building a CLI tool (for example tsc
, ts-node
), that's when kind: "bin",
comes into play. If you are not planning to build a CLI tool, you can remove the second entry point (the whole object).
4. Build your package
First, let's add npm
to .gitignore
:
echo "npm" >> .gitignore
And make build script executable:
chmod +x _build_npm.ts
Now we can build the version 0.0.1
of our package with the build script:
./_build_npm.ts 0.0.1
You can check that the build was successful by running:
(cd npm/; node -e "console.log(require('.'))")
The expected output:
{
isNotBun: [Function: isNotBun],
printIsNotBun: [Function: printIsNotBun]
}
5. Publish your package
We are at the final step, we can publish our package to NPM:
(cd npm/; npm publish)
Keep in mind that if you never published to npm before you will have to create an account and login first:
npm login
That's it
Let's celebrate a bit what we have accomplished:
- We were able to author our package in TypeScript and because we are using Deno we can run
deno run cli.ts
without separate build step or evennpm install
and get nice developer experience (even though we were using dependencies from NPM) - Using dnt we were able to create nice and modern package setup with three output formats: CommonJS (
npm/script/mod.js
), ES Modules (npm/esm/mod.js
) and TypeScript source (npm/src/mod.ts
) - That npm package is checking all the boxes, it generated
.d.ts
files for TypeScript users and even.js.map
with source maps for debugging - As a bonus we even got CLI that you can run with
npx is-not-bun
or as a script in your package.json"scripts": { "fun": "is-not-bun" }
that you can run withnpm run fun
- And we can use some nice built-in Deno tools as extra bonus, which I will cover in the next section
Extra bonus
This part is not technically required, remember when I said that you don't have to actually care about Deno? Well, this is where the sales pitch comes in.
First things first, we have a few files that were created during deno init
, I'm going to guide you through them.
deno.json
and main.ts
Remember how I said that DX for development of packages is great in Deno? It's actually even better than that. deno run
supports --watch
flag that will re-run your code every time you save a file, no need for nodemon
and similar tools.
Similar to package.json
Deno has deno.json
and at the moment it only has one task defined there:
deno task dev
It will run main.ts
with --watch
flag, so you can keep your development code in that file.
So in the GH repo here's the content of main.ts
:
import { isNotBun } from "./mod.ts";
// Learn more at https://deno.land/manual/examples/module_metadata#concepts
if (import.meta.main) {
console.log("In Deno isNotBun is", isNotBun());
}
main_test.ts
Deno has built-in test runner, so you don't need to install vitest
or jest
. To run tests you just do:
deno test
In GH repo this file looks like this:
import { assertEquals } from "https://deno.land/std@0.195.0/testing/asserts.ts";
import { isNotBun } from "./mod.ts";
Deno.test(function bunTest() {
assertEquals(isNotBun(), true);
});
main_bench.ts
It looks like this file is no longer there with the new version of deno init
, but I still have it for old time sake. To run simple benchmarks you can do:
deno bench
In GH repo this file looks like this:
import { isNotBun } from "./mod.ts";
Deno.bench(function benchIsNotBun() {
isNotBun();
});
Conclusion
Creating npm packages can be challenging. We've seen many tools in this domain, and I encourage you to try dnt. With dnt, I was finally able to pass the package.json linter, which is no small achievement (try jotai
, redux
, vite
).
This tutorial hasn't covered many dnt features. For more information, please check out this tweet for further reading and some replies from happy users.
Posted on September 23, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.