How to publish your NPM Package
0. TLDR
- A. Init a project
Open terminal, create a folder, give it a meaningful name, and then run:
npm init
Or if it should be scoped:
npm init --scope=talkohavy
- B. Connect project to GitHub
This step is mandatory. Every npm package needs to be connected to a remote git repo.
Do a:
git init
git remote add origin git@github.com:talkohavy/NAME.git
git push -u origin master
- C. Add some content to
Here are some good options:
- An
srcfolder with anindex.js - A
README.mdfile
- D. Create an .npmrc file
Create an .npmrc file in the root project.
Its contents should be:
registry=https://registry.example.com/
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
You'll of course need to create an access token on npm's website, under your profile. The token always starts with "npm_", so it's easy to recognize.
You can commit the .npmrc file, since it doesn't include any sensitive information.
- E. package.json script
Add these scripts:
{
"scripts": {
"clean": "rm -rf node_modules",
"lint": "eslint",
"lint:fix": "eslint --fix",
"tsc": "tsc -p tsconfig.json",
"build": "node ./build.config.mjs",
"test": "node --test"
},
}
- F. Create a build.config.mjs file
This fil will serve as your build script.
At the root of your project, create a build.config.mjs file:
import { execSync } from 'child_process';
import { build } from 'esbuild';
import fs, { cpSync } from 'fs';
import path from 'path';
/**
* @typedef {{
* version: string,
* private?: string | boolean,
* main: string,
* type: 'module' | 'commonjs'
* types: string,
* scripts?: Record<string, string>,
* publishConfig: {
* access: string
* },
* devDependencies?: Record<string, string>,
* }} PackageJson
*/
const ROOT_PROJECT = process.cwd();
const mode = process.env.NODE_ENV;
const isProd = mode === 'production';
const outDirName = 'dist';
const COLORS = {
green: '\x1b[32m',
blue: '\x1b[34m',
stop: '\x1b[39m',
};
buildPackageConfig();
async function buildPackageConfig() {
cleanDistDirectory();
await runBuild();
copyStaticFiles();
manipulatePackageJsonFile();
console.log('DONE !!!');
}
function cleanDistDirectory() {
console.log(`${COLORS.green}- Step 1:${COLORS.stop} clear the ${outDirName} directory`);
execSync(`rm -rf ${outDirName}`);
}
async function runBuild() {
console.log(`${COLORS.green}- Step 2:${COLORS.stop} build the output dir`);
await build({
entryPoints: ['src/index.ts'],
bundle: true,
outfile: `${outDirName}/index.js`,
sourcemap: !isProd, // <--- defaults to `false`. for 'node', create sourcemaps is for development only.
minify: isProd, // <--- defaults to `false`. should be `true` only in production.
platform: 'node', // <--- defaults to 'browser'. If you're creating a CLI tool, use 'node' value. Setting platform to 'node' is beneficial when for example, all packages that are built-in to node such as fs are automatically marked as external so esbuild doesn't try to bundle them.
format: 'esm', // <--- When platform is set to 'node', this defaults to 'cjs'.
tsconfig: 'tsconfig.json', // <--- Normally the build API automatically discovers tsconfig.json files and reads their contents during a build. However, you can also configure a custom tsconfig.json file to use instead. This can be useful if you need to do multiple builds of the same code with different settings.
treeShaking: true, // <--- defaults to `true`. Removes dead code.
mainFields: ['main', 'module'], // <--- When platform is set to 'node', this defaults to 'module','main'. When platform is set to 'browser', this defaults to 'browser','module','main'. IMPORTANT! The order matters! 'main', 'module' is not the same as 'module', 'main'! I chose the more risky one, that attempts to tree-shake, but could potentially fail.
packages: 'external', // <--- You also may not want to bundle your dependencies with esbuild. There are many node-specific features that esbuild doesn't support while bundling such as __dirname, import.meta.url, fs.readFileSync, and *.node native binary modules. You can exclude all of your dependencies from the bundle by setting packages to external. If you do this, your dependencies must still be present on the file system at run-time since they are no longer included in the bundle.
conditions: [], // <--- If no custom conditions are configured, the Webpack-specific module condition is also included. The module condition is used by package authors to provide a tree-shakable ESM alternative to a CommonJS file without creating a dual package hazard. You can prevent the module condition from being included by explicitly configuring some custom conditions (even an empty list).
/**
* Some npm packages you want to use may not be designed to be run in the browser.
* Sometimes you can use esbuild's configuration options to work around certain issues and successfully
* bundle the package anyway. Undefined globals can be replaced with either the define feature in
* simple cases or the inject feature in more complex cases.
*/
// define :
// inject :
});
}
function copyStaticFiles() {
console.log(`${COLORS.green}- Step 3:${COLORS.stop} copy static files`);
const filesToCopyArr = [
{ filename: 'package.json', sourceDirPath: [], destinationDirPath: [] },
{ filename: 'README.md', sourceDirPath: [], destinationDirPath: [] },
{
filename: '.npmignore',
sourceDirPath: [],
destinationDirPath: [],
isAllowedToFail: true,
},
{
filename: '.npmrc',
sourceDirPath: [],
destinationDirPath: [],
isAllowedToFail: true,
},
];
filesToCopyArr.forEach(({ filename, sourceDirPath, destinationDirPath, isAllowedToFail }) => {
try {
const sourceFileFullPath = path.resolve(ROOT_PROJECT, ...sourceDirPath, filename);
const destinationFileFullPath = path.resolve(ROOT_PROJECT, outDirName, ...destinationDirPath, filename);
cpSync(sourceFileFullPath, destinationFileFullPath);
console.log(` • ${filename}`);
} catch (error) {
console.error(error);
if (isAllowedToFail) return;
throw new Error('File MUST exists in order to PASS build process! cp operation failed...');
}
});
}
function manipulatePackageJsonFile() {
console.log(`${COLORS.green}- Step 4:${COLORS.stop} copy & manipulate the package.json file`);
const packageJsonPath = path.resolve(ROOT_PROJECT, outDirName, 'package.json');
// Step: get the original package.json file
/** @type {PackageJson} */
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath).toString());
delete packageJson.private;
delete packageJson.scripts;
delete packageJson.devDependencies;
packageJson.publishConfig.access = 'public';
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson));
console.log(` • ${COLORS.blue}changed${COLORS.stop} from private to public`);
console.log(` • ${COLORS.blue}deleted${COLORS.stop} "scripts" key`);
console.log(` • ${COLORS.blue}deleted${COLORS.stop} "devDependencies" key`);
console.log(` • ${COLORS.blue}changed${COLORS.stop} publishConfig access to public`);
console.log(` • ${COLORS.blue}package.json${COLORS.stop} file written successfully!`);
}
- G. install lvlup
The flow of versioning is made easy with a tool like lvlup.
Install it:
pnpm add -D lvlup
Init it:
lvlup init
Change the content of .lvlup/config.json file:
{
"$schema": "https://unpkg.com/lvlup@1.0.13/schema.json",
"commit": {
"afterAdd": true,
"afterBump": true
}
}
- H. Edit your package.json
Change these keys in your package.json file:
{
"name": "@talkohavy/dashboard",
"private": true,
"version": "0.0.0",
"main": "dist/main.js",
"types": "dist/main.d.ts",
"scripts": {},
"publishConfig": {
"registry": "https://registry.npmjs.org/",
"access": "restricted"
},
"devDependencies": {},
}
- I. Your new workflow
• Step 1: Open a side-branch, and make some changes (do not commit them yet).
• Step 2: Run the command lvlup add, choose a semver, and add a short description.
• Step 2.5: You can check the status by running lvlup status.
• Step 3: Make a pull request to the master branch.
• Step 4: It's up to the master branch (the CICD pipeline) to bump the version, with lvlup bump. This creates a new commit.
• Step 5: Also, it's up to the master branch (the CICD pipeline) to build the project, cd into the dist folder, and publish the new version/release, with lvlup publish. This does NOT create a new commit.
1. Divide & Conquer Methodology
While you can serve the root of the project as the package itself, it is not recommended. There are a lot of things you'll need to blacklist from ending up in the final output being packaged up and be published to npm.
Even if you'll go with the whitelist approach, instead of the blacklist approach, by using the "files" within the package.json, it is still recommended to avoid serving the root directory (whether its the src or some dist folder). This is because whitelisting is something you need to maintain, and every time you'll add a new feature you'll need to go inside the package.json and update it there.
Instead, we will go with the divide & conquer approach.
The divide & conquer approach dictates that the repo used to develop the package will be separated from the package that will be shipped to npm.
Any package that is designed to be published will have the following steps:
- Clear the dist folder (
rm -rf dist). - Build the project (minify/compile/transpile, i.e.
tsc -p tsconfig.json). - Copy & manipulate the
package.json.
The benefits of the divide & conquer approach:
- You don't have to whitelist anything.
- You don't have to blacklist anything.
- package.json include ONLY what it needs to thanks to the act of manipulation.
- Complete control of the package's structure - the output's end result.
- Complete separation between structures (the root and the package).
The name divide & conquer isn't an industry term, rather than a name I gave to this approach.
2. npm login, npm logout, .npmrc & .npmignore
- A. npm login & .npmrc
Publishing a package to npm requires you to be logged into npm.
You can log into npm by running:
npm login
After running this command you'll be asked to hit enter, and a browser will open up. There, you'll have to enter a 6-digit otp, which appears on an authenticator up you have connected to your npm account beforehand.
But what about CI/CD?
A CI/CD process can't run npm login.
This is where .npmrc comes into the picture.
A configuration file for npm.
The .npmrc file can be:
- per project (found at the project's root directory)
- per user (found at the ~/.npmrc)
- global config file (found at $PREFIX/etc/npmrc)
The .npmrc file will contain a token which will be used to publish our package without having to log in using the npm login command.
registry=https://registry.example.com/
//registry.npmjs.org/:_authToken=npm_some-long-hash
You'll of course need to create an access token on npm's website, under your profile. The token always starts with "npm_", so it's easy to recognize.
- B. npm logout
Just as there is a command of npm login to get you logged in, there's also a command to logout:
npm logout
It is SUPER important to know this command and understand what it does under the hood, because it's not so straightforward.
Not only that npm logout logs you out, it also DELETES the .npmrc file. So if you find yourself asking "where did my npmrc file go?", it could very much be that you ran npm logout, which deleted your file.
Another important piece of information is: if you had a token that should last a year, and you just committed an npm logout, you have invalidated your token permanently! And it can no longer be used!
To sum it up, npm logout does 2 things:
- It deleted the line which includes the token from your .npmrc file.
- If your
.npmrcfile contains only that 1 line with the token, the .npmrc file is deleted for good.
- If your
- It invalidates the token.
- C. .npmignore
You can use the .npmignore to blacklist files and folders from getting to the final package output that'll be published on npm.
However, since we'll be using the divide & conquer approach, the use of .npmignore will most likely not be needed.
- D. npm login Deep Dive
When you run npm login, an .npmrc file is created (or is being updated if it already exists) with a fresh new token. This means that if a previous existing (either by npm login or by copy-pasting a generated Access Token from npm), it is now gone. Erased.
3. Versioning: lvlup v.s. npm
Generally speaking, in order to publish an npm package, all you need is a package.json with a version that is higher than the version of the currently same-named package.
Nothing is stopping you from doing it manually, however you can see why it's not optimal.
lvlup is doing it "manually" - it just hides it behind sub-commands.
npm also has sub-commands for this exact task. npm has 3 commands you can use to help you bump a version:
npm version patchnpm version minornpm version major
When doing a bugfix:
npm version patch -m 'Upgrade to %s: some-message'
When adding a new feature:
npm version minor -m 'Upgrade to %s: some-message'
When doing a breaking-change:
npm version major -m 'Upgrade to %s: some-message'
In order to run either one of these, your git tree must be clean. If it's not, you'll get an error telling you to stash your changes and then try again.
Each of these command simply upgrade the version that's inside your package.json, and makes a commit out of it. That's it! Nothing fancy.
4. The role of package.json
The package.json file plays a huge role when it comes to publishing a package on npm.
It contains keys/fields, that could make or break the publish's output.
- A. REQUIRED keys within your package.json
- name
- version
- main
Your package.json MUST include a name, a version, and a main.
While the lack of name, a version would fail the publish process, the lack of main would not. However, you would not be able to import anything from the end result package! You would even get a runtime error if you try.
- B. IMPORTANT keys inside your package.json
- exports
- files
The exports field in package.json is a relatively new addition to the npm ecosystem. It allows you as the package's author to explicitly define which modules are available for consumption when the package is imported. This helps in providing a more controlled and secure interface, limiting access to internal files that shouldn't be exposed to end-users.
The exports field OVERRIDES whatever that is written under the main key, and can replace it entirely.
{
// "main": "lib/index.js", // <--- not needed now that exports exists. Has no affect.
"exports": {
".": {
"import": "./lib/index.js",
"require": "./lib/index.cjs"
}
},
}
The files key is mean for whitelisting. Only files specified under files will end up in the end-result output of the publish.
- C. Nice to have keys inside your package.json
- repository
- publishConfig
- registry
- access
- license (MIT, ISC, etc.)
8. Publishing a package to an npm registry
Now that everything is set, it's time we upload/publish our package to our npm registry.
You can publish the package by running the command:
npm publish
To help you visualize what exactly is going to be publish, you can run a dry-run publish command:
npm publish --dry-run
This will spit out all sorts of useful information.
By default, scoped packages are published with private visibility. To publish a scoped package with public visibility, use npm publish --access public.
Note: You cannot change the visibility of an unscoped package. Only scoped packages with a paid subscription may be private.
If your package is prefixed (i.e. scoped with @talkohavy), you'll have to add the access public flag:
npm publish --access=public
Without the access public flag, you'll get an error saying you must sign up for private packages, and the the publish command will fail.
This is because when trying to publish, by default, npm thinks you're trying to publish a private package. You can fix this by adding the access flag to the publish command, and setting it to public, telling npm that this package is in fact public.
9. A publish's output - Deep Dive Strategy
Earlier in this guide we talked about the divide & conquer approach, where separate the package's output to be published from the repo itself.
Let's take a deep-dive into what it actually means.
- A. A second package.json
In the build process, we create a dist folder. If we were to use the the main key within our package.json that resides in our root directory, to point to an index.js file within our dist folder, it would result in that dist folder appearing in our end result output. But what if we don't to see a dist folder as part of our end result? What if we wanted to see just a single file in there?
To that goal, and in order to achieve maximum control over our final structure, we would have ourselves a second package.json. This is what we called earlier as "Copy & manipulate the package.json".
In this step, we take the package.json found in our root directory, and copy it into our dist folder. Not before we manipulate it a bit though. The manipulation can have multiple benefits:
- We can remove scripts - stuff like "test" or "lint". They are no longer needed.
- We can change access - We can have the initial package.json start off as "restricted", thus ensuring one can't accidentally publish the root as a package, and only during manipulation change it to "public".
- We can change the private boolean - We can have the initial package.json start off as
"private": true, which is another way to ensure that no one can publish the root as a package, and only during manipulation, we remove this key entirely.
- B. A README.md file
Copy the readme.md file as-is to the dist folder. This readme file will appear on npm main page of the package, serving as a Getting-Started information on how-to-use the package to other developers.
- C. main, types & exports pointing
Since I know that there's going to be another package.json generated within the dist folder, the pointing on the keys "main", "types", and "exports" should be in relation to that package.json.
Let's say you have this project structure:
|-- src/
|-- dist/
| |
| |-- lib/
| | |
| | |-- index.js
| |-- index.d.js
| |-- package.json (copied & manipulated)
| |-- README.md (copied)
|-- package.json
|-- README.md
In the example case above, the pointing of "main" should NOT be "dist/lib/index.js", but rather "lib/index.js". Same for "types", it should be "index.d.js". Same goes for exports:
"exports": {
".": {
"import": "./lib/index.js",
"require": "./lib/index.cjs"
}
},
- D. A complete build process
What I like to do is create a build.config.js at the root of the project, and have it look like so:
import { execSync } from 'child_process';
import fs from 'fs';
const outDirName = 'dist';
buildPackageConfig();
async function buildPackageConfig() {
cleanDistDirectory();
buildWithTsc();
copyReadmeFile();
copyAndManipulatePackageJsonFile();
console.log('DONE !!!');
}
function cleanDistDirectory() {
console.log('- Step 1: clear the dist directory');
execSync('rm -rf dist');
}
function buildWithTsc() {
console.log('- Step 2: build with tsc');
execSync('tsc -p jsconfig.json');
}
function copyReadmeFile() {
console.log('- Step 3: copy the README.md file');
const readStreamReadmeMd = fs.createReadStream('./README.md');
const writeStreamReadmeMd = fs.createWriteStream(`./${outDirName}/README.md`);
readStreamReadmeMd.pipe(writeStreamReadmeMd);
}
function copyAndManipulatePackageJsonFile() {
console.log('- Step 4: copy & manipulate the package.json file');
// Step 1: get the original package.json file
const packageJson = JSON.parse(fs.readFileSync('./package.json').toString());
// Step 2: Remove all scripts
delete packageJson.scripts;
console.log('-- deleted `scripts` key');
// Step 3: Change from private to public
delete packageJson.private;
packageJson.publishConfig.access = 'public';
console.log('-- changed from private to public');
console.log('-- changed publishConfig access to public');
// Step 4: create new package.json file in the output folder
fs.writeFileSync(`./${outDirName}/package.json`, JSON.stringify(packageJson));
console.log('-- package.json file written successfully!');
}
And now i'm just adding the following script:
{
"scripts": {
"build": "node build.config.js",
}
}
- E. The publish command as a script
And finally, i'll create a script that looks like:
{
"scripts": {
// ...
"pub": "npm run build && cd dist && npm publish",
}
}
10. Typescript Types
When you involve typescript, everything changes. New behaviors are introduced, and subtle differences are brought to the table.
- A. Emitting Pure Javascript
If your package emits a plain javascript, without so much as a declaration file (.d.ts file) then nothing changes for you. Everything will work as expected.
- B. Emitting .js & .d.ts files
This is the most common use-case.
When writing a package in typescript, you'll end up needing to compile it down to javascript. Doesn't matter which tool you use, under the hood they all use tsc, so I'll be using it directly.
The end result of the compilation process will create a dist folder, along with some js files, and a main declaration file - index.d.ts. There can be other declaration files emitted during the process, which will be sibling to that index.d.ts.
In this setting, where there are .d.ts files in the final output, there are some subtle differences.
The difference between "exports" and "main"
- C. Using ONLY .ts files
Let's compare 3 cases:
- Having an only js package with
"main": "lib/index.js", - Having an only js package with
"exports": {".": { "import": "./lib/index.js" }} - Having Case 1 with
"types": "index.d.ts" - Having Case 2 with
"types": "index.d.ts"
First of all, it is important to know - not including a "types": "index.d.ts", in your package.json, doesn't mean that it defaults to nothing. In fact, the defaults is "types": "index.d.ts"!
The "types" field only exists for when you have a root .d.ts file with a name that's outside the convention on index.d.ts, or that it is located deeper in some nested folder, and not on the root.
By default, using the exports key sets every possible key that can be added under it to null.
"."
11. Auto-Completion & Auto-Suggestion
12. Versioning Helper - Changesets/cli
While you can use npm version patch/minor/major, it's impractical.
The flow of versioning is made easy with the help of a tool called changesets/cli.
- A. Getting Started
npm install -D @changesets/cli
Or...
pnpm add -D @changesets/cli
If this is your first time using changesets in this project, run this:
pnpm changeset init
This will create a .changeset folder with 2 files inside:
- config.json
- README.md
The config file is filled with all sorts of helpful configurations and tooltips explaining what each one does.
Two things you'll most definitely want/need to change are:
- "baseBranch": "main", which determines the branch that Changesets uses when finding what packages have changed. If you set a branch name that doesn't exists, the process will fail.
- "commit": false, set it to
trueinstead.
- B. Add new scripts to package.json
Add these new scripts to your package.json:
{
"scripts:" {
"cs-add": "pnpm changeset add",
"cs-bump": "pnpm changeset version",
"cs-status": "pnpm changeset status --verbose",
"cs-publish": "cd dist && pnpm changeset publish"
}
}
- C. Add copy changeset to your copy flow
/* eslint-disable */
import { execSync } from 'child_process';
import fs, { cpSync } from 'fs';
const outDirName = 'dist';
buildPackageConfig();
async function buildPackageConfig() {
cleanDistDirectory();
// ...
copyChangesetDirectory();
copyNpmIgnore();
// ...
console.log('DONE !!!');
}
function copyChangesetDirectory() {
console.log('- Step 5: copy the .changeset directory');
cpSync('.changeset', `${outDirName}/.changeset`, { recursive: true });
}
function copyNpmIgnore() {
console.log('- Step 6: copy the .npmignore file');
cpSync('.npmignore', `${outDirName}/.npmignore`);
}
- D. Versioning Flow - How to use
Once you've completed the initial setup, the flow is very simple:
Step 1: Make changes & commit them
Adding a new feature, fixing a bug, or having breaking changes are the only time that this flow is valid. Needless to say that usually when doing either one of these you are standing on a side-branch, away from master.
You DO NOT publish a new version of your package simply because you added eslint, or prettier! Theses aren't bugs, or feature that affect the end result package!
Step 2: Run the changeset ADD command
When all commits are done, it's then time to log what has been done.
Run the newly created script:
pnpm run cs-add
Choose the semver, and give a short description of what has been done.
You can, and sometimes should, run the "cs-add" command several times, once for each change that has been made for this current release.
Each time you run the "cs-add" command, an md file with some weird name will be created under the .changesets directory.
Step 3: Run the changeset STATUS command
To view everything that's going to be added to this release using the ADD command, you can use the STATUS command:
pnpm run cs-status
Step 4: Run the changeset VERSION command
When you're ready to bump the version, with all the logs you've made, run the BUMP script we've added earlier:
pnpm run cs-bump
This action will take all those md files with weird names, calculate the version number, create or update the CHANGELOG.md file with everything you wrote as notes, and then delete all those weird named files, as they've already served their purpose. These files are meant to be temporary. Their only role is to serve as information guide to the VERSION command.
Step 5: Run the changeset PUBLISH command
The PUBLISH command of changesets looks at the config.json file under .changesets, and makes a publish based on the instruction given to it there.
13. Finalize Process - test & build
In order to have the flow easy, let's make sure we are running as few script as possible, in a way that makes sense.
Look at the following snippet from my package.json:
{
"scripts": {
"clean": "rm -rf dist",
"test": "node --test",
"build-full": "node build.config.js",
"cs-add": "pnpm changeset add",
"cs-bump": "npm test && pnpm changeset version",
"cs-status": "pnpm changeset status --verbose",
"cs-publish": "pnpm run build-full && cd dist && pnpm changeset publish"
},
}
Notice that right before running the "bump" command, that's when I'm running my tests, so there can never be a version upgrade when tests are failing.
Notice that right before running the "publish" command, that's when I'm running the full-build process that creates the dist folder, along with everything needed for its publish. Since the publish itself is tightly coupled with the output of the build process, it made sense to chain them together.
999. Check locally using pnpm link --global
Before publishing a package to npm, you can test it locally by importing it to a side-project, and check that it works.
For that we use the command pnpm link --global (original command is npm link).
• Step 1: Run pnpm link on the to-be-published package
Inside the to-be-published package folder, run the following command:
pnpm link --global
Or:
npm link
depending on what you're going to use on the tester project.
Running npm link symlinks a package folder. This is handy for installing your own stuff, so that you can work on it and test iteratively without having to continually rebuild.
• Step 2: Run pnpm link pkg-name on the tester project
Create a dummy project somewhere on your machine, and do:
pnpm init -y
pnpm link <pkg-name>
Or...
npm init -y
npm link <pkg-name>
In the dummy project, create a quick script which imports the to-be-published package, and test it.
HOW DOES NPM LINK WORK?
Package linking is a two-step process:
- Run
npm linkinside the package you wish to publish - Run
npm link <pkg-name>inside the test package which imports the package.
The first step will create a symlink in the global folder < prefix>/lib/node_modules/< name-of-package> that links to the package where the npm link command was executed.
The second step will create a symbolic link from globally-installed package-name to node_modules/ of the current folder.
Note that package-name is taken from package.json, not from the directory name.