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.
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).
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()
andtest()
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 tellesbuild
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 thedist
folder we created earlier (using the same file name as we did for our package code). - Set the
--platform=node
flag tonode
so thatesbuild
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 tellesbuild
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...
- Version our package, updating our
package.json
'sversion
field. - Release our package conditionally to our Verdaccio server, or, to NPM for public release.
- 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:
- First, it makes sure to set the appropriate value for
process.env.NODE_ENV
(eitherdevelopment
orproduction
). - Runs a fresh build of our package via
npm run build
calling to ourbuild
script above. - 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:
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:
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):
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.