Skip to content

Commit aa09b57

Browse files
authored
feat(builtin): first experimental rules for npm tarballs (#2544)
This is not fully designed yet, so it's not included in public API. May be deleted at any time. The newly added README explains what's going on. Based on design: https://hackmd.io/gu2Nj0TKS068LKAf8KanuA
1 parent e7950b0 commit aa09b57

10 files changed

+3833
-1
lines changed

WORKSPACE

+13
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,19 @@ npm_install(
7979
package_lock_json = "//packages/node-patches:package-lock.json",
8080
)
8181

82+
load("@build_bazel_rules_nodejs//internal/npm_tarballs:translate_package_lock.bzl", "translate_package_lock")
83+
84+
# Translate our package.lock file from JSON to Starlark
85+
translate_package_lock(
86+
name = "npm_node_patches_lock",
87+
package_lock = "//packages/node-patches:package-lock.json",
88+
)
89+
90+
load("@npm_node_patches_lock//:index.bzl", _npm_patches_repositories = "npm_repositories")
91+
92+
# # Declare an external repository for each npm package fetchable by the lock file
93+
_npm_patches_repositories()
94+
8295
npm_install(
8396
name = "angular_deps",
8497
package_json = "//packages/angular:package.json",

internal/common/download.bzl

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"Repository rule wrapper around Bazel's downloader"
2+
3+
def _bazel_download(repository_ctx):
4+
repository_ctx.file("BUILD.bazel", repository_ctx.attr.build_file_content)
5+
repository_ctx.download(
6+
output = repository_ctx.attr.output,
7+
url = repository_ctx.attr.url,
8+
integrity = repository_ctx.attr.integrity,
9+
)
10+
11+
bazel_download = repository_rule(
12+
doc = """Utility to call Bazel downloader.
13+
14+
This is a simple pass-thru wrapper for Bazel's
15+
[repository_ctx#download](https://docs.bazel.build/versions/master/skylark/lib/repository_ctx.html#download)
16+
function.
17+
""",
18+
implementation = _bazel_download,
19+
attrs = {
20+
"build_file_content": attr.string(
21+
doc = "Content for the generated BUILD file.",
22+
mandatory = True,
23+
),
24+
"integrity": attr.string(
25+
doc = """
26+
Expected checksum of the file downloaded, in Subresource Integrity format.
27+
This must match the checksum of the file downloaded.
28+
It is a security risk to omit the checksum as remote files can change.
29+
At best omitting this field will make your build non-hermetic.
30+
It is optional to make development easier but should be set before shipping.
31+
""",
32+
mandatory = True,
33+
),
34+
"output": attr.string(
35+
doc = "path to the output file, relative to the repository directory",
36+
mandatory = True,
37+
),
38+
"url": attr.string_list(
39+
doc = "List of mirror URLs referencing the same file.",
40+
mandatory = True,
41+
),
42+
},
43+
)

internal/npm_tarballs/BUILD.bazel

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# No bazel targets in this package

internal/npm_tarballs/README.md

+68
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# npm_tarballs
2+
3+
This is an expermental feature inspired by external package fetching in rules_go and others.
4+
5+
See the design doc: https://hackmd.io/gu2Nj0TKS068LKAf8KanuA
6+
7+
## Rules
8+
9+
`translate_package_lock.bzl` takes a package-lock.json file and produces a Starlark representation of downloader rules for each package listed.
10+
11+
Currently this is implemented only for npm v7 produced lockfiles (version 2 of the spec) but it could be ported to any other lockfile format.
12+
13+
For example, for https://github.com/bazelbuild/rules_nodejs/blob/stable/packages/node-patches/package-lock.json we produce an `index.bzl` file like:
14+
15+
```
16+
"Generated by package_lock.bzl from //packages/node-patches:package-lock.json"
17+
18+
load("@build_bazel_rules_nodejs//internal/common:download.bzl", "bazel_download")
19+
20+
def npm_repositories():
21+
"""Define external repositories to fetch each tarball individually from npm on-demand.
22+
"""
23+
24+
# [...]
25+
26+
bazel_download(
27+
name = "npm_typescript-3.5.3",
28+
output = "typescript-3.5.3.tgz",
29+
integrity = "sha512-ACzBtm/PhXBDId6a6sDJfroT2pOWt/oOnk4/dElG5G33ZL776N3Y6/6bKZJBFpd+b05F3Ct9qDjMeJmRWtE2/g==",
30+
url = ["https://registry.npmjs.org/typescript/-/typescript-3.5.3.tgz"],
31+
build_file_content = """"Generated by package_lock.bzl"
32+
33+
load("@build_bazel_rules_nodejs//internal/npm_tarballs:npm_tarball.bzl", "npm_tarball")
34+
35+
npm_tarball(
36+
name = "npm_typescript-3.5.3",
37+
src = "typescript-3.5.3.tgz",
38+
package_name = "typescript",
39+
deps = [],
40+
visibility = ["//visibility:public"],
41+
)
42+
43+
"""
44+
)
45+
46+
# [...]
47+
```
48+
49+
This generated index.bzl can then be loaded in the WORKSPACE and the `npm_repositories` macro called.
50+
This then declares `bazel_download` rules that are themselves able to fetch packages on-demand.
51+
We also supply a BUILD file content for each of these packages, using a minimal `npm_tarball` rule that
52+
represents the location and dependencies of the downloaded .tgz file.
53+
54+
In addition, we give some syntax sugar.
55+
In the repo produced by `translate_package_lock` we provide "catch-all" targets
56+
`//:dependencies` and `//:devDependencies` that depend on all tarballs so listed in the package-lock.json.
57+
For direct dependencies, we also produce a `//somepackage` target that aliases the version of `somepackage` depended on.
58+
In the above example, that means the user can dep on `@npm_repositories//typescript` rather than
59+
`@npm_typescript-3.5.3` because we know the package depends on version 3.5.3.
60+
61+
## Future work
62+
63+
So far the resulting tarballs aren't used by anything in rules_nodejs (nothing consumes `NpmTarballInfo`).
64+
In later work we'll explore what other rules might want to use the tarballs,
65+
such as a pnpm_install rule that uses pnpm semantics to just symlink things into a tree.
66+
Or maybe an npm_install rule, one for each package, that unpacks the tarballs and runs the postinstall logic on each.
67+
We believe some experimentation will be required to find a good path forward that uses the download-as-needed semantics here,
68+
while keeping most existing semantics of rules_nodejs rules working.

internal/npm_tarballs/npm_tarball.bzl

+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"Info about npm tarball files"
2+
3+
NpmTarballInfo = provider(
4+
doc = "Describe tarballs downloaded from npm registry",
5+
fields = {
6+
"tarballs": "depset of needed tarballs to be able to npm install",
7+
},
8+
)
9+
10+
_DOC = """This rule is a simple reference to a file downloaded from npm.
11+
12+
It is not meant to be used on its own, rather it is generated into BUILD files in external repos
13+
and its provider can then be referenced in actions by tools like pnpm that need to find the .tgz files.
14+
"""
15+
16+
_ATTRS = {
17+
"deps": attr.label_list(
18+
doc = "Other npm_tarball rules for packages this one depends on",
19+
providers = [NpmTarballInfo],
20+
),
21+
"package_name": attr.string(
22+
doc = "the name field from the package.json of the package this tarball contains",
23+
),
24+
"src": attr.label(
25+
doc = "The downloaded tarball",
26+
allow_single_file = [".tgz"],
27+
),
28+
}
29+
30+
def _npm_tarball(ctx):
31+
# Allow aggregate rules like "all_dependencies" to have only deps but no tarball
32+
if ctx.attr.src and not ctx.attr.package_name:
33+
fail("when given a src, must also tell the package_name for it")
34+
direct = []
35+
direct_files = []
36+
if ctx.attr.src:
37+
direct = [struct(
38+
package_name = ctx.attr.package_name,
39+
tarball = ctx.file.src,
40+
)]
41+
direct_files = [ctx.file.src]
42+
43+
transitive = [d[NpmTarballInfo].tarballs for d in ctx.attr.deps]
44+
transitive_files = []
45+
for dset in transitive:
46+
for info in dset.to_list():
47+
transitive_files.append(info.tarball)
48+
return [
49+
NpmTarballInfo(tarballs = depset(
50+
direct,
51+
transitive = transitive,
52+
)),
53+
# For testing
54+
OutputGroupInfo(
55+
direct = direct_files,
56+
transitive = transitive_files,
57+
),
58+
]
59+
60+
npm_tarball = rule(
61+
implementation = _npm_tarball,
62+
attrs = _ATTRS,
63+
doc = _DOC,
64+
)
+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_test")
2+
3+
# In normal usage, some other rule would extract the NpmTarballInfo
4+
# For testing we simply want to grab the file
5+
filegroup(
6+
name = "get_typescript",
7+
# the internal/node-patches package-lock.json depends on this version of typescript
8+
srcs = ["@npm_typescript-3.5.3"],
9+
output_group = "direct",
10+
)
11+
12+
nodejs_test(
13+
name = "test_some_package_fetched",
14+
data = [":get_typescript"],
15+
entry_point = "test_some_package_fetched.js",
16+
)
17+
18+
filegroup(
19+
name = "get_typescript_alias",
20+
# Since typescript is a direct dependency, we can point to the version used by this package
21+
# without having to specify (it's an alias)
22+
srcs = ["@npm_node_patches_lock//typescript"],
23+
output_group = "direct",
24+
)
25+
26+
# Run the same test again but point to this filegroup to be sure the same typescript was there
27+
nodejs_test(
28+
name = "test_alias",
29+
data = [":get_typescript_alias"],
30+
entry_point = "test_some_package_fetched.js",
31+
)
32+
33+
filegroup(
34+
name = "get_all_devdeps",
35+
# Check that there's also a syntax-sugar for "all the devDependencies listed"
36+
srcs = ["@npm_node_patches_lock//:devDependencies"],
37+
output_group = "transitive",
38+
)
39+
40+
# Run that same test again, typescript should be in here
41+
nodejs_test(
42+
name = "test_alldevdeps",
43+
data = [":get_all_devdeps"],
44+
entry_point = "test_some_package_fetched.js",
45+
)
46+
47+
filegroup(
48+
name = "get_ansi-align",
49+
# According to package-lock.json, it depends on string-width@3.1.0
50+
srcs = ["@npm_ansi-align-3.0.0"],
51+
output_group = "transitive",
52+
)
53+
54+
nodejs_test(
55+
name = "test_dependencies_available",
56+
data = [":get_ansi-align"],
57+
entry_point = "test_dependencies_available.js",
58+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
const assert = require('assert');
2+
const {existsSync} = require('fs');
3+
const runfiles = require(process.env['BAZEL_NODE_RUNFILES_HELPER']);
4+
const tarPath = runfiles.resolve('npm_string-width-3.1.0/string-width-3.1.0.tgz');
5+
6+
assert.ok(existsSync(tarPath));
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
const assert = require('assert');
2+
const {existsSync, statSync} = require('fs');
3+
const runfiles = require(process.env['BAZEL_NODE_RUNFILES_HELPER']);
4+
const tarPath = runfiles.resolve('npm_typescript-3.5.3/typescript-3.5.3.tgz');
5+
6+
assert.ok(existsSync(tarPath));
7+
8+
// The size of https://www.npmjs.com/package/typescript/v/3.5.3
9+
expectedSize = 7960741;
10+
assert.strictEqual(
11+
statSync(tarPath).size, expectedSize,
12+
`Expected to download the typescript 3.5.3 release which is ${expectedSize} bytes`);

0 commit comments

Comments
 (0)