pnpm
pnpm is the recommended package manager of choice for TypeScript projects at SEEK.
This topic details how to use pnpm with skuba.
Background
skuba serves as a wrapper for numerous developer tools such as TypeScript, Jest, Prettier & ESLint, abstracting the dependency management of those packages across SEEK projects. When you are using skuba, you do not need to declare these packages as direct devDependencies. In our previously-recommended package manager, Yarn, these packages and others are automatically hoisted to create a flattened dependency tree.
{
"devDependencies": {
"skuba": "7.2.0"
}
}
node_modules
βββ jest
βββ prettier
βββ skuba
βββ other-skuba-deps
However, this behaviour can lead to some silly bugs when updating packages.
pnpm in skuba
pnpm addresses the hoisting issue with a symlinked structure. Each package is guaranteed to resolve compatible versions of its dependencies, rather than whichever versions were incidentally hoisted.
This behaviour is a double-edged sword for a toolkit like skuba. Dependencies like Prettier and ESLint end up nested in a node_modules/skuba/node_modules subdirectory, where most editor and developer tooling integrations will not know to look.
node_modules
βββ skuba -> ./.pnpm/skuba@7.2.0
βββ .pnpm
βββ skuba@7.2.0
β βββ node_modules
β βββ prettier -> ../../prettier@3.0.0
βββ prettier@3.0.0
βββ node_modules
βββ other-dep -> <store>/other-dep
pnpm-workspace.yaml
pnpm allows us to specify dependencies to hoist via command line or pnpm-workspace.yaml. The number of package patterns we need to hoist may fluctuate over time, so specifying hoist patterns via command line would be difficult to maintain.
The skuba-maintained pnpm-workspace.yaml (previously .npmrc) currently instructs pnpm to hoist the following dependencies:
# managed by skuba
packageManagerStrictVersion: true
publicHoistPattern:
- '@types*'
- '*eslint*'
- '*prettier*'
- esbuild
- jest
- tsconfig-seek
# end managed by skuba
From the previous example, this will produce the following node_modules layout, allowing external integrations to find prettier in node_modules/prettier as before.
node_modules
βββ prettier -> ./.pnpm/prettier@3.0.0
βββ skuba -> ./.pnpm/skuba@7.2.0
βββ .pnpm
βββ skuba@7.2.0
β βββ node_modules
β βββ prettier -> ../../prettier@3.0.0
βββ prettier@3.0.0
βββ node_modules
βββ other-dep -> <store>/other-dep
Migrating from Yarn 1.x to pnpm
This migration guide assumes that your project was scaffolded with a skuba template.
-
Install skuba 11.0.0 or greater
-
Add a
packageManagerkey topackage.json"packageManager": "pnpm@10.18.3", -
Install pnpm
corepack enable && corepack install(Check the install guide for alternate methods)
-
Create
pnpm-workspace.yamlSkip this step if your project does not use Yarn workspaces.
packages: # all packages in direct subdirectories of packages/ - 'packages/*'(Optional) If your sub-package
package.jsons reference one another using the syntaxfoo: *, you can replace these references with the workspace protocol using the syntaxfoo: workspace:*. -
Run
pnpm import && rm yarn.lockThis converts
yarn.locktopnpm-lock.yaml. -
Run
pnpm skuba formatThis will synthesise managed hoist patterns into
pnpm-workspace.yaml. -
Include additional hoisting settings in
pnpm-workspace.yamlfor ServerlessSkip this step if your project does not use Serverless. It can also be skipped for Serverless projects that use
esbuildbundling.# managed by skuba packageManagerStrictVersion: true publicHoistPattern: - '@types*' - '*eslint*' - '*prettier*' - esbuild - jest - tsconfig-seek # end managed by skuba + + # Required for Serverless packaging + nodeLinker: hoisted + shamefullyHoist: true -
Run
rm -rf node_modules && pnpm installThis will ensure your local workspace will not have any lingering hoisted dependencies from
yarn.If you have a monorepo, delete all sub-package
node_modulesdirectories. -
Run
pnpm skuba lintAfter running
pnpm install, you may notice that some module imports no longer work. This is intended behaviour as these packages are no longer hoisted by default. Explicitly declare these asdependenciesordevDependenciesinpackage.json.For example:
Cannot find module 'foo'. Did you mean to set the 'moduleResolution' option to 'nodenext', or to add aliases to the 'paths' option? ts(2792)Run
pnpm install footo resolve this error. -
Modify
DockerfileorDockerfile.dev-depsFROM --platform=arm64 node:20-alpine AS dev-deps + RUN --mount=type=bind,source=package.json,target=package.json \ + corepack enable pnpm && corepack install + RUN --mount=type=bind,source=package.json,target=package.json \ + pnpm config set store-dir /root/.pnpm-store WORKDIR /workdir - COPY package.json yarn.lock ./ - COPY packages/foo/package.json packages/foo/ - RUN --mount=type=secret,id=npm,dst=/workdir/.npmrc \ - yarn install --frozen-lockfile --ignore-optional --non-interactive + RUN --mount=type=bind,source=package.json,target=package.json \ + --mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \ + --mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \ + --mount=type=secret,id=npm,dst=/root/.npmrc,required=true \ + pnpm fetchMove the
dstof the ephemeral.npmrcfrom/workdir/.npmrcto/root/.npmrc, and use a bind mount in place ofCOPYto mountpnpm-lock.yaml.pnpm fetchdoes not requirepackage.jsonto be copied to resolve packages; trivial updates topackage.jsonlike a change inscriptswill no longer result in a cache miss.pnpm fetchis also optimised for monorepos and does away with the need to copy nestedpackage.jsons. However, this command only serves to populate a local package store and stops short of installing the packages, the implications of which are covered in the next step.If using the newer
GET_NPM_TOKENenvironment variable, your fetch command should instead look like:RUN --mount=type=bind,source=.npmrc,target=.npmrc \ --mount=type=bind,source=package.json,target=package.json \ --mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \ --mount=type=secret,id=npm,dst=/root/.npmrc,required=true \ --mount=type=secret,id=NPM_TOKEN,env=NPM_TOKEN,required=true \ pnpm fetchReview
Dockerfile.dev-depsfrom the newkoa-rest-apitemplate as a reference point. -
Replace
yarnwithpnpminDockerfileAs
pnpm fetchdoes not actually install packages, run a subsequentpnpm install --offlinebefore any command which may reference a dependency. Swap outyarncommands forpnpmcommands, and drop the unnecessaryAS depsstage.- FROM ${BASE_IMAGE} AS deps - - RUN yarn install --ignore-optional --ignore-scripts --non-interactive --offline --production - - ### - FROM ${BASE_IMAGE} AS build COPY . . - RUN yarn build + RUN pnpm install --offline + RUN pnpm build + RUN pnpm install --offline --prod ### FROM --platform=arm64 gcr.io/distroless/nodejs20-debian12 AS runtime WORKDIR /workdir COPY --from=build /workdir/lib lib - COPY --from=deps /workdir/node_modules node_modules + COPY --from=build /workdir/node_modules node_modules ENV NODE_ENV=production -
Modify plugins in
.buildkite/pipeline.ymlFollowing the Dockerfile changes, apply the analogous changes to the Buildkite pipeline.
We are using an updated caching syntax on
package.jsonwhich caches only on thepackageManagerkey. This requires the seek-oss/docker-ecr-cache plugin version to be >= 2.2.0.If using the older
private-npmsetup:seek-oss/private-npm#v1.3.0: env: NPM_READ_TOKEN + output-path: /tmp/-Β seek-oss/docker-ecr-cache#v2.1.0: + seek-oss/docker-ecr-cache#v2.2.1: cache-on: - - package.json - - yarn.lock + - package.json#.packageManager + - pnpm-lock.yaml + - pnpm-workspace.yaml dockerfile: Dockerfile.dev-deps - secrets: id=npm,src=.npmrc + secrets: id=npm,src=/tmp/.npmrcIf using the newer
GET_NPM_TOKENenvironment variable to abstract awayaws-sm/private-npm, your pipeline docker-ecr-cache plugin should look like:- seek-oss/docker-ecr-cache#v2.2.1: cache-on: - pnpm-workspace.yaml - package.json#.packageManager - pnpm-lock.yaml dockerfile: Dockerfile.dev-deps secrets: - id=npm,src=/var/lib/buildkite-agent/.npmrc - NPM_TOKEN -
Run
pnpm install --offlineand replaceyarnwithpnpmin.buildkite/pipeline.yml- label: π§ͺ Test & Lint commands: + - echo '--- pnpm install --offline' + - pnpm install --offline - - echo '+++ yarn test:ci' - - yarn test:ci - - echo '--- yarn lint' - - yarn lint + - echo '+++ pnpm test:ci' + - pnpm test:ci + - echo '--- pnpm lint' + - pnpm lint -
Search for other references to
yarnin your project. Replace these withpnpmwhere necessary.For example, you may have the lockfile listed in
.github/CODEOWNERS:- yarn.lock + pnpm-lock.yaml
FAQ
Q: Iβm running into ERR_PNPM_CANNOT_DEPLOYβ A deploy is only possible from inside a workspace
A: pnpm deploy is a reserved command. Use pnpm run deploy instead.
Q: Iβm seeing ERR_PNPM_RECURSIVE_EXEC_FIRST_FAILβ Command "<NAME>" not found in my pipeline
A: Ensure pnpm install --offline is referenced earlier within pipeline step as shown in step 14.
Q: Iβm seeing ERR_PNPM_RECURSIVE_EXEC_FIRST_FAILβ Command "workspace" not found in my pipeline
A: pnpm workspace <PACKAGE_NAME> does not work. Replace it with the --filter flag.
Contributing
This guide is not comprehensive just yet, and it may not account for certain intricacies of your project.
If you run into an issue that is not documented here, please start a discussion or contribute a change so others can benefit from your findings. This page may be edited on GitHub.