blob: 5cd11e3b31f819289006c63ae6aad981d1fbc331 [file] [edit]
"""Setup steps, or [extensions](https://bazel.build/docs/bzlmod#extension-definition) for pnpm.
These are used in `MODULE.bazel` files to pin tooling versions, for example assuming
- the desired version of Node.js is specified in a `.nvmrc` file
- the desired version of pnpm is specified in the `package.json#packageManager` field
- package manager preferences (especially `hoist=false`) is in `.npmrc`
- pnpm dependencies were resolved and locked using `pnpm-lock.yaml`
then the following `MODULE.bazel` file can be used:
```starlark
node = use_extension("@rules_nodejs//nodejs:extensions.bzl", "node")
node.toolchain(node_version_from_nvmrc = "//:.nvmrc")
use_repo(node, "nodejs_toolchains")
pnpm = use_extension("@aspect_rules_js//npm:extensions.bzl", "pnpm")
use_repo(pnpm, "pnpm")
pnpm.pnpm(pnpm_version_from = "//:package.json")
npm = use_extension("@aspect_rules_js//npm:extensions.bzl", "npm")
npm.npm_translate_lock(
name = "npm",
npmrc = "//:.npmrc",
pnpm_lock = "//:pnpm-lock.yaml",
)
use_repo(npm, "npm")
```
"""
load("@bazel_lib//lib:repo_utils.bzl", "repo_utils")
load("//npm/private:npm_import.bzl", "npm_import", "npm_import_lib")
load("//npm/private:npm_translate_lock.bzl", "npm_translate_lock_lib", "parse_and_verify_lock")
load("//npm/private:npm_translate_lock_generate.bzl", "generate_repository_files")
load("//npm/private:npm_translate_lock_helpers.bzl", npm_translate_lock_helpers = "helpers")
load("//npm/private:npmrc.bzl", "parse_npmrc")
load("//npm/private:pnpm_extension.bzl", "DEFAULT_PNPM_REPO_NAME", "resolve_pnpm_repositories")
load("//npm/private:pnpm_repository.bzl", "pnpm_repository", _DEFAULT_PNPM_VERSION = "DEFAULT_PNPM_VERSION", _LATEST_PNPM_VERSION = "LATEST_PNPM_VERSION")
DEFAULT_PNPM_VERSION = _DEFAULT_PNPM_VERSION
LATEST_PNPM_VERSION = _LATEST_PNPM_VERSION
_FORBIDDEN_OVERRIDE_TAG = """\
The "npm.{tag_class}" tag can only be used in the root Bazel module, \
but module "{module_name}" attempted to use it.
Package replacements affect the entire dependency graph and must be controlled \
by the root module to ensure consistency across all dependencies.
If you need to replace a package in a non-root module move the npm_replace_package() call to your root MODULE.bazel file
For more information, see: https://github.com/aspect-build/rules_js/blob/main/docs/pnpm.md
"""
def _fail_on_non_root_overrides(module, tag_class):
"""Prevents non-root modules from using restricted tags.
Args:
module: The module being processed
tag_class: The name of the tag class to check (e.g., "npm_replace_package")
"""
if module.is_root:
return
if getattr(module.tags, tag_class):
fail(_FORBIDDEN_OVERRIDE_TAG.format(
tag_class = tag_class,
module_name = module.name,
))
def _npm_extension_impl(module_ctx):
# Collect all exclude_package_contents tags and build exclusion dictionary
exclude_package_contents_config = _build_exclude_package_contents_config(module_ctx)
# Collect all package replacements across all modules
replace_packages = {}
for mod in module_ctx.modules:
# Validate that only root modules (or isolated extensions) use npm_replace_package
_fail_on_non_root_overrides(mod, "npm_replace_package")
for attr in mod.tags.npm_replace_package:
if attr.package in replace_packages:
fail("Package '{}' already has a replacement defined in another module".format(attr.package))
replace_packages[attr.package] = "@@{}//{}:{}".format(attr.replacement.repo_name, attr.replacement.package, attr.replacement.name)
# Process npm_translate_lock and npm_import tags
for mod in module_ctx.modules:
for attr in mod.tags.npm_translate_lock:
_npm_translate_lock_bzlmod(module_ctx, mod, attr, exclude_package_contents_config, replace_packages)
for i in mod.tags.npm_import:
_npm_import_bzlmod(i)
return module_ctx.extension_metadata(reproducible = True)
def _build_exclude_package_contents_config(module_ctx):
"""Build exclude_package_contents configuration from tags across all modules."""
exclusions = {}
for mod in module_ctx.modules:
for exclude_tag in mod.tags.npm_exclude_package_contents:
# Process the package in the tag
package = exclude_tag.package
if package in exclusions:
fail("Duplicate exclude_package_contents tag for package: {}".format(package))
# Store patterns and presets separately - don't expand here,
# they will be processed via npm_import attributes
exclusions[package] = struct(
patterns = exclude_tag.patterns,
presets = exclude_tag.presets,
)
return exclusions
def _hub_repo_impl(rctx):
for path, contents in rctx.attr.contents.items():
rctx.file(path, contents)
# Support bazel <v8.3 by returning None if repo_metadata is not defined
if not hasattr(rctx, "repo_metadata"):
return None
return rctx.repo_metadata(reproducible = True)
_hub_repo = repository_rule(
implementation = _hub_repo_impl,
attrs = {
"contents": attr.string_dict(
doc = "A mapping of file names to text they should contain.",
mandatory = True,
),
},
)
def _npm_translate_lock_bzlmod(module_ctx, mod, attr, exclude_package_contents_config, replace_packages):
state = parse_and_verify_lock(module_ctx, mod, attr)
module_ctx.report_progress("Generating starlark for npm dependencies")
registries = {}
npm_auth = {}
if attr.npmrc:
npmrc = parse_npmrc(module_ctx.read(attr.npmrc))
(registries, npm_auth) = npm_translate_lock_helpers.get_npm_auth(npmrc, module_ctx.path(attr.npmrc), module_ctx)
if attr.use_home_npmrc:
home_directory = repo_utils.get_home_directory(module_ctx)
if home_directory:
home_npmrc_path = "{}/{}".format(home_directory, ".npmrc")
if module_ctx.path(home_npmrc_path).exists:
home_npmrc = parse_npmrc(module_ctx.read(home_npmrc_path))
(registries2, npm_auth2) = npm_translate_lock_helpers.get_npm_auth(home_npmrc, home_npmrc_path, module_ctx)
registries.update(registries2)
npm_auth.update(npm_auth2)
else:
# buildifier: disable=print
print("""
WARNING: Cannot determine home directory in order to load home `.npmrc` file in module extension `npm_translate_lock(name = "{attr_name}")`.
""".format(attr_name = attr.name))
imports = npm_translate_lock_helpers.get_npm_imports(
state = state,
replace_packages = replace_packages,
attr = attr,
registries = registries,
npm_auth = npm_auth,
exclude_package_contents_config = exclude_package_contents_config,
)
# attr.pnpm_lock.repo_name is a canonical repository name, so it needs to be qualified with an extra '@'.
link_workspace = "@" + attr.pnpm_lock.repo_name
for i in imports:
npm_import(
name = i.repo_name,
key = i.package_key,
bins = i.bins,
commit = i.commit,
custom_postinstall = i.custom_postinstall,
deps = i.deps,
deps_oss = i.deps_oss,
deps_cpus = i.deps_cpus,
integrity = i.integrity,
generate_package_json_bzl = i.is_direct_dep,
extract_full_archive = None,
extra_build_content = "",
generate_bzl_library_targets = attr.generate_bzl_library_targets,
lifecycle_hooks = i.lifecycle_hooks if i.lifecycle_hooks else [],
lifecycle_hooks_env = i.lifecycle_hooks_env,
lifecycle_hooks_execution_requirements = i.lifecycle_hooks_execution_requirements,
lifecycle_hooks_use_default_shell_env = i.lifecycle_hooks_use_default_shell_env,
link_workspace = link_workspace,
npm_auth = i.npm_auth,
npm_auth_basic = i.npm_auth_basic,
npm_auth_password = i.npm_auth_password,
npm_auth_username = i.npm_auth_username,
package = i.package,
package_visibility = i.package_visibility,
patch_tool = i.patch_tool,
patch_args = i.patch_args,
patches = i.patches,
exclude_package_contents = i.exclude_package_contents,
exclude_package_contents_presets = i.exclude_package_contents_presets,
replace_package = i.replace_package,
root_package = state.root_package(),
transitive_closure = i.transitive_closure,
url = i.url,
version = i.version,
)
files = generate_repository_files(
attr,
state,
imports,
)
_hub_repo(
name = attr.name,
contents = files,
)
def _npm_import_bzlmod(i):
# Assume package+version is a unique key for any package store this import is placed in
package_key = "{}@{}".format(i.package, i.version)
npm_import(
name = i.name,
key = package_key,
generate_package_json_bzl = True, # Always generate package_json.bzl explicitly declared imports
generate_bzl_library_targets = None,
extract_full_archive = None,
bins = i.bins,
commit = i.commit,
custom_postinstall = i.custom_postinstall,
deps = i.deps,
deps_oss = None,
deps_cpus = None,
extra_build_content = i.extra_build_content,
integrity = i.integrity,
lifecycle_hooks = i.lifecycle_hooks,
lifecycle_hooks_env = i.lifecycle_hooks_env,
lifecycle_hooks_execution_requirements = i.lifecycle_hooks_execution_requirements,
lifecycle_hooks_use_default_shell_env = i.lifecycle_hooks_use_default_shell_env,
link_workspace = None,
npm_auth = i.npm_auth,
npm_auth_basic = i.npm_auth_basic,
npm_auth_username = i.npm_auth_username,
npm_auth_password = i.npm_auth_password,
package = i.package,
package_visibility = i.package_visibility,
patch_tool = i.patch_tool,
patch_args = i.patch_args,
patches = i.patches,
exclude_package_contents = i.exclude_package_contents,
exclude_package_contents_presets = i.exclude_package_contents_presets,
replace_package = i.replace_package,
root_package = i.root_package,
transitive_closure = None,
url = i.url,
version = i.version,
)
_NPM_IMPORT_ATTRS = npm_import_lib.attrs | {
# Add macro attrs that aren't in the rule attrs.
"name": attr.string(),
}
_EXCLUDE_PACKAGE_CONTENT_ATTRS = {
"package": attr.string(
doc = "Package name to apply exclusions to. Supports wildcards like '*' for all packages.",
mandatory = True,
),
"patterns": attr.string_list(
doc = "List of glob patterns to exclude from the specified package.",
default = [],
),
"presets": attr.string_list(
doc = """\
Which preset exclusion patterns to include. Multiple presets can be combined. Valid values:
- "basic": basic exclusions such as README files, tests, and development files.
- "yarn_autoclean": Yarn autoclean exclusions (see https://github.com/yarnpkg/yarn/blob/7cafa512a777048ce0b666080a24e80aae3d66a9/src/cli/commands/autoclean.js#L16)
""",
default = ["basic"],
),
}
_EXCLUDE_PACKAGE_CONTENT_DOCS = """Configuration for excluding package contents from npm packages.
This tag can be used multiple times to specify different exclusion patterns for different package specifiers.
More specific package matches override less specific ones (the wildcard "*" is only used if no specific
package match is found).
By default, `presets` is set to `["basic"]` which excludes common files such as `*.md` and development-related
files. Multiple presets can be combined.
Example:
```
npm.npm_exclude_package_contents(
package = "*",
patterns = ["**/docs/**"],
)
npm.npm_exclude_package_contents(
package = "my-package@1.2.3",
# Overrides the "*" config for this specific package
presets = ["yarn_autoclean"],
)
```
"""
_REPLACE_PACKAGE_ATTRS = {
"package": attr.string(
doc = "The package name and version to replace (e.g., 'chalk@5.3.0')",
mandatory = True,
),
"replacement": attr.label(
doc = "The target to use as replacement for this package",
mandatory = True,
),
}
_REPLACE_PACKAGE_DOCS = """Replace a package with a custom target.
This allows replacing packages declared in package.json with custom implementations.
Multiple npm_replace_package tags can be used to replace different packages.
Targets must produce `JsInfo` or `NpmPackageInfo` providers such as `js_library` or `npm_package` targets.
The injected package targets may optionally contribute transitive npm package dependencies on top
of the transitive dependencies specified in the pnpm lock file for their respective packages, however, these
transitive dependencies must not collide with pnpm lock specified transitive dependencies.
Any patches specified for the packages will be not applied to the injected package targets. They
will be applied, however, to the fetched sources for their respective packages so they can still be useful
for patching the fetched `package.json` files, which are used to determine the generated bin entries for packages.
NB: lifecycle hooks and custom_postinstall scripts, if implicitly or explicitly enabled, will be run on
the injected package targets. These may be disabled explicitly using the `lifecycle_hooks` attribute.
Example:
```starlark
npm.npm_replace_package(
package = "chalk@5.3.0",
replacement = "@chalk_501//:pkg",
)
```"""
npm = module_extension(
implementation = _npm_extension_impl,
tag_classes = {
"npm_translate_lock": tag_class(attrs = npm_translate_lock_lib.attrs, doc = npm_translate_lock_lib.doc),
"npm_import": tag_class(attrs = _NPM_IMPORT_ATTRS, doc = npm_import_lib.doc),
"npm_exclude_package_contents": tag_class(attrs = _EXCLUDE_PACKAGE_CONTENT_ATTRS, doc = _EXCLUDE_PACKAGE_CONTENT_DOCS),
"npm_replace_package": tag_class(attrs = _REPLACE_PACKAGE_ATTRS, doc = _REPLACE_PACKAGE_DOCS),
},
)
def _pnpm_extension_impl(module_ctx):
resolved = resolve_pnpm_repositories(module_ctx)
for note in resolved.notes:
# buildifier: disable=print
print(note)
for name, pnpm in resolved.repositories.items():
pnpm_repository(
name = name,
pnpm_version = pnpm["version"],
integrity = pnpm["integrity"],
include_npm = pnpm["include_npm"],
)
kwargs = {}
if resolved.facts:
kwargs["facts"] = resolved.facts
return module_ctx.extension_metadata(reproducible = True, **kwargs)
pnpm = module_extension(
implementation = _pnpm_extension_impl,
tag_classes = {
"pnpm": tag_class(
attrs = {
"name": attr.string(
doc = """Name of the generated repository, allowing more than one pnpm version to be registered.
Overriding the default is only permitted in the root module.""",
default = DEFAULT_PNPM_REPO_NAME,
),
"include_npm": attr.bool(
doc = "If true, include the npm package along with the pnpm binary.",
default = False,
),
"pnpm_version": attr.string(
doc = "pnpm version to use. The string `latest` will be resolved to LATEST_PNPM_VERSION.",
default = DEFAULT_PNPM_VERSION,
),
"pnpm_version_from": attr.label(
doc = """Label to a package.json file to read the pnpm version from.
It should appear as an attribute like `"packageManager": "pnpm@10.20.0"`
""",
default = None,
),
"pnpm_version_integrity": attr.string(),
},
),
},
)