node_modules¶
An importer's node_modules. One node_modules target per Bazel package
declares a symlink node_modules/<name> into the store for
every npm package the importer declares, and one node_modules_member per
workspace member it links. ts_codegen, ts_binary, ts_dev_server and the
ruleset's esbuild_bundle take the target and stage its links and every store
tree they reach; ts_compile and ts_test take it as the chain
a direct npm dep resolves along.
Usage¶
load("@rules_typescript//npm:defs.bzl", "node_modules", "node_modules_member")
node_modules(
name = "node_modules",
deps = ["@npm//:vite"],
hoist = ":node_modules/.pnpm/node_modules",
)
node_modules(
name = "node_modules",
deps = [
"@npm//web:react",
"@npm//web:vite",
],
parent = "//:node_modules",
)
node_modules_member(
name = "node_modules/@acme/ui",
member = "@npm//:acme_ui",
)
ts_dev_server(
name = "dev",
server = "@rules_typescript//vite:dev_server",
entry_point = ":app",
node_modules = ":node_modules",
)
Gazelle writes both in every lockfile importer's package
(What Gazelle Writes); one in a
package that is no importer is hand-written under # keep.
Attributes¶
node_modules:
| Attribute | Type | Default | Description |
|---|---|---|---|
deps |
label_list |
[] |
The npm packages the importer declares, as hub labels: the importer's own package (@npm//web:react) where it declares the name, the root's otherwise. Empty for an importer that declares nothing |
parent |
label |
None |
The node_modules target of the importer above; the lockfile's root importer names hoist instead |
hoist |
label |
None |
The lockfile's npm_store_hoist target, :node_modules/.pnpm/node_modules in the lockfile's package; the root importer names it where every other importer names parent, and its deps come from that lockfile |
node_modules_member:
| Attribute | Type | Default | Description |
|---|---|---|---|
member |
label |
required | The hub's view of the workspace member, @npm//:<name>; the target is named node_modules/<name> |
The Layout¶
Every entry is a declared symlink with a relative target into the lockfile package's store, pnpm's top level:
node_modules/ ← the root importer's, beside .pnpm
.pnpm/ ← the store: one tree per snapshot
minimatch@9.0.9/node_modules/minimatch/
minimatch@9.0.9/node_modules/brace-expansion → ../../brace-expansion@2.0.2/node_modules/brace-expansion
minimatch → .pnpm/minimatch@9.0.9/node_modules/minimatch
shared → .pnpm/shared@0.0.0/node_modules/shared ← a node_modules_member
web/node_modules/
react → ../../node_modules/.pnpm/react@19.0.0/node_modules/react
A package's own imports resolve from its realpath,
.pnpm/<key>/node_modules/<name>/, to the links beside its tree, so every
dependent reaches the resolution pnpm recorded for it: test-exclude's
minimatch is 10.2.4 beside its tree while the importer links 9.0.9 at the
top. Node, tsgo and Vite realpath a package before resolving its imports;
--preserve-symlinks walks from the link and meets no edge, as over pnpm's
own install.
A node_modules target's outputs are node_modules/<name>, so one target per
Bazel package holds them, and they sit in the store's repository: a relative
link has one text in the execroot and the runfiles tree only while link and
target share one, so a dep whose store is another module's lockfile fails at
analysis naming it. DefaultInfo.files is every link and every store tree the
links reach; a consumer that runs outside an action stages them and takes the
directory, which no artifact names. NodeModulesInfo carries label, dir
(the directory as a bin-dir path), links (name to NpmLinkInfo(link, store)),
parent and hoist (the lockfile's hidden hoist, the root importer's hoist
target's NpmHoistInfo on every importer of the chain), and a
node_modules_member returns NpmLinkInfo beside the
view's TsInfo and NpmPackageInfo, so a target names it in deps where it
named the view (Providers).
Native executables derive a build-owned runtime view from these same declared Files. Each selected store has one copied authority per view; importer aliases use relative links to it, preserving the package's peer graph and singleton identity. Package the complete view with its generated config. Copy cost is not measured; runtime wrappers are resolved from their original tool runfiles.
One Link per Name¶
Two resolutions of one name in deps is one link name twice, and fails:
node_modules: @@//src/app:node_modules: 'minimatch' linked twice, to
@@+npm+npm__minimatch__10_2_4//:pkg and @@+npm+npm__minimatch__9_0_9//:pkg:
one link per name
Two peer resolutions of one version are two keys and fail the same way. Each
dependent reaches its own resolution beside its tree; the importer's deps is
what the importer itself declared, one resolution per name.
A workspace member in deps fails naming the node_modules_member to write
instead. The link is a target of its own because a member's compile walks up
to the importers above it: an importer target holding the member's link would
depend on the member's tree, and through it on the member's compile, a cycle
for every member the root links. ts_compile, ts_test and ts_codegen name
the link target in deps. The hidden hoist's link to a member the root does
not link is by name alone for the same reason (The Store).
The Store¶
Every lockfile has a virtual store, pnpm's node_modules/.pnpm, declared in
the lockfile's own package by npm_virtual_store, a macro the lockfile's hub
writes into @<hub>//:defs.bzl from the graph the npm extension computes,
named after the directory it declares:
The call declares, manual and public:
- one
npm_storeper snapshot, named after its tree,node_modules/.pnpm/<key>/node_modules/<name>, where<key>is<name with / as +>@<version>plus_<peer id>when pnpm resolved the package against a peer set (NpmPackageInfo.peer_id). Its one action,NpmStore, copies the fetched package directory into the tree withtsaction stage, the directory one source artifact rather than one target per file; beside the tree it declares one symlink per dependency edge the lockfile records,node_modules/.pnpm/<key>/node_modules/<dep>->../../<dep key>/node_modules/<dep>, under the name the snapshot imports the dependency by, so an npm alias is a link name and not a second copy. An edge to a platform-partitioned snapshot is declared under the sameselect()the snapshot's repository writes, so no platform fetches another's tarball. An edge the extension cut to break a cycle in the target graph is a link all the same, with no dependency behind it: its tree is in a closure through the edge that closes the cycle, as it is in pnpm's store. - one
npm_store_memberper workspace member whose BUILD file declares its target,node_modules/.pnpm/<name with / as +>@0.0.0/node_modules/<name>: the member's.js,.js.map,.d.tsand data srcs at their package-relative paths, thepackage.jsonas built in place of the src (every source-file target rewritten to the emitted file, what a workspace member is imported as); and one link per dependency the member's importer declares, other members among them. A compile that stages nopackage.jsonfails the store naming the src to add. - one
npm_store_hoist,node_modules/.pnpm/node_modules: pnpm's hidden hoist. For every namehoist-patternmatches it links one resolution atnode_modules/.pnpm/node_modules/<name>, and for every namepublic-hoist-patternmatches one at the root importer'snode_modules/<name>, so a package importing a dependency it does not declare resolves as it does in the checkout. The settings are the lockfile package's.npmrc--hoist,hoist-pattern,public-hoist-pattern,hoist-workspace-packages-- overridden by the corresponding camelCase settings in itspnpm-workspace.yaml, as in pnpm 11. Workspace patterns use block lists or JSON string arrays; boolean settings usetrueorfalse. Both files are watched inputs. pnpm's defaults apply where neither sets a value: hoist on,hoist-pattern*,public-hoist-patternempty, members hoisted. The resolution hoisted for a name follows pnpm's hoist step: the walk starts at every importer's direct dependencies, marks each level's children in order before it descends into the first child, and visits the snapshots it reached by depth and then by snapshot id; the first snapshot whose dependencies name a not-yet-taken name claims it, workspace members and the importers' own direct dependencies first (the root importer's are never hoisted: they sit at the root already), a snapshot skipped on the target platform claims nothing, and a name is taken case-insensitively. pnpm's tie-break at one depth is its graph's key: the snapshot id after a resolving install, the store directory's name after a frozen one, which spells/as+and a peer suffix's brackets as_and so can order two ids the other way round. A pattern is pnpm's:*matches any run,!negates, the last matching pattern decides. Where platforms disagree the link is under aselect(). A workspace member the root importer does not link is hoisted under its own name, and its link is declared by name alone,node_modules/.pnpm/node_modules/<name>->../<name with / as +>@0.0.0/node_modules/<name>, with no dependency on the member's tree: the root importer'snode_modulesnames the hoist, and the member's compile walks up to thatnode_modules, so a dependency on the tree would close a cycle through every hoisted member. A member whose BUILD file declares no target gets no link, there being no tree.tests/npm/hoisted_dependencies.bzlis pnpm's own answer over the fixture lockfile, andtests/npm:store_tests_hoistasserts the store's. The target returns the links asNpmHoistInfo, the members' undermembers; the root importer names it inhoist, and every importer on its chain carries it asNodeModulesInfo.hoist. Ats_compileorts_teststages the hoist links whose names its npm closure holds, with the store trees they enter -- pnpm's pick for a name can be a snapshot no edge of the closure reaches; a member's tree comes with the member's link target (//tests/npm/nested:hoisted_member_chain_test) -- so a store package's import of a name it does not declare resolves as in the checkout, and the action's inputs stay the closure's names: a bump re-runs the targets whose closure holds the package. A first-party source importing an undeclared name still fails the ownership check (ts_compile).//tests/npm/features/hoistruns one.
A tree holds no symlink, so Bazel hashes it as files and restores it as files
from a disk or remote cache in every download mode, and a fresh output base
over a populated cache executes no NpmStore; the links are internal actions,
re-run per output base. The store sits in the lockfile's package because that
is pnpm's node_modules/.pnpm, and in the lockfile's repository because a
relative link has one text in the execroot and the runfiles tree only while
link and target share a repository: two lockfiles in one package would share
one store, so the npm extension refuses them, and a module's hub the
extension fills in from another module's lockfile serves that module's own
targets, never a consumer's.
Every ts_npm_package carries its snapshot's store as NpmPackageInfo.store
(NpmStoreInfo: key, tree, links, transitive), and a
member's hub view carries the member's. The importer's links above read it,
and the chain below is where ts_compile and ts_test find them.
The Chain¶
ts_compile and ts_test build no tree. Each names in node_modules the
nearest lockfile importer at or above its package, and a direct npm dep
resolves along that target and its parents nearest first, pnpm's walk-up
from the importing file: the link whose store is the dep's resolution is the
one the program reads. A name no importer on the chain links fails analysis
naming the nearest importer's package.json; a name linked at another
resolution fails naming the importer-scoped label, @npm//web:marked. A
workspace member is named by the importer's link target,
//web:node_modules/@acme/ui, and its store tree comes with the link.
The action stages the links and the store files the program reaches and
nothing else: the chain's links for the direct names, the @types/<name>
twin an importer on the chain links, the member links deps name, every store
tree and edge link their closures hold, the hoist links whose names the
closure holds with the trees they enter (The Store), and each
first-party dep's (TsInfo.npm_files). tsaction lays a program root out
under the target's output directory, linking declared Files at logical
compiler paths, with each importer's node_modules at the importer's
directory -- the
lockfile's root importer's at the program root's node_modules -- so a source
at web/src/a.ts walks up through
web/node_modules to the root's, and a dep's declaration under
bazel-out/<cfg>/bin/packages/design-system/ through packages/design-system/node_modules
(ts_compile). A ts_test runs in
the same layout: the runfiles hold the links and store files at their own
paths, NODE_PATH names the chain's directories nearest first, and where the
walk up from the tests meets no node_modules before the workspace's root
the launcher links the chain's root in there (ts_test).
npm_bin¶
@rules_typescript//npm:defs.bzl exports one more rule, npm_bin. It is what
every generated @npm//:<pkg>_bin label instantiates: npm_import writes one
per entry in a package's bin field, and bazel run @npm//:vitest_bin -- --version
runs it. Nothing in this repository writes one by hand; the labels are the
interface, documented under
Bin scripts.
| Attribute | Type | Default | Description |
|---|---|---|---|
entry_script |
string |
required | The bin entry's path inside the package, e.g. vitest.mjs |
package_files |
label_list |
[] |
Every file of the package, for a target with no store; a generated target runs the script from its store tree |
entry_missing |
string |
"" |
The error npm_import found at fetch time when the package lacks entry_script, reported when the target is analysed |
optional_dep_packages |
label_list |
[] |
Sibling package targets holding the platform-specific native binaries the script resolves at run time. The launcher links them under a node_modules/ so require.resolve() finds them inside a sandbox or a runfiles tree |
runtime |
label |
None |
A JS runtime binary for this target, taking priority over the js_runtime toolchain |
The runtime comes from the js_runtime toolchain when runtime is unset. The
launcher cds to RUNFILES_DIR before running the script, which is why a
linter run through one gets execroot-absolute paths
(Lint § Paths).