> ## Documentation Index
> Fetch the complete documentation index at: https://site.aspect.build/llms.txt
> Use this file to discover all available pages before exploring further.

# pnpm and rules_js

> Bazel rules_js pnpm integration guide: how npm_translate_lock and npm_import use pnpm-lock.yaml to fetch and link JavaScript npm packages reproducibly.

`rules_js` models npm package dependency handling on [pnpm](https://pnpm.io/),
closely mimicking pnpm's behavior.

When a non-Bazel tool—typically pnpm—performs dependency resolution and solves version constraints, it also determines the `node_modules` tree structure for runtime. This information is encoded in a lockfile that is checked into the source repository.

`rules_js` defines [`npm_import`](/docs/bazel/javascript/aspect_rules_js/npm_extensions#tag-npm_import) targets for each external package based on the pnpm lockfile, which allows Bazel's downloader to fetch packages individually.
The lockfile also includes the integrity hash that the package manager calculates, so Bazel can guarantee supply-chain security.

Bazel only fetches packages the requested targets require. You can safely use a large `pnpm-lock.yaml` file without unnecessary package fetches. In benchmarks with 800+ importers and \~15,000 npm packages, processing completes in about 3 seconds when Bazel detects an input change.

While you can use the [`npm_import`](/docs/bazel/javascript/aspect_rules_js/npm_extensions#tag-npm_import)
rule to bring individual packages into Bazel, most users import their entire lockfile using the `npm_translate_lock` rule, which is described below. For more information, see the [npm\_translate\_lock API documentation](/docs/bazel/javascript/aspect_rules_js/npm_extensions#tag-npm_translate_lock).

## Rules overview

The primary rules and targets for fetching and linking npm package dependencies are:

* [`npm.translate_lock()`](/docs/bazel/javascript/aspect_rules_js/npm_extensions#tag-npm_translate_lock):
  Generates targets representing packages from a pnpm lockfile.
* `npm_link_all_packages()`: Defines a `node_modules` tree and the associated
  `node_modules/{package}` targets. This rule is required in the following BUILD files:
  * Each package in the pnpm workspace that has npm packages linked into a `node_modules` folder that Bazel uses
  * The package at the root of the pnpm workspace where the lockfile resides
* `:node_modules/{package}`: Represents each package dependency from a `package.json`
  within the pnpm project, as generated by `npm_link_all_packages()`.

For example:

```
pnpm-lock.yaml
MODULE.bazel
> npm.npm_translate_lock()
BUILD.bazel
> npm_link_all_packages()
├── A/
    ├── BUILD.bazel
        > npm_link_all_packages()
├── B/
    ├── BUILD.bazel
        > npm_link_all_packages()
```

The following example shows the pnpm workspace structure that generated the lockfile
above, with two projects, A and B:

```
package.json
pnpm-lock.yaml
pnpm-workspace.yaml
├── A/
    ├── package.json
├── B/
    ├── package.json
```

Bazel targets such as `js_library()` rules can depend on npm packages using the  `:node_modules/{package}` targets that each `npm_link_all_packages()` generates.  The `:node_modules/{package}` targets follow Node.js resolution rules: a package can depend on `node_modules` from its own BUILD file and any BUILD file above it.

## Ignoring node\_modules

You must configure Bazel to ignore pnpm `node_modules` directories.

**Bazel 8+**: Add to `REPO.bazel`:

```js theme={null}
ignore_directories(["**/node_modules"])
```

**Bazel 7.x (deprecated)**: Use the `verify_node_modules_ignored` attribute, which checks that `.bazelignore` includes all `node_modules` folders:

```js theme={null}
npm.npm_translate_lock(
    name = "npm",
    pnpm_lock = "//:pnpm-lock.yaml",
    verify_node_modules_ignored = "//:.bazelignore",  # Bazel 7.x only
)
use_repo(npm, "npm")
```

You can call `npm.npm_translate_lock` more than once if your repository contains more than one pnpm lockfile.

## Hoisting

When [hoisting](https://pnpm.io/npmrc#hoist) is disabled, `rules_js` produces a
`node_modules` tree that is bug-for-bug compatible with the one pnpm produces.

To match this behavior outside of Bazel, add `hoist=false` to your `.npmrc`:

```shell theme={null}
echo "hoist=false" >> .npmrc
```

Adding `hoist=false` prevents pnpm from creating a hidden
`node_modules/.pnpm/node_modules` folder. Without this folder, packages cannot
depend on undeclared "phantom" dependencies.

With hoisting disabled, most import/require failures in third-party npm packages
are reproducible with pnpm outside of Bazel, whether they occur during
type-checking or at runtime.

`rules_js` does not support pnpm "phantom" hoisting,
which allows packages to depend on undeclared dependencies. All packages must declare
their dependencies to support lazy fetching and linking of npm dependencies.

For help, see [Troubleshooting](https://github.com/aspect-build/rules_js/blob/main/docs/troubleshooting.md).

## Creating and updating the pnpm-lock.yaml file

### update\_pnpm\_lock

If you're migrating from another package manager, use the `update_pnpm_lock`
attribute of `npm_translate_lock` to have Bazel manage the `pnpm-lock.yaml` file.

You can also use this mode if you want `package.json` changes to automatically
update the lockfile.

`update_pnpm_lock` also requires the `data` attribute, which must include
`pnpm-workspace.yaml` and all `package.json` files in the pnpm workspace. The
update fails if `data` is missing any files that `pnpm install --lockfile-only`
or `pnpm import` needs.

**Tip:** To list all local `package.json` files that pnpm requires, run:
`pnpm recursive ls --depth -1 --porcelain`

When `pnpm-lock.yaml` is out of date, `npm_translate_lock` automatically runs
one of the following:

* `pnpm import`, if the `npm_package_lock` or `yarn_lock` attribute is specified
* `pnpm install --lockfile-only`, if neither attribute is specified

To manually update `pnpm-lock.yaml`, use one of the following methods:

* [Install pnpm](https://pnpm.io/installation) and run
  `pnpm install --lockfile-only` or `pnpm import`
* Use the Bazel-managed pnpm by running
  `bazel run -- @pnpm//:pnpm --dir $PWD install --lockfile-only` or
  `bazel run -- @pnpm//:pnpm --dir $PWD import`

When `update_pnpm_lock` is `true`, setting the `ASPECT_RULES_JS_FROZEN_PNPM_LOCK`
environment variable causes the build to fail if `pnpm-lock.yaml` is out of date.
Set this variable on CI to enforce lockfile hygiene.

Setting `ASPECT_RULES_JS_DISABLE_UPDATE_PNPM_LOCK` disables `update_pnpm_lock`
even if it is set to `true`. This is useful in CI environments where multiple jobs
run Bazel but only one job should verify that `pnpm-lock.yaml` is up to date.

#### `npm_translate_lock_<hash>`

`npm_translate_lock` creates a
`.aspect/rules/external_repository_action_cache/npm_translate_lock_<hash>` file
to determine when `pnpm-lock.yaml` needs updating. This file tracks the state of
package and lock files that affect `pnpm-lock.yaml` generation. Check this file
into source control alongside `pnpm-lock.yaml`.

`npm_translate_lock_<hash>` can cause merge conflicts in workspaces with frequent
lockfile or `package.json` changes. Because `npm_translate_lock` generates and
updates this file, manual merge conflict resolution is never necessary.

To minimize disruption to developer workflows, configure `git` to ignore merge conflicts using `.gitattributes` and a [custom merge driver](https://git-scm.com/docs/gitattributes#_defining_a_custom_merge_driver). For more information, see the Aspect blog post, [Easier merges on lockfiles](/blog/easier-merges-on-lockfiles).

First, mark the `npm_translate_lock_<hash>` file to use a custom merge driver.
Replace `<hash>` with the hash generated in your workspace. This example uses a
driver named `ours`:

```text theme={null}
.aspect/rules/external_repository_action_cache/npm_translate_lock_<hash>= merge=ours
```

Second, define the `ours` custom merge driver in your git configuration to always
accept local changes:

```bash theme={null}
git config --global merge.ours.driver true
```

## Working with packages

### Patching with pnpm.patchedDependencies

`rules_js` automatically applies patches listed in
[pnpm.patchedDependencies](https://pnpm.io/next/package_json#pnpmpatcheddependencies).
Include these patches in the `data` attribute of `npm_translate_lock`:

```json theme={null}
{
    ...
    "pnpm": {
        "patchedDependencies": {
            "fum@0.0.1": "patches/fum@0.0.1.patch"
        }
    }
}
```

```js theme={null}
npm_translate_lock(
    ...
    data = [
        "//:patches/fum@0.0.1.patch",
    ],
)
```

Patching in `rules_js` may behave slightly differently from standard pnpm patching. `rules_js` uses the [bazel-lib patch util](https://github.com/aspect-build/bazel-lib) instead of pnpm's internal patching mechanism. For example, a malformed patch file may partially apply when using pnpm outside of Bazel, but fail entirely under `rules_js`.

### Patching with the `patches` attribute

Use `pnpm.patchedDependencies` for patching when possible. If you're importing a yarn or npm lockfile and `pnpm.patchedDependencies` is not available in your `package.json`, use the `patches` and `patch_args` attributes of `npm_translate_lock` instead. These attributes behave similarly to the same-named attributes of  [http\_archive](https://bazel.build/rules/lib/repo/http#http_archive-patch_args).

Paths in patch files must be relative to the root of the package. If you omit the version from the package name, the patch applies to every version of that npm package. `patch_args` defaults to `-p0`, but git-generated patches typically require `-p1`.

If multiple entries in `patches` match, the patches are additive—`rules_js` appends more specific matches to previous ones. If multiple entries in `patch_args` match, the most specific match takes precedence. `rules_js` applies `patches` after any patches in `pnpm.patchedDependencies`.

The following example applies patches to `@foo/bar` and `fum@0.0.1`:

```js theme={null}
npm_translate_lock(
    ...
    patches = {
        "@foo/bar": ["//:patches/foo+bar.patch"],
        "fum@0.0.1": ["//:patches/fum@0.0.1.patch"],
    },
    patch_args = {
        "*": ["-p1"],
        "@foo/bar": ["-p0"],
        "fum@0.0.1": ["-p2"],
    },
)
```

### Lifecycle hooks

npm packages support [lifecycle scripts](https://docs.npmjs.com/cli/v9/using-npm/scripts#life-cycle-scripts) such as `postinstall`, referred to in this document as *lifecycle hooks*.

The [`onlyBuiltDependencies` setting](https://pnpm.io/settings#onlybuiltdependencies) in `pnpm-workspace.yaml` defines an explicit list of packages that may run lifecycle hooks.

When a package has lifecycle hooks, the `lifecycle_*` attributes control which hooks run and how they run. For example, to restrict lifecycle hooks across all packages to only run `postinstall`:

> `lifecycle_hooks = { "*": ["postinstall"] }` in `npm_translate_lock`.

Because `rules_js` models hook execution as build actions rather than repository rules, the remote cache can store and share results between developers. By default, these actions run outside of Bazel's action sandbox to avoid the overhead of setting up and tearing down sandboxes.

Bazel also supports other [`execution_requirements`](https://bazel.build/rules/lib/actions#run) for actions beyond sandboxing. Control these using the `lifecycle_hooks_execution_requirements` attribute of `npm_translate_lock`.

If a hook fails to run under `rules_js` and you don't need it, use the `lifecycle_hooks_exclude` attribute of `npm_translate_lock` to disable it for that package. This is equivalent to setting `lifecycle_hooks` to an empty list for that package.

Set environment variables for hook build actions using the `lifecycle_hooks_envs` attribute of `npm_translate_lock`. If hooks depend on environment variables from [`use_default_shell_env`](https://bazel.build/rules/lib/builtins/actions#run.use_default_shell_env), enable this for hook build actions using the `lifecycle_hooks_use_default_shell_env` attribute of `npm_translate_lock`.

If multiple entries match, attribute behavior depends on the attribute type: some are additive, where `rules_js` appends more specific matches to previous ones, while others use specificity, where the most specific match takes precedence.

| attribute                                 | behavior    |
| ----------------------------------------- | ----------- |
| lifecycle\_hooks                          | specificity |
| lifecycle\_hooks\_envs                    | additive    |
| lifecycle\_hooks\_execution\_requirements | specificity |

The following example demonstrates lifecycle management across multiple packages:

```js theme={null}
npm_translate_lock(
    ...
    lifecycle_hooks = {
        # These three values are the default if lifecycle_hooks was absent
        # do not sort
        "*": [
            "preinstall",
            "install",
            "postinstall",
        ],
        # This package comes from a git url so prepare has to run to compile some things
        "@kubernetes/client-node": ["prepare"],
        # Disable install and preinstall for this package, maybe they are broken
        "fum@0.0.1": ["postinstall"],
    },
    lifecycle_hooks_envs = {
        # Set some values for all hook actions
        "*": [
            "GLOBAL_KEY1=value1",
            "GLOBAL_KEY2=value2",
        ],
        # ... but override for this package
        "@foo/bar": [
            "GLOBAL_KEY2=",
            "PREBULT_BINARY=http://downloadurl",
        ],
    },
    lifecycle_hooks_execution_requirements = {
        # This is the default if lifecycle_hooks_execution_requirements was absent
        "*":         ["no-sandbox"],
        # Omit no-sandbox for this package, maybe it relies on sandboxing to succeed
        "@foo/bar":  [],
        # This one is broken in remote execution for whatever reason
        "fum@0.0.1": ["no-sandbox", "no-remote-exec"],
    }
)
```

In this example:

* `@kubernetes/client-node` runs only the `prepare` hook. `fum@0.0.1` runs only
  `postinstall`. All other packages run the default hooks.
* `@foo/bar` hooks run with Bazel's sandbox enabled, with the following effective
  environment:
  * `GLOBAL_KEY1=value1`
  * `GLOBAL_KEY2=`
  * `PREBULT_BINARY=http://downloadurl`
* `fum@0.0.1` has remote execution disabled. Like all packages except `@foo/bar`,
  the action sandbox is disabled for performance.
