Package Manager and Monorepo using PNPM

pnpm as Package Manager and Monorepo using PNPM

a new generation of package management tools, and npm , yarn The comparative advantages are:

  • Package installation is fast
  • Efficient use of disk space All files in node_modules are linked from a single storage location
  • support **monorepo** pass pnpm-workspace.yaml designated workspace
  • Strict permissions The node_modules created by pnpm are not flat by default, so code cannot access arbitrary packages

Official website address: www.pnpm.cn/

Initialize a new PNPM workspace

To get started, let’s make sure you have PNPM installed. The official docs have an installation page with detailed instructions. I also recommend using something like Volta in particular if you have to deal with multiple different versions of NPM/PNPM and node versions.

Let’s create a new folder named pnpm-mono, cd into it and then run pnpm init to generate a top-level package.json. This will be the root package.json for our PNPM monorepo.

mkdir pnpm-mono  
❯ cd pnpm-mono  
❯ pnpm init

It is probably also handy to initialize a new Git repository such that we can commit and backup things as we progress in the setup:

git init

At this point let’s also create a .gitignore file to immediately exclude things like node_modules and common build output folders.

# .gitignore  
node_modules  
dist  
build

Setting up the Monorepo structure

The structure of a monorepo might vary depending on what you plan to use it for. There are generally two kinds of monorepo:

  • package centric repositories which are used for developing and publishing a cohesive set of reusable packages. This is a common setup in the open source world and can be seen in repositories such as Angular, React, Vue and many others. Those repos are characterized by most commonly having a packages folder and which are then commonly published to some public registry such as NPM.
  • app centric repositories which are used mainly for developing applications and products. This is a common setup in companies. Such repos are characterized in having an apps and packages or libs folder, where the apps folder contains the buildable and deployable applications, while the packages or libs folder contains libraries that are specific to one or multiple applications that are being developed within the monorepo. You can still also publish some of these libs to a public registry.

In this article we’re going to use the “app centric” approach, to demonstrate how we can have an application that consumes packages from within the monorepo.

Create an apps and packages folder within pnpm-mono:

mkdir apps packages

Now let’s configure PNPM to properly recognize the monorepo workspace. Basically we have to create a pnpm-workspace.yaml file at the root of the repository, defining our monorepo structure:

pnpm-workspace.yaml

packages:  
  # executable/launchable applications  
  - 'apps/*'  
  # all packages in subdirs of packages/ and components/  
  - 'packages/*'

Adding a Remix application

We should now be ready to add our first application. For this example I picked Remix but you can really host any type of application in here, it won’t really matter. react, angular or any other app

Since we want to have the app within the apps folder, we need to cd into it:

cd apps  
❯ npx create-remix@latest

You will be asked for an app name. Let’s just go with “my-remix-app” which we’ll be using for the rest of this article. Obviously feel free to use a different one. In addition, the Remix setup process is also going to ask you a couple of questions that customize the exact setup. The particular options are not really relevant for our article here, so feel free to choose whatever best suits your needs.

You should have now a Remix app, within the apps/my-other-app folder or whatever name you chose. Remix has already a package.json with corresponding scripts configured:

{  
  "private": true,  
  "sideEffects": false,  
  "scripts": {  
    "build": "remix build",  
    "dev": "remix dev",  
    "start": "remix-serve build"  
  },  
  ...  
}

Usually, in a monorepo you want to run commands from the root of the repository to not have to constantly switch between folders. PNPM workspaces have a way to do that, by passing a filter argument, like:

❯ pnpm --filter

Now it happens (at the writing of this article) that Remix’s default package.json doesn't have a name property defined which PNPM wants to run the package. So let's define one in the apps/my-other-app/package.json:

{  
  "name": "my-other-app",  
  "private": true,  
  "sideEffects": false,  
  ...  
}

You should now be able to serve your Other Appin dev-mode by using:

pnpm --filter my-other-app dev

Create a Shared UI library

Now that we have our app set up, let’s create a library package that can be consumed by our application.

cd packages  
❯ mkdir shared-ui

Next, let’s create a package.json with the following content (you can also use pnpm init and adjust it):

{  
  "private": true,  
  "name": "shared-ui",  
  "description": "Shared UI components",  
  "scripts": {},  
  "keywords": [],  
  "author": "",  
  "license": "ISC",  
  "dependencies": {  
  },  
  "devDependencies": {  
  }  
}

Note, we declare it as private because we don't want to publish it to NPM or somewhere else, but rather just reference and use it locally within our workspace. I also removed the version property since it is not used.

As the technology stack I’ve chosen to go with React (so we can import it in Remix) and TypeScript (because it can almost be considered a standard nowadays). Let’s install these dependencies from the root of the workspace:

pnpm add --filter shared-ui react  
❯ pnpm add --filter shared-ui typescript -D

By passing --filter shared-ui to the installation command, we install these NPM packages locally to the shared-ui library.

