Publish a node module to your private Github registry

Publish a node module to your private Github registry

In this Article

Overview

In this article I am going to explain step by step how to implement a node module and make it re-usable later on in other projects by packaging it and publishing it to the Github registry. Let’s dive into it.

Setup

Let’s start by creating a project directory, I am going to call mine coderpunktech-common:

$ mkdir coderpunktech-common

Next, we are going ahead and create the following files:

Let’s get on that! Your .gitignore file should look like this:

node_modules
dist
coverage

The .npmrc is required for telling npm what registry to use to push or pull packages. Since in this case we want to use the Github Registry we are going to tell it that:

@coderpunktech:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GH_REGISTRY_TOKEN}

On the first line I am basically saying that I would like to use the github registry with my organization which is coderpunktech you will have to replace that with yours. The second line is defining the token required by npm for authenticating to your github. We will get back to that later when we have to generate one. Let’s move on.

Your build.js file should look like this:

#!/usr/bin/env node
const esbuild = require('esbuild');

/**
 * Setup the common config
 */
const common = {
  entryPoints: ['./src/index.ts'],
  bundle: true,
  sourcemap: true,
  minify: true,
  platform: 'node',
  target: ['esnext'],
}

// build the js
esbuild.build({
  ...common,
  outfile: 'dist/index.js',
  format: 'esm'
}).catch(() => process.exit(1));

// build the esm
esbuild.build({
  ...common,
  outfile: 'dist/index.esm.js',
  format: 'esm'
 }).catch(() => process.exit(1));

In this file we are basically telling esbuild to take the src/index.ts file as an input and produce both an index.js and index.esm.js inside a dist folder.

Then for the typescript and jest configurations you should have:

{
  "compilerOptions": {
    "declaration": true,
    "module": "commonjs",
    "skipLibCheck": true,
    "preserveConstEnums": true,
    "target": "esnext",
    "lib": ["esnext"],
    "strict": true,
    "noImplicitAny": false,
    "esModuleInterop": true,
    "moduleResolution": "node",
    "outDir": "dist"
  },
  "include": ["src"],
  "exclude": [
    "node_modules", 
    "dist", 
    "**/*.test.ts"]
}

and

module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  moduleFileExtensions: ['ts', 'js', 'json'],
  coverageReporters: [
    "lcov", 
    ["text"]
  ],
  coverageThreshold: {
      global: {
        branches: 100,
        functions: 100,
        lines: 100,
        statements: 100
    }
  },
  collectCoverageFrom: [
    "./src/**/*.ts",
    "!**/node_modules/**"
  ],
  transform: {
    '^.+\\.tsx?$': ['@swc/jest'] // faster transpiler than ts-node
  }
}

If you wanna know more about those configurations you can checkout the typesctipt and jest documentation. Finally for the package.json you should have:

{
  "name": "@coderpunktech/common",
  "version": "0.0.1",
  "private": false,
  "author": {
    "name": "Coderpunktech",
    "email": "[email protected]"
  },
  "description": "A set of common reusable functions",
  "files": [
    "dist/*.js",
    "dist/**/*.js",
    "dist/**/*.d.ts"
  ],
  "repository": {
    "type": "git",
    "url": "[email protected]:coderpunktech/common.git"
  },
  "publishConfig": {
    "registry": "https://npm.pkg.github.com"
  },
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "module": "dist/index.esm.js",
  "scripts": {
    "release": "yarn build && npm publish",
    "ts-types": "tsc --emitDeclarationOnly",
    "build": "rimraf dist && ./build.js && yarn ts-types",
    "test": "jest --config jest.config.js",
    "test:coverage": "jest --config jest.config.js --coverage"
  },
  "devDependencies": {
    "@swc/core": "^1.2.182",
    "@swc/jest": "^0.2.21",
    "@types/jest": "^26.0.23",
    "@types/node": "^17.0.32",
    "esbuild": "^0.14.39",
    "jest": "^27.0.1",
    "rimraf": "^3.0.2",
    "ts-jest": "^27.0.1",
    "ts-loader": "^9.2.2",
    "ts-node": "^10.7.0",
    "typescript": "^4.3.2"
  }
}

