blob: 691d493b8fb4727dba79fb56dd17c9a2da0c911a [file] [log] [blame]
"Utility functions for npm rules"
load("@aspect_bazel_lib//lib:paths.bzl", "relative_file")
load("@aspect_bazel_lib//lib:repo_utils.bzl", "repo_utils")
load("@bazel_skylib//lib:paths.bzl", "paths")
INTERNAL_ERROR_MSG = "ERROR: rules_js internal error, please file an issue: https://github.com/aspect-build/rules_js/issues"
DEFAULT_REGISTRY_DOMAIN = "registry.npmjs.org"
DEFAULT_REGISTRY_DOMAIN_SLASH = "{}/".format(DEFAULT_REGISTRY_DOMAIN)
DEFAULT_REGISTRY_PROTOCOL = "https"
DEFAULT_EXTERNAL_REPOSITORY_ACTION_CACHE = ".aspect/rules/external_repository_action_cache"
def _sorted_map(m):
result = dict()
for key in sorted(m.keys()):
result[key] = m[key]
return result
def _sanitize_rule_name(string):
# Workspace names may contain only A-Z, a-z, 0-9, '-', '_' and '.'
result = ""
for c in string.elems():
if c == "@" and (not result or result[-1] == "_"):
result += "at"
if not c.isalnum() and c != "-" and c != "_" and c != ".":
c = "_"
result += c
return result.strip("_-")
def _bazel_name(name, version = None):
"Make a bazel friendly name from a package name and (optionally) a version that can be used in repository and target names"
escaped_name = _sanitize_rule_name(name)
if not version:
return escaped_name
# Separate name + version with extra _
return "%s__%s" % (escaped_name, _sanitize_rule_name(version))
def _package_key(name, version):
"Make a name/version pnpm-style name for a package name and version"
return "%s@%s" % (name, version)
def _friendly_name(name, version):
"Make a name@version developer-friendly name for a package name and version"
return "%s@%s" % (name, version)
def _escape_target_name(name):
return name.replace("://", "/").replace("/", "+").replace(":", "+")
def _package_store_name(pnpm_name, pnpm_version):
"Make a package store name for a given package and version"
if pnpm_version.startswith("link:") or pnpm_version.startswith("file:"):
name = pnpm_name
version = "0.0.0"
elif pnpm_version.startswith("npm:"):
name, version = pnpm_version[4:].rsplit("@", 1)
else:
name = pnpm_name
version = pnpm_version
if version.startswith("@"):
# Special case where the package name should _not_ be included in the package store name.
# See https://github.com/aspect-build/rules_js/issues/423 for more context.
return _escape_target_name(version)
else:
return "%s@%s" % (_escape_target_name(name), _escape_target_name(version))
def _make_symlink(ctx, symlink_path, target_path):
symlink = ctx.actions.declare_symlink(symlink_path)
ctx.actions.symlink(
output = symlink,
target_path = relative_file(target_path, symlink.path),
)
return symlink
def _parse_package_name(package):
# Parse a @scope/name string and return a (scope, name) tuple
if package[0] == "@":
scope_end = package.find("/", 1)
if scope_end > 0:
return (package[0:scope_end], package[scope_end + 1:])
return ("", package)
def _npm_registry_url(package, registries, default_registry):
(package_scope, _) = _parse_package_name(package)
return registries[package_scope] if package_scope in registries else default_registry
def _npm_registry_download_url(package, version, registries, default_registry):
"Make a registry download URL for a given package and version"
(_, package_name_no_scope) = _parse_package_name(package)
registry = _npm_registry_url(package, registries, default_registry)
return "{0}/{1}/-/{2}-{3}.tgz".format(
registry.removesuffix("/"),
package,
package_name_no_scope,
# Strip the rules_js peer/patch metadata off the version. See pnpm.bzl
version[:version.find("_")] if version.find("_") != -1 else version,
)
def _is_git_repository_url(url):
return url.startswith("git+ssh://") or url.startswith("git+https://") or url.startswith("git@")
def _to_registry_url(url):
return "{}://{}".format(DEFAULT_REGISTRY_PROTOCOL, url) if url.find("//") == -1 else url
def _default_registry_url():
return _to_registry_url(DEFAULT_REGISTRY_DOMAIN_SLASH)
def _hash(s):
# Bazel's hash() resolves to a 32-bit signed integer [-2,147,483,648 to 2,147,483,647].
# NB: There has been discussion of adding a sha256 built-in hash function to Starlark but no
# work has been done to date.
# See https://github.com/bazelbuild/starlark/issues/36#issuecomment-1115352085.
return str(hash(s))
def _dicts_match(a, b):
if len(a) != len(b):
return False
for key in a.keys():
if not key in b:
return False
if a[key] != b[key]:
return False
return True
# Copies a file from the external repository to the same relative location in the source tree
def _reverse_force_copy(rctx, label, dst = None):
if type(label) != "Label":
fail(INTERNAL_ERROR_MSG)
dst = dst if dst else str(rctx.path(label))
src = str(rctx.path(paths.join(label.package, label.name)))
if repo_utils.is_windows(rctx):
fail("Not yet implemented for Windows")
# rctx.file("_reverse_force_copy.bat", content = """
# @REM needs a mkdir dirname(%2)
# xcopy /Y %1 %2
# """, executable = True)
# result = rctx.execute(["cmd.exe", "/C", "_reverse_force_copy.bat", src.replace("/", "\\"), dst.replace("/", "\\")])
else:
rctx.file("_reverse_force_copy.sh", content = """#!/usr/bin/env bash
set -o errexit -o nounset -o pipefail
mkdir -p $(dirname $2)
cp -f $1 $2
""", executable = True)
result = rctx.execute(["./_reverse_force_copy.sh", src, dst])
if result.return_code != 0:
msg = """
ERROR: failed to copy file from {src} to {dst}:
STDOUT:
{stdout}
STDERR:
{stderr}
""".format(
src = src,
dst = dst,
stdout = result.stdout,
stderr = result.stderr,
)
fail(msg)
# This uses `rctx.execute` to check if the file exists since `rctx.exists` does not exist.
def _exists(rctx, p):
if type(p) == "Label":
fail("ERROR: dynamic labels not accepted since they should be converted paths at the top of the repository rule implementation to avoid restarts after rctx.execute() calls")
p = str(p)
if repo_utils.is_windows(rctx):
fail("Not yet implemented for Windows")
# rctx.file("_exists.bat", content = """IF EXIST %1 (
# EXIT /b 0
# ) ELSE (
# EXIT /b 42
# )""", executable = True)
# result = rctx.execute(["cmd.exe", "/C", "_exists.bat", str(p).replace("/", "\\")])
else:
rctx.file("_exists.sh", content = """#!/usr/bin/env bash
set -o errexit -o nounset -o pipefail
if [ ! -f $1 ]; then exit 42; fi
""", executable = True)
result = rctx.execute(["./_exists.sh", str(p)])
if result.return_code == 0: # file exists
return True
elif result.return_code == 42: # file does not exist
return False
else:
fail(INTERNAL_ERROR_MSG)
def _replace_npmrc_token_envvar(token, npmrc_path, environ):
# A token can be a reference to an environment variable
if token.startswith("$"):
# ${NPM_TOKEN} -> NPM_TOKEN
# $NPM_TOKEN -> NPM_TOKEN
token = token.removeprefix("$").removeprefix("{").removesuffix("}")
if environ.get(token, False):
token = environ[token]
else:
# buildifier: disable=print
print("""
WARNING: Issue while reading "{npmrc}". Failed to replace env in config: ${{{token}}}
""".format(
npmrc = npmrc_path,
token = token,
))
return token
def _default_external_repository_action_cache():
return DEFAULT_EXTERNAL_REPOSITORY_ACTION_CACHE
def _is_tarball_extension(ext):
# Takes an extension (without leading dot) and return True if the extension
# is a common tarball extension as per
# https://en.wikipedia.org/wiki/Tar_(computing)#Suffixes_for_compressed_files
tarball_extensions = [
"tar",
"tar.bz2",
"tb2",
"tbz",
"tbz2",
"tz2",
"tar.gz",
"taz",
"tgz",
"tar.lz",
"tar.lzma",
"tlz",
"tar.lzo",
"tar.xz",
"txz",
"tar.Z",
"tZ",
"taZ",
"tar.zst",
"tzst",
]
return ext in tarball_extensions
utils = struct(
bazel_name = _bazel_name,
sorted_map = _sorted_map,
package_key = _package_key,
friendly_name = _friendly_name,
package_store_name = _package_store_name,
make_symlink = _make_symlink,
# Symlinked node_modules structure package store path under node_modules
package_store_root = ".aspect_rules_js",
# Suffix for npm_import links repository
links_repo_suffix = "__links",
# Output group name for the package directory of a linked npm package
package_directory_output_group = "package_directory",
npm_registry_url = _npm_registry_url,
npm_registry_download_url = _npm_registry_download_url,
is_git_repository_url = _is_git_repository_url,
to_registry_url = _to_registry_url,
default_external_repository_action_cache = _default_external_repository_action_cache,
default_registry = _default_registry_url,
hash = _hash,
dicts_match = _dicts_match,
reverse_force_copy = _reverse_force_copy,
exists = _exists,
replace_npmrc_token_envvar = _replace_npmrc_token_envvar,
is_tarball_extension = _is_tarball_extension,
)
# Exported only to be tested
utils_test = struct(
parse_package_name = _parse_package_name,
)