Learn · Learning Bazel
seedling
npm Packages Are Labels
The same job in the other language, where the lockfile is the source of truth, packages become labels in your own workspace, and the build refuses to run install scripts until you name them.
By the end you will have an npm package in the build, and you will be able to explain why packages appear as labels in your own workspace, why the build refuses install scripts by default, and why the emitted JavaScript stopped running the way it used to.
The same job, the other language
Give the TypeScript side the same improvement the Go side got:
web/greeting/greeting.ts
import { capitalCase } from 'change-case';
export function greet(name: string): string {
return `Hello ${capitalCase(name)}`;
}Importing a real package rather than doing case conversion by hand.
Declare it the ordinary way:
package.json
{
"name": "greet",
"private": true,
"dependencies": {
"change-case": "5.4.4"
}
} A package manifest. Nothing surprising, and nothing build-system-specific.
Then generate a lockfile with your package manager, exactly as you would in any project. That lockfile — not the manifest — is what the build reads:
MODULE.bazel
module(name = "greet")
# Go.
bazel_dep(name = "rules_go", version = "0.61.1")
go_sdk = use_extension("@rules_go//go:extensions.bzl", "go_sdk")
go_sdk.download(version = "1.26.5")
# TypeScript. rules_ts needs a compiler version, which is the chapter's
# point in miniature: the compiler is an input too.
bazel_dep(name = "aspect_rules_js", version = "3.2.2")
bazel_dep(name = "aspect_rules_ts", version = "3.8.11")
bazel_dep(name = "rules_nodejs", version = "6.7.4")
node = use_extension("@rules_nodejs//nodejs:extensions.bzl", "node")
node.toolchain(node_version = "22.22.2")
rules_ts_ext = use_extension("@aspect_rules_ts//ts:extensions.bzl", "ext")
rules_ts_ext.deps(ts_version = "5.9.3")
use_repo(rules_ts_ext, "npm_typescript")
# Build-file generation for Go.
bazel_dep(name = "gazelle", version = "0.51.3")
# Third-party Go modules come from go.mod. The extension reads it and
# creates one repository per module; use_repo names the ones the build
# actually depends on.
go_deps = use_extension("@gazelle//:extensions.bzl", "go_deps")
go_deps.from_file(go_mod = "//:go.mod")
use_repo(go_deps, "org_golang_x_text")
# npm packages come from the pnpm lockfile, which stays the source of
# truth. The extension turns it into one repository per package.
npm = use_extension("@aspect_rules_js//npm:extensions.bzl", "npm")
npm.npm_translate_lock(
name = "npm",
pnpm_lock = "//:pnpm-lock.yaml",
verify_node_modules_ignored = "//:.bazelignore",
)
use_repo(npm, "npm")The npm extension takes the lockfile as its input. The lockfile stays the source of truth; the build does not invent its own.
The first failure
Build, and nothing builds:
$ bazel build //web/...
ERROR: pnpm 'allowBuilds' (or 'onlyBuiltDependencies' in pnpm < 10.26)
configuration required.
Packages that rules_js should generate lifecycle hook actions for must
be declared in 'allowBuilds' or 'onlyBuiltDependencies'.The extension refuses to run at all. This is not a missing package or a typo — it is a policy the build will not proceed without.
Here is what that is about, and it is the best thing in this chapter.
Installing an npm package can run arbitrary code on your machine. Packages declare install scripts, and package managers run them — that is the design, and it is how a native module gets compiled during installation. It is also, occasionally, how a compromised package gets its foothold.
The build will not run any of them until you say which ones may. Not a warning, not a prompt: a refusal.
This is the book's through-line arriving somewhere you did not expect it. A build that insists on knowing what each step read is the same build that insists on knowing which packages get to execute code. "Undeclared" is undeclared, whether it is a data file or somebody else's install script.
For this project, the answer is that none of them need to:
pnpm-workspace.yaml
packages: []
# Installing a package can run arbitrary code through its lifecycle
# hooks. The build refuses to run any unless it is named here — an
# empty list means none, which is what this project wants.
onlyBuiltDependencies: [] An empty list is a real answer, and the one most projects should be able to give.
The key name depends on your package manager's version — a newer release renamed it, and passing the new name to these pinned rules fails deep inside with a message about a list having no items method, which tells you nothing. The spelling above matches the versions this book pins.
Packages are labels
With the policy declared, the build proceeds. Look at how the dependency is written:
web/greeting/BUILD.bazel
load("@aspect_rules_ts//ts:defs.bzl", "ts_project")
ts_project(
name = "greeting",
srcs = ["greeting.ts"],
declaration = True,
transpiler = "tsc",
tsconfig = "//web:tsconfig",
visibility = ["//web:__subpackages__"],
# npm packages are labels in your own workspace, not bare names.
deps = ["//:node_modules/change-case"],
)//:node_modules/change-case — a label in your own workspace, not a bare package name.
That is the most unfamiliar idea in this chapter. There is no implicit resolution against a directory somewhere up the tree. A target at the workspace root turns every package in the lockfile into a label:
BUILD.bazel
load("@gazelle//:def.bzl", "gazelle")
load("@npm//:defs.bzl", "npm_link_all_packages")
# gazelle:prefix example.com/greet
gazelle(name = "gazelle")
# Creates the //:node_modules/* labels the TypeScript targets depend on.
npm_link_all_packages(name = "node_modules")npm_link_all_packages creates the //:node_modules/* labels the TypeScript targets depend on.
Once you see it, the consistency is the point. A third-party package is a target with a name, and your code depends on it the same way it depends on your own library — which is why chapter three's rdeps works across the boundary, and why a package bump invalidates exactly the targets that use it.
The second failure
Now run it the way you have been running it since chapter two:
$ node bazel-bin/web/greet.js
Error: Cannot find module 'change-case'The emitted JavaScript no longer runs standalone. It is looking for a package, and the directory it is running in does not have one.
Nothing is broken. The emitted file is correct, and the package exists in the build. What is missing is the runfiles tree — the directory layout a program needs at runtime, which the build knows how to construct and bare node knows nothing about.
Declare a binary and the build assembles it:
web/BUILD.bazel
load("@aspect_rules_js//js:defs.bzl", "js_binary")
load("@aspect_rules_ts//ts:defs.bzl", "ts_config", "ts_project")
ts_config(
name = "tsconfig",
src = "tsconfig.json",
visibility = ["//web:__subpackages__"],
)
ts_project(
name = "web",
srcs = ["greet.ts"],
declaration = True,
transpiler = "tsc",
tsconfig = ":tsconfig",
deps = ["//web/greeting"],
)
# Running the emitted file with node directly cannot resolve npm
# packages. A js_binary builds the runfiles tree that can.
js_binary(
name = "greet",
data = [":web"],
entry_point = "greet.js",
)js_binary wraps the emitted entry point with everything it needs to run.
$ bazel run //web:greet
Hello WorldRunning through the build, which sets up the runtime layout first.
Both languages now print the same thing, and both got there through a library neither of us wrote.
The difference between a recipe and a packed lunch. Compiling produces the recipe; the runfiles tree is the bag with all the ingredients in it. You can read a recipe anywhere. You can only eat the lunch.
What the lockfile is really holding
The lifecycle-hook policy is about code that runs during installation. There is a second guarantee underneath it, about the bytes themselves.
Look at what the lockfile actually records for a package:
change-case@5.4.4:
resolution:
integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6...
Not just a version. A hash of the exact archive that version resolved to.
That hash is the pin. The version number is a name the registry controls; the hash is a fact about the contents, and it is what makes "the same dependency" mean something.
To see it working, corrupt it — change one character, as a registry serving different bytes than it served last week would effectively do:
$ bazel build //web/...
WARNING: Download from https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz
failed: Checksum was sha512-HRQyTk2/... but wanted sha512-AAAyTk2/...
ERROR: no such package '@@...npm__change-case__5.4.4//'The download completed, was hashed, did not match, and was thrown away.
The build fetched the package, hashed it, compared it against the lockfile, and refused to use it. Nothing was installed and no code from that archive ran — the check happens before the archive is unpacked, which is the only order that helps.
A version number says which release you asked for. A hash says which bytes you got. Only the second survives a registry being compromised, a mirror being wrong, or a maintainer republishing a tag — and only the second is what "reproducible" can be built on.
This is the same idea as chapter four's sandbox, one layer out. There, a step could not read a file nobody declared. Here, a build cannot use an archive whose contents nobody vouched for. Both are the through-line applied to a place where the answer had previously been "trust whatever showed up."
Why this half is harder
It is fair to notice that Go took four lines and this took three failures. Some of that is incidental — version skew in a key name is nobody's design.
But the substantive part is that JavaScript packaging carries more ambiguity for the build to pin down. A package's dependencies can be hoisted, its install scripts can run code, its entry point can be resolved several ways at runtime. Each of those is a place where "what did this step actually read" has historically been answered by convention. Making the answer explicit is more work here because there was more left implicit.
The lockfile stays the source of truth; the build reads it rather than resolving packages itself. Packages become labels in your own workspace, so third-party code is an ordinary graph node. Install scripts do not run unless declared — an empty list is a valid and meaningful answer. Emitted JavaScript needs a runfiles tree to run, which js_binary builds.
Try this in your own repository
Find out which of your dependencies run install scripts. Most package managers can list them. For each, decide whether it needs to — and notice how few of the answers you can give confidently, on packages you have been installing for years.
Check that your integrity hashes are actually checked. Corrupt one in a scratch branch and see whether anything complains. A lockfile whose hashes nothing verifies is documentation, not a control.
What you can now do
Add an npm package to a build, explain the lifecycle-hook policy to a colleague who has never been asked about it, and run emitted JavaScript that depends on packages. Both languages now reach outside — and your module file has grown long enough to be worth reorganizing, which is the next chapter.