It’s important that your package is not private. The repository field is required so if you haven’t done that already go ahead and create a repository. The name field should follow the **/** convention. The **publishConfig** is to tell npm what registry to use. The **files** field is to define which files should be included in the package. Finally **main**, **type** and **module** are necessary for the package to work as expected. I am not going to breakdown each script or the dependencies but feel free to take your time to look at this file until it sinks in.

With this out of the way, let’s code something to package.

Implementing the function to expose

We are going to code a simple function that checks wether a string is a palindrome or not. We are going to write unit tests for it and also add a type declaration for our function. The structure will look like this:

The index file is responsible for exposing the function it will simply look like this:

import { isPalindrome } from "./lib/palindrome";

/**
 * Expose lib of functions here
 */
export default {
  isPalindrome
}

The actual implementation of isPalindrome can be found in the palindrome.ts file which looks like the following:

/**
 * Read the provided text backwards and compare that with the original text.
 * 
 * @param text the text to check
 * @returns true if the string is a palindrome, false otherwise
 */
export const isPalindrome = (text: string): boolean => {
  return text === text.split('').reverse().join('');
}

Now let’s write unit tests to make sure the function behaves as expected:

import { isPalindrome } from './palindrome';

describe('The utility method to check wether a string is a palindrome or not', () => {
  test('It should return false when the string is not a palindrome', () => {
    expect(isPalindrome('not a palindrome')).toEqual(false);
    expect(isPalindrome('race car')).toEqual(false);
  });

  test('It should return true when the string is a palindrome', () => {
    expect(isPalindrome('racecar')).toEqual(true);
  });
});

The declaration type for this function will look like this:

/**
 * Check wether the provided text is a palindrome or not
 * 
 * @param text the text to check
 * @returns true if the string is a palindrome, false otherwise
 */
export declare const isPalindrome: (text: string) => boolean;

Alright, we have got a function to expose in our node module, let’s have a look at how to publish it.

Preparing for publishing

In order to publish we will need to generate a secure token with registry read and write permission. To do so navigate to your github settings page and under developer settings select the Personal access tokens menu item on the left. Click on generate a new token and you should see a page like the following:

You don’t need to check the delete:packages like in the image above unless you want to be able to delete packages that you have previously published. Once you create the token you can save that in your system by exporting a variable in your bash profile configuration. Just remember the name of the variable must match the variable referenced in the .npmrc file. In this case we named it GH_REGISTRY_TOKEN.

Use Github Workflow to automate the package publishing

We finally have all the ingredients so let’s try to manually publish from our local machine by typing the following:

$ export GH_REGISTRY_TOKEN=<your-generated-token>
$ yarn

You should see a similar output:

Now if you navigate to your repository packages tab you should see your published package. It can take a few minutes for the package to show up so don’t worry if you don’t see it immediately. Once you have verified that your package is there the only thing left to do is to automate this process using Github workflows. Let’s go ahead and in your root folder create .github > workflows > publish.ci.yml. We are going to have two jobs running sequentially, the first one runs the unit tests with coverage, the second one will be building and publishing the package.

name: Publish the Common package

on:
  push:
    branches:
      - main

env:
  GH_REGISTRY_TOKEN: $

jobs:
  test:
    runs-on: ubuntu-latest
    name: Unit Tests
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v3
        with:
          node-version: '17.6.0'
      - name: Running Unit Tests
        run: yarn && yarn test:coverage
  build-and-publish:
    needs: test
    runs-on: ubuntu-latest
    name: Build and Publish
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v3
        with:
          node-version: '17.6.0'
      - name: Building and Publishing
        run: yarn && yarn release

You don’t need to worry about creating the GITHUB_TOKEN secret since you get that out of the box. Now if you push the changes you should see the pipeline in action but with a failure:

That’s because you can’t publish a package with the same version, you will have to bump the package.json version up to 0.0.2. If you push again now you’ll see the jobs are running as expected:

You can even add a badge to your README.md file to keep an eye on the job status:

This is it! You can now start using that package into different projects, just remember whenever you need to pull your package as a dependency you will have to add a .npmrc file specifying the registry source so your package can be found.