Node package code starter setup

Want to write an open-source library targeting Node, but don't know where to start? Just curious about a fellow open-source library author's default setup for Node packages? Have 5 minutes of your time? Look no further, I got you covered!

In this post, I will share with you a "basic" (there is unfortunately no such thing in the JavaScript ecosystem ... yet) setup I have used to build open-source Node packages.

Always bet on ... TypeScript

This is not going to be a piece trying to convince you that you should use TypeScript, and that vanilla JavaScript is bad. For all we know, both TypeScript and JavaScript have their use cases and are both valid choices depending on the constraints of a project.

For libraries though, I would more often than not default to using TypeScript. It adds a useful layer of static analysis with its type checker, and it automatically generates type files which can be useful for consumers of your library.

ES modules where a great addition to modern JavaScript, but until fairly recently they were not natively supported in Node, meaning that most libraries defaulted to CommonJS to support both use cases, to the detriment of browsers now natively supporting ES modules. To circumvent that dichotomy, we can use a build pipeline centered around Rollup, which would generate both an ES module package and a CommonJS module. Then, we can point consumers to the right type of package via the corresponding fields in package.json.

All in all, the setup looks like the following:
package.json

{
  "name": "...",
  "version": "1.0.0",
  "description": "...",
  "main": "dist/index.cjs.js",
  "module": "dist/index.esm.js",
  "types": "dist/index.d.ts",
  "files": [
    "dist/index.cjs.js",
    "dist/index.esm.js",
    "dist/index.d.ts"
  ],
  "scripts": {
  	...
    "type:check": "tsc --noEmit",
    ...
	"prebuild": "rimraf dist && mkdir dist",
    "build": "npm run build:types && npm run build:lib",
    "build:types": "tsc --declaration --emitDeclarationOnly --outDir dist",
    "build:lib": "rollup -c",
  	...
  },
  ...
  "devDependencies": {
    ...
	"@rollup/plugin-typescript": "^8.2.1",
	...
    "rimraf": "^3.0.2",
    "rollup": "^2.52.1",
    "rollup-plugin-terser": "^7.0.2",
    "tslib": "^2.3.0",
    "typescript": "^4.3.4"
  }
}

tsconfig.json

{
  "compilerOptions": {
    "target": "es5",
    "module": "esnext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}

rollup.config.js

import typescript from "@rollup/plugin-typescript";
import { terser } from "rollup-plugin-terser";

export default [
  {
    input: "src/index.ts",
    output: {
      file: "dist/index.cjs.js",
      format: "cjs",
      exports: "default", // Remove this line if using named exports
    },
    plugins: [
      typescript(),
      terser({
        format: {
          comments: false,
        },
      }),
    ],
  },
  {
    input: "src/index.ts",
    output: {
      file: "dist/index.esm.js",
      format: "es",
    },
    plugins: [
      typescript(),
      terser({
        format: {
          comments: false,
        },
      }),
    ],
  },
];

No tests, no glory

Another primordial aspect of open-source code is testing.

In our case, we will focus on a setup centered around Jest. As we are writing our source code in TypeScript, we also need Babel to help transpile the code. One of the advantages of Jest is that it bundles a lot of tools around automated testing into one: namely, a test runner, an assertion library and code instrumentation for code coverage.

For good measure, as we are going to be writing our tests in JavaScript, let's throw in ESLint into the mix!

package.json

{
  "name": "...",
  "version": "1.0.0",
  "description": "...",
  ...
  "scripts": {
    ...
	"test": "jest",
	...
  },
  ...
  "devDependencies": {
    "@babel/core": "^7.14.6",
    "@babel/preset-env": "^7.14.5",
    "@babel/preset-typescript": "^7.14.5",
    ...
    "@types/jest": "^26.0.23",
    "babel-jest": "^27.0.2",
    "eslint": "^7.29.0",
    "eslint-config-prettier": "^8.3.0",
    "eslint-plugin-jest": "^24.3.6",
    ...
    "jest": "^27.0.4",
	...
  }
}

jest.config.js

module.exports = {
  collectCoverage: true,
  coverageDirectory: "coverage",
  coverageProvider: "v8",
  coverageThreshold: {
    global: {
      branches: 100,
      functions: 100,
      lines: 100,
      statements: 100,
    },
  },
  testEnvironment: "node",
};

babel.config.js

module.exports = {
  presets: [
    ["@babel/preset-env", { targets: { node: "current" } }],
    "@babel/preset-typescript",
  ],
};

.eslintrc.js

module.exports = {
  env: {
    es2021: true,
    node: true,
    "jest/globals": true,
  },
  extends: ["eslint:recommended", "prettier"],
  parserOptions: {
    ecmaVersion: 12,
    sourceType: "module",
  },
  plugins: ["jest"],
  rules: {
    "no-console": "error",
  },
};

Automate, automate, automate

Finally, because we want to be doing the less amount of repetitive work possible, let's look at automating a few aspects of writing and maintaining open-source libraries.

First of, let's get rid of any formatting shenanigans by bringing Prettier on board. This will also help potential contributors, as their submissions will already be formatted according to the configuration of your library.

Next, we would like to ensure that code passes a certain bar of quality before being committed. To do that, we can leverage husky's pre-commit hooks, coupled with lint-staged to only affect staged changes.

package.json

{
  "name": "...",
  "version": "1.0.0",
  "description": "...",
  ...
  "scripts": {
    "prepare": "husky install",
    "type:check": "tsc --noEmit",
    "format": "prettier --write --ignore-unknown {src,test}/*",
	...
	"pre-commit": "lint-staged",
    ...
  },
  "devDependencies": {
	...
	"husky": "^6.0.0",
    ...
	"lint-staged": "^11.0.0",
    "prettier": "^2.3.1",
	...
  }
}

.husky/pre-commit

#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

npm run test
npm run pre-commit

.lintstagedrc.js

module.exports = {
  "*.ts": ["tsc --noEmit", "prettier --write"],
  "*.js": ["prettier --write", "eslint --fix"],
};

With this setup, tests, static analysis (type checking, linting) and formatting will always be run on changes before they are committed and ready to be pushed.

Finally, we want to also automate building and publishing our package to npm (or any other relevant repositories). To achieve that, if you are hosting your code on GitHub, you can leverage GitHub Actions.

The script below runs tests, builds the code and publishes a package to npm every time a new release is created on the repository. Note that for this script to work, you will need to add a secret named NPM_TOKEN with an "Automation" token generated from your npm account.

.github/workflows/publish.yml

name: publish

on:
  release:
    types: [created]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v2
        with:
          node-version: 14
      - run: npm ci
      - run: npm test

  publish-npm:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v2
        with:
          node-version: 14
          registry-url: https://registry.npmjs.org/
      - run: npm ci
      - run: npm run build
      - run: npm publish --access=public
        env:
          NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}

There are, of course, many avenues of improvement for this setup, but I would argue it provides a good basis when writing a Node package.

Antonio Villagra De La Cruz

Antonio Villagra De La Cruz

Multicultural software engineer passionate about building products that empower people.