Skip to content
GitHubRSS

@vzn/vx — technical documentation

vx is a task runner and content-addressed build cache for JavaScript monorepos, shipped as one self-contained binary. It runs your task graph in parallel, caches every result by content, and replays work it has already done — and it stops there: everything beyond running and caching is a plugin on a documented seam.

That description fits several tools. What follows is the part that doesn’t: the problems vx treats as the hard ones, and what it actually does about each.

Every other failure degrades. This one lies. A stale hit replays bytes from a build whose inputs no longer exist, under a green checkmark, and nothing downstream can tell. It is the only bug class where “the build passed” is the symptom.

Most of vx’s design is a response to this:

  • The config is evaluated, then hashed. Your vx.config.ts is a TypeScript program. A tool that hashes the config file misses the preset it imported, the constant it computed, the environment it read. vx hashes the resolved object, so imports participate in cache identity. (caching.md)
  • Declared outputs are wiped before execution AND before restore. The tree ends every run byte-identical to the artifact — no survivor from a previous build masquerading as output.
  • Upstream identity is folded in by INPUT, not output. A dependency’s key cascades to its dependents, so a change anywhere upstream re-keys everything downstream, without making a task’s key depend on bytes that were produced non-deterministically.

A cache key is one opaque hash. When it changes, the tool says “miss” and you guess. vx persists the per-component input fingerprint for every entry, so vx why <task> names the exact component that moved — this file, that env var, that upstream — rather than the fact that something did. (cli.md)

3. Knowing what to run is a different question from knowing what changed

Section titled “3. Knowing what to run is a different question from knowing what changed”

--affected maps changed files to projects. That mapping has holes that are easy to miss and expensive to hit: a shared preset that no project owns, a config that imports a file from another package. If input hashing can see a change, selection has to see it too, or CI runs nothing and reports success. vx routes changed files to projects through three channels — directory containment, declared workspaceFiles globs, and a static scan of what each config imports. (modules/affected.md)

A project’s globs never reach into another project’s directory. Inputs are declared, never inferred — vx deliberately does not trace filesystem reads, because an explicit input set is a correctness property. A task that wants its reads confined to the set it declared asks for that with exec.sandbox.

5. Doing all that without becoming the platform

Section titled “5. Doing all that without becoming the platform”

Core is a pipeline with a hook at every stage — config (the workspace config every verb sees), project (a project’s tasks), graph (the edges), key (extra key material), fingerprint (a lockfile claimed and keyed per project), schedule (which ready task runs first), admit (whether it runs now), executor (where one task’s command runs), cache (where artifacts live), telemetry (where run records go), commands (which verbs exist), with setup and teardown around the run — and applies none of them by default. Running here and caching here are its floor: the local executor and the local cache sit at the tail of every list, so a workspace that declares nothing still runs and caches, and a plugin that declines a task hands it back to this machine. Nothing here is a first-party product you have to adopt to get the good behaviour, and nothing distributed ships in this repo — the hooks are how it gets built. (modules/plugin.md, the design in design/pipeline-2026-09.md, and the “Extending vx” guides on the docs site)

Numbers come from packages/vx-bench/ and are reproducible; the invariant behind each is recorded in optimizations.md.

  • A fully-cached run on a 100-project workspace completes in 74 ms wall-clock; on 476 packages / 1,428 tasks in 297 ms, where Turborepo 2.10 takes 342 ms and Nx 23 takes 1.38 s on the identical workspace — and restoring every output is 1.5× faster than Turbo (benchmarks.md, 2026-09).
  • At 15k input files, deriving every cache key costs zero file reads — hashes come from git’s index for tracked, clean files.
  • No daemon. Nothing to keep warm, nothing to restart.
Terminal window
bun add -d @vzn/vx
# …or globally, as the prebuilt standalone binary:
npm install -g @vzn/vx

Drop a vx.config.ts next to any workspace package:

