@vzn/vx 0.0.118 → 0.0.120
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -16
- package/package.json +5 -5
- package/src/workspace/migrate-scripts.ts +91 -27
- package/src/workspace/project-loader.ts +24 -5
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@ burns 35 s of CPU in vx, 73 s in Turborepo and 114 minutes in Nx; a
|
|
|
38
38
|
fully cached run replays the graph in 510 ms.
|
|
39
39
|
<!-- bench:end -->
|
|
40
40
|
|
|
41
|
-
Measured on
|
|
41
|
+
Measured on fourteen real Turborepo, Nx, moon, wireit and lage repos too: [tried on real repos](#tried-on-real-repos).
|
|
42
42
|
|
|
43
43
|
## Install
|
|
44
44
|
|
|
@@ -145,25 +145,27 @@ Turbo and Nx feature is in vx, pinned by a test:
|
|
|
145
145
|
|
|
146
146
|
## Tried on real repos
|
|
147
147
|
|
|
148
|
-
|
|
148
|
+
Fourteen public monorepos, each run with its own `turbo.json`, Nx graph, moon, wireit or lage config
|
|
149
149
|
and nothing rewritten, against the tool it ships with. Both tools on the same machine and
|
|
150
150
|
tasks per row, medians; vx first, theirs second. Where the other tool
|
|
151
151
|
wins, the row says so.
|
|
152
152
|
|
|
153
|
-
| Repo
|
|
154
|
-
|
|
|
155
|
-
| [solidjs/solid](https://github.com/solidjs/solid)
|
|
156
|
-
| [withastro/astro](https://github.com/withastro/astro)
|
|
157
|
-
| [payloadcms/payload](https://github.com/payloadcms/payload)
|
|
158
|
-
| [medusajs/medusa](https://github.com/medusajs/medusa)
|
|
159
|
-
| [n8n-io/n8n](https://github.com/n8n-io/n8n)
|
|
160
|
-
| [calcom/cal.com](https://github.com/calcom/cal.com)
|
|
161
|
-
| [TanStack/query](https://github.com/TanStack/query)
|
|
162
|
-
| [strapi/strapi](https://github.com/strapi/strapi)
|
|
163
|
-
| [novuhq/novu](https://github.com/novuhq/novu)
|
|
164
|
-
| [TanStack/router](https://github.com/TanStack/router)
|
|
165
|
-
| [refinedev/refine](https://github.com/refinedev/refine)
|
|
166
|
-
| [kindspells/astro-shield](https://github.com/kindspells/astro-shield)
|
|
153
|
+
| Repo | Against | Cold build | Restore outputs | Nothing to do |
|
|
154
|
+
| ----------------------------------------------------------------------- | ------- | --------------------- | --------------- | ------------------- |
|
|
155
|
+
| [solidjs/solid](https://github.com/solidjs/solid) | Turbo | 40.6 s / 45.5 s | 66 ms / 127 ms | 51 ms / 95 ms |
|
|
156
|
+
| [withastro/astro](https://github.com/withastro/astro) | Turbo | 48.0 s / 57.9 s | 478 ms / 1.43 s | 249 ms / 1.38 s |
|
|
157
|
+
| [payloadcms/payload](https://github.com/payloadcms/payload) | Turbo | 126.9 s / 127.5 s | 3.44 s / 3.46 s | 256 ms / **237 ms** |
|
|
158
|
+
| [medusajs/medusa](https://github.com/medusajs/medusa) | Turbo | 308 s / 315 s | 3.86 s / 7.16 s | 947 ms / 3.29 s |
|
|
159
|
+
| [n8n-io/n8n](https://github.com/n8n-io/n8n) | Turbo | 137.2 s / **133.7 s** | 8.27 s / 12.5 s | 842 ms / 1.36 s |
|
|
160
|
+
| [calcom/cal.com](https://github.com/calcom/cal.com) | Turbo | 245.7 s / 250.7 s | 17.4 s / 19.9 s | 14.7 s / 18.5 s |
|
|
161
|
+
| [TanStack/query](https://github.com/TanStack/query) | Nx | 47.4 s / 55.1 s | 656 ms / 1.98 s | 174 ms / 1.88 s |
|
|
162
|
+
| [strapi/strapi](https://github.com/strapi/strapi) | Nx | 238.2 s / 245.7 s | 1.61 s / 3.94 s | 341 ms / 4.02 s |
|
|
163
|
+
| [novuhq/novu](https://github.com/novuhq/novu) | Nx | 291.2 s / 299.4 s | 3.10 s / 9.05 s | 655 ms / 8.55 s |
|
|
164
|
+
| [TanStack/router](https://github.com/TanStack/router) | Nx | 199.9 s / 226.7 s | 1.00 s / 3.33 s | 505 ms / 3.21 s |
|
|
165
|
+
| [refinedev/refine](https://github.com/refinedev/refine) | Nx | 96.2 s / 105.9 s | 1.06 s / 1.19 s | 303 ms / 1.16 s |
|
|
166
|
+
| [kindspells/astro-shield](https://github.com/kindspells/astro-shield) | moon | 2.64 s / 3.78 s | 125 ms / 2.46 s | 111 ms / 2.37 s |
|
|
167
|
+
| [FormidableLabs/spectacle](https://github.com/FormidableLabs/spectacle) | wireit | 21.5 s / 22.2 s | 394 ms / 574 ms | 387 ms / 593 ms |
|
|
168
|
+
| [microsoft/lage](https://github.com/microsoft/lage) | lage | 32.4 s / **29.4 s** | 730 ms / 1.70 s | 284 ms / 1.56 s |
|
|
167
169
|
|
|
168
170
|
**Bold** marks the other tool winning. Versions, revisions, scopes and
|
|
169
171
|
harness per repo: [benchmarks](packages/vx/docs/benchmarks.md) (astro
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.120",
|
|
4
4
|
"description": "A fast, correct task runner and build cache for JavaScript monorepos.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -17,10 +17,10 @@
|
|
|
17
17
|
"node": ">=18"
|
|
18
18
|
},
|
|
19
19
|
"optionalDependencies": {
|
|
20
|
-
"@vzn/vx-linux-x64": "0.0.
|
|
21
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
22
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
23
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
20
|
+
"@vzn/vx-linux-x64": "0.0.120",
|
|
21
|
+
"@vzn/vx-linux-arm64": "0.0.120",
|
|
22
|
+
"@vzn/vx-darwin-x64": "0.0.120",
|
|
23
|
+
"@vzn/vx-darwin-arm64": "0.0.120"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
// hook is usually `rimraf dist`); and a script that is nothing but
|
|
24
24
|
// `<pm> run <other>` becomes a GROUP over `<other>`, so the graph sees the
|
|
25
25
|
// dependency instead of a package-manager subprocess it cannot cache.
|
|
26
|
-
//
|
|
27
|
-
//
|
|
26
|
+
// Where the manager runs no such hooks (Yarn 2+, D-31; npm or pnpm told
|
|
27
|
+
// not to, D-33) `prebuild` is a script like any other.
|
|
28
28
|
|
|
29
29
|
import { existsSync, readFileSync } from 'node:fs'
|
|
30
30
|
import path from 'node:path'
|
|
@@ -95,39 +95,90 @@ const OWN_COMMANDS: Readonly<Record<string, ReadonlySet<string>>> = {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
/**
|
|
98
|
-
* Whether the package manager that owns `dir`
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
98
|
+
* Whether the package manager that owns `dir` runs `pre` / `post` hooks
|
|
99
|
+
* around a script. The manager is the nearest `packageManager` field or
|
|
100
|
+
* lockfile, and its settings are read beside it, as each reads them for a
|
|
101
|
+
* member too. Probed with a `prebuild` / `build` / `postbuild` trio: Yarn
|
|
102
|
+
* 2+ runs none (D-31); npm runs none under `ignore-scripts=true` in
|
|
103
|
+
* `.npmrc`; pnpm runs none under `enable-pre-post-scripts=false` there or
|
|
104
|
+
* `enablePrePostScripts: false` in `pnpm-workspace.yaml` (D-33); Bun and
|
|
105
|
+
* Yarn 1 run them whatever those say. No manager found is npm's default.
|
|
102
106
|
*/
|
|
103
|
-
function
|
|
107
|
+
function runsScriptHooks(dir: string, memo: Map<string, boolean>): boolean {
|
|
104
108
|
const known = memo.get(dir)
|
|
105
109
|
if (known !== undefined) return known
|
|
106
|
-
|
|
107
|
-
|
|
110
|
+
const read = (f: string): string | undefined => {
|
|
111
|
+
try {
|
|
112
|
+
return readFileSync(path.join(dir, f), 'utf8')
|
|
113
|
+
} catch {
|
|
114
|
+
return undefined
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
let manager: string | undefined
|
|
108
118
|
try {
|
|
109
|
-
const pm = (JSON.parse(
|
|
119
|
+
const pm = (JSON.parse(read('package.json') ?? '') as { packageManager?: unknown })
|
|
110
120
|
.packageManager
|
|
111
|
-
if (typeof pm === 'string')
|
|
121
|
+
if (typeof pm === 'string')
|
|
122
|
+
manager = /^yarn@([2-9]|\d{2,})/.test(pm) ? 'berry' : pm.split('@')[0]
|
|
112
123
|
} catch {}
|
|
113
|
-
if (
|
|
114
|
-
const
|
|
115
|
-
if (
|
|
116
|
-
else if (
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
)
|
|
120
|
-
)
|
|
121
|
-
answer = false
|
|
124
|
+
if (manager === undefined) {
|
|
125
|
+
const yarnLock = read('yarn.lock')
|
|
126
|
+
if (yarnLock !== undefined) manager = yarnLock.includes('\n__metadata:') ? 'berry' : 'yarn'
|
|
127
|
+
else if (existsSync(path.join(dir, 'pnpm-lock.yaml'))) manager = 'pnpm'
|
|
128
|
+
else if (existsSync(path.join(dir, 'package-lock.json'))) manager = 'npm'
|
|
129
|
+
else if (['bun.lock', 'bun.lockb'].some((f) => existsSync(path.join(dir, f)))) manager = 'bun'
|
|
122
130
|
}
|
|
123
|
-
|
|
131
|
+
let answer: boolean
|
|
132
|
+
if (manager === undefined) {
|
|
124
133
|
const up = path.dirname(dir)
|
|
125
|
-
answer = up === dir ?
|
|
134
|
+
answer = up === dir ? true : runsScriptHooks(up, memo)
|
|
135
|
+
} else {
|
|
136
|
+
const npmrc = read('.npmrc') ?? ''
|
|
137
|
+
const set = (key: string, value: string): boolean =>
|
|
138
|
+
new RegExp(`^\\s*${key}\\s*=\\s*${value}\\s*$`, 'm').test(npmrc)
|
|
139
|
+
answer =
|
|
140
|
+
manager === 'berry'
|
|
141
|
+
? false
|
|
142
|
+
: manager === 'npm'
|
|
143
|
+
? !set('ignore-scripts', 'true')
|
|
144
|
+
: manager === 'pnpm'
|
|
145
|
+
? !set('enable-pre-post-scripts', 'false') &&
|
|
146
|
+
!/^enablePrePostScripts:\s*false\s*$/m.test(read('pnpm-workspace.yaml') ?? '')
|
|
147
|
+
: true
|
|
126
148
|
}
|
|
127
149
|
memo.set(dir, answer)
|
|
128
150
|
return answer
|
|
129
151
|
}
|
|
130
152
|
|
|
153
|
+
const MANIFEST_IMPORT = "import pkg from './package.json' with { type: 'json' }"
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The `$npm_*` variables a command reads. Every manager sets them for a
|
|
157
|
+
* script (probed: npm, pnpm, bun and yarn printed the version, the name
|
|
158
|
+
* and the script's name) and vx sets none, so a migrated `echo
|
|
159
|
+
* $npm_package_version` printed nothing (D-34). The name and version are
|
|
160
|
+
* read from the manifest at evaluation, so a bump reaches them; the event
|
|
161
|
+
* is the script's own name, which a folded hook does not share.
|
|
162
|
+
*/
|
|
163
|
+
function npmEnv(
|
|
164
|
+
command: string,
|
|
165
|
+
script: string,
|
|
166
|
+
folded: boolean,
|
|
167
|
+
): { define: Record<string, unknown>; unset: string[]; readsManifest: boolean } {
|
|
168
|
+
const define: Record<string, unknown> = {}
|
|
169
|
+
const unset: string[] = []
|
|
170
|
+
let readsManifest = false
|
|
171
|
+
for (const [, v] of command.matchAll(/\$\{?(npm_[A-Za-z0-9_]+)/g)) {
|
|
172
|
+
if (v! in define || unset.includes(v!)) continue
|
|
173
|
+
if (v === 'npm_package_name' || v === 'npm_package_version') {
|
|
174
|
+
define[v] = { raw: v === 'npm_package_name' ? 'pkg.name' : 'pkg.version' }
|
|
175
|
+
readsManifest = true
|
|
176
|
+
} else if (v === 'npm_lifecycle_event' && !folded) define[v] = script
|
|
177
|
+
else unset.push(v!)
|
|
178
|
+
}
|
|
179
|
+
return { define, unset, readsManifest }
|
|
180
|
+
}
|
|
181
|
+
|
|
131
182
|
function scriptsOf(meta: ProjectMeta): Record<string, unknown> {
|
|
132
183
|
// package.json is a boundary: `scripts` is whatever the file holds. A
|
|
133
184
|
// string or an array would enumerate its indices as script names.
|
|
@@ -178,10 +229,10 @@ function upstreamBuildOnWorker(tasks: GeneratedTask[]): void {
|
|
|
178
229
|
|
|
179
230
|
export function migrateScripts(metas: readonly ProjectMeta[]): MigrationPlan {
|
|
180
231
|
const projects: GeneratedProject[] = []
|
|
181
|
-
const
|
|
232
|
+
const hookMemo = new Map<string, boolean>()
|
|
182
233
|
for (const meta of metas) {
|
|
183
234
|
const scripts = scriptsOf(meta)
|
|
184
|
-
const runsHooks =
|
|
235
|
+
const runsHooks = runsScriptHooks(meta.dir, hookMemo)
|
|
185
236
|
const runnable = Object.keys(scripts).filter(
|
|
186
237
|
(n) => typeof scripts[n] === 'string' && scripts[n] !== '',
|
|
187
238
|
)
|
|
@@ -205,6 +256,7 @@ export function migrateScripts(metas: readonly ProjectMeta[]): MigrationPlan {
|
|
|
205
256
|
return hookOf === null || !has(hookOf[2]!) || LIFECYCLE.test(hookOf[2]!)
|
|
206
257
|
}
|
|
207
258
|
const tasks: GeneratedTask[] = []
|
|
259
|
+
let readsManifest = false
|
|
208
260
|
for (const name of names) {
|
|
209
261
|
if (!isTask(name)) continue
|
|
210
262
|
|
|
@@ -239,9 +291,18 @@ export function migrateScripts(metas: readonly ProjectMeta[]): MigrationPlan {
|
|
|
239
291
|
`npm ran ${hooks.map((h) => `\`${h}\``).join(' and ')} around this script without being asked; folded into the command in that order`,
|
|
240
292
|
)
|
|
241
293
|
}
|
|
242
|
-
const
|
|
294
|
+
const exec: Record<string, unknown> = { command }
|
|
295
|
+
const npm = npmEnv(command, name, hooks.length > 0)
|
|
296
|
+
if (Object.keys(npm.define).length > 0) exec['env'] = { define: npm.define }
|
|
297
|
+
if (npm.readsManifest) readsManifest = true
|
|
298
|
+
for (const v of npm.unset) {
|
|
299
|
+
todos.push(
|
|
300
|
+
`the script reads $${v}, which the package manager sets and vx does not: define it under exec.env.define or drop it`,
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
const task: Record<string, unknown> = { exec }
|
|
243
304
|
if (PERSISTENT_TASK_NAMES.has(name)) {
|
|
244
|
-
|
|
305
|
+
exec['persistent'] = {}
|
|
245
306
|
todos.push(PERSISTENT_TODO)
|
|
246
307
|
}
|
|
247
308
|
if (name === 'build') {
|
|
@@ -262,7 +323,10 @@ export function migrateScripts(metas: readonly ProjectMeta[]): MigrationPlan {
|
|
|
262
323
|
})
|
|
263
324
|
}
|
|
264
325
|
upstreamBuildOnWorker(tasks)
|
|
265
|
-
if (tasks.length > 0)
|
|
326
|
+
if (tasks.length > 0) {
|
|
327
|
+
const importLines = readsManifest ? [MANIFEST_IMPORT] : []
|
|
328
|
+
projects.push({ name: meta.name, dir: meta.dir, importLines, tasks })
|
|
329
|
+
}
|
|
266
330
|
}
|
|
267
331
|
return {
|
|
268
332
|
headerNotes: [
|
|
@@ -345,8 +345,9 @@ export async function loadProjectConfigs(
|
|
|
345
345
|
const hit = cacheKey === null ? undefined : hits.get(cacheKey)
|
|
346
346
|
// Stored AFTER validation, so a hit needs none; the key covers every
|
|
347
347
|
// byte the evaluation could have read.
|
|
348
|
-
|
|
349
|
-
|
|
348
|
+
const cached = hit === undefined ? undefined : storedConfig(hit)
|
|
349
|
+
if (cached !== undefined) {
|
|
350
|
+
out.push(cached)
|
|
350
351
|
continue
|
|
351
352
|
}
|
|
352
353
|
// A fast key that missed: the closure is stale or the file changed.
|
|
@@ -365,9 +366,10 @@ export async function loadProjectConfigs(
|
|
|
365
366
|
})
|
|
366
367
|
key = keyed?.key ?? null
|
|
367
368
|
closure = keyed !== null && keyed.indexable ? keyed.closure : undefined
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
369
|
+
const slowRow = key === null ? null : (store!.getConfigEval(key) ?? null)
|
|
370
|
+
const slowHit = slowRow === null ? undefined : storedConfig(slowRow)
|
|
371
|
+
if (slowHit !== undefined) {
|
|
372
|
+
out.push(slowHit)
|
|
371
373
|
if (closure !== undefined) learnedClosures.push([configPath, closure])
|
|
372
374
|
continue
|
|
373
375
|
}
|
|
@@ -488,3 +490,20 @@ export async function loadWorkspaceConfig(root: string): Promise<WorkspaceConfig
|
|
|
488
490
|
}
|
|
489
491
|
return null
|
|
490
492
|
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* A stored evaluation, or undefined when the row is not one: a row cut
|
|
496
|
+
* short (a crash mid-write, a bad disk) failed every later run with a
|
|
497
|
+
* `SyntaxError` stack from this loader until the cache was wiped
|
|
498
|
+
* (fuzzed, L-17). As a miss it is evaluated again and the row replaced.
|
|
499
|
+
*/
|
|
500
|
+
function storedConfig(json: string): ProjectConfig | undefined {
|
|
501
|
+
try {
|
|
502
|
+
const v: unknown = JSON.parse(json)
|
|
503
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
504
|
+
? (v as ProjectConfig)
|
|
505
|
+
: undefined
|
|
506
|
+
} catch {
|
|
507
|
+
return undefined
|
|
508
|
+
}
|
|
509
|
+
}
|