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 likeimport
andexport
- 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
inpackage.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!
Top comments (0)