import { defineProject } from '@vzn/vx'
export default defineProject({
tasks: {
build: {
// Cache-input env vars must ALSO be passed through — the child
// env is isolated, and a key that varies on a var the task
// can't see is incoherent.
exec: { command: 'tsc -p .', env: { passThrough: ['NODE_ENV'] } },
cache: {
inputs: { files: ['src/**'], env: ['NODE_ENV'] },
outputs: { files: ['dist/**'] },
},
},
test: {
dependsOn: ['build'],
exec: { command: 'bun test' },
cache: { inputs: { files: ['src/**', 'tests/**'] }, outputs: { files: [] } },
},
dev: {
exec: { command: 'vite', timeout: 30_000, persistent: { readyWhen: 'Local:' } },
},
},
})

Optionally declare plugins — a remote cache, a remote executor, telemetry — in vx.workspace.ts (vx init and @vzn/vx-migrate emit it). Core applies none by default; running here and caching in .vx/cache are its floor, so the file can be absent:

vx.workspace.ts
import { defineWorkspace } from '@vzn/vx'
export default defineWorkspace({ plugins: [] })

Run things:

Terminal window
vx run build # current package (+ its dependency graph)
vx run build test --all # every package, shared graph
vx run build --filter "@app/*" # pnpm-style filters
vx run test --affected # what changed vs the base branch, and what depends on it
vx watch dev # re-run on file change
vx run build --dry # predicted hits/misses, no execution
vx why app#build # what changed the key last time
vx last # replay the previous run's summary
vx cache prune --older-than 7d --max-size 5gb

Remote caching is plugin-driven: a cache plugin fills core’s RemoteCacheLayer seam. @vzn/vx-reapi speaks Bazel’s ActionCache + CAS, so NativeLink / BuildBuddy / Buildbarn / bazel-remote all work — and the same package can run tasks on those servers via the executor seam. Any other store implements the same seam in a plugin.

You want to…Read
The pitch: what vx does that others don’tcomparison.md § Where vx is ahead
Understand the overall shapearchitecture.md
Author a vx.config.tsschema.md
Reason about cachingcaching.md
Trace what vx run actually doesexecution.md
See each scenario as a diagramflows.md
See every perf decision + invariantoptimizations.md
Use the CLI from a shellcli.md
Write a plugin / replace a seammodules/plugin.md
Benchmarks + side-by-side vs other runnersbenchmarks.md, comparison.md
Coming from Turbo or Nx: the same thing, spelled in vx, with its testparity.md
Check a Turbo or Nx bug report against vx, with the test that holds itupstream-ledger.md
Modify, fork, or replace a modulemodules/ (one page per source module)
Read forward-looking design notesdesign/

If you have ten minutes: read comparison.md § Where vx is ahead, then architecture.md. Together they cover the why and the shape.

A Bun-workspaces monorepo: core @vzn/vx is packages/vx, and the plugins that ship alongside it are its siblings under packages/.

Core src/ is seven modules — each directory’s index.ts is its contract, and cross-module imports go through it only, enforced by tests/module-boundaries.test.ts:

ModuleOwns
cli/subcommand parsers, help, plan formatting
orchestrator/run composition: discover → graph → schedule → execute → record
workspace/project discovery, filters, --affected, the lockfile
graph/the task graph and the two-tier scheduler
cache/the local store (the floor), the layering, the RemoteCacheLayer seam
exec/per-task execution primitives (spawn, env, sandbox), the local executor (the floor)
util/small shared helpers

Every source file is documented under modules/. Tests live in tests/. The published plugin packages are @vzn/vx-reapi (Bazel remote cache and remote execution), @vzn/vx-otel (OpenTelemetry traces, metrics and logs), @vzn/vx-github (job summary and Checks API), @vzn/vx-lockfile (per-project keys from the package manager’s lockfile), @vzn/vx-schedule-history (order by the critical path learned from run history), @vzn/vx-mcp (vx mcp, a server for AI agents) and @vzn/vx-migrate (a Turbo, Nx, moon, wireit, lage or scripts-only repo run unchanged, a Turbo or Nx remote cache kept, vx.config.ts written from any of them), each importing core only through the public @vzn/vx specifier — a boundary the test suite enforces.