Publishing your first npm library

Publishing your first npm library

Introduction

I recently published my first npm library called pushdown-automaton which allows users to create Pushdown Automata. And while it is a very niche use-case I am still proud of my achievement.
This article’s purpose is highlighting anything important I ran into to help everyone reading this.

Librarification of your code

Personally, I started off with something like this:

/
PushdownAutomaton.js
Stack.js
State.js
TerminationMessage.js
TransitionFunction.js
package.json
.gitignore
.tool-versions

And while this is fine to hand in for a school project, there is still a lot missing to turn it into a library.

Using typescript (optional)

First, the project should be converted to TypeScript. That makes using the library as an end-user much easier, as there are type-errors in case someone uses it wrong:

Coverting your files

Firstly you should change all *.js files to *.ts.
Then you need to add types everywhere:

let automaton: PushdownAutomaton;
automaton = new PushdownAutomaton(test);

let oneState: State;
oneState = new State(q0);
let otherState: State;
otherState = new State(q1);

While I did it manually, you can probably just feed all your files into ChatGPT and make it do the manual labor. Just use at your own discretion.

Changing folder structure

To make everything more readable and easier to understand you might want to move the source *.ts files into their own folder:

/
src/
PushdownAutomaton.ts
Stack.ts
State.ts
TerminationMessage.ts
TransitionFunction.ts
package.json
.gitignore
.tool-versions

Later we will set up an out/ folder that holds our end-user code.

Setting up the ts compiler

As we still want to make the library usable for non-ts users we have to add the tscompiler that turns our code into JavaScript.
As we only need it when developing and not when sending our package to the user, make sure to only install it in development:

npm install –save-dev typescript

And now we define a few commands in our package.json that make compilation easier:

“scripts”: {
“build”: “tsc –outDir out/”,
},

This allows us to just run npm run … and have it compile directly into the correct directory. Now running any of those commands doesn’t work as of now:

➜ npm run build

> pushdown-automaton@1.1.3 build
> tsc –outDir out/

Version 5.4.5
tsc: The TypeScript Compiler – Version 5.4.5

COMMON COMMANDS

TypeScript config

This happens, as we don’t yet have a typescript config set up.
Luckily, we can generate one by running:

➜ npx tsc –init

Created a new tsconfig.json with:
TS
target: es2016
module: commonjs
strict: true
esModuleInterop: true
skipLibCheck: true
forceConsistentCasingInFileNames: true

And the generated tsconfig.json might look like this:

{
“compilerOptions”: {
“target”: “es2016”,
“module”: “commonjs”,
“esModuleInterop”: true,
“forceConsistentCasingInFileNames”: true,
“strict”: true,
“skipLibCheck”: true
}
}

And while this works, it’s not quite what we want. After changing it around a bit, this one looked pretty good for me:

{
“compilerOptions”: {
/* Basic Options */
“target”: “es6”,
“module”: “ESNext”,
“lib”: [“es6”, “dom”],
“declaration”: true,
“sourceMap”: true,
“outDir”: “./out”,

/* Strict Type-Checking Options */
“strict”: true,

/* Module Resolution Options */
“moduleResolution”: “node”,
“esModuleInterop”: true,
“forceConsistentCasingInFileNames”: true,

/* Advanced Options */
“skipLibCheck”: true
},
“exclude”: [“node_modules”, “test”, “examples”],
“include”: [“src/**/*.ts”]
}

Important settings are:

target: This is the JS Version our files will be transpiled to
module: This defines the module system our code will use. ESNext allows for keywords like import and export

lib: This defines what will be included in our compilation environment
declaration: This option tells the compiler to create declaration files (explained better under the chapter *.d.ts)
sourceMap: This option tells the compiler to create sourcemap files (explained better under the chapter *.js.map)
outDir: Where our files are sent to (if nothing is specified in the command)
include: What glob pattern to use when searching for files to be compiled

Now we can re-run our commands sucessfully:

➜ npm run build

> pushdown-automaton@1.1.3 build
> tsc –outDir out/

Inside of the out/ folder you should now see a bunch of files, having following endings:

*.js

These are JavaScript files. They contain the actual code.

.d.ts

These are Type declarations: These files tell any TypeScript compilers about types, etc. giving them the ability to catch type errors before runtime.
The content looks like a Java interface:

declare class PushdownAutomaton {
//…
run(): TerminationMessage;

step(): TerminationMessage;

setStartSate(state: State): void;
//…
}

.js.map

These files are used by the browser to allow users to see the original files instead of the compiled ones. Reading them doesn’t make much sense, as they are just garbage.

ESNext issues when using TypeScript

