tutorial // Dec 03, 2021

How to Write, Test, and Publish an NPM Package

How to build your own package, write tests, run the package locally, and release it to NPM.

How to Write, Test, and Publish an NPM Package

Getting Started

For this tutorial, you will want to make sure you have Node.js installed (the latest LTS version is recommended—as of writing, 16.13.1) on your computer. If you haven't installed Node.js before, give this tutorial a read first.

Setting up a project

To get started, we're going to set up a new folder for our package on our computer.

Terminal

mkdir package-name

Next, we want to cd into that folder and create a package.json file:

Terminal

cd package-name && npm init -f

Here, npm init -f tells NPM (Node Package Manager, the tool we'll be using to publish our package) to initialize a new project, creating a package.json file in the directory where the command was run. The -f stands for "force" and tells NPM to spit out a template package.json file. If you exclude the -f, NPM will help you create the package.json file using their step-by-step wizard.

Once you have a package.json file, next, we want to make a slight modification to the file. If you open it up, we want to add a special field type to the object set to a value of "module" as a string, like this:

{
  "type": "module",
  "name": "@cheatcodetuts/calculator",
  "version": "0.0.0",
  "description": "",
  "main": "./dist/index.js",
  "scripts": { ... },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": { ... }
}

At the very top of the JSON object, we've added "type": "module". When our code is run, this tells Node.js that we expect the file to use ES Module (ECMAScript Module or ESM for short) syntax as opposed to the Common JS syntax. ESM uses the modern import and export syntax whereas CJS uses the require() statement and module.exports syntax. We prefer a modern approach, so by setting "type": "module", we enable support for using import and export in our code.

After this, next, we want to create two folders inside of our package folder: src and dist.

  • src will contain the "source" files for our package.
  • dist will contain the built (compiled and minified) files for our package (this is what other developers will be loading in their app when they install our package).

Inside of the src directory, we want to create an index.js file. This is where we will write the code for our package. Later, we'll look at how we take this file and build it, automatically outputting the built copy into dist.

/src/index.js

export default {
  add: (n1, n2) => {
    if (isNaN(n1) || isNaN(n2)) {
      throw new Error('[calculator.add] Passed arguments must be a number (integer or float).');
    }

    return n1 + n2;
  },
  subtract: (n1, n2) => {
    if (isNaN(n1) || isNaN(n2)) {
      throw new Error('[calculator.subtract] Passed arguments must be a number (integer or float).');
    }

    return n1 - n2;
  },
  multiply: (n1, n2) => {
    if (isNaN(n1) || isNaN(n2)) {
      throw new Error('[calculator.multiply] Passed arguments must be a number (integer or float).');
    }

    return n1 * n2;
  },
  divide: (n1, n2) => {
    if (isNaN(n1) || isNaN(n2)) {
      throw new Error('[calculator.divide] Passed arguments must be a number (integer or float).');
    }

    return n1 / n2;
  },
};

For our package, we're going to create a simple calculator with four functions: add, subtract, multiply, and divide with each accepting two numbers to perform their respective mathematic function on.

The functions here aren't terribly important (hopefully their functionality is clear). What we really want to pay attention to is the export default at the top and the throw new Error() lines inside of each function.

Notice that instead of defining each of our functions individually, we've defined them on a single object that's being exported from our /src/index.js file. The goal here being to have our package imported in an app like this:

import calculator from 'package-name';

calculator.add(1, 3);

Here, the object being exported is calculator and each function (in JavaScript, functions defined on an object are referred to as "methods") is accessed via that object like we see above. Note: this is how we want our example package to behave but your package may behave differently—this is all for example.

Focusing on the throw new Error() statements, notice that these are all nearly identical. The goal here is to say "if the n1 argument or the n2 arguments are not passed as numbers (integers or floats), throw an error."

Why are we doing this? Well, consider what we're doing: we're building a package for others to use. This is different from how we might write our own code where inputs are predictable or controlled. When developing a package, we need to remain aware of the potential misuse of that package. We can account for this in two ways: writing really good documentation, but also, by making our code fault tolerant and instructive.

Here, because our package is a calculator, we can help the user to use the package correctly by having a strict requirement that they pass us numbers to perform math on. If they don't, we give a hint as to what they got wrong and how to fix the problem at the code level. This is important for package adoption. The more developer-friendly your code is, the more likely it is that your package will be used by others.

Further pushing this point, next, we're going to learn how to write some tests for our package and learn how to run them.

Writing tests for your package code

We want to have as much confidence as possible in our code before we make it available to other developers. While we can just blindly trust what we've written as functional, this isn't wise. Instead, before we release our package, we can write automated tests that simulate a user properly (or improperly) using our package and make sure that our code responds how we'd expect.

To write our tests, we're going to use the Jest library from Facebook. Jest is a unique tool in that it combines:

  • Functionality for authoring test suites and individual tests.
  • Functionality for performing assertions within tests.
  • Functionality for running tests.
  • Functionality for reporting the results of tests.

Traditionally, these tools are made available to us through multiple, independent packages. Jest makes getting a testing environment setup effortless by combining them all together. To add Jest to our own package, we need to install its packages via NPM (meta!):

Terminal

npm install -D jest jest-cli

Here, we're saying to install jest and its jest-cli package (the latter being the command-line interface that we use to run tests) as development-only dependencies (by passing the -D flag to npm install). This means that we only intend to use Jest in development and do not want it added as a dependency that will be installed alongside of our own package in our user's code.

/package.json

{
  "type": "module",
  "name": "@cheatcodetuts/calculator",
  "version": "0.0.0",
  "description": "",
  "scripts": {
    "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
    "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "jest": "^27.4.3",
    "jest-cli": "^27.4.3",
  }
}

Now to dig into the details. Here, in our package.json file, we want to add two lines to our scripts object. These scripts are known as "NPM scripts" which are, like the name implies, reusable command line scripts that we can run using NPM's npm run function in the terminal.

Here, we're adding test and test:watch. The first script will be used to run our tests one time and generate a report while test:watch will run our tests once and then again whenever a test file (or related code) changes. The former being useful for a quick check of things before deployment and the latter being useful for running tests during development.

Looking close at the test script node --experimental-vm-modules node_modules/jest/bin/jest.js we're running this in a strange way. Typically, we could write our script as nothing more than jest (literally, "test": "jest") and it would work, however, because we'd like to write our tests using ES Modules (as opposed to Common JS), we need to enable this in Jest, just like we did here in our package.json for our package code.

To do that, we need to run Jest directly via Node.js so that we can pass the --experimental-vm-modules flag to Node.js (required by Jest as the APIs they use to implement ESM support still consider it an experimental feature).

axzVaBsOO4ocr1rH/6Cta3qZQ0lVWkr8B.0

Because we're using Node to execute Jest (and not the jest-cli's jest command directly), we also need to point to the binary version of Jest directly (this is technically what jest-cli points to for us via jest but because of the flag requirement, we have to go direct).

