Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.aspect.build/llms.txt

Use this file to discover all available pages before exploring further.

rules_js models npm package dependency handling on pnpm, 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 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 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.

Rules overview

The primary rules and targets for fetching and linking npm package dependencies are:
  • 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:
ignore_directories(["**/node_modules"])
Bazel 7.x (deprecated): Use the verify_node_modules_ignored attribute, which checks that .bazelignore includes all node_modules folders:
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 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:
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.

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 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. For more information, see the Aspect blog post, 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:
.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:
git config --global merge.ours.driver true

Working with packages

Patching with pnpm.patchedDependencies

rules_js automatically applies patches listed in pnpm.patchedDependencies. Include these patches in the data attribute of npm_translate_lock:
{
    ...
    "pnpm": {
        "patchedDependencies": {
            "fum@0.0.1": "patches/fum@0.0.1.patch"
        }
    }
}
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 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. 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:
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 such as postinstall, referred to in this document as lifecycle hooks. The onlyBuiltDependencies setting 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 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, 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.
attributebehavior
lifecycle_hooksspecificity
lifecycle_hooks_envsadditive
lifecycle_hooks_execution_requirementsspecificity
The following example demonstrates lifecycle management across multiple packages:
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.