If you already tried using your library you might have realized that nothing works. That is for one simple reason: TypeScript imports don’t get .js added after filenames with tsc:

// This import in ts:
export { default as PushdownAutomaton } from ./PushdownAutomaton;

// Gets turned into this in js:
export { default as PushdownAutomaton } from ./PushdownAutomaton;

// While this is needed:
export { default as PushdownAutomaton } from ./PushdownAutomaton.js;

To fix that, I used some random npm package I found, called fix-esm-import-path.
Automating the process of using this needs us to add more scripts in our package.json:

“scripts”: {
“build”: “npm run build:compile && npm run build:fix”,
“build:fix”: “fix-esm-import-path out/*.js”,
“build:compile”: “tsc”
}

Reflecting the changes in our package.json

We made many structural changes to our project, we need to change the package.json by adding an indicator for the type of project we have and where our files are:

{
“files”: [“out/**/*”],
“type”: “module”
}

Adding JSDocs

JavaScript supports something called “JSDocs”. They are those helpful messages you sometimes see when using a function:

Adding these docs to every method and class will increase the usability by a lot, so I would suggest you do that.

Creating the “entry point”

When someone uses our package now, that person would expect to import our libraries code like this:

import { PushdownAutomaton, State, TransitionFunction } from pushdown-automaton;

But as of now that isn’t possible. They would have to do this:

import PushdownAutomaton from ‘pushdown-automaton/out/PushdownAutomaton’

To enable this first type of imports we will create something called an entry point. That file is located under src/index.ts and looks like this:

export { default as PushdownAutomaton } from ./PushdownAutomaton;
export { default as Stack } from ./Stack;
export { default as State } from ./State;
export { default as TransitionFunction } from ./TransitionFunction;

All this does is just bundle up everything the user needs. Configuring it like this increases ease of use.

Setting that in our package.json

Now we need to define the entry point in our package.json file:

{
“main”: “out/index.js”,
“types”: “out/index.d.ts”,
}

All this does is tell the end-user where to find the “entry point” and its types.

Clean code and testing (optional)

Most libraries make use of things like linters and tests to guarantee maintainability and expand-ability.
While this is not needed, I always advocate for it. It makes the development experience for you and any potential future maintainers much better.

Clean code check

First, we want to set up eslint, which is a JavaScript library that allows us to check for certain clean-code standards and if we are following them.

Installing packages

We will start by installing a few packages:

npm install –save-dev eslint @eslint/js typescript-eslint

Configuring eslint

Next, we will create a file called eslint.config.mjs. It will be pretty empty, only having following content:

// @ts-check

import eslint from @eslint/js;
import tseslint from typescript-eslint;

export default tseslint.config(
eslint.configs.recommended,
tseslint.configs.recommended,
);

This just takes what clean-code rules are popular at the moment and enforces them.

Testing

Next, we will set up jest in combination with istanbul to check coverage.

Installing

With following command you can install jest, which also contains istanbul:

npm install –save-dev jest ts-jest @types/jest

Configuring

To configure jest you can add following content to your jest.config.mjs:

// jest.config.mjs
export default {
preset: ts-jest,
testEnvironment: node,
roots: [<rootDir>/tests],
testRegex: (/__tests__/.*|(\.|/)(test|spec))\.tsx?$,
moduleFileExtensions: [ts, tsx, js, jsx, json, node],
collectCoverage: true,
coverageDirectory: coverage,
coverageReporters: [text, lcov],
coverageThreshold: {
global: {
branches: 100,
functions: 100,
lines: 100,
statements: 100,
},
},
};

Now we can run our tests and see the coverage listed of every mentioned file:

The config a few interesting options, which you can look up yourself. Important are following options:

roots

This defines where the tests are. In this case they are under /tests/.

testRegex

This defines the syntax filenames of tests have to follow. This regex enforces the format something.test.ts but also allows similar names like something.spec.tsx.

coverageTheshold

This defines what percentage of lines have to be touched by our tests. In this case all options are set to 100%, which enforces complete test coverage.

Adding scripts

After adding and configuring both a linter and tests, we need to have a standard way of running them.
That can be achieved by adding following options to our package.json:

{
“scripts”: {
“test”: “jest –coverage”,
“lint”: “eslint ‘src/**/*.ts'”,
“fix-lint”: “eslint ‘src/**/*.ts’ –fix”,
}
}

Automated testing and git hooks (optional)

To make enforcing of code-quality easier we will add git-hooks and GitHub actions to run our linter and tests.

git hooks

To help us with adding git hooks, we will use husky:

npm install –save-dev husky

Luckily husky has tools to help us with setting up the hook:

npx husky init

This adds following things:

A pre-commit script under .husky

Adds prepare in package.json

