src/workspace/project-loader.ts — config file evaluation
Purpose
Section titled “Purpose”Evaluate a vx.config.{ts,mts,js,mjs} file and return the resolved
ProjectConfig object. Bun runs TypeScript natively, so the loader is
a thin wrapper around await import().
Two paths, chosen by whether this process has loaded that path before:
- First load — in-process
await import()with a content-hash query-string bust. The singlevx runhot path only ever takes this one, so it costs exactly what it always did. - Repeat load — re-evaluated in a Worker (
config-eval.ts), because the bust cannot reach the config’s import closure.
Public surface
Section titled “Public surface”export async function loadProjectConfig( configPath: string, opts?: LoadProjectConfigOptions,): Promise<ProjectConfig>export async function loadWorkspaceConfig(workspaceRoot: string): Promise<WorkspaceConfig | null>
// The batch form every reading verb goes through: one staged evaluation// for many configs, served from the evaluation cache unless `fresh`.export interface LoadProjectConfigOptions { // Observe the CURRENT environment: no eval cache, even beside `evalCache`. fresh?: boolean evalCache?: { store: ConfigEvalStore; workspaceRoot?: string; workspaceFingerprint: string }}export async function loadProjectConfigs( configPaths: readonly string[], opts?: LoadProjectConfigOptions,): Promise<ProjectConfig[]>
// The names a workspace config may take, in resolution order.export const WORKSPACE_CONFIG_FILENAMES = [ 'vx.workspace.ts', 'vx.workspace.mts', 'vx.workspace.js', 'vx.workspace.mjs',]
// A Bun `ResolveMessage` / `BuildMessage` as a one-line UserError naming the// config (and the file, for a syntax error in an import); null for anything else.export function configLoadError(err: unknown, configPath: string, kind: string): UserError | nullWhat the evaluated object may contain is config-schema.ts’s
business (validateProjectConfig, validateWorkspace); the loader
calls both after evaluation and re-exports validateProjectConfig for
readers that reach it here.
Loading rules
Section titled “Loading rules”- Supported extensions:
.ts,.mts,.js,.mjs. Each is handed to a nativeawait import(). Bun resolves TypeScript natively — no transpile step, no separate loader, nojiti. - On a first load the import specifier is
<absolutePath>?vx-bust=<xxh3-of-bytes>, or?vx-held=for a project config evaluated from the bytes the loader read (below). Content changes produce a different query string → different ESM module identity → fresh evaluation. Same content → cached module (the no-op fast path). - On a repeat load the path is evaluated in a Worker instead, and the resolved object comes back as JSON.
- The default export must be a non-null object. Anything else throws
"Project config at <path> did not export a default object"(Workspace config at …for a workspace file) — from the same check on both paths. - A Promise default export is awaited on both paths, so an async config resolves to its object on the first load and in the Worker alike (D-5). The awaited value is checked again, a workspace config’s too (D-6).
- Validation runs on whichever object the two paths produced, so a
malformed config reports the identical
UserErroreither way. - A value JSON cannot carry (a function,
NaN, aMap, …) is refused by both paths with one message (item 701, config-schema.md): the first load’svalidateProjectConfigsees the live object, and the Worker runs the samenonJsonPaths, embedded in its inline source bytoString(), BEFORE itsJSON.stringifywould drop the evidence. It replies with the paths instead of the JSON, andevaluateConfigFreshthrows theUserError. The compiled binary’s Worker is held to it byscripts/check-binary.ts(check.binary).
Evaluated from the bytes it read
Section titled “Evaluated from the bytes it read”The loader reads a project config’s bytes to key it (config-cache.ts)
and to refuse an unprovided import; import() then opened and read the
file again. A first load of a project config is now served those bytes
by the vx-config-bytes onLoad (Bun.plugin), under ?vx-held=: one
read of the config per run, and the evaluation runs exactly the bytes
the key saw. It is also the cheaper import — 1,000 configs took 80–100
ms served against 180 ms read by Bun, and the cold load configs stage
of a 1,000-package vx run --dry dropped from 256 to 181 ms (min of 7,
interleaved, 2026-09-24; 100 packages: 51 to 33).
What onLoad source cannot be, it is not handed:
- Only ESM. Source from
onLoadis always evaluated as a module; a file Bun would run as CommonJS (noexport, andmodule.exports,exports,require,thisor__dirnameat the top) would lose its exports.hasEsmExport(config-imports.ts) asks Bun’s own parser for an ESMexport; without one, the config takes Bun’s path,?vx-bust=. - Only UTF-8. Bun’s loader reads invalid UTF-8 as Latin-1 and a
decoder would repair it to U+FFFD, a different string; a strict decode
that fails sends the config down Bun’s path. And the source goes over
as a string:
onLoadreads a byte array as Latin-1 (aücame backü, caught by config-eval’s JSON-data row). - The query never reaches the user. A served module’s stack frames
name its specifier, rewritten to the path; a
ResolveMessagehas either query stripped. - Not the workspace config. Proving it ESM is the parser’s first
use in a warm run (~0.27 ms) and
loadWorkspaceConfigmeasured 0.3 ms slower served, so Bun reads it a second time.
A repeat load (the Worker, below) reads the file in the Worker’s own
registry; nothing is served there. What stays read twice in a cold run,
and why — the project’s package.json and directory, which Bun’s
resolver visits for the importer whatever vx hands it — is pinned with
strace in tests/read-once.unsafe.test.ts.
Why a Worker on a repeat load
Section titled “Why a Worker on a repeat load”The content-hash bust only changes the entry’s specifier. Bun caches
an evaluated module by its resolved specifier, so an
import './preset.js' inside a config resolves to the same key no
matter what query the entry carries — a busted entry re-evaluates
against a stale preset.
Shared presets are the documented composition mechanism (@vzn/vx-migrate
generates a vx-preset.ts), so through a whole vx watch session a
preset edit was invisible; and because the resolved config feeds the
cache key, vx answered up-to-date for a command that had changed on
disk — a stale cache hit.
A Worker gets its own module registry, so everything it imports is read
from disk now. It is the only mechanism for this that the runtime
exposes as public API: globalThis.Loader.registry — the obvious place
to evict from — exists on Bun 1.3.11 and is gone on 1.3.14, where an
eviction-based fix degrades to no fix at all while still reporting
success.
Every path hands out a tree of its own: a cache hit and the lock
parse JSON, and a first in-process load, which yields the module
object, now returns the parse of the JSON it already builds for the
evaluation cache. The module object shares what the source shares — one
preset task imported by two configs, one exec in two tasks — so a
project hook that edits in place (the way the API invites) edited all
of them at once: a cold run ran echo P +plug +plug where the warm run
and --frozen ran echo P +plug, under another key (item 967). The copy
costs about 1.7 ms per 1,000 configs, and only on the live path.
Two properties make the swap safe:
- Key stability. The config crosses back as JSON, which is already
this project’s contract for a config object:
hashTaskConfigderives the cache key fromJSON.stringify(config)andvx lockstores the same round-trip. SinceJSON.stringify(JSON.parse(s)) === s, a config re-read through a Worker derives the same cache key as the in-process first load — which is why this needed noCACHE_VERSIONbump. - Round sharing. A round is one
loadProjectConfigscall, the pathloadProjectstakes for a run and for every watch cycle. It evaluates its repeat loads one after another insidebeginEvalRound(), which holds the Worker open until the round ends, so a round costs one Worker, not one per project, and the next round still starts from an empty registry. Loads in flight at the same moment from separate calls also share one. Before item 694 nothing held the round: the in-flight count reached zero between configs, so 5 repeat configs made 5 Workers, and a 50-config repeat round took 148 ms against 10.5 ms now. Sharing within a round is also the more faithful semantics: two configs importing the same preset evaluate it once, exactly as in a freshvx runprocess.
The Worker source is an inline data: URL rather than a sibling file
because bun build --compile does not embed a Worker entry point —
it resolves the URL from disk at runtime, so a sibling file would make
the shipped standalone binary fail with ModuleNotFound.
What this does NOT do
Section titled “What this does NOT do”- Validates each
TaskConfigshape at load time and surfacesUserErroron malformed input. Rules enforced:execmust be an object with a non-emptycommandstring.exec.persistentrejects malformed shapes; non-stringreadyWhenis rejected.cache+persistenttogether is a hard error (no exit to cache).- A task with no
execMUST declaredependsOn(group task) — a no-op task is rejected. cacherequiresexecAND requires bothinputs.filesandoutputs.filesarrays.dependsOnmust be astring[].descriptionmust be a string.
- Doesn’t sandbox the evaluated config — config code runs with the caller’s full Bun permissions. The user wrote it, the user trusts it.
- Doesn’t transform imports — relative imports inside the config
resolve normally via Bun’s loader. Including from
node_modules. This is what enables presets.
Caveats consumers should know
Section titled “Caveats consumers should know”- Side effects in config files run every load. Authors writing
process.env.SET_AT_LOAD_TIME = 'oops'will see that env mutation. Date.now()in config = always-different configHash. Resolved values get baked into the object, including non-deterministic ones. This is a footgun documented inarchitecture.md.- Imports from
node_modulesare cached by Bun on a first load. Within one Bun process animport from 'pkg'resolves once; a repeat load re-resolves it in a fresh Worker registry. loadWorkspaceConfighas no Worker path.vx.workspace.tsdeclaresplugins, which are objects holding functions — they cannot cross a Worker boundary at all. A repeat load busts the config’s own URL, so an edit tovx.workspace.tsitself is read, but Bun answers what it imports from the registry:vx mcpserved an edited local plugin’s first version on every call (item 1046). So a repeat load walks the config’s relative imports and refuses, naming the file, when one changed since the first successful load in this process; the fix is a restart, asvx watchsays when it sees the edit. A process that loads once (every CLI verb but these two) pays nothing for it.
tests/project-loader.test.ts covers:
- Loads a default-exported object from
.mjs. - Throws on no-default export.
- Throws on non-object default export.
- Group-task validation (accepts task with only
dependsOn; rejects task with neitherexecnordependsOn; rejectscacheon a group task). loadWorkspaceConfigreturns null when novx.workspace.*file exists, validatesconcurrencyandcacheDir.- The served first load (
a project config is evaluated from the bytes vx read): a CommonJS config keeps its exports, non-UTF-8 bytes evaluate as Bun reads them, and neither a runtime throw’s stack nor an unresolvable import names the query.
Replacing this module
Section titled “Replacing this module”Drop in any function that takes an absolute config path and returns
ProjectConfig. Alternatives:
- esbuild / oxc-based loader — fastest TS evaluation but ships an extra dep and gives up Bun’s native TS support.
- Subprocess isolation — a fresh process per repeat load also gets
a clean registry, but measured ~30-50 ms against the Worker’s
~8-15 ms, and a compiled binary cannot spawn
bun(it would need an internal subcommand onprocess.execPath).
Before either evaluation path (the first in-process import, the repeat
load’s worker), a bare import nothing above the config provides is refused
with the install named (refuseUnprovidedImports, item 239): left to Bun, a
workspace with no node_modules would auto-install it from the registry
first, and a config must never download.