Info: Be aware that this might potentially cause version conflicts if the React/TypeScript version used by the library package and the consumer (e.g. our app) differs. Adopting a single version policy, where you move the packages to the root of the monoreopo, is a possible solution for that.

Our first component will be a very simple Button component. So let's create one:

// packages/shared-ui/Button.tsxexport function Button(props: any) {  
  return <button onClick={() => props.onClick()}>{props.children}</button>;  
}
export default Button;

We also want to have a public API where we export components to be used outside of our shared-ui package:

// packages/shared-ui/index.tsx  
export * from './Button';

For sake of simplicity we just use the TypeScript compiler to compile our package. We could have some more sophisticated setup for bundling multiple files together etc with something like Rollup or whatever you prefer using, but that’s outside the scope of this article.

To create the desired compilation output create a packages/shared-ui/tsconfig.json file with the following configuration.

{  
  "compilerOptions": {  
    "jsx": "react-jsx",  
    "allowJs": true,  
    "esModuleInterop": true,  
    "allowSyntheticDefaultImports": true,  
    "module": "commonjs",  
    "outDir": "./dist"  
  },  
  "include": ["."],  
  "exclude": ["dist", "node_modules", "**/*.spec.ts"]  
}

In a monorepo it is good practice to extract the common config part into a higher-level config (e.g. at the root) and then extend it here in the various projects. This to avoid a lot of duplication across the various monorepo packages. For the sake of simplicity I kept it all in one place here.

As you can see the outDir points to a package-local dist folder. So we should add a main entry point in the shared-ui package's package.json:

{  
  "private": true,  
  "name": "shared-ui",  
  "main": "dist/index.js",  
}

Finally, the actual build consists of deleting some residual folders from the previous output and then invoking the TypeScript compiler (tsc). Here's the complete packages/shared-ui/package.json file:

{  
  "private": true,  
  "name": "shared-ui",  
  "description": "Shared UI components",  
  "main": "dist/index.js",  
  "scripts": {  
    "build": "rm -rf dist && tsc"  
  },  
  "keywords": [],  
  "author": "",  
  "license": "ISC",  
  "dependencies": {  
    "react": "^17.0.2"  
  },  
  "devDependencies": {  
    "typescript": "^4.6.4"  
  }  
}

Use the following command to run the build from the root of the PNPM workspace:

pnpm --filter shared-ui build

If the build succeeds, you should see the compiled output in the packages/shared-ui/dist folder.

Consuming our shared-ui package from the Remix app

Our shared-ui library is ready so we can use it in the Remix application hosted within the apps folder of our repository. We can either manually add the dependency to Remix's package.json or use PNPM to add it:

pnpm add shared-ui --filter my-other-app --workspace

This adds it to the dependency in the apps/my-other-app/package.json:

{  
  "name": "my-other-app",  
  "private": true,  
  "sideEffects": false,  
  ...  
  "dependencies": {  
    ...  
    "shared-ui": "workspace:*"  
  },  
  ...  
}

workspace:* denotes that the package is resolved locally in the workspace, rather than from some remote registry (such as NPM). The * simply indicates that we want to depend on the latest version of it, rather than a specific one. Using a specific version really just makes sense if you're using external NPM packages.

To use our Button component we now import it from some Remix route. Replace the content of apps/my-other-app/app/routes/index.tsx with the following:

// apps/my-other-app/app/routes/index.tsx  
import { Button } from 'shared-ui';export default function Index() {  
  return (  
    <div>  
      <Button onClick={() => console.log('clicked')}>Click me</Button>  
    </div>  
  );  
}

If you now run the Other Appagain you should see the button being rendered.

pnpm --filter my-other-app dev

Running commands with PNPM

PNPM comes with handy features to run commands across the monorepo workspace. We have already seen how to scope commands on single packages using the --filter :

pnpm --filter my-other-app dev

You can also run a command recursively on all the packages in the workspace using the -r flag. Imagine for instance running the build for all projects.

pnpm run -r buildScope: 2 of 3 workspace projects  
packages/shared-ui build$ rm -rf dist && tsc  
└─ Done in 603ms  
apps/my-other-app build$ remix build  
│ Building Other App in production mode...  
│ The path "shared-ui" is imported in app/routes/index.tsx but shared-ui is not listed in your package.json   
│ Built in 156ms  
└─ Done in 547ms

Similarly you can parallelize the run by using --parallel

pnpm run --parallel -r buildScope: 2 of 3 workspace projects  
apps/my-other-app build$ remix build  
packages/shared-ui build$ rm -rf dist && tsc  
apps/my-other-app build: Building Other App in production mode...  
apps/my-other-app build: The path "shared-ui" is imported in app/routes/index.tsx but shared-ui is not listed in your package.json dependencies. Did you forget to install it?  
apps/my-other-app build: Built in 176ms  
apps/my-other-app build: Done  
packages/shared-ui build: Done

References

Comments