Finally, we can add our linter under .husky/pre-commit:

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

echo “Running pre-commit hooks…”

# Run ESLint
echo “Linting…”
npm run lint
if [ $? -ne 0 ]; then
echo “ESLint found issues. Aborting commit.”
exit 1
fi

echo “Pre-commit checks passed.”

Now it runs the linter before every commit and forbids us from finishing the commit if there are any complaints by eslint. That might look like this:

Setting up GitHub actions

Now we want to set up GitHub actions so it runs our tests and lints on every push.
For that, we will create .github/workflows/tests.yml. In there we define the workflow:

name: Tests on push

on:
push:
branches:
**’

jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [16.x, 18.x]
os: [ubuntu-latest, windows-latest, macos-latest]

steps:
uses: actions/checkout@v3
name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
registry-url: https://registry.npmjs.org’

name: Cache node modules
uses: actions/cache@v2
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles(‘**/package-lock.json’) }}
restore-keys: |
${{ runner.os }}-node-

name: Install dependencies
run: npm ci

name: Lint Code
run: npm run lint

name: Run Tests
run: npm test

This runs our tests on ubuntu, windows and macos on versions 16 and 18 of node.
Feel free to change the matrix!

Publishing a package

Finally, we can publish our package. For that we need to create an account under npmjs.com.

Final settings

Some final things we will want to configure before uploading are in our package.json:

{
“name”: “Some name”,
“version”: “1.0.0”,
“description”: “Some description”,
“repository”: {
“type”: “git”,
“url”: “git+ssh://git@github.com/user/repo”
},
“keywords”: [
“some”,
“keywords”
],
“author”: “you, of course :)”,
“license”: “MIT I hope”,
“bugs”: {
“url”: “https://github.com/user/repo/issues”
},
“homepage”: “https://github.com/user/repo#readme”
}

Also, we will want to create a file called CHANGELOG.md and reference it in our README. The file looks as follows for now:

# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.0.0] – yyyy-mm-dd
Initial release

Check out how to keep a changelog and Semantic Versioning to always keep your library understandable.

Manual publish

To publish the package manually, we can do that in our console.
First we log in by running:

npm adduser

That will open the browser window and ask us to log in.
After doing that you can run:

npm run build; npm publish

Automating that work

If you want to automate this work we can configure a GitHub action to automatically publish on npm when pushing a new tag.

YAML config to publish

With following file under .github/workflows/publish.yml a new release gets triggered on every new tag.
Special about this file is also, that it makes sure our package.json has the same version for our package as the pushed tag.

name: Publish to npm registry

on:
push:
tags:
**’

jobs:
check-tag-version:
runs-on: ubuntu-latest
steps:
uses: actions/checkout@v3
name: Check if tag matches version in package.json
run: |
TAG_NAME=${GITHUB_REF#refs/tags/}
PACKAGE_VERSION=$(jq -r ‘.version’ package.json)
if [ “$TAG_NAME” != “$PACKAGE_VERSION” ]; then
echo “::error::Tag version ($TAG_NAME) does not match version in package.json ($PACKAGE_VERSION)”
exit 1
fi

check-code:
runs-on: ubuntu-latest
steps:
uses: actions/checkout@v3
name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 18.x’
registry-url: https://registry.npmjs.org’

name: Cache node modules
uses: actions/cache@v2
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles(‘**/package-lock.json’) }}
restore-keys: |
${{ runner.os }}-node-

name: Install dependencies
run: npm ci

name: Lint Code
run: npm run lint

name: Run Tests
run: npm test

publish:
needs: [check-tag-version, check-code]
runs-on: ubuntu-latest
steps:
uses: actions/checkout@v3
name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 18.x’
registry-url: https://registry.npmjs.org’

name: Cache node modules
uses: actions/cache@v2
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles(‘**/package-lock.json’) }}
restore-keys: |
${{ runner.os }}-node-

name: Install dependencies
run: npm ci

name: Build the project
run: npm run build

name: Publish to npm
run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

Generating an access token

After adding this, you will need to add an npm auth token to your GitHub Actions environment variables.
Get that key under “Access Tokens” after clicking on your profile picture. Generate a “Classic Token”.
On that page, add a name and choose “Automation” to allow managing the package in our CI

Adding that token

To now add that token to GitHub Actions secrets.
You can find that setting under (Project) Settings > Secrets and variables > Actions.
Then click on “New repository secret” and add NPM_TOKEN:

Testing our automated publish

If we did everything correctly a new tag should trigger the “publish” action, which automatically publishes:

Conclusion

Now you can finally check out your own npm package on the official website. Good job!

Leave a Reply

Your email address will not be published. Required fields are marked *