DEV Community

loading...
Charper Bonaroo

Creating a TS-written NPM package for use in Node-JS or Browser.

tobyhinloopen profile image Toby Hinloopen ・14 min read

Creating a TS-written NPM package for use in Node-JS or Browser: The Long Guide

In this guide, I'll explain how to create an NPM package for NodeJS or the browser using Typescript without leaving built artifacts in your repository. At the end, my example library will be able to be included in any Javascript or Typescript project, including:

  • Imported as a script in a <script> tag, using either direct download or a free CDN service.
  • Installed in a client-side application using npm and a bundler like webpack.
  • Installed in a server-side NodeJS application using npm.

Furthermore, the whole build and publish process will be automated as much as possible, while keeping the repository free from builds.

For this post, I'll be using a tiny library I wrote as an example. The library itself is meaningless and not very useful, which makes it a fine distraction-free example for this guide.

The example library

The example library will be called bonaroo-able, only exporting a namespace called Able.

Able contains a small set of functions for managing a list of strings that act as abilities (permissions) for some user. This example library is written in Typescript and it has no browser- or NodeJS specific dependencies (EG it doesn't rely on the DOM or filesystem). More about this library later. For now, let's start with creating some config files.

The NPM package

First, we need a package.json. The package.json file contains details about your Javascript package, including the name, author and dependencies. You can read about package.json files in the npm docs.

To create a package.json file, we use npm. In your library folder, run npm init and follow the instructions. For this guide, I'll be using jest to test my library. We can just use jest as a test command: We'll be installing this dependency later.

The entry point is the file that will be included when our package is included in another project. To allow our package to be used in non-Typescript projects, this entry point must be a regular Javascript file.

This Javascript file must include all of our library. I like to have an index.js file that requires all our of library. Because this is a Typescript project, we will have separate Typescript and Javascript files. We keep these in src (written source) and dist (distributed files) folders.

We'll be writing a src/index.ts file importing all of our library, and use the Typescript compiler to generate a Javascript variant in dist/index.js. This dist/index.js will be our package's entry point. We'll configure the Typescript compiler later.

$ npm init
This utility will walk you through creating a package.json file.
It only covers the most common items, and tries to guess sensible defaults.

See `npm help json` for definitive documentation on these fields
and exactly what they do.

Use `npm install <pkg>` afterwards to install a package and
save it as a dependency in the package.json file.

Press ^C at any time to quit.
package name: (bonaroo-able)
version: (1.0.0)
description: A tiny library handling abilities
entry point: (index.js) dist/index.js
test command: jest
git repository: https://github.com/tobyhinloopen/bonaroo-able
keywords: Abilities, Permissions
author: Charper Bonaroo BV
license: (ISC) UNLICENSED
About to write to /home/toby/bonaroo-able//package.json:

{
  "name": "bonaroo-able",
  "version": "1.0.0",
  "description": "A tiny library handling abilities",
  "main": "dist/index.js",
  "scripts": {
    "test": "jest"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/tobyhinloopen/bonaroo-able.git"
  },
  "keywords": [
    "Abilities",
    "Permissions"
  ],
  "author": "Charper Bonaroo BV",
  "license": "UNLICENSED",
  "bugs": {
    "url": "https://github.com/tobyhinloopen/bonaroo-able/issues"
  },
  "homepage": "https://github.com/tobyhinloopen/bonaroo-able#readme"
}


Is this OK? (yes)
$

Next, we'll be needing some dependencies. Obviously you'll be needing Typescript. We'll also be installing jest, ts-jest and @types/jest.

npm i -D typescript jest ts-jest @types/jest

Configuring Typescript

Next, we need to configure Typescript. Let's create a minimal tsconfig.json file.

tsconfig.json

{
  "compilerOptions": {
    "outDir": "dist",
    "lib": ["es2016"],
    "sourceMap": true
  },
  "include": [
    "src/**/*.ts"
  ]
}

About these options

  • compilerOptions.outDir: Generate JS files in this directory.
  • compilerOptions.lib: Tell the Typescript compiler the es2016 standard functions are available, like Array.prototype.includes.
  • include: Ensure only the Typescript in src is compiled & ignore all other source files (like the tests)

Since you cannot invoke Node binaries directly in all environments, I like to add all my commonly used commands to npm scripts. Add "build": "tsc" to the scripts section in your package.json

package.json (partial)

  "scripts": {
    "build": "tsc",
    "test": "jest"
  },

To test whether everything is setup correctly, I like to create an entry point with a dummy function.

src/index.ts

export function hello(name: string): string {
  return `Hello ${name}`;
}

Let's attempt to built this:

$ npm run build

> bonaroo-able@1.0.0 build /home/toby/bonaroo-able
> tsc

$

No errors. That's great. Also, note that Typescript has created some Javascript files for us! If you take a look at dist/index.js, you'll see a Javascript variant of our Typescript file. My generated file looks like this:

dist/index.js (generated)

"use strict";
exports.__esModule = true;
function hello(name) {
    return "Hello " + name;
}
exports.hello = hello;

Note that all type information has been stripped, and the file has been changed to be compatible with older Javascript runtimes by changing the template string to a regular string with concat operator: "Hello " + name.

Writing a test

Now test our "library": Let's write a test!

I like to create tests in a test directory, with a filenames matching the src files. For example, to test src/Foo.ts, I put my tests in test/Foo.spec.ts.

test/index.spec.ts

import { hello } from "../src";

test("hello", () => {
  expect(hello("foo")).toEqual("Hello foo");
});

To be able to write our tests in Typescript, we need to configure jest first. We can generate a config file with ts-jest config:init.

$ node_modules/.bin/ts-jest config:init

Jest configuration written to "/home/toby/bonaroo-able/jest.config.js".
$

Now we're ready to confirm our test suite is working:

$ npm t

> bonaroo-able@1.0.0 test /home/toby/bonaroo-able
> jest

 PASS  test/index.spec.ts
  ✓ hello (2ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.267s, estimated 2s
Ran all test suites.
$

Configuring GIT

Before we continue, let's configure source control to persist our working setup.

To keep our git repository clean, we omit node_modules and dist from the git repository.

.gitignore

dist/
node_modules/

Now let's create a git repository. Replace the remote with your git repo.

git init
git add --all
git commit -m "Initial commit"
git remote add origin git@github.com:tobyhinloopen/bonaroo-able.git
git push -u origin master

Writing our Library

Now let's write the code for our library. Writing code is outside the scope of this guide. Here's an overview of my Able library. The filename points to the current version of the complete file on github.

src/Able.ts (overview, no function bodies)

export namespace Able {
  export type AbilitySet = string[];
  export interface GroupDefinition { [key: string]: AbilitySet; }
  export interface ValueMap { [key: string]: string|string[]; }

  export function flatten(definition: GroupDefinition, abilities: AbilitySet): AbilitySet;
  export function extractValues(abilities: AbilitySet): [ValueMap, AbilitySet];
  export function applyValues(abilities: AbilitySet, values: ValueMap): AbilitySet;
  export function resolve(definition: GroupDefinition, abilities: AbilitySet): AbilitySet;
  export function getMissingAbilities(abilities: AbilitySet, requiredAbilities: AbilitySet): AbilitySet;
  export function canAccess(appliedAbilities: AbilitySet, requiredAbilities: AbilitySet): boolean;
}

src/index.ts

import { Able } from "./Able";
export default Able;
Object.assign(module.exports, Able);

test/index.spec.ts (snippet, remaining tests removed)

import { Able } from "../src/Able";

describe("Able", () => {
  it("flatten() includes own name", () => {
    expect(Able.flatten({}, ["foo"])).toContain("foo");
  });

  // ...remaining tests...
});

test/Able.spec.ts

import Able from "../src";

test("Able is exported", () => {
  expect(Able).toBeInstanceOf(Object);
});

Testing our build

In some cases, our tests might succeed while our build fails, or the build is
somehow invalid. To ensure the build is working, I like to add a very crude test to confirm the build is working and the exports are in-place.

This test will build the code, and run a simple JS file using the build to confirm the build is working.

In this build test, we copy one of our test suite's tests. I think it is safe to assume that if one test actually using the library succeeds, the library is built and exported correctly.

test-build.js

const assert = require("assert");
const Able = require("./dist");

const definition = { foo: ["bar"] };
const abilities = ["foo", "bam"];
const result = Able.flatten(definition, abilities).sort();
assert.deepStrictEqual(result, ["foo", "bar", "bam"].sort());

Note that we're importing ./dist here: We are explicitly importing dist/index.js that way. We need to build our code before we can import dist/index.js.

To build the code & run test-build.js, we'll add a script to package.json, called test-build.

package.json (partial)

  "scripts": {
    "build": "tsc",
    "test": "jest",
    "test-build": "npm run build && node test-build.js"
  },

I like to run all automated checks, currently npm t and npm run test-build, from a single script called ci. This script will run all automated checks and only pass when all automated checks passed.

Let's add ci to the scripts as well:

package.json (partial)

  "scripts": {
    "build": "tsc",
    "ci": "npm run test-build & npm t & wait",
    "test": "jest",
    "test-build": "npm run build && node test-build.js"
  },

This ci script will be used to verify our build every release. Let's try it!

$ npm run ci

> bonaroo-able@1.0.0 ci /home/toby/bonaroo-able/
> npm run test-build & npm t & wait


> bonaroo-able@1.0.0 test-build /home/toby/bonaroo-able/
> npm run build && node test-build.js


> bonaroo-able@1.0.0 test /home/toby/bonaroo-able/
> jest


> bonaroo-able@1.0.0 build /home/toby/bonaroo-able/
> tsc

 PASS  test/Able.spec.ts
 PASS  test/index.spec.ts

Test Suites: 2 passed, 2 total
Tests:       11 passed, 11 total
Snapshots:   0 total
Time:        1.816s
Ran all test suites.

Later we'll make sure to only accept changes in the master branch that have passed this npm run ci call. That way, we'll make sure the master branch always features a valid build.

Let's commit all of our changes to git and start deploying our library.

NPM release

The first and most useful release is the npm release. This allows our library users to npm i our library in most projects.

Both server-side projects and client-side projects that use a bundler like webpack can use an npm release without any changes.

Let's prepare our library for publication to NPM.

Preparing our package for release

Let's first define what files we actually want to include in our package. You can peek the contents of your package-to-be using npm publish --dry-run:

$ npm publish --dry-run
npm notice
npm notice 📦  bonaroo-able@1.0.0
npm notice === Tarball Contents ===
npm notice 862B  package.json
npm notice 56B   .git
npm notice 69B   jest.config.js
npm notice 284B  test-build.js
npm notice 114B  tsconfig.json
npm notice 3.9kB dist/Able.d.ts
npm notice 6.1kB dist/Able.js
npm notice 3.4kB dist/Able.js.map
npm notice 52B   dist/index.d.ts
npm notice 184B  dist/index.js
npm notice 198B  dist/index.js.map
npm notice 6.0kB src/Able.ts
npm notice 24B   src/index.ts
npm notice 3.4kB test/Able.spec.ts
npm notice 108B  test/index.spec.ts
npm notice === Tarball Details ===
...
+ bonaroo-able@1.0.0

This built includes all kinds of things the user wouldn't care about. With package.json's files property you can whitelist the files you want to include.

Only the built files are required to use our library: Let's add only the dist folder to the package:

package.json (partial)

{
  "main": "dist/index.js",
  "files": ["dist"],
  // ...
}

Now let's peek at our package's contents again:

$ npm publish --dry-run
npm notice
npm notice 📦  bonaroo-able@1.0.0
npm notice === Tarball Contents ===
npm notice 1.3kB  package.json
npm notice 3.9kB  dist/Able.d.ts
npm notice 6.1kB  dist/Able.js
npm notice 3.4kB  dist/Able.js.map
npm notice 52B    dist/index.d.ts
npm notice 184B   dist/index.js
npm notice 198B   dist/index.js.map
npm notice === Tarball Details ===
npm notice name:          bonaroo-able
...
+ bonaroo-able@1.0.0

That seems about right to me. Let's publish it!

Publishing to NPM

Either sign-in npm login or sign-up npm adduser. After that, we're ready to publish our package.

npm publish

$ npm publish
npm notice
npm notice 📦  bonaroo-able@1.0.0
npm notice === Tarball Contents ===
npm notice 883B   package.json
npm notice 3.9kB  dist/Able.d.ts
npm notice 6.1kB  dist/Able.js
npm notice 3.4kB  dist/Able.js.map
npm notice 52B    dist/index.d.ts
npm notice 184B   dist/index.js
npm notice 198B   dist/index.js.map
npm notice === Tarball Details ===
npm notice name:          bonaroo-able
npm notice version:       1.0.0
npm notice package size:  2.3 kB
npm notice unpacked size: 7.1 kB
npm notice shasum:        4b25f5d01b4ef46259d947d0c0ce1455b92b8433
npm notice integrity:     sha512-mX7RA0CS8hprb[...]lFsx3AGk5XIeA==
npm notice total files:   7
npm notice
+ bonaroo-able@1.0.0

Nice!

Testing our release in Node

Now we can use our package in Node projects! Let's create a temporary Node project to test our package.

mkdir /tmp/node-test
cd $_
npm i bonaroo-able
node
> const Able = require("bonaroo-able");
undefined
> const definition = { foo: ["bar"] };
undefined
> const abilities = ["foo", "bam"];
undefined
> result = Able.flatten(definition, abilities).sort();
[ 'bam', 'bar', 'foo' ]

Testing our release in a webpack project

To use our package in the brower, the package user might be using webpack. Let's try our package in webpack!

mkdir /tmp/webpack-test
cd $_
npm init -y
npm i bonaroo-able
npm i -D webpack webpack-cli html-webpack-plugin webpack-dev-server clean-webpack-plugin

webpack.config.js

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');

module.exports = {
  entry: {
    app: './src/index.js',
  },
  plugins: [
    new CleanWebpackPlugin(),
    new HtmlWebpackPlugin({ title: "Titled Document" }),
  ],
  output: {
    filename: '[name].bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
};

src/index.js

const Able = require("bonaroo-able");

document.addEventListener("DOMContentLoaded", () => {
  const definition = { foo: ["bar"] };
  const abilities = ["foo", "bam"];
  const result = Able.flatten(definition, abilities);

  const code = document.createElement("code");
  code.textContent = result.join(", ");
  document.body.appendChild(code);
});

package.json (partial)

  "scripts": {
    "build": "webpack",
    "start": "webpack-dev-server --open"
  },

Let's start the webpack dev server:

npm start

We are greeted with foo, bam, bar in our browser: Webpack build works!

Building our library for usage in browser

One cannot use the dist/* files in the browser directly - we must combine these files somehow to create a single bundle for the browser.

Bundling libraries for use in the browser is a hairy subject. There are many solutions, none of them are perfect. In this guide, I'll cover only one solution: We'll be creating something called an IIFE build using rollup.js.

An IIFE build looks something like this:

var Able = (function() {
  var Able = {};
  var otherVars = 1;

  Able.flatten = /* ... */

  return Able;
})();

Because the library is defined inside a function expression that is invoked immediately using (function() {})(), all definitions inside the function are hidden, and only the return value is exposed to the global scoped.

Since the Function Expression is Immediately Invoked, it is called an IIFE.

Let's install rollup, add a build command to our package.json, and add a config file for rollup. Also, let's also add a reference to our browser bundle in the package.json's browser property.

npm i -D rollup rollup-plugin-commonjs rollup-plugin-node-resolve rollup-plugin-babel-minify

package.json (partial)

{
  "browser": "dist/bonaroo-able.min.js",
  "scripts": {
    // ...
    "rollup": "rollup -c"
    // ...
  }
}

rollup.config.js

import resolve from 'rollup-plugin-node-resolve';
import commonjs from 'rollup-plugin-commonjs';
import minify from 'rollup-plugin-babel-minify';
import pkg from './package.json';

export default [{
  input: 'dist/index.js',
  output: {
    name: "Able",
    file: pkg.browser,
    format: 'iife',
    sourcemap: true,
  },
  plugins: [
    resolve(),
    commonjs(),
    minify({ comments: false }),
  ],
}];

Let's test or browser build:

example.html

<!DOCTYPE html>
<title>bonaroo-able test</title>
<script src="./dist/bonaroo-able.min.js"></script>
<script>
document.addEventListener("DOMContentLoaded", function() {
  const definition = { foo: ["bar"] };
  const abilities = ["foo", "bam"];
  const result = Able.flatten(definition, abilities);

  const code = document.createElement("code");
  code.textContent = result.join(", ");
  document.body.appendChild(code);
});
</script>

You should see foo, bam, bar again in your browser when opening example.html.


Build before publish

You can configure NPM to build automatically before publishing by adding a prepublish script to your package.json. Because npm publish publishes the built files, we want to make sure the files are built and tested before every publish.

We already have npm run ci to both build & test our build. Let's add rollup to ci, and add npm run ci to prepublishOnly:

package.json (partial)

  "scripts": {
    // ...
    "ci": "(npm run test-build && npm run rollup) & npm t & wait",
    "prepublishOnly": "npm run ci && npm run rollup",
    // ...
  }

Let's publish our new build. NPM uses semantic versioning. Every release, you must update your version number. Since we introduced a new feature (browser build) without breaking changes, we can release a new minor version. You can increment your build number with npm version minor, push our new version to git with git push, and finish with npm publish to publish our new version.

npm version minor
git push
npm publish

Including our library in a browser directly from a CDN

unpkg is a fast, global content delivery network for everything on npm. Use it to quickly and easily load any file from any package using a URL like:

unpkg.com/:package@:version/:file

Thanks unpkg - I couldn't have explained it better myself. Let's try this!

  • package: Our package name, bonaroo-able.
  • version: We just minor-bumped our version to 1.1.0.
  • file: The browser file: dist/bonaroo-able.min.js.

That makes https://unpkg.com/bonaroo-able@1.1.0/dist/bonaroo-able.min.js. Let's grab our example.html again, and change the script source to this URL:

example.html

<!DOCTYPE html>
<title>bonaroo-able test</title>
<script src="https://unpkg.com/bonaroo-able@1.1.0/dist/bonaroo-able.min.js"></script>
<script>
document.addEventListener("DOMContentLoaded", function() {
  const definition = { foo: ["bar"] };
  const abilities = ["foo", "bam"];
  const result = Able.flatten(definition, abilities);

  const code = document.createElement("code");
  code.textContent = result.join(", ");
  document.body.appendChild(code);
});
</script>

Great. Works for me. Now let's write a readme.

Writing a readme

A readme is the entry point of the documentation of our library and should include a short summary of the following:

  • What is our library?
  • Why does it exist?
  • What can it be used for?
  • How to install it
  • How to use it
  • Requirements & dependencies

Writing a good readme is outside the scope of this guide. This guide will only cover installation instructions.

README.md (partial)

## Installation - NPM
```sh
npm i bonaroo-able
```
## Installation - Browser
```html
<script src="https://unpkg.com/bonaroo-able@1.1.1/dist/bonaroo-able.min.js"></script>
```

The script tag in the readme now includes the version number, which will not be updated automatically. Let's add a simple script that bumps the version in the readme everytime we update the NPM version.

When using npm version, npm will invoke multiple hooks automatically, two of which are called preversion (Run BEFORE bumping the package version) and version (Run AFTER bumping the package version, but BEFORE commit).

My approach is to dump the version before bumping the version, and after bumping the version to replace all occurances of the old version in the README.md with the new version.

preversion.sh

#!/usr/bin/env bash
node -e 'console.log(require("./package.json").version)' > .old-version

version.sh

#!/usr/bin/env bash
sed "s/$(cat .old-version)/$(node -e 'console.log(require("./package.json").version)')/g" < README.md > ~README.md
rm README.md .old-version
mv ~README.md README.md
git add README.md

package.json (partial)

  "scripts": {
    // ...
    "preversion": "./preversion.sh",
    // ...
    "version": "./version.sh",
    // ...
  },

sh

chmod +x preversion.sh version.sh

Now let's commit our changes & bump the library version.

sh

git add --all
git commit -am "Introduce README.md"
npm version patch
git push
npm publish

Our readme is now updated! Neat.

## Installation - NPM
```sh
npm i bonaroo-able
```
## Installation - Browser
```html
<script src="https://unpkg.com/bonaroo-able@1.1.2/dist/bonaroo-able.min.js"></script>
```

Final words

Now every time you change something about your library, commit the changes, update the version, push the version change & publish your new version:

git add --all
git commit -m "Describe your changes here."
npm version minor
git push
npm publish

If you're still here, thanks so much for reading! And if you want to know more or have any other questions, please get in touch with us via info@bonaroo.nl

Discussion (1)

pic
Editor guide
Collapse
charlyoleg profile image
charlyoleg

Thanks for this very good post. In my case (tsc version 3.8.3), I need the flag '--declaration' to generate the according '.d.ts' files. So, my package.json looks like:
"build": "tsc --declaration",