The test:watch command is nearly identical. The only difference is that on the end, we need to add the --watch flag which tells Jest to keep running and watching for changes after its initial run.

/src/index.test.js

import calculator from './index';

describe('index.js', () => {
  test('calculator.add adds two numbers together', () => {
    const result = calculator.add(19, 88);
    expect(result).toEqual(107);
  });
});

When it comes to writing our tests, Jest will automatically run any tests located within a *.test.js file where * can be any name we wish. Above, we're naming our test file to match the file where our package code lives: index.test.js. The idea here being that we want to keep our test code next to the real code it's designed to test.

That may sound confusing, but consider what we're doing: we're trying to simulate a real-world user calling our code from their application. This is what tests are in programming. The tests themselves are just the means that we use to automate the process (e.g., as opposed to having a spreadsheet of manual steps that we'd follow and perform by hand).

Above, our test file consists of two main parts: a suite and one or more tests. In testing, a "suite" represents a group of related tests. Here, we're defining a single suite to describe our index.js file using the describe() function in Jest. That function takes two arguments: the name of the suite as a string (we're just using the name of the file we're testing) and a function to call within which our tests are defined.

Keep in mind: Jest automatically provides the describe() and test() functions globally when running our tests so we do not need to import these from Jest directly.

A test follows a similar setup. It takes a description of the test as a string for its first argument and then a function that's called to run the test.

Focusing on the test() function we have here, as an example, we've added a test that ensures our calculator.add() method works as intended and adds two numbers together to produce the correct sum. To write the actual test (known in testing lingo as "execution"), we call our calculator.add() function passing two numbers and storing the sum in the variable result. Next, we verify that the function returned the value we expect.

Here, we expect result to equal 107 which is the sum we'd expect to get if our function is behaving properly. In Jest (and any testing library), we can add multiple assertions to a test if we wish. Again, just like the actual code in our package, the what/when/how/why of this will change based on your code's intent.

Let's add another test to verify the bad or unhappy path for our calculator.add() function:

/src/index.test.js

import calculator from './index';

describe('index.js', () => {
  test('calculator.add throws an error when passed arguments are not numbers', () => {
    expect(() => {
      calculator.add('a', 'b');
    }).toThrow('[calculator.add] Passed arguments must be a number (integer or float).');
  });

  test('calculator.add adds two numbers together', () => {
    const result = calculator.add(19, 88);
    expect(result).toEqual(107);
  });
});

Slightly different here. Recall that earlier in our package code, we added a check to make sure that the values passed to each of our calculator functions were passed numbers as arguments (throwing an error if not). Here, we want to test that an error is actually thrown when a user passes the incorrect data.

This is important! Again, when we're writing code that others will consume in their own project, we want to be as close to certain as possible that our code will do what we expect (and what we tell other developers we expect) it to do.

Here, because we want to verify that our calculator function throws an error, we pass a function to our expect() and call our function from within that function, passing it bad arguments. Like the test says, we expect calculator.add() to throw an error if the arguments passed to it are not numbers. Here, because we're passing two strings, we expect the function to throw which the function passed to expect() will "catch" and use to evaluate whether the assertion is true using the .toThrow() assertion method.

That's the gist of writing our tests. Let's take a look at the full test file (identical conventions just being repeated for each individual calculator function).

/src/index.test.js

import calculator from './index';

describe('index.js', () => {
  test('calculator.add throws an error when passed argumen ts are not numbers', () => {
    expect(() => {
      calculator.add('a', 'b');
    }).toThrow('[calculator.add] Passed arguments must be a number (integer or float).');
  });

  test('calculator.subtract throws an error when passed arguments are not numbers', () => {
    expect(() => {
      calculator.subtract('a', 'b');
    }).toThrow('[calculator.subtract] Passed arguments must be a number (integer or float).');
  });

  test('calculator.multiply throws an error when passed arguments are not numbers', () => {
    expect(() => {
      calculator.multiply('a', 'b');
    }).toThrow('[calculator.multiply] Passed arguments must be a number (integer or float).');
  });

  test('calculator.divide throws an error when passed arguments are not numbers', () => {
    expect(() => {
      calculator.divide('a', 'b');
    }).toThrow('[calculator.divide] Passed arguments must be a number (integer or float).');
  });

  test('calculator.add adds two numbers together', () => {
    const result = calculator.add(19, 88);
    expect(result).toEqual(107);
  });

  test('calculator.subtract subtracts two numbers', () => {
    const result = calculator.subtract(128, 51);
    expect(result).toEqual(77);
  });

  test('calculator.multiply multiplies two numbers', () => {
    const result = calculator.multiply(15, 4);
    expect(result).toEqual(60);
  });

  test('calculator.divide divides two numbers', () => {
    const result = calculator.divide(20, 4);
    expect(result).toEqual(5);
  });
});

For each calculator function, we've repeated the same pattern: verify that an error is thrown if the arguments passed are not numbers and expect the function to return the correct result based on the intended method (add, subtract, multiply, or divide).

If we give this a run in Jest, we should see our tests run (and pass):

That's it for our tests and package code. Now we're ready to move into the final phases of preparing our package for release.

Building our code

While we could technically release this code now, we want to be mindful of two things: whether or not a developer's own project will support our package code, and, the size of the code.

Generally speaking, it's good to use a build tool for your code to help with these problems. For our package, we're going to use the esbuild package: a simple and incredibly fast build tool for JavaScript written in Go. To start, let's add it to our project as a dependency:

Terminal

npm install -D esbuild

Again, like we learned earlier with Jest, we're only going to need esbuild in development so we use the npm install -D command to install the package in our devDependencies.

/package.json

{
  "type": "module",
  "name": "@cheatcodetuts/calculator",
  "version": "0.0.0",
  "description": "",
  "main": "./dist/index.js",
  "scripts": {
    "build": "./node_modules/.bin/esbuild ./src/index.js --format=esm --bundle --outfile=./dist/index.js --platform=node --target=node16.3 --minify",
    "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
    "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "esbuild": "^0.14.1",
    "jest": "^27.4.3",
    "jest-cli": "^27.4.3",
    "semver": "^7.3.5"
  }
}

Similar to what we did for Jest above, back in our package.json file we want to add another script, this time called build. This script will be responsible for calling to esbuild to generate the built copy of our package code.

./node_modules/.bin/esbuild ./src/index.js --format=esm --bundle --outfile=./dist/index.js --platform=node --target=node16.3 --minify

To call to esbuild, again, similar to how we ran Jest, we start off our script with ./node_modules/.bin/esbuild. Here, the ./ at the beginning is a short-hand way to say "run the script at this path" and assumes that the file at that path contains a shell script (notice we're importing this from the .bin folder via node_modules with the esbuild script their being automatically installed as part of npm install -D esbuild).

When we call that function, as the first argument we pass the path to the file we want it to build, in this case: ./src/index.js. Next, we use some optional flags to tell esbuild how to perform the build and where to store it's output. We want to do the following:

  • Use the --format=esm flag to ensure that our code is built using the ESM syntax.
  • Use the --bundle flag to tell esbuild to bundle any external JavaScript into the output file (not necessary for us as we don't have any third-party dependencies in this package but good to know for your own).
  • Use the --outfile=./dist/index.js flag to store the final build in the dist folder we created earlier (using the same file name as we did for our package code).
  • Set the --platform=node flag to node so that esbuild knows how to properly treat any built-in Node.js dependencies.
  • Set the --target=16.3 flag to the Node.js version we want to target our build. This is the version of Node.js running on my machine while writing this tutorial but you can adjust as necessary based on the requirements of your own package.
  • Use the --minify flag to tell esbuild to minify the code it outputs.

That last one --minify will simplify our code down and compress it to the smallest possible version to ensure our package is as lightweight as possible.

That's all we need to do. Verify that your script is correct and then in your terminal (from the root of your package folder) run:

Terminal

npm run build

After a few milliseconds (esbuild is incredibly fast), you should see a message that the build is complete and if you look in the /dist folder, you should see a new index.js file containing the compiled, minified version of our package code (this will not be human-readable).

Real quick before we call this step "done," we need to update our package.json's main field to make sure that NPM points developers to the correct version of our code when they import it into their own projects:

/package.json

{
  "type": "module",
  "name": "@cheatcodetuts/calculator",
  "version": "0.0.0",
  "description": "",
  "main": "./dist/index.js",
  "scripts": {
    "build": "./node_modules/.bin/esbuild ./src/index.js --format=esm --bundle --outfile=./dist/index.js --platform=node --target=node16.3 --minify",
    "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
    "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "esbuild": "^0.14.1",
    "jest": "^27.4.3",
    "jest-cli": "^27.4.3",
    "semver": "^7.3.5"
  }
}

Here, the part we want to pay attention to is the "main": "./dist/index.js". This ensures that when our package is installed, the code that runs is the code located at the path specified here. We want this to be our built copy (via esbuild) and not our source code as, like we hinted at above, the built copy is both smaller and more likely to be supported by the developer's app.

Writing a release script

For the final stretch, now, we want to make our long-term work on our package a little easier. Technically speaking, we can release our package via NPM just using npm publish. While this works, it creates a problem: we don't have a way to test our package locally. Yes, we can test the code via our automated tests in Jest, but it's always good to verify that our package will work as intended when it's consumed in another developer's application (again: this process is all about increasing confidence our code works as intended).

Unfortunately, NPM itself does not offer a local testing option. While we can install a package locally on our machine via NPM, the process is a bit messy and adds confusion that can lead to bugs.

In the next section, we're going to learn about a tool called Verdaccio (vur-dah-chee-oh) that helps us to run a mock NPM server on our computer that we can "dummy publish" our package to (without releasing our code to the public).

In preparation for that, now, we're going to write a release script for our package. This release script will allow us to dynamically...

  1. Version our package, updating our package.json's version field.
  2. Release our package conditionally to our Verdaccio server, or, to NPM for public release.
  3. Avoid our public package's version number getting out of sync with our development version number.

To get started, #3 is a hint. We want to open our package.json file once again and add a new field: developmentVersion, setting it to 0.0.0.

/package.json

{
  "type": "module",
  "name": "@cheatcodetuts/calculator",
  "version": "0.0.0",
  "developmentVersion": "0.0.0",
  "description": "",
  "main": "./dist/index.js",
  "scripts": {
    "build": "./node_modules/.bin/esbuild ./src/index.js --format=esm --bundle --outfile=./dist/index.js --platform=node --target=node16.3 --minify",
    "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
    "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "esbuild": "^0.14.1",
    "jest": "^27.4.3",
    "jest-cli": "^27.4.3"
  }
}

Near the top of our file, just underneath the version field, we've added developmentVersion and set it to 0.0.0. It's important to note developmentVersion is a non-standard field in a package.json file. This field is just for us and is not recognized by NPM.

Our goal with this field—as we'll see next—is to have a version of our package that's independent from the production version. This is because whenever we release our package (locally or to production/public), NPM will attempt to version our package. As we're likely to have several development versions, we want to avoid jumping production versions from something like 0.1.0 to 0.50.0 where the 49 releases between the two are just us testing our development version of the package (and not reflective of actual changes to the core package).

To avoid that scenario, our release script will negotiate between these two versions based on the value of process.env.NODE_ENV and keep our versions tidy.

/release.js

import { execSync } from "child_process";
import semver from "semver";
import fs from 'fs';

const getPackageJSON = () => {
  const packageJSON = fs.readFileSync('./package.json', 'utf-8');
  return JSON.parse(packageJSON);
};

const setPackageJSONVersions = (originalVersion, version) => {
  packageJSON.version = originalVersion;
  packageJSON.developmentVersion = version;
  fs.writeFileSync('package.json', JSON.stringify(packageJSON, null, 2));
};

const packageJSON = getPackageJSON();
const originalVersion = `${packageJSON.version}`;
const version = semver.inc(
  process.env.NODE_ENV === 'development' ? packageJSON.developmentVersion : packageJSON.version,
  'minor'
);

const force = process.env.NODE_ENV === "development" ? "--force" : "";

const registry =
  process.env.NODE_ENV === "development"
    ? "--registry http://localhost:4873"
    : "";

try {
  execSync(
    `npm version ${version} --allow-same-version ${registry} && npm publish --access public ${force} ${registry}`
  );
} catch (exception) {
  setPackageJSONVersions(originalVersion, version);
}

if (process.env.NODE_ENV === 'development') {
  setPackageJSONVersions(originalVersion, version);
}

This is the entirety of our release script. Real quick, up top you will notice an additional dependency that we need to add semver:

Terminal

npm install -D semver

Focusing on the middle of our release script code, the first thing we need to do is get the current contents of our package.json file loaded into memory. To do this, near the top of our file, we've added a function getPackageJSON() which reads the contents of our file into memory as a string using fs.readFileSync() and then parses that string into a JSON object using JSON.parse().

Next, with our package.json file loaded in the variable packageJSON, we store or "copy" the originalVersion, making sure to store the value inside of a string using backticks (this will come into play when we dynamically set the version back in our package.json file later in the script).

After this, using the semver package we just installed, we want to increment the version for our package. Here, semver is short for semantic version which is a widely-accepted standard for writing software versions. The semver package we're using here helps us to generate semantic version numbers (like 0.1.0 or 1.3.9) and parse them for evaluation in our code.

Here, semver.inc() is designed to increment the semantic version we pass as the first argument, incrementing it based on the "rule" that we pass as the second argument. Here, we're saying "if process.env.NODE_ENV is development, we want to increment the developmentVersion from our package.json and if not, we want to increment the normal version field from our package.json."

For the second argument here, we're using the minor rule which tells semver to increment our version based on the middle number in our code. So that's clear, a semantic version has three numbers:

major.minor.patch

By default, we set both our developmentVersion and version to 0.0.0 and so the first time we run a release, we'd expect this number to be incremented to 0.1.0 and then 0.2.0 and so on.

With our new version stored in the version variable, next, we need to make two more decisions, both based on the value of process.env.NODE_ENV. The first is to decide if we want to force the publication of our package (this will force the version being published) and the second decides which registry we want to publish to (our Verdaccio server, or, to the main NPM registry). For the registry variable, we anticipate Verdaccio to be running at its default port on localhost, so we set the --registry flag to http://localhost:4873 where 4873 is the default Verdaccio port.

Because we'll embed these variables force and registry into a command below, if they're not required, we just return an empty string (which is akin to an empty value/no setting).

/release.js

try {
  execSync(
    `npm version ${version} --allow-same-version ${registry} && npm publish --access public ${force} ${registry}`
  );
} catch (exception) {
  setPackageJSONVersions(originalVersion, version);
}

if (process.env.NODE_ENV === 'development') {
  setPackageJSONVersions(originalVersion, version);
}

Now for the fun part. In order to create a release, we need to run two commands: npm version and npm publish. Here, npm version is responsible for updating the version of our package inside of package.json and npm publish performs the actual publication of the package.

For the npm version step, notice that we're passing the incremented version we generated using semver.inc() above as well as the registry variable we determined just before this line. This tells NPM to set the version to the one passed as version and to make sure to run this version against the appropriate registry.

Next, for the actual publish, we call to the npm publish command passing the --access flag as public along with our force and registry flags. Here, the --access public part ensures that packages using a scoped name are made accessible to the public (by default, these types of packages are made private).

A scoped package is one who's name looks something like @username/package-name where the @username part is the "scope." An unscoped package, by contrast, is just package-name.

To run this command, notice that we're using the execSync() function imported from the Node.js child_process package (this is built-in to Node.js and not something we need to install separately).

While this technically takes care of our release, there are two more lines to call out. First, notice that we've run our execSync() call in a try/catch block. This is because we need to anticipate any potential failures in the publication of our package. More specifically, we want to make sure we don't accidentally leave a new version that hasn't been published yet (due to the script failing) in our package.json file.

To help manage this, we've added a function up top called setPackageJSONVersions() which takes in the originalVersion and new version we created earlier in the script. We call this in the catch block of our code here to make sure versions are kept clean in the event of a failure.

/release.js

const setPackageJSONVersions = (originalVersion, version) => {
  packageJSON.version = originalVersion;
  packageJSON.developmentVersion = version;
  fs.writeFileSync('package.json', JSON.stringify(packageJSON, null, 2));
};

This function takes the packageJSON value we retrieved earlier and stored in that variable and modifies its version and developmentVersion fields. If we look close, we're making sure to set the version field back to the originalVersion and the developmentVersion to the new version.

This is intentional. When we run npm version in the command we passed to execSync(), no matter what, NPM will attempt to increment the version field in our package.json file. This is problematic as we only want to do this when we're trying to perform an actual production release. This code mitigates this problem by writing over any changes that NPM makes (what we'd consider as an accident), ensuring our versions stay in sync.

If we look back down in our release script, right at the bottom, we make a call to this function again if process.env.NODE_ENV === 'development', the intent being to overwrite the changed version field back to the original/current version and update the developmentVersion to the new version.

Almost done! Now, with our release script ready, we need to make one last addition to our package.json file:

/package.json

{
  "type": "module",
  "name": "@cheatcodetuts/calculator",
  "version": "0.4.0",
  "developmentVersion": "0.7.0",
  "description": "",
  "main": "./dist/index.js",
  "scripts": {
    "build": "./node_modules/.bin/esbuild ./src/index.js --format=esm --bundle --outfile=./dist/index.js --platform=node --target=node16.3 --minify",
    "release:development": "export NODE_ENV=development && npm run build && node ./release.js",
    "release:production": "export NODE_ENV=production && npm run build && node ./release.js",
    "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
    "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "esbuild": "^0.14.1",
    "jest": "^27.4.3",
    "jest-cli": "^27.4.3",
    "semver": "^7.3.5"
  }
}

Here, we want to add two new scripts: release:development and release:production. The names should be fairly obvious here. One script is intended to release a new version of our package in development (to Verdaccio), while the other is intended to publish to the main NPM registry.

The script has three parts:

  1. First, it makes sure to set the appropriate value for process.env.NODE_ENV (either development or production).
  2. Runs a fresh build of our package via npm run build calling to our build script above.
  3. Runs our release script using node ./release.js.

That's it. Now when we run either npm run release:development or npm run release:production, we'll set the appropriate environment, build our code, and release our package.

Local testing with Verdaccio and Joystick

Now, to give all of this a test, we're finally going to get Verdaccio set up locally. The good news: we only have to install one package and then start up the server; that's it.

Terminal

npm install -g verdaccio

Here, we're using npm install but notice that we're using the -g flag which means to install Verdaccio globally on our computer, not just within our project (intentional as we want to be able to run Verdaccio from anywhere).

Terminal

verdaccio

Once installed, to run it, all we need to do is type verdaccio into our terminal and run it. After a few seconds, you should see some output like this:

$ verdaccio
warn --- config file  - /Users/rglover/.config/verdaccio/config.yaml
warn --- Plugin successfully loaded: verdaccio-htpasswd
warn --- Plugin successfully loaded: verdaccio-audit
warn --- http address - http://localhost:4873/ - verdaccio/5.2.0

With that running, now we can run a test release of our package. Back in the root of the package folder, let's try running this:

Terminal

npm run release:development

If all goes well, you should see some output similar to this (your version number will be 0.1.0:

> @cheatcodetuts/calculator@0.4.0 build
> ./node_modules/.bin/esbuild ./src/index.js --format=esm --bundle --outfile=./dist/index.js --platform=node --target=node16.3 --minify

  dist/index.js  600b

âš¡ Done in 19ms
npm WARN using --force Recommended protections disabled.
npm notice
npm notice 📦  @cheatcodetuts/calculator@0.8.0
npm notice === Tarball Contents ===
npm notice 50B   README.md
npm notice 600B  dist/index.js
npm notice 873B  package.json
npm notice 1.2kB release.js
npm notice 781B  src/index.js
npm notice 1.6kB src/index.test.js
npm notice === Tarball Details ===
npm notice name:          @cheatcodetuts/calculator
npm notice version:       0.8.0
npm notice filename:      @cheatcodetuts/calculator-0.8.0.tgz
npm notice package size:  1.6 kB
npm notice unpacked size: 5.1 kB
npm notice shasum:        87560b899dc68b70c129f9dfd4904b407cb0a635
npm notice integrity:     sha512-VAlFAxkb53kt2[...]EqCULQ77OOt0w==
npm notice total files:   6
npm notice

Now, to verify our package was released to Verdaccio, we can open up our browser to http://localhost:4873 and see if our package appears:

axzVaBsOO4ocr1rH/lREqmcmlUoWhVQLr.0
Our package published to Verdaccio.

While it's great that this worked, now, we want to give this package a quick test in a real app.

Testing out the package in development

To test out our package, we're going to leverage CheatCode's Joystick framework to help us quickly spin up an app we can test with. To install it, in your terminal run:

Terminal

npm install -g @joystick.js/cli

And once it's installed, from outside of your package directory, run:

Terminal

joystick create package-test

After a few seconds you will see a message from Joystick telling you to cd into package-test and run joystick start. Before you run joystick start lets install our package in the folder that was created for us:

Terminal

cd package-test && npm install @cheatcodetuts/calculator --registry http://localhost:4873

Here, we cd into our test app folder and run npm install specifying the name of our package followed by a --registry flag set to the URL for our Verdaccio server http://localhost:4873. This tells NPM to look for the specified package at that URL. If we leave the --registry part out here, NPM will try to install the package from its main registry.

Once your package has installed, go ahead and start up Joystick:

Terminal

joystick start

Next, go ahead an open up that package-test folder in an IDE (e.g., VSCode) and then navigate to the index.server.js file generated for you at the root of that folder:

/index.server.js

import node from "@joystick.js/node";
import calculator from "@cheatcodetuts/calculator";
import api from "./api";

node.app({
  api,
  routes: {
    "/": (req, res) => {
      res.status(200).send(`${calculator.divide(51, 5)}`);
    },
    "*": (req, res) => {
      res.render("ui/pages/error/index.js", {
        layout: "ui/layouts/app/index.js",
        props: {
          statusCode: 404,
        },
      });
    },
  },
});

At the top of that file, we want to import the default export from our package (in the example, the calculator object we passed to export default in our package code).

To test it out, we've "hijacked" the example / route in our demo app. There, we use the Express.js server built-in to Joystick to say "return a status code of 200 and a string containing the results of calling calculator.divide(51, 5)." Assuming that this works, if we open up our web browser, we should see the number 10.2 printed in the browser:

axzVaBsOO4ocr1rH/Lf6uO9ivFoN9v3Ir.0
The expected result of our call to calculator.divide() in the browser.

Awesome! If we can see this, that means that our package is working as we were able to import it into our app and call to its functionality without any issues (getting the intended result).

Releasing to production

Okay. Time for the big finish. With all of that complete, we're finally ready to publish our package to the public via NPM. Real quick, make sure that you've set up an account on NPM and have logged in to that account on your computer using the npm login method:

Terminal

npm login

After that, the good news: it's just a single command to get it done. From the root of our package folder:

Terminal

npm run release:production

Identical to what we saw with our call to release:development, we should see some output like this after a few seconds:

$ npm run release:production

> @cheatcodetuts/calculator@0.4.0 release:production
> export NODE_ENV=production && npm run build && node ./release.js


> @cheatcodetuts/calculator@0.4.0 build
> ./node_modules/.bin/esbuild ./src/index.js --format=esm --bundle --outfile=./dist/index.js --platform=node --target=node16.3 --minify

  dist/index.js  600b

âš¡ Done in 1ms
npm notice
npm notice 📦  @cheatcodetuts/calculator@0.5.0
npm notice === Tarball Contents ===
npm notice 50B   README.md
npm notice 600B  dist/index.js
npm notice 873B  package.json
npm notice 1.2kB release.js
npm notice 781B  src/index.js
npm notice 1.6kB src/index.test.js
npm notice === Tarball Details ===
npm notice name:          @cheatcodetuts/calculator
npm notice version:       0.5.0
npm notice filename:      @cheatcodetuts/calculator-0.5.0.tgz
npm notice package size:  1.6 kB
npm notice unpacked size: 5.1 kB
npm notice shasum:        581fd5027d117b5e8b2591db68359b08317cd0ab
npm notice integrity:     sha512-erjv0/VftzU0t[...]wJoogfLORyHZA==
npm notice total files:   6
npm notice

That's it! If we head over to NPM, we should see our package published (fair warning, NPM has an aggressive cache so you may need to refresh a few times before it shows up):

axzVaBsOO4ocr1rH/2psJ3iIv6jPTIWS9.0
Our package published to NPM.

All done. Congratulations!

Wrapping up

In this tutorial, we learned how to write an NPM package using Node.js and JavaScript. We learned how to write our package code, write tests for it using Jest, and how to build it for a production release using esbuild. Finally, we learned how to write a release script that helped us to publish to both a local package repository (using Verdaccio) and to the main NPM repository.

Written By
Ryan Glover

Ryan Glover

CEO/CTO @ CheatCode