DEV Community

Cover image for Moving to vx from Turborepo: a temporary start, then native config
VX
VX

Posted on Originally published at vznjs.github.io

Moving to vx from Turborepo: a temporary start, then native config

vx is shaped like Turborepo on purpose. Same per-package model, same
dependsOn micro-syntax ('build', '^build', 'pkg#build'), same
--filter DSL, same --affected. The migration is easy because
almost nothing has to change in how you think about the graph; what
changes is where the config lives and what it can say.

Step zero: a temporary start

turbo() from @vzn/vx-migrate fills vx's project stage from your existing
turbo.json and each package's scripts. It is a bridge while you
migrate, not a way to keep turbo.json: vx is fast on native config, and
only native config is what its benchmarks measure. vx init writes it
beside turbo.json:

npm install -D @vzn/vx       # or pnpm / yarn / bun
npx vx init                  # writes vx.workspace.ts, below, and prints next:
npm install -D @vzn/vx-migrate && npx vx run build --all   # the next: line
Enter fullscreen mode Exit fullscreen mode
import type { WorkspaceConfig } from '@vzn/vx/config'
import { turbo } from '@vzn/vx-migrate'

export default { plugins: [turbo()] } satisfies WorkspaceConfig
Enter fullscreen mode Exit fullscreen mode

Whatever the mapping cannot express becomes a warning on every run,
which is the same list bunx @vzn/vx-migrate --dry prints once. A
package that writes its own vx.config.ts keeps it; the plugin fills
and never overwrites. So you can migrate one package at a time, until
every package has its own config and vx.workspace.ts drops turbo().

Step one: let the tool write the files

bunx @vzn/vx-migrate --dry   # preview the generated files and a report
bunx @vzn/vx-migrate         # write them; never overwrites without --force
Enter fullscreen mode Exit fullscreen mode

@vzn/vx-migrate is its own package so it runs before any vx file
exists, and it is the whole adoption in one command: in a terminal it
asks native (the default) or keep (--keep, the turbo() file
vx init writes), writes vx.workspace.ts with the plugins the repo
calls for, and installs what the files import. It reads the root pipeline and any per-package extends,
inlines the matching package.json script as the task's command, and
emits one vx.config.ts per package. A task goes where the script
exists, and to a package without it that another package's ^ task
reaches: a cached true with no outputs, Turbo's no-op node, so an edit
there still re-keys its dependants. Anything it cannot infer becomes a TODO(vx-migrate)
comment, never a silently wrong value. It renders from the same mapper
turbo() runs, so the files say exactly what the plugin was
already doing.

What maps, and what is better

turbo.json vx.config.ts
tasks / pipeline tasks
dependsOn dependsOn, identical syntax
inputs / outputs cache.inputs.files / cache.outputs.files
env cache.inputs.env and exec.env.passThrough
passThroughEnv exec.env.passThrough
cache: false omit the cache block
persistent: true exec.persistent: {}, with a TODO to set readyWhen when a task depends on it
with dependsOn a persistent sidecar, started beside the task
interactive: true exec.interactive: true: the task gets the terminal, alone
extends a package task merges over the root's; false alone opts out, false + keys runs on those alone
interruptible nothing: vx watch keeps a persistent task up and restarts it only when its config changes or it dies
tags nothing: labels Turbo keeps out of the hash and the behaviour
outputLogs (Turbo 1: outputMode) no per-task knob: the per-run --output-logs flag
$TURBO_ROOT$/file cache.inputs.workspaceFiles
dotEnv (Turbo 1) cache.inputs.runtime: a probe that hashes the .env files
command (Turbo 2.11) the task's exec.command; null is no task
description the task's description
globalDependencies, globalEnv, globalPassThroughEnv, globalDotEnv a generated vx-preset.ts you import and spread

Those are every key the mapper knows. Any other key in a task becomes
a TODO naming it, so nothing is dropped silently.

Three things you get that the JSON could not give you:

  • The command is in the config. Turborepo runs the script with the task's name; vx makes exec.command explicit. A task is one shell command, and you can read it where it is declared.
  • Inputs are required and explicit. The migration writes Turbo's default, every file in the package, as **/*, where you can see and narrow it; the sandbox can then prove them.
  • Presets are imports. globalDependencies becomes a constant in a file every config imports, and the resolved-config hash sees it. No list to keep in sync.

The deliberate divergences

  • A bare task name never widens an anchored task's scope. In Turbo, turbo run web#lint build also runs web#build; in vx, build takes the filter scope and web#lint stays anchored.
  • No --parallel. It exists in Turbo as an escape hatch for over-declared edges. dependsOn in vx is explicit; --concurrency 1 serialises, and --exclude-dependencies[=<names>] drops edges.
  • Failure propagation starts one notch further along. Turbo stops the run at the first failure; a vx run with no flag is deps-ok — a task runs when its own dependencies succeeded, and only its dependents are skipped. --continue=never is Turbo's default behaviour, and bare --continue is always, which is what bare --continue means in Turbo too.

Every other Turbo behaviour a user would reach for is pinned by a
parity case that runs vx's real CLI against the Turbo contract it
stands in for. The full guide, with before/after
configs, is Migrate from Turborepo.


Originally published on the vx blog. vx is an MIT task runner and build cache for JS monorepos: vznjs.github.io/vx · GitHub.

Written with AI assistance.

Top comments (0)