DEV Community

Cover image for Publishing your first npm library
anes
anes

Posted on

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
Enter fullscreen mode Exit fullscreen mode

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:
Screenshot of a type error when using the library 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");
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

"scripts": {
  "build": "tsc --outDir out/",
},
Enter fullscreen mode Exit fullscreen mode

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
...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

And the generated tsconfig.json might look like this:

{
  "compilerOptions": {
    "target": "es2016",
    "module": "commonjs",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  }
}
Enter fullscreen mode Exit fullscreen mode

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"]
}
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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;
    //...
}
Enter fullscreen mode Exit fullscreen mode
.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';
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

Adding JSDocs

JavaScript supports something called "JSDocs". They are those helpful messages you sometimes see when using a function:
Image of a jsdoc help message
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';
Enter fullscreen mode Exit fullscreen mode

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

import PushdownAutomaton from 'pushdown-automaton/out/PushdownAutomaton'
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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",
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

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

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",
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

npx husky init
Enter fullscreen mode Exit fullscreen mode

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."
Enter fullscreen mode Exit fullscreen mode

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:
Image of a commit with hooks

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
Enter fullscreen mode Exit fullscreen mode

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

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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

npm run build; npm publish
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

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
Image of access token generation

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:
Image of an added env key

Testing our automated publish

If we did everything correctly a new tag should trigger the "publish" action, which automatically publishes:
Image of the automatic publish

Conclusion

Now you can finally check out your own npm package on the official website. Good job!
Image of a list with npm packages

Top comments (0)