How we use npm workspaces
Recently I wrote about changing the tooling we use for our JavaScript monorepo and mentioned we are using npm
7’s new workspaces feature. Workspaces have been available to try since version 7 was released and is now considered “generally available.” I have been familiar with yarn workspaces for some time and I was a bit confused at first about what npm workspaces actually did. I’ll try to give a brief overview of our directory structure and how npm’s workspaces work for us.
For us, the new workspaces feature means two things: 1) all dependencies of the root package + sub-packages are installed into a single node_modules
folder at the root and 2) sub-packages are symlinked into node_modules
during npm install
.
Directory structure
We’ve broken our project up into three different types of packages: apps which are preact
apps intended to be bundled and deployed somewhere, modules which are plain npm packages for node/browsers and do not bundle their dependencies, and workers which are either Worker
or ServiceWorker
scripts entirely bundled up with no imports or exports. We don’t have to keep these three types of packages separated, but it helps us navigate around. It looks similar to this:
monorepo
├ …
├ build.js
├ package.json
├ apps
│ ├ …
│ └ download-app
│ ├ …
│ └ package.json
├ modules
│ ├ …
│ └ socket
│ ├ …
│ └ package.json
└ workers
├ …
└ download-service-worker
├ …
└ package.json
Our root package.json
adds those three directories’ children as workspaces:
{
"private": true,
"name": "@shareup/monorepo",
"version": "0.1.0",
"author": "Shareup <hello@shareup.app>",
"license": "UNLICENSED",
"type": "module",
"engines": { "node": "15.x" },
"workspaces": [
"apps/*",
"modules/*",
"workers/*"
]
}
And this gives us the two things I mentioned above:
1) All dependencies are installed into the root node_modules
npm install
will check every workspace, accumulate all the unique dependencies, and install them all into the root node_modules
directory. For example: since download-app
depends on preact
, npm
will install that version of preact
into the node_modules
folder at the root.
An important caveat is: any project can require any dependency from the shared node_modules
even if it’s not directly included in the specific sub-project’s package.json
file. We use this “feature” so our build tools are only listed in the root package.json
file and we don’t duplicate those out into every sub-project. However, there are currently no tools to help identify and/or enforce any undeclared dependencies in a sub-project, so right now it’s a human problem or you’ll need to write your own tooling.
Another important thing to remember is: don’t run npm install
inside a sub-project. npm
isn’t smart enough to figure out it’s inside a workspace and will assume it’s a normal project, create a local node_modules
directory inside the sub-project, etc. I hope this changes soon and npm
can detect the root package.json
and perform the install up at the root.
2) All sub-projects are symlinked into the root node_modules
npm
will create a symlink inside the root node_modules
directory to each sub-project. If you remember the above directory structure, then this example would create the following symlinks (we prefix all our module names with @shareup
):
node_modules
├ …
└ @shareup
├ download-app → ../../apps/download-app/
├ download-service-worker → ../../workers/download-service-worker/
└ socket → ../../modules/socket/
So any project can import { createSocket } from '@shareup/socket'
and it will find it on disk through the symlink.
Give it a try
We recommend trying out the new npm
workspaces and providing feedback to the npm
team so they get even better over time.