bldr/node-tools
Building Node.js projects: dependency installs, builds, npm packages, static sites.
Building Node.js projects: dependency installs, builds, npm packages, static sites.
Import from bldr/node-tools in a .bldr.ts. Generated from the module's TypeScript.
Contents
browserImage | function | |
DEFAULT_BIOME_ROOT | variable | The root Biome config a lint pod writes above the project. |
nodeProject | function | |
NodeProject | class | A Node.js project build over a container rootfs. Point it at the project |
SITE_ROOT | variable | Where staticSiteImage expects the site to be mounted. |
staticSite | function | |
staticSiteImage | function |
Types
Each has its own page.
BiomeLinterOptions | interface | Lint with Biome — one binary for both the linter and |
NodeBuildOptions | interface | Options for NodeProject.build. |
NodeCheckOptions | interface | A declared type-check target. |
NodeLinterOptions | type | A declared lint target. |
NodeProjectConfig | interface | How nodeProject is configured. image and source are required, so |
NodeSource | type | A rootfs source for nodeProject: an image member, a built image, or a |
NodeTargetBuildOptions | interface | A declared build output. |
NodeTestOptions | interface | A declared test target. |
NodeVisualOptions | interface | A declared visual-snapshot target, plus the baselines that feed it. |
NpmPackageOptions | interface | Options for NodeProject.npmPackage. |
ProxyRoute | interface | One forwarded path in a StaticSiteServerConfig. |
StaticSiteConfig | interface | How staticSite is configured: a server, plus the site it serves. |
StaticSiteServerConfig | interface | How the nginx server itself is configured — everything except what it serves, |
WithId | type | An entry in an array-valued target spec: its options plus the id it is |
browserImage(base: any, arch?: any)
function
A Node image with Chromium added, for browser-driven tests.
Built from the same Node base the rest of the member uses rather than pulled
from a ready-made browser image, because those are Alpine/musl and the
lockfiles here are resolved on glibc: npm prunes optional native bindings to
the platform that generated the lock, so a musl pod fails with
Cannot find module './rolldown-binding.linux-x64-musl.node'. Same libc in,
same libc out.
The browser's path is exported as BLDR_BROWSER_PATH, which is where the
shared visual harness looks — a build pod has no network to download one.
browserImage(base: any, arch?: any): ContainerImageSee ContainerImage.
DEFAULT_BIOME_ROOT: Record<string, unknown>
variable
The root Biome config a lint pod writes above the project.
Two rules the repo has decided against, for the same reasons the checked-in
root biome.json gives: ! is used deliberately after a lookup that was
just proved, and (x ??= …) is the memo idiom here. Everything else is the
member's own config to state.
nodeProject(config: NodeProjectConfig)
function
Configure a Node.js project build.
nodeProject(config: NodeProjectConfig): NodeProject| Parameter | Type | |
|---|---|---|
config | NodeProjectConfig | The container image and project source (both required), plus optional generated overlays, local dependencies, env and manifest globs. |
Returns NodeProject — A builder whose methods produce outputs.
See NodeProject, NodeProjectConfig.
const dist = nodeProject({
image: nodeImage,
source: member.rootDirectory,
generated: [{ tree: apiClient(), dest: "src/gen" }],
deps: { "@bldr/design-system": designSystem() },
}).build({ outDir: "build/client" });NodeProject
class
A Node.js project build over a container rootfs. Point it at the project source, optionally layer in generated code, then take the built tree:
const dist = nodeProject({
image: nodeImage,
source: uiTree,
generated: [{ tree: apiClient(), dest: "src/gen" }],
}).build({ outDir: "dist" });.constructor(config: NodeProjectConfig)
constructor
Prefer the nodeProject factory.
constructor(config: NodeProjectConfig): NodeProjectSee NodeProjectConfig.
.build(opts?: NodeBuildOptions)
method
Run npm run <script> (default build) and capture <outDir> (default
dist) as a Directory — the release build. Uses the cached
nodeModules.
The merged tree and node_modules are mounted as writable
overlays at /app and /app/node_modules — real directories the build
tools can write to (tsc's .tsbuildinfo, Vite's dist), not read-only
binds or symlinks (which break TypeScript's package exports resolution).
The command is just the build: all tree assembly happened at the content
level, so the pod does no heavy filesystem mutation.
build(opts?: NodeBuildOptions): DirectorySee Directory, NodeBuildOptions.
.format(opts?: BiomeFormatOptions)
method
The project's files as Biome formats them, for writing back over the
member the way lockfile refreshes package-lock.json:
bldr build --export <member>/formatted:members/<dir>
Formatting is otherwise a developer running the toolchain by hand. As a
build it runs where lint runs, with the same root config written above
the project, so the bytes it writes are the bytes lint accepts — and an
editor with no local toolchain can produce them. Captured as the mount's
diff: only the files Biome rewrote, nothing it left alone.
format(opts?: BiomeFormatOptions): DirectorySee Directory.
.lint(opts: BiomeLinterOptions)
method
The lint findings: a directory holding the linter's report.
Two runs of the same check, because one output cannot be both. The first
uses the default reporter and its output is both printed and kept, so
the pod's log holds the findings as a person reads them — file, line,
rule, the offending source — and report.txt holds the same bytes. The
second writes those findings as JSON to report.json, for anything that
consumes them as data.
The output is data, not a gate. The pod exits 0 whatever the linter
says, because a target that fails produces nothing, and a report you only
get when there is nothing to report is not a report. The verdict rides
along in exit-code for a consumer that wants to act on it.
Returns both halves: reports is the pod's captured directory (the files
above), and diagnostics is the JSON one converted to a Diagnostics
block — the findings as structured data, which is what the declared
target registers.
Nothing is written back to the source: a build runs on a content-addressed copy, so formatting it there would produce a tree nobody sees. The useful answer is whether what is committed is clean.
lint(opts: BiomeLinterOptions): { … }See BiomeLinterOptions, Diagnostics, Directory.
.lockfile()
method
A refreshed package-lock.json, as a one-file Directory.
npm install --package-lock-only over the manifests and the vendored
dependencies — the same inputs nodeModules installs from — so the
lock it writes accounts for whatever those dependencies now require.
This exists because a lockfile goes stale in a member that nobody
touched. Growing a shared package's dependencies leaves every consumer's
lock short, npm ci then fails with "Missing: … from lock file", and the
member that broke is not the member that changed. Only npm can write that
file, so the fix has to run npm somewhere — and without this, "somewhere"
meant a five-step manual ritual (export the dependency, stage it as a
vendored dep by hand, bldr fs put, run npm in a bldr pod run).
The nodeProject factory registers this as the member's
lockfile output automatically, so refreshing a lock is a build:
$ bldr build --export my-member/lockfile:members/my-memberlockfile(): DirectoryReturns Directory — A Directory holding just package-lock.json.
See Directory.
.lsp(opts?: NodeLspOptions)
method
A TypeScript language server for this project, run in a pod.
typescript-language-server over tsserver, because the project it
answers about is the one bldr installed: the same node_modules, the
same tsconfig.json, the same generated trees layered in. A server on
the developer's machine sees a checkout without any of that — no
src/gen, no vendored .bldr-deps — and reports every import of them as
missing.
It must be a dependency of the project. The command below is the
binary npm installs into node_modules/.bin, so the project's
package.json needs typescript-language-server in its devDependencies.
That is why this target is off unless asked for: a default-on target that
needs an undeclared dependency would start a pod per member and have each
one fail identically.
lsp(opts?: NodeLspOptions): any.nodeModules()
method
Installed node_modules, produced by npm ci over just the manifest
files — so it is cached and re-runs only when dependencies change,
independently of source edits.
One of the two pods of a project approved for the internet (the other is
lockfile): installing is downloading. Every pod that builds,
tests or lints mounts this tree and runs without a network.
nodeModules(): DirectoryReturns Directory — A Directory of the installed tree.
See Directory.
.npmPackage(opts?: NpmPackageOptions)
method
The publishable package: what npm publish would upload, as a
Directory. This is the tree another member vendors through deps.
A package is not "the build output" — it is package.json, whatever
files names, and npm's own always-included set (the README, the
LICENSE, the main/bin targets). Assembling that by hand means
restating files in the build definition, and the two then drift
silently: bldr/design-system shipped an exports entry for ./visual
while the built tree contained no visual/ at all, and nothing complained
until a consumer imported it.
So this does not reimplement the rules — it runs npm pack over the built
tree and unpacks the tarball. npm decides what a package is, which is the
only definition that stays correct when files, .npmignore or the
default set changes.
export function pkg(): Directory {
return nodeProject({ scope, image: nodeImage, source }).npmPackage();
}npmPackage(opts?: NpmPackageOptions): Directory| Parameter | Type | |
|---|---|---|
opts? | NpmPackageOptions | outDir/script for the build that runs first (same defaults as build), or prebuilt to skip it. |
Returns Directory — The unpacked package tree, package.json at its root.
See Directory, NpmPackageOptions.
.testReport(opts?: NodeTestOptions)
method
The unit test suite's report, as a Report a test target accepts.
The script is expected to end in || true, so a failing case is data
folded into the report rather than a failed pod. A pod that fails
produces no report at all, which reads as "the suite did not run" and is
a worse answer than "three cases failed".
testReport(opts?: NodeTestOptions): ReportSee NodeTestOptions, Report.
.tree()
method
The project tree with every generated overlay and vendored dependency
grafted in at the content level (fs.at + fs.merge) — no container
copies. Overlays win for their own paths; everything else comes from the
source.
Useful directly when you need the assembled tree for something other than
NodeProject.build — a dev-server mount, for instance.
tree(): DirectoryReturns Directory — The merged project tree.
See Directory.
.visual(opts?: NodeVisualOptions)
method
The visual snapshot suite: its report, and the baselines that feed it.
Two runs of the same suite. The first compares against the checked-in
snapshots and reports. The second runs with the update variable set, so
the harness writes what it rendered instead of comparing, and its output
is the tree you export back over visual/__screenshots__.
Snapshots are rendered where they are verified: a PNG from a developer's machine does not match one from the build image, so updating them locally produces a baseline that fails for everyone else.
visual(opts?: NodeVisualOptions): { … }See Directory, NodeVisualOptions, Report.
SITE_ROOT: "/usr/share/nginx/html"
variable
Where staticSiteImage expects the site to be mounted.
staticSite(config: StaticSiteConfig)
function
A Runnable serving site over nginx on port.
The configuration is baked into an image layer rather than mounted, so it is content-addressed and cached with everything else, and the runnable itself is a single service with no bridge network — a service attached to one gets no host port forwarding at all, which would make it unreachable.
A site that shares a runnable with the API it calls wants
staticSiteImage instead: this builds a one-service runnable, and the
proxy routes only reach a backend the pod can address.
staticSite(config: StaticSiteConfig): RunnableSee Runnable, StaticSiteConfig.
scope.addRunnable("site", staticSite({ image: nginxImage, site: dist(), port: 8080 }));staticSiteImage(config: StaticSiteServerConfig)
function
The nginx image behind staticSite: the server configuration baked
into a layer, serving SITE_ROOT on port.
The configuration is a layer rather than a mount so it is content-addressed
and cached with everything else. Split out from staticSite because a site
that shares a runnable with its API needs the image without the one-service
runnable wrapped around it. The caller mounts the site at SITE_ROOT.
staticSiteImage(config: StaticSiteServerConfig): ContainerImage