diff --git a/Cargo.lock b/Cargo.lock index 96c0ac32e5..8295e55216 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4214,6 +4214,7 @@ dependencies = [ name = "oxc-node" version = "0.0.0" dependencies = [ + "base64-simd", "idna", "memchr", "napi", @@ -8354,12 +8355,14 @@ dependencies = [ "anyhow", "async-trait", "base64-simd", + "blake3", "clap", "console", "cow-utils", "napi", "napi-build", "napi-derive", + "nix 0.31.3", "oxc-node", "petgraph", "pretty_assertions", @@ -8385,6 +8388,7 @@ dependencies = [ "vt_select", "vt_str", "vt_workspace", + "winapi-util", ] [[package]] diff --git a/bench/vpx-cache.ts b/bench/vpx-cache.ts new file mode 100644 index 0000000000..9f21751828 --- /dev/null +++ b/bench/vpx-cache.ts @@ -0,0 +1,584 @@ +/** + * Compare a preserved baseline package with the vpx cache prototype. + * + * node bench/vpx-cache.ts --baseline-package /tmp/baseline/cli \ + * --candidate-package ./packages/cli --node /absolute/path/to/node \ + * --runs 50 --json /tmp/vpx-cache-results.json + * + * Both packages need built dist/ and binding/ files. The optional --vpx executable + * adds global-launcher rows; its isolated config selects the requested Node.js + * through system-first mode. Every launch verifies the actual executable. + * + * Cold samples clear only the contender's disposable cache and temporary files. + * Warm samples use fresh processes after completed priming runs. Baseline, + * disabled, transform-only, compile-only, and combined modes run in seeded random + * blocks. The five historical fixtures are shared with vpx-script.ts. + */ + +import { spawn, spawnSync } from 'node:child_process'; +import { createHash, randomUUID } from 'node:crypto'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { arch, cpus, platform, release, tmpdir, totalmem } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { parseArgs } from 'node:util'; + +import { generateVpxFixtures, vpxFixtures } from './vpx-fixtures.ts'; + +const { values } = parseArgs({ + options: { + 'baseline-package': { type: 'string' }, + 'candidate-package': { type: 'string' }, + node: { type: 'string', multiple: true }, + vpx: { type: 'string' }, + runs: { type: 'string', default: '50' }, + warmup: { type: 'string', default: '3' }, + 'cold-runs': { type: 'string', default: '5' }, + 'timeout-ms': { type: 'string', default: '30000' }, + seed: { type: 'string', default: '1' }, + scenario: { type: 'string', multiple: true }, + launcher: { type: 'string', multiple: true }, + mode: { type: 'string', multiple: true }, + json: { type: 'string' }, + diagnostics: { type: 'boolean', default: false }, + keep: { type: 'boolean', default: false }, + help: { type: 'boolean', default: false }, + }, +}); + +if (values.help) { + console.log(`Usage: node bench/vpx-cache.ts --baseline-package --json + --candidate-package Default: this checkout's packages/cli + --node Repeatable; default: current Node.js + --vpx Also test the global launcher in system-first mode + --runs --warmup Warm measured runs and priming runs (50, 3) + --cold-runs Empty-cache measured runs (5; 0 to skip) + --scenario Repeatable; default: all five historical fixtures + --launcher Repeatable; default: direct, local, and supplied global + --mode baseline, disabled, transform, compile, combined + --seed Deterministic shuffle seed (1) + --timeout-ms Per-launch timeout (30000) + --diagnostics Extra untimed runs with native cacheStats, if available + --keep Retain the temporary fixture/cache tree`); + process.exit(0); +} + +function integer(value: string, name: string, minimum = 1): number { + const number = Number(value); + if (!Number.isSafeInteger(number) || number < minimum) { + throw new Error(`${name} must be an integer >= ${minimum}`); + } + return number; +} + +const runs = integer(values.runs, '--runs'); +const warmup = integer(values.warmup, '--warmup'); +const coldRuns = integer(values['cold-runs'], '--cold-runs', 0); +const timeoutMs = integer(values['timeout-ms'], '--timeout-ms'); +const seed = integer(values.seed, '--seed', 0); +const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +if (!values['baseline-package'] || !values.json) { + throw new Error('--baseline-package and --json are required; use --help for usage'); +} +const baseline = realpathSync(values['baseline-package']); +const candidate = realpathSync(values['candidate-package'] ?? path.join(repo, 'packages/cli')); +const jsonPath = path.resolve(values.json); +const globalVpx = values.vpx && path.resolve(values.vpx); +const launchers = values.launcher ?? ['direct', 'local', ...(globalVpx ? ['global'] : [])]; +const modes = values.mode ?? ['baseline', 'disabled', 'transform', 'compile', 'combined']; +const scenarios = vpxFixtures.filter( + (fixture) => !values.scenario || values.scenario.includes(fixture.name), +); +for (const name of values.scenario ?? []) { + if (!vpxFixtures.some((fixture) => fixture.name === name)) { + throw new Error(`Unknown scenario: ${name}`); + } +} +for (const name of launchers) { + if (!['direct', 'local', 'global'].includes(name) || (name === 'global' && !globalVpx)) { + throw new Error(`Invalid launcher or missing --vpx: ${name}`); + } +} +for (const name of modes) { + if (!['baseline', 'disabled', 'transform', 'compile', 'combined'].includes(name)) { + throw new Error(`Unknown cache mode: ${name}`); + } +} + +// Do not inherit loader/config/cache selectors from the developer's shell. A +// separate temporary directory also isolates the old local launcher's default +// Node compile cache without enabling it in the baseline script process. +const cleanEnv = Object.fromEntries( + Object.entries(process.env).filter( + ([key]) => + !/^(?:VP.*|OXC_.*|TSX_.*|TS_NODE_.*|NAPI_RS_.*|NODE_OPTIONS|NODE_PATH|NODE_COMPILE_CACHE.*|NODE_DISABLE_COMPILE_CACHE|NODE_V8_COVERAGE)$/i.test( + key, + ), + ), +); + +interface Runtime { + requested: string; + execPath: string; + node: string; + v8: string; + platform: string; + arch: string; + sha256: string; +} + +function digest(file: string): string { + return createHash('sha256').update(readFileSync(file)).digest('hex'); +} + +function inspectRuntime(requested: string): Runtime { + const probe = spawnSync( + requested, + [ + '-p', + `JSON.stringify({execPath:process.execPath,node:process.versions.node,v8:process.versions.v8,platform:process.platform,arch:process.arch})`, + ], + { + env: cleanEnv, + encoding: 'utf8', + timeout: timeoutMs, + }, + ); + if (probe.status !== 0 || probe.stderr || probe.error) { + throw new Error(`Cannot inspect Node.js ${requested}: ${probe.error ?? probe.stderr}`); + } + const info = JSON.parse(probe.stdout) as Omit; + return { + requested, + ...info, + execPath: realpathSync(info.execPath), + sha256: digest(info.execPath), + }; +} + +function inspectPackage(packageDir: string) { + const entries = [ + 'dist/script-preload.cjs', + 'dist/script-register.js', + 'dist/script-esm-hooks.js', + 'dist/vpx-bin.js', + 'bin/vpx', + 'binding/index.cjs', + ]; + const bindingDir = path.join(packageDir, 'binding'); + const native = readdirSync(bindingDir).filter((file) => file.endsWith('.node')); + const files = [...entries, ...native.map((file) => `binding/${file}`)]; + return { + path: packageDir, + version: ( + JSON.parse(readFileSync(path.join(packageDir, 'package.json'), 'utf8')) as { version: string } + ).version, + files: Object.fromEntries( + files.map((file) => [ + file, + { + sha256: digest(path.join(packageDir, file)), + bytes: statSync(path.join(packageDir, file)).size, + }, + ]), + ), + }; +} + +const runtimes = (values.node ?? [process.execPath]).map(inspectRuntime); +const root = mkdtempSync(path.join(tmpdir(), 'vite-plus-bench-vpx-cache-')); +const fixtures = path.join(root, 'fixtures'); +const packageLink = path.join(fixtures, 'node_modules/vite-plus'); +// Plain JavaScript under node_modules bypasses Oxc's TypeScript-only dependency +// policy. Instrumentation must not add another cacheable transform to hello. +const probeFile = path.join(fixtures, 'node_modules/vpx-benchmark-probe/probe.cjs'); +const marker = '__VPX_BENCH_READY__'; +mkdirSync(path.dirname(packageLink), { recursive: true }); +mkdirSync(path.dirname(probeFile), { recursive: true }); +writeFileSync(path.join(path.dirname(probeFile), 'package.json'), '{"type":"commonjs"}\n'); +generateVpxFixtures(fixtures, "globalThis[Symbol.for('vpx:benchmark:ready')]();\n"); +writeFileSync( + probeFile, + `const fs = require('node:fs'); +globalThis[Symbol.for('vpx:benchmark:ready')] = () => { + const result = { + token: process.env.VPX_BENCH_TOKEN, + execPath: process.execPath, + node: process.versions.node, + v8: process.versions.v8, + preload: process.execArgv[process.execArgv.indexOf('--require') + 1], + }; + if (process.env.VPX_BENCH_BINDING) { + result.cacheStats = require(process.env.VPX_BENCH_BINDING).oxcNode.cacheStats?.(); + } + process.stdout.write(${JSON.stringify(marker)} + JSON.stringify(result) + '\\n'); + fs.writeFileSync(process.env.VPX_BENCH_OUTPUT, result.token); +}; +`, +); + +interface Command { + id: string; + runtime: Runtime; + scenario: (typeof vpxFixtures)[number]; + mode: string; + launcher: string; + packageDir: string; + home: string; + program: string; + args: string[]; + env: NodeJS.ProcessEnv; +} + +type Phase = 'cold' | 'prime' | 'warm' | 'diagnostic'; +interface Sample { + sequence: number; + timestamp: string; + command: string; + phase: Phase; + iteration: number; + readyMs: number; + exitMs: number; + cacheStats?: unknown; +} + +const commands: Command[] = []; +for (const [runtimeIndex, runtime] of runtimes.entries()) { + for (const scenario of scenarios) { + for (const launcher of launchers) { + for (const mode of modes) { + const packageDir = mode === 'baseline' ? baseline : candidate; + const id = `${runtimeIndex}/${scenario.name}/${launcher}/${mode}`; + const home = path.join(root, 'homes', id); + mkdirSync(path.join(home, 'tmp'), { recursive: true }); + // VP_HOME is intentionally temporary; the global launcher selects the + // exact runtime on PATH rather than downloading a managed installation. + writeFileSync(path.join(home, 'config.json'), '{"nodeShimMode":"system_first"}\n'); + const env: NodeJS.ProcessEnv = { + ...cleanEnv, + VP_HOME: home, + VP_SELF_SETUP_NO_MODIFY_PATH: '1', + PATH: [path.dirname(runtime.execPath), cleanEnv.PATH ?? ''].join(path.delimiter), + TMPDIR: path.join(home, 'tmp'), + TMP: path.join(home, 'tmp'), + TEMP: path.join(home, 'tmp'), + }; + if (mode === 'disabled') { + env.VPX_DISABLE_CACHE = '1'; + } else if (mode !== 'baseline') { + env.VPX_CACHE = mode === 'combined' ? '1' : mode; + } + const nodeArgs = [ + '--require', + probeFile, + path.join(fixtures, scenario.name, scenario.entry), + ]; + const args = + launcher === 'direct' + ? [ + '--require', + path.join(packageDir, 'dist/script-preload.cjs'), + '--import', + pathToFileURL(path.join(packageDir, 'dist/script-register.js')).href, + ...nodeArgs, + ] + : launcher === 'local' + ? [path.join(packageDir, 'bin/vpx'), ...nodeArgs] + : nodeArgs; + commands.push({ + id, + runtime, + scenario, + mode, + launcher, + packageDir, + home, + program: launcher === 'global' ? globalVpx! : runtime.execPath, + args, + env, + }); + } + } + } +} + +const samples: Sample[] = []; +const report = { + schema: 1, + startedAt: new Date().toISOString(), + fixtureRoot: root, + harness: { execPath: realpathSync(process.execPath), node: process.version }, + globalLauncher: globalVpx + ? { path: globalVpx, realpath: realpathSync(globalVpx), sha256: digest(globalVpx) } + : undefined, + machine: { + platform: platform(), + release: release(), + arch: arch(), + cpu: cpus()[0]?.model, + logicalCpus: cpus().length, + memoryBytes: totalmem(), + }, + packages: { baseline: inspectPackage(baseline), candidate: inspectPackage(candidate) }, + runtimes, + settings: { + runs, + warmup, + coldRuns, + seed, + timeoutMs, + globalVpx, + globalRuntimePolicy: 'system_first', + diagnostics: values.diagnostics, + }, + commands: commands.map(({ id, mode, launcher, packageDir, program, args }) => ({ + id, + mode, + launcher, + packageDir, + program, + args, + })), + samples, + summaries: [] as ReturnType[], + error: undefined as string | undefined, + completedAt: undefined as string | undefined, +}; + +function save(): void { + mkdirSync(path.dirname(jsonPath), { recursive: true }); + writeFileSync(jsonPath, `${JSON.stringify(report, null, 2)}\n`); +} + +let randomState = seed >>> 0; +function shuffled(items: T[]): T[] { + const result = [...items]; + for (let i = result.length - 1; i > 0; i--) { + randomState = (Math.imul(randomState, 1664525) + 1013904223) >>> 0; + const j = Math.floor((randomState / 0x100000000) * (i + 1)); + [result[i], result[j]] = [result[j], result[i]]; + } + return result; +} + +function clearCaches(command: Command): void { + rmSync(path.join(command.home, 'cache'), { recursive: true, force: true }); + rmSync(path.join(command.home, 'tmp'), { recursive: true, force: true }); + mkdirSync(path.join(command.home, 'tmp'), { recursive: true }); +} + +let selectedPackage: string | undefined; +async function measure(command: Command, phase: Phase, iteration: number): Promise { + if (selectedPackage !== command.packageDir) { + rmSync(packageLink, { recursive: true, force: true }); + symlinkSync(command.packageDir, packageLink, process.platform === 'win32' ? 'junction' : 'dir'); + selectedPackage = command.packageDir; + } + const token = randomUUID(); + const outputFile = path.join(command.home, 'execution-token'); + rmSync(outputFile, { force: true }); + const env = { + ...command.env, + VPX_BENCH_TOKEN: token, + VPX_BENCH_OUTPUT: outputFile, + ...(phase === 'diagnostic' + ? { VPX_BENCH_BINDING: path.join(command.packageDir, 'binding/index.cjs') } + : {}), + }; + const timestamp = new Date().toISOString(); + const started = performance.now(); + return new Promise((resolve, reject) => { + const child = spawn(command.program, command.args, { + cwd: path.join(fixtures, command.scenario.name), + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + let readyMs: number | undefined; + const timer = setTimeout(() => { + child.kill('SIGKILL'); + reject(new Error(`${command.id} timed out after ${timeoutMs} ms`)); + }, timeoutMs); + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + if ( + readyMs === undefined && + stdout.includes(marker) && + stdout.slice(stdout.indexOf(marker)).includes('\n') + ) { + readyMs = performance.now() - started; + } + }); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + child.on('error', (error) => { + clearTimeout(timer); + reject(error); + }); + child.on('close', (code, signal) => { + const exitMs = performance.now() - started; + clearTimeout(timer); + try { + const pieces = stdout.split(marker); + if ( + code !== 0 || + signal || + stderr || + readyMs === undefined || + pieces.length !== 2 || + pieces[0].trim() !== command.scenario.output + ) { + throw new Error( + `exit=${code} signal=${signal} ready=${readyMs}\nstdout: ${stdout}\nstderr: ${stderr}`, + ); + } + const identity = JSON.parse(pieces[1]) as { + token: string; + execPath: string; + node: string; + v8: string; + preload: string; + cacheStats?: unknown; + }; + if ( + identity.token !== token || + !existsSync(outputFile) || + readFileSync(outputFile, 'utf8') !== token + ) { + throw new Error('The script did not execute its per-run side effect'); + } + if ( + realpathSync(identity.execPath) !== command.runtime.execPath || + identity.node !== command.runtime.node || + identity.v8 !== command.runtime.v8 + ) { + throw new Error( + `Runtime mismatch: requested ${JSON.stringify(command.runtime)}, observed ${JSON.stringify(identity)}`, + ); + } + if ( + realpathSync(identity.preload) !== + realpathSync(path.join(command.packageDir, 'dist/script-preload.cjs')) + ) { + throw new Error(`Loader mismatch: ${identity.preload} is not from ${command.packageDir}`); + } + resolve({ + sequence: samples.length, + timestamp, + command: command.id, + phase, + iteration, + readyMs, + exitMs, + ...(phase === 'diagnostic' ? { cacheStats: identity.cacheStats } : {}), + }); + } catch (error) { + reject(new Error(`${command.id} ${phase} #${iteration}: ${String(error)}`)); + } + }); + }); +} + +function percentile(numbers: number[], quantile: number): number { + const sorted = numbers.toSorted((a, b) => a - b); + const index = (sorted.length - 1) * quantile; + const lower = Math.floor(index); + const upper = Math.ceil(index); + return sorted[lower] + (sorted[upper] - sorted[lower]) * (index - lower); +} + +function summarize(command: Command, phase: 'cold' | 'warm') { + const selected = samples.filter( + (sample) => sample.command === command.id && sample.phase === phase, + ); + return { + command: command.id, + phase, + count: selected.length, + ready: { + p50: percentile( + selected.map((sample) => sample.readyMs), + 0.5, + ), + p95: percentile( + selected.map((sample) => sample.readyMs), + 0.95, + ), + }, + exit: { + p50: percentile( + selected.map((sample) => sample.exitMs), + 0.5, + ), + p95: percentile( + selected.map((sample) => sample.exitMs), + 0.95, + ), + }, + }; +} + +try { + save(); + for (const [phase, count] of [ + ['cold', coldRuns], + ['prime', warmup], + ['warm', runs], + ['diagnostic', values.diagnostics ? 1 : 0], + ] as const) { + for (let iteration = 0; iteration < count; iteration++) { + for (const command of shuffled(commands)) { + if (phase === 'cold' || (phase === 'prime' && iteration === 0)) { + clearCaches(command); + } + samples.push(await measure(command, phase, iteration)); + } + save(); + console.error( + `${phase} block ${iteration + 1}/${count}: ${commands.length} validated launches`, + ); + } + } + report.summaries = commands.flatMap((command) => + coldRuns + ? [summarize(command, 'cold'), summarize(command, 'warm')] + : [summarize(command, 'warm')], + ); + report.completedAt = new Date().toISOString(); + save(); + console.log( + '| Runtime / fixture / launcher / mode | State | Ready p50 | Ready p95 | Exit p50 | Exit p95 |', + ); + console.log('| --- | --- | ---: | ---: | ---: | ---: |'); + for (const summary of report.summaries) { + console.log( + `| ${summary.command} | ${summary.phase} | ${summary.ready.p50.toFixed(2)} ms | ${summary.ready.p95.toFixed(2)} ms | ${summary.exit.p50.toFixed(2)} ms | ${summary.exit.p95.toFixed(2)} ms |`, + ); + } + console.error(`Results: ${jsonPath}`); + if (!values.keep) { + rmSync(root, { recursive: true, force: true }); + } else { + console.error(`Fixtures and caches retained: ${root}`); + } +} catch (error) { + report.error = String(error); + save(); + console.error(`Failed benchmark retained fixtures and caches at ${root}`); + throw error; +} diff --git a/bench/vpx-fixtures.ts b/bench/vpx-fixtures.ts new file mode 100644 index 0000000000..4b8d0b0786 --- /dev/null +++ b/bench/vpx-fixtures.ts @@ -0,0 +1,84 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; + +const modules = 300; + +export const vpxFixtures = [ + { name: 'hello', entry: 'hello.ts', output: 'hello' }, + { name: 'esm-app', entry: 'index.ts', output: 'total 44850' }, + { name: 'cjs-app', entry: 'index.ts', output: 'total 44850' }, + { name: 'large-cjs', entry: 'main.ts', output: 'total 4950' }, + { name: 'large-esm', entry: 'main.ts', output: 'total 4950' }, +] as const; + +function moduleSource(i: number): string { + return `export interface Item${i} { + id: number; + name: string; +} + +export enum Kind${i} { + A = 'a${i}', + B = 'b${i}', +} + +export class Store${i} { + items: Item${i}[] = []; + + constructor(private readonly prefix: string) {} + + add(id: number): Item${i} { + const item = { id, name: \`\${this.prefix}-\${id}\` }; + this.items.push(item); + return item; + } +} + +export function run${i}(): number { + return new Store${i}(Kind${i}.A).add(${i}).id; +} +`; +} + +/** Keep the historical startup fixtures identical across benchmark runners. */ +export function generateVpxFixtures(root: string, afterMain = ''): void { + function write(file: string, content: string): void { + const target = path.join(root, file); + mkdirSync(path.dirname(target), { recursive: true }); + writeFileSync(target, content); + } + + write('hello/package.json', '{ "type": "module" }\n'); + write('hello/hello.ts', `const message: string = 'hello';\nconsole.log(message);\n${afterMain}`); + + const index = [ + ...Array.from({ length: modules }, (_, i) => `import { run${i} } from './mod${i}.ts';`), + 'let total = 0;', + ...Array.from({ length: modules }, (_, i) => `total += run${i}();`), + "console.log('total', total);", + ].join('\n'); + for (const [name, packageJson] of [ + ['esm-app', '{ "type": "module" }\n'], + ['cjs-app', '{ "name": "cjs-app" }\n'], + ]) { + write(`${name}/package.json`, packageJson); + write(`${name}/index.ts`, `${index}\n${afterMain}`); + for (let i = 0; i < modules; i++) { + write(`${name}/mod${i}.ts`, moduleSource(i)); + } + } + + // One 1.8 MB file without import/export, in each module format. + let large = ''; + for (let i = 0; i < 12000; i++) { + large += + `interface Shape${i} { id: number; label: string }\n` + + `function make${i}(id: number): Shape${i} { return { id, label: 'shape-${i}-' + id.toString(16) }; }\n`; + } + large += + "let total: number = 0;\nfor (let i = 0; i < 100; i++) total += make0(i).id;\nconsole.log('total', total);\n"; + write('large-cjs/package.json', '{ "name": "large-cjs" }\n'); + write('large-cjs/main.ts', large + afterMain); + write('large-esm/package.json', '{ "type": "module" }\n'); + write('large-esm/main.ts', large + afterMain); +} diff --git a/bench/vpx-script.ts b/bench/vpx-script.ts index c123805fd9..929a9bcf29 100644 --- a/bench/vpx-script.ts +++ b/bench/vpx-script.ts @@ -31,10 +31,11 @@ import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { parseArgs } from 'node:util'; +import { generateVpxFixtures, vpxFixtures } from './vpx-fixtures.ts'; + const repo = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); const fixtures = path.join(tmpdir(), 'vite-plus-bench-vpx-script'); const dist = path.join(repo, 'packages/cli/dist'); -const modules = 300; const { values } = parseArgs({ options: { @@ -54,94 +55,18 @@ const tsxPackage = createRequire(path.join(repo, 'packages/core/package.json')). const tsxDir = path.dirname(tsxPackage); const tsxVersion = (JSON.parse(readFileSync(tsxPackage, 'utf8')) as { version: string }).version; -function write(file: string, content: string): void { - mkdirSync(path.dirname(path.join(fixtures, file)), { recursive: true }); - writeFileSync(path.join(fixtures, file), content); -} - -function moduleSource(i: number): string { - return `export interface Item${i} { - id: number; - name: string; -} - -export enum Kind${i} { - A = 'a${i}', - B = 'b${i}', -} - -export class Store${i} { - items: Item${i}[] = []; - - constructor(private readonly prefix: string) {} - - add(id: number): Item${i} { - const item = { id, name: \`\${this.prefix}-\${id}\` }; - this.items.push(item); - return item; - } -} - -export function run${i}(): number { - return new Store${i}(Kind${i}.A).add(${i}).id; -} -`; -} - function generateFixtures(): void { rmSync(fixtures, { recursive: true, force: true }); - // The global `vpx` resolves the loader from the project's `vite-plus` first. mkdirSync(path.join(fixtures, 'node_modules'), { recursive: true }); symlinkSync( path.join(repo, 'packages/cli'), path.join(fixtures, 'node_modules/vite-plus'), 'dir', ); - // One erasable file, so Node.js type stripping can run it too. - write('hello/package.json', '{ "type": "module" }\n'); - write('hello/hello.ts', "const message: string = 'hello';\nconsole.log(message);\n"); - - // Many modules with enums and parameter properties (not erasable), as an ES module - // package and as a package without `"type"`. - const index = [ - ...Array.from({ length: modules }, (_, i) => `import { run${i} } from './mod${i}.ts';`), - 'let total = 0;', - ...Array.from({ length: modules }, (_, i) => `total += run${i}();`), - "console.log('total', total);", - ].join('\n'); - for (const [name, packageJson] of [ - ['esm-app', '{ "type": "module" }\n'], - ['cjs-app', '{ "name": "cjs-app" }\n'], - ]) { - write(`${name}/package.json`, packageJson); - write(`${name}/index.ts`, `${index}\n`); - for (let i = 0; i < modules; i++) { - write(`${name}/mod${i}.ts`, moduleSource(i)); - } - } - - // One 1.8 MB file without import/export, as CommonJS and as an ES module. - let large = ''; - for (let i = 0; i < 12000; i++) { - large += - `interface Shape${i} { id: number; label: string }\n` + - `function make${i}(id: number): Shape${i} { return { id, label: 'shape-${i}-' + id.toString(16) }; }\n`; - } - large += - "let total: number = 0;\nfor (let i = 0; i < 100; i++) total += make0(i).id;\nconsole.log('total', total);\n"; - write('large-cjs/package.json', '{ "name": "large-cjs" }\n'); - write('large-cjs/main.ts', large); - write('large-esm/package.json', '{ "type": "module" }\n'); - write('large-esm/main.ts', large); + generateVpxFixtures(fixtures); } -const scenarios = [ - { name: 'hello', entry: 'hello.ts' }, - { name: 'esm-app', entry: 'index.ts' }, - { name: 'cjs-app', entry: 'index.ts' }, - { name: 'large-cjs', entry: 'main.ts' }, - { name: 'large-esm', entry: 'main.ts' }, -]; +const scenarios = vpxFixtures; interface Command { label: string; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_cache/legacy.cts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_cache/legacy.cts new file mode 100644 index 0000000000..f34b56dc32 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_cache/legacy.cts @@ -0,0 +1,4 @@ +enum Value { + Current = 'one', +} +module.exports = Value.Current; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_cache/mutate.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_cache/mutate.mjs new file mode 100644 index 0000000000..ec0bab9189 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_cache/mutate.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import { readdirSync, readFileSync, statSync, utimesSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; + +const action = process.argv[2]; +if (action === 'stamp') { + const timestamp = new Date('2020-01-01T00:00:00Z'); + for (const file of ['value.ts', 'legacy.cts']) { + utimesSync(file, timestamp, timestamp); + } +} else if (action === 'edit') { + // vpt has no operation that changes bytes while restoring the exact timestamp. + for (const file of ['value.ts', 'legacy.cts']) { + const previous = statSync(file); + writeFileSync(file, readFileSync(file, 'utf8').replace("'one'", "'two'")); + utimesSync(file, previous.atime, previous.mtime); + const current = statSync(file); + assert.equal(current.size, previous.size); + assert.equal(current.mtimeMs, previous.mtimeMs); + } +} else if (action === 'corrupt') { + const require = createRequire(import.meta.url); + const packageRoot = path.dirname(require.resolve('vite-plus/package.json')); + const { oxcNode } = require(path.join(packageRoot, 'binding/index.cjs')); + // Use the native directory API, so the test follows VpDirs on every platform. + const root = oxcNode.initializeCache(false, process.versions.node); + assert.ok(root); + let records = 0; + const corrupt = (directory) => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const file = path.join(directory, entry.name); + if (entry.isDirectory()) corrupt(file); + else if (/^[a-f0-9]{64}(?:\.pack)?$/.test(entry.name)) { + writeFileSync(file, 'broken cache record'); + records++; + } + } + }; + corrupt(path.join(root, 'transform')); + assert.ok(records > 0, 'the priming runs must create transform artifacts'); +} else { + throw new Error(`unknown action: ${action}`); +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_cache/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_cache/package.json new file mode 100644 index 0000000000..44bfce3a01 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_cache/package.json @@ -0,0 +1,8 @@ +{ + "name": "command-vpx-script-cache", + "private": true, + "type": "module", + "engines": { + "node": "22.23.2" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_cache/probe.tsx b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_cache/probe.tsx new file mode 100644 index 0000000000..7bfeef592b --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_cache/probe.tsx @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import { appendFileSync, readFileSync } from 'node:fs'; +import { createRequire, getCompileCacheDir } from 'node:module'; +import path from 'node:path'; + +import { Value } from './value.ts'; + +const require = createRequire(import.meta.url); +const legacy = require('./legacy.cts'); +const preload = process.execArgv.find((arg) => path.basename(arg) === 'script-preload.cjs'); +assert.ok(preload, 'the CLI must preload the selected loader'); +const packageRoot = path.dirname(path.dirname(preload)); +const { oxcNode } = require(path.join(packageRoot, 'binding/index.cjs')); + +function h(tag: string): string { + return `h:${tag}`; +} +function j(tag: string): string { + return `j:${tag}`; +} + +const [value, factory, state] = process.argv.slice(2); +assert.equal(Value.Current, value); +assert.equal(legacy, value); +assert.equal(, `${factory}:entry`); +const stats = oxcNode.cacheStats(); +if (state === 'warm') { + assert.ok(stats.hits > 0, 'warm launch must reuse a transform'); + assert.equal(stats.misses, 0, 'unchanged transforms must not run again'); + assert.equal(stats.errors, 0, 'warm records must be readable'); +} else if (state === 'cold' || state === 'edited') { + assert.ok(stats.misses > 0, 'new sources must be transformed'); + assert.ok(stats.writes > 0, 'successful transforms must be persisted'); +} else if (state === 'repaired') { + assert.ok(stats.errors > 0, 'corrupt records must be rejected'); + assert.ok(stats.writes > 0, 'corrupt records must be replaced'); +} else if (state === 'disabled') { + assert.deepEqual(stats, { hits: 0, misses: 0, writes: 0, errors: 0 }); +} +if (process.env.VPX_CACHE === '1' && state !== 'disabled') { + assert.ok(getCompileCacheDir(), 'the script process must enable its compile cache'); +} + +// Count a visible side effect separately from transform hits. Every invocation runs. +appendFileSync('effects.txt', `${value}\n`); +const runs = readFileSync('effects.txt', 'utf8').trim().split('\n').length; +console.log(`cache ${state}: esm=${Value.Current} cjs=${legacy} jsx=${factory} run=${runs}`); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_cache/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_cache/snapshots.toml new file mode 100644 index 0000000000..6c21625c5a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_cache/snapshots.toml @@ -0,0 +1,34 @@ +[[case]] +name = "vpx_script_cache_invalidation" +vp = ["local", "global"] +env = { VPX_CACHE = "1" } +comment = "Persistent transform and compile caches keep ESM, CommonJS, JSX settings, and side effects current. Cache corruption is repaired." +steps = [ + { argv = ["node", "mutate.mjs", "stamp"], snapshot = false }, + ["vpx", "./probe.tsx", "one", "h", "cold"], + ["vpx", "./probe.tsx", "one", "h", "warm"], + { argv = ["node", "mutate.mjs", "edit"], snapshot = false }, + { argv = ["vpx", "./probe.tsx", "two", "h", "edited"], comment = "Source bytes change while file size and mtime stay identical" }, + { argv = ["vpt", "json-edit", "tsconfig.json", "compilerOptions.jsxFactory", "\"j\""], snapshot = false }, + { argv = ["vpx", "./probe.tsx", "two", "j", "configured"], comment = "The effective JSX configuration changes the generated code" }, + { argv = ["node", "mutate.mjs", "corrupt"], snapshot = false }, + ["vpx", "./probe.tsx", "two", "j", "repaired"], + ["vpx", "./probe.tsx", "two", "j", "warm"], + { argv = ["vpx", "./probe.tsx", "two", "j", "disabled"], envs = [["VPX_DISABLE_CACHE", "1"]], comment = "Disabling the managed caches still executes the same script" }, +] + +[[case]] +name = "vpx_script_cache_async_hooks" +vp = ["local", "global"] +env = { VP_NODE_VERSION = "22.18.0", VPX_CACHE = "transform" } +comment = "Off-thread ESM hooks inherit the cache policy; CommonJS hooks retain their own cache counters." +steps = [ + { argv = ["node", "mutate.mjs", "stamp"], snapshot = false }, + ["vpx", "./probe.tsx", "one", "h", "cold"], + ["vpx", "./probe.tsx", "one", "h", "warm"], + { argv = ["node", "mutate.mjs", "edit"], snapshot = false }, + ["vpx", "./probe.tsx", "two", "h", "edited"], + { argv = ["vpt", "json-edit", "tsconfig.json", "compilerOptions.jsxFactory", "\"j\""], snapshot = false }, + ["vpx", "./probe.tsx", "two", "j", "configured"], + ["vpx", "./probe.tsx", "two", "j", "warm"], +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_cache/snapshots/vpx_script_cache_async_hooks.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_cache/snapshots/vpx_script_cache_async_hooks.global.md new file mode 100644 index 0000000000..f372aa3f01 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_cache/snapshots/vpx_script_cache_async_hooks.global.md @@ -0,0 +1,42 @@ +# vpx_script_cache_async_hooks + +Off-thread ESM hooks inherit the cache policy; CommonJS hooks retain their own cache counters. + +## `node mutate.mjs stamp` + + +## `vpx ./probe.tsx one h cold` + +``` +cache cold: esm=one cjs=one jsx=h run=1 +``` + +## `vpx ./probe.tsx one h warm` + +``` +cache warm: esm=one cjs=one jsx=h run=2 +``` + +## `node mutate.mjs edit` + + +## `vpx ./probe.tsx two h edited` + +``` +cache edited: esm=two cjs=two jsx=h run=3 +``` + +## `vpt json-edit tsconfig.json compilerOptions.jsxFactory '"j"'` + + +## `vpx ./probe.tsx two j configured` + +``` +cache configured: esm=two cjs=two jsx=j run=4 +``` + +## `vpx ./probe.tsx two j warm` + +``` +cache warm: esm=two cjs=two jsx=j run=5 +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_cache/snapshots/vpx_script_cache_async_hooks.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_cache/snapshots/vpx_script_cache_async_hooks.local.md new file mode 100644 index 0000000000..f372aa3f01 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_cache/snapshots/vpx_script_cache_async_hooks.local.md @@ -0,0 +1,42 @@ +# vpx_script_cache_async_hooks + +Off-thread ESM hooks inherit the cache policy; CommonJS hooks retain their own cache counters. + +## `node mutate.mjs stamp` + + +## `vpx ./probe.tsx one h cold` + +``` +cache cold: esm=one cjs=one jsx=h run=1 +``` + +## `vpx ./probe.tsx one h warm` + +``` +cache warm: esm=one cjs=one jsx=h run=2 +``` + +## `node mutate.mjs edit` + + +## `vpx ./probe.tsx two h edited` + +``` +cache edited: esm=two cjs=two jsx=h run=3 +``` + +## `vpt json-edit tsconfig.json compilerOptions.jsxFactory '"j"'` + + +## `vpx ./probe.tsx two j configured` + +``` +cache configured: esm=two cjs=two jsx=j run=4 +``` + +## `vpx ./probe.tsx two j warm` + +``` +cache warm: esm=two cjs=two jsx=j run=5 +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_cache/snapshots/vpx_script_cache_invalidation.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_cache/snapshots/vpx_script_cache_invalidation.global.md new file mode 100644 index 0000000000..e7eb7ce159 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_cache/snapshots/vpx_script_cache_invalidation.global.md @@ -0,0 +1,63 @@ +# vpx_script_cache_invalidation + +Persistent transform and compile caches keep ESM, CommonJS, JSX settings, and side effects current. Cache corruption is repaired. + +## `node mutate.mjs stamp` + + +## `vpx ./probe.tsx one h cold` + +``` +cache cold: esm=one cjs=one jsx=h run=1 +``` + +## `vpx ./probe.tsx one h warm` + +``` +cache warm: esm=one cjs=one jsx=h run=2 +``` + +## `node mutate.mjs edit` + + +## `vpx ./probe.tsx two h edited` + +Source bytes change while file size and mtime stay identical + +``` +cache edited: esm=two cjs=two jsx=h run=3 +``` + +## `vpt json-edit tsconfig.json compilerOptions.jsxFactory '"j"'` + + +## `vpx ./probe.tsx two j configured` + +The effective JSX configuration changes the generated code + +``` +cache configured: esm=two cjs=two jsx=j run=4 +``` + +## `node mutate.mjs corrupt` + + +## `vpx ./probe.tsx two j repaired` + +``` +cache repaired: esm=two cjs=two jsx=j run=5 +``` + +## `vpx ./probe.tsx two j warm` + +``` +cache warm: esm=two cjs=two jsx=j run=6 +``` + +## `VPX_DISABLE_CACHE=1 vpx ./probe.tsx two j disabled` + +Disabling the managed caches still executes the same script + +``` +cache disabled: esm=two cjs=two jsx=j run=7 +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_cache/snapshots/vpx_script_cache_invalidation.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_cache/snapshots/vpx_script_cache_invalidation.local.md new file mode 100644 index 0000000000..e7eb7ce159 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_cache/snapshots/vpx_script_cache_invalidation.local.md @@ -0,0 +1,63 @@ +# vpx_script_cache_invalidation + +Persistent transform and compile caches keep ESM, CommonJS, JSX settings, and side effects current. Cache corruption is repaired. + +## `node mutate.mjs stamp` + + +## `vpx ./probe.tsx one h cold` + +``` +cache cold: esm=one cjs=one jsx=h run=1 +``` + +## `vpx ./probe.tsx one h warm` + +``` +cache warm: esm=one cjs=one jsx=h run=2 +``` + +## `node mutate.mjs edit` + + +## `vpx ./probe.tsx two h edited` + +Source bytes change while file size and mtime stay identical + +``` +cache edited: esm=two cjs=two jsx=h run=3 +``` + +## `vpt json-edit tsconfig.json compilerOptions.jsxFactory '"j"'` + + +## `vpx ./probe.tsx two j configured` + +The effective JSX configuration changes the generated code + +``` +cache configured: esm=two cjs=two jsx=j run=4 +``` + +## `node mutate.mjs corrupt` + + +## `vpx ./probe.tsx two j repaired` + +``` +cache repaired: esm=two cjs=two jsx=j run=5 +``` + +## `vpx ./probe.tsx two j warm` + +``` +cache warm: esm=two cjs=two jsx=j run=6 +``` + +## `VPX_DISABLE_CACHE=1 vpx ./probe.tsx two j disabled` + +Disabling the managed caches still executes the same script + +``` +cache disabled: esm=two cjs=two jsx=j run=7 +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_cache/tsconfig.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_cache/tsconfig.json new file mode 100644 index 0000000000..58c05d46ac --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_cache/tsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "jsx": "react", + "jsxFactory": "h" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_cache/value.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_cache/value.ts new file mode 100644 index 0000000000..dcfece70c0 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_cache/value.ts @@ -0,0 +1,3 @@ +export enum Value { + Current = 'one', +} diff --git a/packages/cli/binding/Cargo.toml b/packages/cli/binding/Cargo.toml index 97cba1600f..8eb0503369 100644 --- a/packages/cli/binding/Cargo.toml +++ b/packages/cli/binding/Cargo.toml @@ -8,13 +8,14 @@ publish = false rust-version.workspace = true [features] -oxc-node = ["dep:oxc-node"] +oxc-node = ["dep:oxc-node", "dep:nix", "dep:winapi-util"] rolldown = ["dep:rolldown_binding"] [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } base64-simd = { workspace = true } +blake3 = { workspace = true } clap = { workspace = true, features = ["derive"] } cow-utils = { workspace = true } rustc-hash = { workspace = true } @@ -44,7 +45,14 @@ vt_workspace = { workspace = true } rolldown_binding = { workspace = true, optional = true, features = ["disable_panic_hook"] } oxc-node = { workspace = true, optional = true } +[target.'cfg(unix)'.dependencies] +nix = { workspace = true, optional = true } + +[target.'cfg(windows)'.dependencies] +winapi-util = { version = "0.1.11", optional = true } + [build-dependencies] +blake3 = { workspace = true } napi-build = { workspace = true } [dev-dependencies] diff --git a/packages/cli/binding/build.rs b/packages/cli/binding/build.rs index 0f1b01002b..270973fb19 100644 --- a/packages/cli/binding/build.rs +++ b/packages/cli/binding/build.rs @@ -1,3 +1,45 @@ fn main() { napi_build::setup(); + if std::env::var_os("CARGO_FEATURE_OXC_NODE").is_some() { + script_cache_build_id(); + } +} + +// Include the compiler, helper ABI, and host adapter in the cache namespace. A +// package version alone is not sufficient for development builds and previews. +#[expect( + clippy::disallowed_macros, + clippy::print_stdout, + clippy::disallowed_types, + reason = "build scripts use Cargo's stdout protocol and do not depend on vt_path" +)] +fn script_cache_build_id() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.."); + let mut hash = blake3::Hasher::new(); + for relative in [ + "Cargo.lock", + "Cargo.toml", + "pnpm-lock.yaml", + "pnpm-workspace.yaml", + "oxc-node/Cargo.toml", + "oxc-node/src/lib.rs", + "oxc-node/src/transform_cache.rs", + "oxc-node/src/windows_file_url.rs", + "packages/tools/.upstream-versions.json", + "packages/cli/package.json", + "packages/cli/src/script-hooks.ts", + "packages/cli/binding/build.rs", + "packages/cli/binding/src/oxc_node.rs", + "packages/cli/binding/src/oxc_node/cache.rs", + "packages/cli/binding/src/oxc_node/cache/packed.rs", + ] { + let path = root.join(relative); + println!("cargo:rerun-if-changed={}", path.display()); + let bytes = std::fs::read(&path).expect("script cache build input must exist"); + hash.update(relative.as_bytes()); + hash.update(&(bytes.len() as u64).to_le_bytes()); + hash.update(&bytes); + } + hash.update(std::env::var("TARGET").expect("Cargo sets TARGET").as_bytes()); + println!("cargo:rustc-env=VPX_CACHE_BUILD_ID={}", hash.finalize().to_hex()); } diff --git a/packages/cli/binding/index.d.cts b/packages/cli/binding/index.d.cts index 3f25e0aab7..01681cc853 100644 --- a/packages/cli/binding/index.d.cts +++ b/packages/cli/binding/index.d.cts @@ -4252,6 +4252,7 @@ export declare namespace oxcNode { constructor(cwd?: string | undefined | null, helperModuleName?: string | undefined | null); transform(path: string, source: string | Uint8Array): oxcNode.TransformOutput; } + export function cacheStats(): oxcNode.TransformCacheStats; export function createResolve( specifier: string, context: ResolveContext, @@ -4264,6 +4265,16 @@ export declare namespace oxcNode { export function freezeTransformDeps(): string; /** Freeze the effective config before registering the off-thread ESM hooks. */ export function freezeTsconfig(cwd: string): FrozenTsconfig; + /** + * Initialize an opt-in cache for this loader thread. Storage is lazy: the JS + * loader reports the returned owned root to Vite Task before any artifact I/O. + * An explicit root carries the main thread's selection to off-thread ESM hooks. + */ + export function initializeCache( + enabled: boolean, + runtimeVersion: string, + root?: string | undefined | null, + ): string | null; /** Apply a frozen selection, including discovery without the receiving thread's env. */ export function initializeLoader( path?: string | undefined | null, @@ -4281,6 +4292,16 @@ export declare namespace oxcNode { export function setTransformDeps(mode?: string | undefined | null): void; export function setTsconfigPath(path?: string | undefined | null): void; export function takeWatchDependencies(): Array; + /** + * Counters for opt-in diagnostics and cache equivalence tests. Reading them does + * not print anything or change cache behavior. + */ + export interface TransformCacheStats { + hits: number; + misses: number; + writes: number; + errors: number; + } /** A file transformed for the CommonJS loader. */ export interface TransformOutput { source: string; diff --git a/packages/cli/binding/src/oxc_node.rs b/packages/cli/binding/src/oxc_node.rs index f05ad15ee0..fe7c6649e9 100644 --- a/packages/cli/binding/src/oxc_node.rs +++ b/packages/cli/binding/src/oxc_node.rs @@ -4,6 +4,10 @@ //! `enableWatchReport` and `takeWatchDependencies` feed watch mode the tsconfig files //! oxc-node read and the paths a failed resolve looked for. +mod cache; + +use std::{cell::RefCell, sync::Arc}; + use ::oxc_node::{ FrozenTsconfig, LoadContext, LoadFnOutput, OxcResolveOptions, ResolveContext, ResolveFnOutput, }; @@ -13,6 +17,56 @@ use napi::{ }; use napi_derive::napi; +thread_local! { + static CACHE_STATS: RefCell>> = const { RefCell::new(None) }; +} + +/// Initialize an opt-in cache for this loader thread. Storage is lazy: the JS +/// loader reports the returned owned root to Vite Task before any artifact I/O. +/// An explicit root carries the main thread's selection to off-thread ESM hooks. +#[napi(namespace = "oxcNode")] +pub fn initialize_cache( + enabled: bool, + runtime_version: String, + root: Option, +) -> Option { + ::oxc_node::set_transform_cache(None); + CACHE_STATS.with(|slot| *slot.borrow_mut() = None); + let root = match root { + Some(root) => vt_path::AbsolutePathBuf::new(root.into())?, + None => vp_shared::EnvConfig::get().dirs.cache.join("vpx"), + }; + let result = root.as_path().to_str()?.to_owned(); + if enabled { + let store = cache::DiskTransformCache::new(root, &runtime_version); + CACHE_STATS.with(|slot| *slot.borrow_mut() = Some(Arc::clone(&store.stats))); + ::oxc_node::set_transform_cache(Some(Arc::new(store))); + } + Some(result) +} + +/// Counters for opt-in diagnostics and cache equivalence tests. Reading them does +/// not print anything or change cache behavior. +#[napi(object, namespace = "oxcNode")] +pub struct TransformCacheStats { + pub hits: u32, + pub misses: u32, + pub writes: u32, + pub errors: u32, +} + +#[napi(namespace = "oxcNode")] +pub fn cache_stats() -> TransformCacheStats { + CACHE_STATS.with(|slot| { + let stats = slot.borrow(); + stats + .as_ref() + .map_or(TransformCacheStats { hits: 0, misses: 0, writes: 0, errors: 0 }, |stats| { + stats.snapshot() + }) + }) +} + /// What oxc-node's own module hooks do, which `default-features = false` leaves to the /// host: honor `OXC_LOG`, and keep `using` native on Node.js 24 and later. #[napi(module_exports)] diff --git a/packages/cli/binding/src/oxc_node/cache.rs b/packages/cli/binding/src/oxc_node/cache.rs new file mode 100644 index 0000000000..3a2f99fb6d --- /dev/null +++ b/packages/cli/binding/src/oxc_node/cache.rs @@ -0,0 +1,433 @@ +//! Local artifact storage for the opt-in script-cache experiment. Vite Task's +//! current public client coordinates ignored paths; it has no artifact store. +//! Keep this adapter independent so a future shared store can replace it. + +mod packed; + +use std::{ + fs::{self, File, OpenOptions}, + io::{self, Read, Write}, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicU32, Ordering}, + }, +}; + +use oxc_node::{CachedTransform, TransformCache}; +use vt_path::{AbsolutePath, AbsolutePathBuf}; + +use super::TransformCacheStats; + +const MAGIC: &[u8; 8] = b"VPXTC01\0"; +const HEADER_BYTES: usize = 8 + 32 + 32; +const MAX_RECORD_BYTES: u64 = 64 * 1024 * 1024; + +#[derive(Default)] +pub(super) struct CacheStats { + hits: AtomicU32, + misses: AtomicU32, + writes: AtomicU32, + errors: AtomicU32, +} + +impl CacheStats { + pub(super) fn snapshot(&self) -> TransformCacheStats { + TransformCacheStats { + hits: self.hits.load(Ordering::Relaxed), + misses: self.misses.load(Ordering::Relaxed), + writes: self.writes.load(Ordering::Relaxed), + errors: self.errors.load(Ordering::Relaxed), + } + } +} + +pub(super) struct DiskTransformCache { + root: AbsolutePathBuf, + directory: AbsolutePathBuf, + namespace_ready: AtomicBool, + packs: Mutex, + pub(super) stats: Arc, +} + +impl DiskTransformCache { + pub(super) fn new(root: AbsolutePathBuf, runtime_version: &str) -> Self { + let runtime = blake3::hash(runtime_version.as_bytes()).to_hex(); + let directory = + root.join("transform").join(env!("VPX_CACHE_BUILD_ID")).join(runtime.as_str()); + Self { + root, + directory, + namespace_ready: AtomicBool::new(false), + packs: Mutex::default(), + stats: Arc::default(), + } + } + + fn key(parts: &[&[u8]]) -> blake3::Hash { + let mut hasher = blake3::Hasher::new(); + for part in parts { + hasher.update(&(part.len() as u64).to_le_bytes()); + hasher.update(part); + } + hasher.finalize() + } + + fn path(&self, key: &blake3::Hash) -> AbsolutePathBuf { + let hex = key.to_hex(); + self.directory.join(&hex[..2]).join(hex.as_str()) + } + + fn read(&self, parts: &[&[u8]], key: &blake3::Hash) -> io::Result> { + if !self.namespace_ready.load(Ordering::Relaxed) { + for directory in self.directories() { + match fs::symlink_metadata(&directory) { + Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {} + Ok(_) => return Err(io::Error::from(io::ErrorKind::InvalidInput)), + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error), + } + } + // The private namespace is stable for this process. Do not repeat + // four ancestor stats for every imported module. + self.namespace_ready.store(true, Ordering::Relaxed); + } + if let Some(group) = packed::source_directory(parts) { + let result = self + .packs + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .open(&self.directory, group) + .and_then(|pack| pack.get(key)); + match result { + Ok(Some(hit)) => return Ok(Some(hit)), + Ok(None) => {} + Err(error) => { + return self.read_loose(key)?.map(Some).ok_or(error); + } + } + } + self.read_loose(key) + } + + fn read_loose(&self, key: &blake3::Hash) -> io::Result> { + let path = self.path(key); + match fs::symlink_metadata(path.parent().expect("artifact shard")) { + Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {} + Ok(_) => return Err(io::Error::from(io::ErrorKind::InvalidInput)), + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error), + } + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error), + }; + if !metadata.is_file() || metadata.len() > MAX_RECORD_BYTES { + return Err(io::Error::from(io::ErrorKind::InvalidData)); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + File::open(&path)?.take(MAX_RECORD_BYTES + 1).read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_RECORD_BYTES { + return Err(io::Error::from(io::ErrorKind::InvalidData)); + } + decode(&bytes, key).map(Some).ok_or_else(|| io::Error::from(io::ErrorKind::InvalidData)) + } + + fn write( + &self, + parts: &[&[u8]], + key: &blake3::Hash, + value: &CachedTransform, + ) -> io::Result<()> { + let size = + value.code.len().saturating_add(value.source_map.as_ref().map_or(0, String::len)); + if size.saturating_add(HEADER_BYTES + 17) as u64 > MAX_RECORD_BYTES { + return Ok(()); + } + let payload = encode(value); + // Every directory below the owned root is ours. Refuse symlinks rather + // than publishing through a cache path redirected outside that root. + if !self.namespace_ready.load(Ordering::Relaxed) { + for directory in self.directories() { + create_private_directory(&directory)?; + } + self.namespace_ready.store(true, Ordering::Relaxed); + } + if payload.len() <= packed::MAX_PAYLOAD + && let Some(group) = packed::source_directory(parts) + { + let result = self + .packs + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .open(&self.directory, group) + .and_then(|pack| pack.put(key, &payload)); + match result { + Ok(packed::Publication::Added) => { + self.stats.writes.fetch_add(1, Ordering::Relaxed); + return Ok(()); + } + Ok(packed::Publication::Existing) => return Ok(()), + Ok(packed::Publication::Loose) => {} + Err(_) => { + self.stats.errors.fetch_add(1, Ordering::Relaxed); + } + } + } + let path = self.path(key); + let parent = path.parent().expect("artifact path has a shard parent"); + create_private_directory(parent)?; + let temporary = parent.join(vt_str::format!(".{}.tmp", uuid::Uuid::new_v4()).as_str()); + let result = (|| { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + let mut file = options.open(&temporary)?; + file.write_all(MAGIC)?; + file.write_all(key.as_bytes())?; + file.write_all(blake3::hash(&payload).as_bytes())?; + file.write_all(&payload)?; + drop(file); + // Replacement also heals a corrupt record. Concurrent producers + // publish equivalent values; a reader keeps its open old inode. + fs::rename(&temporary, &path)?; + self.stats.writes.fetch_add(1, Ordering::Relaxed); + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&temporary); + } + result + } + + fn directories(&self) -> [AbsolutePathBuf; 4] { + [ + self.root.clone(), + self.root.join("transform"), + self.directory.parent().expect("build namespace").to_absolute_path_buf(), + self.directory.clone(), + ] + } +} + +impl TransformCache for DiskTransformCache { + fn get(&self, parts: &[&[u8]]) -> Option { + match self.read(parts, &Self::key(parts)) { + Ok(Some(value)) => { + self.stats.hits.fetch_add(1, Ordering::Relaxed); + Some(value) + } + Ok(None) => { + self.stats.misses.fetch_add(1, Ordering::Relaxed); + None + } + Err(_) => { + self.stats.errors.fetch_add(1, Ordering::Relaxed); + self.stats.misses.fetch_add(1, Ordering::Relaxed); + None + } + } + } + + fn put(&self, parts: &[&[u8]], value: &CachedTransform) { + if self.write(parts, &Self::key(parts), value).is_err() { + self.stats.errors.fetch_add(1, Ordering::Relaxed); + } + } +} + +fn create_private_directory(path: &AbsolutePath) -> io::Result<()> { + if let Ok(metadata) = fs::symlink_metadata(path) { + return if metadata.is_dir() && !metadata.file_type().is_symlink() { + Ok(()) + } else { + Err(io::Error::from(io::ErrorKind::InvalidInput)) + }; + } + let mut builder = fs::DirBuilder::new(); + builder.recursive(true); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt as _; + builder.mode(0o700); + } + builder.create(path) +} + +fn encode(value: &CachedTransform) -> Vec { + let map_bytes = value.source_map.as_deref().map_or(&[][..], str::as_bytes); + let mut payload = Vec::with_capacity(17 + value.code.len() + map_bytes.len()); + payload.push(u8::from(value.module)); + payload.extend_from_slice(&(value.code.len() as u64).to_le_bytes()); + let map_len = value.source_map.as_ref().map_or(u64::MAX, |map| map.len() as u64); + payload.extend_from_slice(&map_len.to_le_bytes()); + payload.extend_from_slice(value.code.as_bytes()); + payload.extend_from_slice(map_bytes); + payload +} + +fn decode(bytes: &[u8], key: &blake3::Hash) -> Option { + if bytes.get(..8)? != MAGIC || bytes.get(8..40)? != key.as_bytes() { + return None; + } + let payload = bytes.get(HEADER_BYTES..)?; + if bytes.get(40..HEADER_BYTES)? != blake3::hash(payload).as_bytes() { + return None; + } + decode_payload(payload) +} + +fn decode_payload(payload: &[u8]) -> Option { + let module = match *payload.first()? { + 0 => false, + 1 => true, + _ => return None, + }; + let code_len = usize::try_from(u64::from_le_bytes(payload.get(1..9)?.try_into().ok()?)).ok()?; + let map_len = u64::from_le_bytes(payload.get(9..17)?.try_into().ok()?); + let code_end = 17_usize.checked_add(code_len)?; + let code = str::from_utf8(payload.get(17..code_end)?).ok()?.to_owned(); + let map_bytes = payload.get(code_end..)?; + let source_map = if map_len == u64::MAX { + if !map_bytes.is_empty() { + return None; + } + None + } else { + if map_bytes.len() as u64 != map_len { + return None; + } + Some(str::from_utf8(map_bytes).ok()?.to_owned()) + }; + Some(CachedTransform { code, source_map, module }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn store(directory: &tempfile::TempDir, runtime: &str) -> DiskTransformCache { + DiskTransformCache::new( + AbsolutePathBuf::new(directory.path().join("vpx")).unwrap(), + runtime, + ) + } + + fn artifact() -> CachedTransform { + CachedTransform { + code: "export const answer = 42;".into(), + source_map: Some("{\"version\":3,\"mappings\":\"AAAA\"}".into()), + module: true, + } + } + + #[test] + fn persists_across_instances_and_partitions_inputs_and_runtime() { + let directory = tempfile::tempdir().unwrap(); + let first = store(&directory, "22.23.4"); + assert!(!first.root.as_path().exists(), "initialization must not write before ignoreInput"); + let key: &[&[u8]] = &[b"/project/main.ts", b"source", b"options"]; + first.put(key, &artifact()); + let second = store(&directory, "22.23.4"); + let hit = second.get(key).unwrap(); + assert_eq!(hit.code, artifact().code); + assert_eq!(hit.source_map, artifact().source_map); + assert!(hit.module); + assert!(second.get(&[b"/project/main.ts", b"edited", b"options"]).is_none()); + assert!(second.get(&[b"/project/main.ts", b"source", b"new-options"]).is_none()); + assert!(store(&directory, "24.15.0").get(key).is_none()); + assert_ne!( + DiskTransformCache::key(&[b"ab", b"c"]), + DiskTransformCache::key(&[b"a", b"bc"]) + ); + } + + #[test] + fn corrupt_and_truncated_records_miss_and_are_repaired() { + let directory = tempfile::tempdir().unwrap(); + let cache = store(&directory, "22"); + let key: &[&[u8]] = &[b"key"]; + cache.put(key, &artifact()); + let path = cache.path(&DiskTransformCache::key(key)); + let bytes = fs::read(&path).unwrap(); + for broken in [bytes[..20].to_vec(), { + let mut changed = bytes.clone(); + *changed.last_mut().unwrap() ^= 1; + changed + }] { + fs::write(&path, broken).unwrap(); + assert!(cache.get(key).is_none()); + cache.put(key, &artifact()); + assert_eq!(cache.get(key).unwrap().code, artifact().code); + } + assert_eq!(cache.stats.snapshot().errors, 2); + } + + #[test] + fn concurrent_publication_never_exposes_partial_records() { + let directory = tempfile::tempdir().unwrap(); + let cache = Arc::new(store(&directory, "22")); + std::thread::scope(|scope| { + for _ in 0..8 { + let cache = Arc::clone(&cache); + scope.spawn(move || { + for _ in 0..20 { + cache.put(&[b"shared"], &artifact()); + assert_eq!(cache.get(&[b"shared"]).unwrap().code, artifact().code); + } + }); + } + }); + assert_eq!(cache.stats.snapshot().errors, 0); + } + + #[test] + fn unavailable_directory_is_a_nonfatal_miss() { + let directory = tempfile::tempdir().unwrap(); + let cache = store(&directory, "22"); + fs::write(&cache.root, b"not a directory").unwrap(); + cache.put(&[b"key"], &artifact()); + assert!(cache.get(&[b"key"]).is_none()); + assert!(cache.stats.snapshot().errors > 0); + } + + #[cfg(unix)] + #[test] + fn symlink_record_is_not_read() { + let directory = tempfile::tempdir().unwrap(); + let cache = store(&directory, "22"); + cache.put(&[b"key"], &artifact()); + let path = cache.path(&DiskTransformCache::key(&[b"key"])); + let target = directory.path().join("target"); + fs::rename(&path, &target).unwrap(); + std::os::unix::fs::symlink(&target, &path).unwrap(); + assert!(cache.get(&[b"key"]).is_none()); + } + + #[cfg(unix)] + #[test] + fn symlink_namespace_and_shards_are_not_read() { + let directory = tempfile::tempdir().unwrap(); + let cache = store(&directory, "22"); + cache.put(&[b"key"], &artifact()); + let shard = cache + .path(&DiskTransformCache::key(&[b"key"])) + .parent() + .unwrap() + .to_absolute_path_buf(); + let target = directory.path().join("shard-target"); + fs::rename(&shard, &target).unwrap(); + std::os::unix::fs::symlink(&target, &shard).unwrap(); + assert!(cache.get(&[b"key"]).is_none()); + fs::remove_file(&shard).unwrap(); + fs::rename(&target, &shard).unwrap(); + let namespace_target = directory.path().join("namespace-target"); + fs::rename(&cache.directory, &namespace_target).unwrap(); + std::os::unix::fs::symlink(&namespace_target, &cache.directory).unwrap(); + assert!(store(&directory, "22").get(&[b"key"]).is_none()); + } +} diff --git a/packages/cli/binding/src/oxc_node/cache/packed.rs b/packages/cli/binding/src/oxc_node/cache/packed.rs new file mode 100644 index 0000000000..65800c0746 --- /dev/null +++ b/packages/cli/binding/src/oxc_node/cache/packed.rs @@ -0,0 +1,223 @@ +//! Small transforms from one source directory usually load together. A process +//! indexes that directory's pack once, then looks up complete content keys in RAM. + +use std::{ + fs::{File, OpenOptions, TryLockError}, + io::{self, Read, Seek, SeekFrom, Write}, + ops::Range, +}; + +use oxc_node::CachedTransform; +use rustc_hash::FxHashMap; +use vt_path::{AbsolutePath, AbsolutePathBuf}; + +use super::decode_payload; + +const MAX_SOURCE: usize = 64 * 1024; +pub(super) const MAX_PAYLOAD: usize = 64 * 1024; +const MAX_PACK: usize = 4 * 1024 * 1024; +const RESIDENT_PACKS: usize = 4; +const FRAME_HEADER: usize = 32 + 32; // BLAKE3(key || payload), key +const LENGTH_BYTES: usize = 4; + +pub(super) fn source_directory<'a>(parts: &'a [&[u8]]) -> Option<&'a [u8]> { + // The loader supplies tag, request kind, filename, source, options and mode. + let [b"oxc-node-transform-v1", _, path, source, _, _] = parts else { return None }; + if source.len() > MAX_SOURCE { + return None; + } + let split = path.iter().rposition(|&c| c == b'/' || (cfg!(windows) && c == b'\\'))?; + Some(&path[..split]) +} + +#[derive(Default)] +pub(super) struct SmallPacks { + entries: Vec<(Vec, Pack)>, +} + +impl SmallPacks { + pub(super) fn open(&mut self, root: &AbsolutePath, group: &[u8]) -> io::Result<&mut Pack> { + // Keep the most recently used directory at the end. + if let Some(pos) = self.entries.iter().rposition(|(dir, _)| dir == group) { + self.entries[pos..].rotate_left(1); + } else { + let name = vt_str::format!("{}.pack", blake3::hash(group).to_hex()); + let pack = Pack::load(root.join(name.as_str()))?; + if self.entries.len() >= RESIDENT_PACKS { + self.entries.remove(0); + } + self.entries.push((group.to_vec(), pack)); + } + Ok(&mut self.entries.last_mut().expect("loaded pack").1) + } +} + +pub(super) struct Pack { + path: AbsolutePathBuf, + bytes: Vec, + index: FxHashMap<[u8; 32], Range>, + damaged_tail: bool, +} + +impl Pack { + fn load(path: AbsolutePathBuf) -> io::Result { + let mut pack = + Self { path, bytes: Vec::new(), index: FxHashMap::default(), damaged_tail: false }; + match open_file(&pack.path, false) { + Ok((mut file, size)) => { + pack.bytes.reserve_exact(size); + pack.extend_from(&mut file)?; + pack.damaged_tail |= pack.bytes.is_empty(); + } + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + Err(err) => return Err(err), + } + Ok(pack) + } + + // Only index complete frames. Readers can race a writer or find a partial + // append after a crash. The next writer rechecks the suffix under its lock. + fn extend_from(&mut self, file: &mut File) -> io::Result<()> { + let mut offset = self.bytes.len(); + file.seek(SeekFrom::Start(offset as u64))?; + let limit = MAX_PACK - offset; + if let Err(error) = file.take(limit as u64 + 1).read_to_end(&mut self.bytes) { + self.bytes.truncate(offset); + return Err(error); + } + if self.bytes.len() > MAX_PACK { + self.bytes.truncate(offset); + return Err(io::ErrorKind::InvalidData.into()); + } + while let Some(len) = self.bytes.get(offset..offset + LENGTH_BYTES) { + let len = u32::from_le_bytes(len.try_into().expect("frame length")) as usize; + if !(FRAME_HEADER + 17..=FRAME_HEADER + MAX_PAYLOAD).contains(&len) { + break; + } + let start = offset + LENGTH_BYTES; + let end = start + len; + let Some(frame) = self.bytes.get(start..end) else { break }; + let key = frame[32..FRAME_HEADER].try_into().expect("frame key"); + self.index.insert(key, start..end); + offset = end; + } + self.damaged_tail = offset != self.bytes.len(); + self.bytes.truncate(offset); + Ok(()) + } + + pub(super) fn get(&mut self, key: &blake3::Hash) -> io::Result> { + let Some(range) = self.index.get(key.as_bytes()) else { + return if std::mem::take(&mut self.damaged_tail) { + Err(io::ErrorKind::InvalidData.into()) + } else { + Ok(None) + }; + }; + let frame = &self.bytes[range.clone()]; + if blake3::hash(&frame[32..]).as_bytes() != &frame[..32] { + return Err(io::ErrorKind::InvalidData.into()); + } + decode_payload(&frame[FRAME_HEADER..]) + .map(Some) + .ok_or_else(|| io::ErrorKind::InvalidData.into()) + } + + pub(super) fn put(&mut self, key: &blake3::Hash, payload: &[u8]) -> io::Result { + let (mut file, _) = open_file(&self.path, true)?; + // A paused writer must not stall a script. A contending writer can + // publish this key through the individually atomic loose-file path. + match file.try_lock() { + Ok(()) => {} + Err(TryLockError::WouldBlock) => return Ok(Publication::Loose), + Err(TryLockError::Error(err)) => return Err(err), + } + let length = file.metadata()?.len(); + if length < self.bytes.len() as u64 { + self.bytes.clear(); + self.index.clear(); + } + if length != self.bytes.len() as u64 { + self.extend_from(&mut file)?; + if self.damaged_tail { + file.set_len(self.bytes.len() as u64)?; + } + } + self.damaged_tail = false; + if let Some(range) = self.index.get(key.as_bytes()) { + let frame = &self.bytes[range.clone()]; + if &frame[FRAME_HEADER..] == payload + && blake3::hash(&frame[32..]).as_bytes() == &frame[..32] + { + return Ok(Publication::Existing); + } + } + let start = self.bytes.len(); + let frame_len = FRAME_HEADER + payload.len(); + if start + LENGTH_BYTES + frame_len > MAX_PACK { + return Ok(Publication::Loose); + } + let mut frame = Vec::with_capacity(LENGTH_BYTES + frame_len); + frame.extend_from_slice(&(frame_len as u32).to_le_bytes()); + frame.extend_from_slice(&[0; 32]); + frame.extend_from_slice(key.as_bytes()); + frame.extend_from_slice(payload); + let checksum = blake3::hash(&frame[LENGTH_BYTES + 32..]); + frame[LENGTH_BYTES..LENGTH_BYTES + 32].copy_from_slice(checksum.as_bytes()); + file.seek(SeekFrom::Start(start as u64))?; + file.write_all(&frame)?; + self.bytes.extend_from_slice(&frame); + self.index.insert(*key.as_bytes(), start + LENGTH_BYTES..self.bytes.len()); + Ok(Publication::Added) + } +} + +pub(super) enum Publication { + Added, + Existing, + Loose, +} + +// An append mutates the inode, unlike the loose store's atomic rename. Open +// without following links and check the handle before reading, appending or repairing. +fn open_file(path: &AbsolutePath, write: bool) -> io::Result<(File, usize)> { + let mut options = OpenOptions::new(); + options.read(true).write(write).create(write).truncate(false); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600).custom_flags(nix::libc::O_NOFOLLOW | nix::libc::O_NONBLOCK); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt as _; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); + } + let file = options.open(path)?; + let meta = file.metadata()?; + if !meta.is_file() || meta.len() > MAX_PACK as u64 { + return Err(io::ErrorKind::InvalidData.into()); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt as _; + if meta.nlink() != 1 { + return Err(io::ErrorKind::InvalidData.into()); + } + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt as _; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + if meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 + || winapi_util::file::information(&file)?.number_of_links() != 1 + { + return Err(io::ErrorKind::InvalidData.into()); + } + } + Ok((file, meta.len() as usize)) +} + +#[cfg(test)] +mod tests; diff --git a/packages/cli/binding/src/oxc_node/cache/packed/tests.rs b/packages/cli/binding/src/oxc_node/cache/packed/tests.rs new file mode 100644 index 0000000000..18852690a0 --- /dev/null +++ b/packages/cli/binding/src/oxc_node/cache/packed/tests.rs @@ -0,0 +1,191 @@ +use std::fs; + +use oxc_node::TransformCache; + +use super::{super::DiskTransformCache, *}; + +fn store(temp: &tempfile::TempDir) -> DiskTransformCache { + DiskTransformCache::new(AbsolutePathBuf::new(temp.path().join("vpx")).unwrap(), "24") +} + +fn key<'a>(file: &'a [u8], source: &'a [u8]) -> [&'a [u8]; 6] { + [b"oxc-node-transform-v1", b"transform", file, source, b"options", b"0"] +} + +fn value(code: &str) -> CachedTransform { + CachedTransform { code: code.into(), source_map: None, module: true } +} + +fn pack_path(store: &DiskTransformCache) -> AbsolutePathBuf { + store.directory.join(vt_str::format!("{}.pack", blake3::hash(b"/app").to_hex()).as_str()) +} + +#[test] +fn related_modules_share_a_pack_and_keep_distinct_keys() { + let temp = tempfile::tempdir().unwrap(); + let store1 = store(&temp); + let a = key(b"/app/a.ts", b"before"); + let b = key(b"/app/b.ts", b"before"); + store1.put(&a, &value("A")); + store1.put(&b, &value("B")); + assert_eq!(fs::read_dir(&store1.directory).unwrap().count(), 1); + assert!(pack_path(&store1).as_path().is_file()); + + let store2 = store(&temp); + assert_eq!(store2.get(&a).unwrap().code, "A"); + assert_eq!(store2.get(&b).unwrap().code, "B"); + assert!(store2.get(&key(b"/app/a.ts", b"after")).is_none()); + let mut changed_options = a; + changed_options[4] = b"new-options"; + assert!(store2.get(&changed_options).is_none()); +} + +#[test] +fn large_input_or_output_uses_the_loose_store() { + let temp = tempfile::tempdir().unwrap(); + let store1 = store(&temp); + let large_input = vec![0; MAX_SOURCE + 1]; + let a = key(b"/app/a.ts", &large_input); + let b = key(b"/app/b.ts", b"short"); + let large = "X".repeat(MAX_PAYLOAD); + store1.put(&a, &value("small")); + store1.put(&b, &value(&large)); + assert!(!pack_path(&store1).as_path().exists()); + let store2 = store(&temp); + assert_eq!(store2.get(&a).unwrap().code, "small"); + assert_eq!(store2.get(&b).unwrap().code, large); +} + +#[test] +fn incomplete_append_preserves_prior_records_and_is_repaired() { + let temp = tempfile::tempdir().unwrap(); + let store1 = store(&temp); + let a = key(b"/app/a.ts", b"a"); + let b = key(b"/app/b.ts", b"b"); + store1.put(&a, &value("A")); + let path = pack_path(&store1); + let prefix = fs::read(&path).unwrap(); + OpenOptions::new().append(true).open(&path).unwrap().write_all(&[0xff; 3]).unwrap(); + let store2 = store(&temp); + assert_eq!(store2.get(&a).unwrap().code, "A"); + assert!(store2.get(&b).is_none()); + assert_eq!(store2.stats.snapshot().errors, 1); + store2.put(&b, &value("B")); + assert!(fs::read(path).unwrap().starts_with(&prefix)); + let store3 = store(&temp); + assert_eq!(store3.get(&a).unwrap().code, "A"); + assert_eq!(store3.get(&b).unwrap().code, "B"); + assert_eq!(store3.stats.snapshot().errors, 0); +} + +#[test] +fn checksum_binds_the_key_to_the_payload() { + let temp = tempfile::tempdir().unwrap(); + let store1 = store(&temp); + let a = key(b"/app/a.ts", b"a"); + let b = key(b"/app/b.ts", b"b"); + store1.put(&a, &value("A")); + let path = pack_path(&store1); + let mut bytes = fs::read(&path).unwrap(); + bytes[LENGTH_BYTES + 32..LENGTH_BYTES + FRAME_HEADER] + .copy_from_slice(DiskTransformCache::key(&b).as_bytes()); + fs::write(&path, bytes).unwrap(); + let store2 = store(&temp); + assert!(store2.get(&b).is_none()); + assert_eq!(store2.stats.snapshot().errors, 1); + store2.put(&b, &value("B")); + assert_eq!(store(&temp).get(&b).unwrap().code, "B"); +} + +#[test] +fn competing_publishers_merge_or_use_loose_records() { + let temp = tempfile::tempdir().unwrap(); + let first = store(&temp); + let second = store(&temp); + let a = key(b"/app/a.ts", b"a"); + let b = key(b"/app/b.ts", b"b"); + let c = key(b"/app/c.ts", b"c"); + first.put(&a, &value("A")); + assert!(second.get(&b).is_none()); + first.put(&b, &value("B")); + let path = pack_path(&first); + let length = fs::metadata(&path).unwrap().len(); + second.put(&b, &value("B")); + assert_eq!(second.stats.snapshot().writes, 0); + assert_eq!(fs::metadata(&path).unwrap().len(), length); + let locked = OpenOptions::new().read(true).write(true).open(&path).unwrap(); + locked.lock().unwrap(); + second.put(&c, &value("C")); + drop(locked); + assert_eq!(fs::metadata(path).unwrap().len(), length); + let reader = store(&temp); + assert_eq!(reader.get(&b).unwrap().code, "B"); + assert_eq!(reader.get(&c).unwrap().code, "C"); + assert!(reader.path(&DiskTransformCache::key(&c)).as_path().is_file()); +} + +#[test] +fn hardlinked_pack_is_never_modified() { + let temp = tempfile::tempdir().unwrap(); + let first = store(&temp); + let a = key(b"/app/a.ts", b"a"); + let b = key(b"/app/b.ts", b"b"); + first.put(&a, &value("A")); + let path = pack_path(&first); + let before = fs::read(&path).unwrap(); + let external = temp.path().join("external"); + fs::hard_link(&path, &external).unwrap(); + let second = store(&temp); + assert!(second.get(&a).is_none()); + second.put(&b, &value("B")); + assert_eq!(fs::read(external).unwrap(), before); + assert_eq!(store(&temp).get(&b).unwrap().code, "B"); +} + +#[cfg(unix)] +#[test] +fn symlink_pack_is_never_followed() { + let temp = tempfile::tempdir().unwrap(); + let first = store(&temp); + let a = key(b"/app/a.ts", b"a"); + first.put(&a, &value("A")); + let path = pack_path(&first); + let external = temp.path().join("external"); + fs::rename(&path, &external).unwrap(); + std::os::unix::fs::symlink(&external, &path).unwrap(); + let before = fs::read(&external).unwrap(); + let second = store(&temp); + assert!(second.get(&a).is_none()); + second.put(&a, &value("A")); + assert_eq!(fs::read(external).unwrap(), before); + assert_eq!(store(&temp).get(&a).unwrap().code, "A"); +} + +#[test] +fn full_pack_falls_back_for_new_keys_and_corrupt_records() { + let temp = tempfile::tempdir().unwrap(); + let first = store(&temp); + let value = value(&"x".repeat(MAX_PACK / 128 - LENGTH_BYTES - FRAME_HEADER - 17)); + for i in 0_u8..128 { + first.put(&key(b"/app/a.ts", &[i]), &value); + } + let path = pack_path(&first); + assert_eq!(fs::metadata(&path).unwrap().len(), MAX_PACK as u64); + // Damage the first frame's checksum. A full pack has no room to append + // its replacement, so a reader must still look for a valid loose record. + let mut file = OpenOptions::new().write(true).open(&path).unwrap(); + file.seek(SeekFrom::Start(LENGTH_BYTES as u64)).unwrap(); + file.write_all(&[0; 32]).unwrap(); + drop(file); + let second = store(&temp); + let broken = key(b"/app/a.ts", &[0]); + let new = key(b"/app/a.ts", &[200]); + assert!(second.get(&broken).is_none()); + second.put(&broken, &value); + second.put(&new, &value); + assert_eq!(fs::metadata(&path).unwrap().len(), MAX_PACK as u64); + let third = store(&temp); + assert_eq!(third.get(&broken).unwrap().code, value.code); + assert_eq!(third.get(&new).unwrap().code, value.code); + assert_eq!(third.stats.snapshot().errors, 0); +} diff --git a/packages/cli/src/__tests__/script-cache.spec.ts b/packages/cli/src/__tests__/script-cache.spec.ts new file mode 100644 index 0000000000..8eda9a8df0 --- /dev/null +++ b/packages/cli/src/__tests__/script-cache.spec.ts @@ -0,0 +1,208 @@ +import Module from 'node:module'; +import path from 'node:path'; + +import { ignoreInput, ignoreOutput } from '@voidzero-dev/vite-task-client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; + +import { freezeScriptCache, initializeScriptCache, scriptCachePolicy } from '../script-cache.ts'; +import { watchCacheArgs } from '../script-watch-cache.ts'; + +vi.mock('@voidzero-dev/vite-task-client', () => ({ + ignoreInput: vi.fn(), + ignoreOutput: vi.fn(), +})); + +vi.mock('node:module', async (importOriginal) => { + const actual = await importOriginal(); + return { + default: { + constants: actual.constants, + enableCompileCache: vi.fn(() => ({ status: actual.constants.compileCacheStatus.ENABLED })), + getCompileCacheDir: vi.fn(() => undefined), + }, + }; +}); + +beforeEach(() => { + vi.clearAllMocks(); + for (const name of [ + 'VPX_CACHE', + 'VPX_DISABLE_CACHE', + 'NODE_COMPILE_CACHE', + 'NODE_DISABLE_COMPILE_CACHE', + 'NODE_V8_COVERAGE', + 'NODE_OPTIONS', + ]) { + vi.stubEnv(name, undefined); + } + vi.mocked(Module.getCompileCacheDir).mockReturnValue(undefined); + vi.mocked(Module.enableCompileCache).mockReturnValue({ + status: Module.constants.compileCacheStatus.ENABLED, + }); +}); + +afterEach(() => vi.unstubAllEnvs()); + +describe('script cache policy', () => { + it('keeps the prototype opt-in and supports separate cache measurements', () => { + expect(scriptCachePolicy({}, [], '22.23.4')).toEqual({ + transform: false, + compile: false, + runtimeVersion: '22.23.4', + }); + expect(scriptCachePolicy({ VPX_CACHE: '1' }, [])).toMatchObject({ + transform: true, + compile: true, + }); + expect(scriptCachePolicy({ VPX_CACHE: 'transform' }, [])).toMatchObject({ + transform: true, + compile: false, + }); + expect(scriptCachePolicy({ VPX_CACHE: 'compile' }, [])).toMatchObject({ + transform: false, + compile: true, + }); + expect(scriptCachePolicy({ VPX_CACHE: '1', VPX_DISABLE_CACHE: '1' }, [])).toMatchObject({ + transform: false, + compile: false, + }); + }); + + it.each([ + { NODE_COMPILE_CACHE: '/user-selected' }, + { NODE_DISABLE_COMPILE_CACHE: '1' }, + { NODE_V8_COVERAGE: '/coverage' }, + { NODE_OPTIONS: '--experimental-test-coverage' }, + ])('preserves Node settings and coverage: %j', (env) => { + expect(scriptCachePolicy({ VPX_CACHE: '1', ...env }, [])).toMatchObject({ + transform: true, + compile: false, + }); + }); + + it('does not automatically enable compile caching for command-line coverage', () => { + expect(scriptCachePolicy({ VPX_CACHE: '1' }, ['--experimental_test_coverage'])).toMatchObject({ + transform: true, + compile: false, + }); + }); +}); + +describe('script cache initialization', () => { + const root = path.resolve('owned-cache', 'vpx'); + + it('does no native or runner setup on an ordinary launch', () => { + const binding = { initializeCache: vi.fn(() => root) }; + initializeScriptCache(binding); + expect(binding.initializeCache).not.toHaveBeenCalled(); + expect(ignoreInput).not.toHaveBeenCalled(); + expect(Module.enableCompileCache).not.toHaveBeenCalled(); + }); + + it('excludes owned artifacts before enabling compilation and freezes the result', () => { + vi.stubEnv('VPX_CACHE', '1'); + const binding = { initializeCache: vi.fn(() => root) }; + const policy = initializeScriptCache(binding); + expect(binding.initializeCache).toHaveBeenCalledWith(true, process.versions.node, undefined); + expect(ignoreInput).toHaveBeenCalledExactlyOnceWith(root); + expect(ignoreOutput).toHaveBeenCalledExactlyOnceWith(root); + expect(Module.enableCompileCache).toHaveBeenCalledExactlyOnceWith(path.join(root, 'node')); + expect(vi.mocked(ignoreOutput).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(Module.enableCompileCache).mock.invocationCallOrder[0], + ); + expect(freezeScriptCache()).toEqual(policy); + expect(policy).toMatchObject({ transform: true, compile: true, root }); + }); + + it('uses the frozen main-thread policy and root in an ESM hooks thread', () => { + vi.stubEnv('VPX_DISABLE_CACHE', '1'); + const binding = { initializeCache: vi.fn(() => root) }; + const frozen = { transform: true, compile: false, root, runtimeVersion: '22.18.0' }; + expect(initializeScriptCache(binding, frozen)).toEqual(frozen); + expect(binding.initializeCache).toHaveBeenCalledWith(true, '22.18.0', root); + }); + + it('never excludes a user-selected Node cache, even if it is the workspace root', () => { + vi.stubEnv('VPX_CACHE', '1'); + vi.stubEnv('NODE_COMPILE_CACHE', process.cwd()); + const policy = initializeScriptCache({ initializeCache: vi.fn(() => root) }); + expect(ignoreInput).toHaveBeenCalledExactlyOnceWith(root); + expect(ignoreInput).not.toHaveBeenCalledWith(process.cwd()); + expect(Module.enableCompileCache).not.toHaveBeenCalled(); + expect(policy.compile).toBe(false); + }); + + it('keeps a compile cache enabled by an earlier preload', () => { + vi.stubEnv('VPX_CACHE', 'compile'); + vi.mocked(Module.getCompileCacheDir).mockReturnValue(path.resolve('other-cache')); + const binding = { initializeCache: vi.fn(() => root) }; + initializeScriptCache(binding); + expect(binding.initializeCache).not.toHaveBeenCalled(); + expect(ignoreInput).not.toHaveBeenCalled(); + expect(Module.enableCompileCache).not.toHaveBeenCalled(); + }); + + it('disables both caches when the native binding rejects the root', () => { + vi.stubEnv('VPX_CACHE', '1'); + const policy = initializeScriptCache({ initializeCache: vi.fn(() => undefined) }); + expect(policy).toMatchObject({ transform: false, compile: false }); + expect(ignoreInput).not.toHaveBeenCalled(); + expect(Module.enableCompileCache).not.toHaveBeenCalled(); + }); + + it('continues with transform caching if Node cannot enable its compile cache', () => { + vi.stubEnv('VPX_CACHE', '1'); + vi.mocked(Module.enableCompileCache).mockReturnValue({ + status: Module.constants.compileCacheStatus.FAILED, + }); + const policy = initializeScriptCache({ initializeCache: vi.fn(() => root) }); + expect(policy).toMatchObject({ transform: true, compile: false, root }); + }); + + it('does not hide a connected runner reporting failure', () => { + vi.stubEnv('VPX_CACHE', '1'); + vi.mocked(ignoreInput).mockImplementationOnce(() => { + throw new Error('runner disconnected'); + }); + expect(() => initializeScriptCache({ initializeCache: vi.fn(() => root) })).toThrow( + 'runner disconnected', + ); + expect(Module.enableCompileCache).not.toHaveBeenCalled(); + }); +}); + +describe('watch cache arguments', () => { + it('consumes only the watcher flag, leaving script arguments intact', () => { + expect(watchCacheArgs(['--no-cache', './main.ts', '--no-cache'])).toEqual({ + disabled: true, + args: ['./main.ts', '--no-cache'], + }); + expect(watchCacheArgs(['./main.ts', '--no-cache'])).toEqual({ + disabled: false, + args: ['./main.ts', '--no-cache'], + }); + }); + + it.each([ + ['--exclude', '--no-cache', './main.ts'], + ['--require', '--no-cache', './main.ts'], + ['--', '--no-cache'], + ])('does not reinterpret option values or escaped entries: %j', (...args) => { + expect(watchCacheArgs(args)).toEqual({ disabled: false, args }); + }); + + it('handles inline/normalized flags and bare Node config flags', () => { + expect(watchCacheArgs(['--no_cache=false', '--experimental-config-file', './main.ts'])).toEqual( + { + disabled: true, + args: ['--experimental-config-file', './main.ts'], + }, + ); + expect( + watchCacheArgs(['--experimental-config-file', './node.json', '--no-cache', './main.ts']), + ).toEqual({ + disabled: true, + args: ['--experimental-config-file', './node.json', './main.ts'], + }); + }); +}); diff --git a/packages/cli/src/script-cache.ts b/packages/cli/src/script-cache.ts new file mode 100644 index 0000000000..988d929bcf --- /dev/null +++ b/packages/cli/src/script-cache.ts @@ -0,0 +1,98 @@ +/** Optional persistent caches for the script loader, shared by its CJS and ESM entries. */ +import Module from 'node:module'; +import path from 'node:path'; + +import { ignoreInput, ignoreOutput } from '@voidzero-dev/vite-task-client'; + +export interface ScriptCachePolicy { + transform: boolean; + compile: boolean; + runtimeVersion: string; + /** The native binding resolves this owned directory through VpDirs. */ + root?: string; +} + +interface CacheBinding { + initializeCache( + enabled: boolean, + runtimeVersion: string, + root?: string, + ): string | null | undefined; +} + +const CACHE_POLICY = Symbol.for('vite-plus:script-cache-policy'); + +/** Opt in explicitly while the persistent cache is a performance prototype. */ +export function scriptCachePolicy( + env: NodeJS.ProcessEnv = process.env, + execArgv: readonly string[] = process.execArgv, + runtimeVersion = process.versions.node, +): ScriptCachePolicy { + const mode = env.VPX_DISABLE_CACHE === '1' ? undefined : env.VPX_CACHE; + // Cached V8 code can make precise coverage less accurate. Keep the user's own + // compile-cache selection intact, but do not enable a managed one for coverage. + const coverage = + env.NODE_V8_COVERAGE !== undefined || + execArgv.some((arg) => /^--experimental[-_]test[-_]coverage(?:=|$)/.test(arg)) || + /(?:^|[\s"])--experimental[-_]test[-_]coverage(?:[=\s"]|$)/.test(env.NODE_OPTIONS ?? ''); + return { + transform: mode === '1' || mode === 'transform', + compile: + (mode === '1' || mode === 'compile') && + env.NODE_DISABLE_COMPILE_CACHE !== '1' && + env.NODE_COMPILE_CACHE === undefined && + !coverage, + runtimeVersion, + }; +} + +/** + * Initialize this thread before its first transform. Off-thread hooks receive the + * frozen policy and root, rather than reading their stale environment snapshot. + */ +export function initializeScriptCache( + binding: CacheBinding, + frozen?: ScriptCachePolicy, +): ScriptCachePolicy { + const policy = { ...(frozen ?? scriptCachePolicy()) }; + // An earlier preload can enable its own cache without NODE_COMPILE_CACHE. Do not + // change or exclude that arbitrary directory either. + if (!frozen && policy.compile && Module.getCompileCacheDir?.()) { + policy.compile = false; + } + if (policy.transform || policy.compile) { + const root = binding.initializeCache(policy.transform, policy.runtimeVersion, policy.root); + if (root) { + policy.root = root; + // Only exclude the binding's dedicated owned root. In particular, never + // exclude NODE_COMPILE_CACHE: the user can set it to their workspace root. + // The backend creates no files until a transform is stored. + ignoreInput(root); + ignoreOutput(root); + if (policy.compile) { + const result = Module.enableCompileCache?.(path.join(root, 'node')); + const status = Module.constants?.compileCacheStatus; + policy.compile = + result !== undefined && + (result.status === status?.ENABLED || result.status === status?.ALREADY_ENABLED); + } + } else { + // A rejected/unavailable cache root stays disabled in the hooks thread too. + policy.transform = false; + policy.compile = false; + } + } + (globalThis as { [CACHE_POLICY]?: ScriptCachePolicy })[CACHE_POLICY] = policy; + return policy; +} + +/** The preload and register entries are separate bundles in the same thread. */ +export function freezeScriptCache(): ScriptCachePolicy { + return ( + (globalThis as { [CACHE_POLICY]?: ScriptCachePolicy })[CACHE_POLICY] ?? { + transform: false, + compile: false, + runtimeVersion: process.versions.node, + } + ); +} diff --git a/packages/cli/src/script-esm-hooks.ts b/packages/cli/src/script-esm-hooks.ts index 6a0166b7ed..87b780dd89 100644 --- a/packages/cli/src/script-esm-hooks.ts +++ b/packages/cli/src/script-esm-hooks.ts @@ -8,6 +8,7 @@ import type { InitializeHook, LoadHook, ResolveHook } from 'node:module'; import { isMainThread, type MessagePort, MessageChannel } from 'node:worker_threads'; import type { FrozenTsconfig } from '../binding/index.js'; +import { initializeScriptCache, type ScriptCachePolicy } from './script-cache.ts'; import { HELPER_MODULE_NAME, loadOxcNode, resolveWithOxcNode, withFlush } from './script-hooks.ts'; const oxcNode = loadOxcNode(); @@ -35,12 +36,14 @@ let watchHooks: { resolve: ResolveHook; load: LoadHook } | undefined; export const initialize: InitializeHook<{ tsconfig: FrozenTsconfig; transformDeps: string; + cache: ScriptCachePolicy; watchPort?: MessagePort; }> = (data) => { // An absent path explicitly selects discovery, without consulting a stale // environment snapshot in the hooks thread. oxcNode.initializeLoader(data.tsconfig.path, data.tsconfig.source); oxcNode.setTransformDeps(data.transformDeps); + initializeScriptCache(oxcNode, data.cache); const port = data.watchPort; if (port) { port.unref(); diff --git a/packages/cli/src/script-preload.ts b/packages/cli/src/script-preload.ts index 9166e3c73e..c392cd6b42 100644 --- a/packages/cli/src/script-preload.ts +++ b/packages/cli/src/script-preload.ts @@ -20,6 +20,7 @@ import { isMainThread } from 'node:worker_threads'; import { addHook } from 'pirates'; import { installSignalAck } from '../../../oxc-node/packages/core/signal-ack.mjs'; +import { initializeScriptCache } from './script-cache.ts'; import { canRegisterSyncHooks, flush, @@ -52,6 +53,7 @@ if (isMainThread) { // Loaded after the version check so the native addon never loads on an // unsupported Node.js. const oxcNode = loadOxcNode(); +initializeScriptCache(oxcNode); // Under `vpx --watch`, report what Node.js does not: the tsconfig files oxc-node read // and the paths a failed resolve looked for. Turned on before the first transform or diff --git a/packages/cli/src/script-register.ts b/packages/cli/src/script-register.ts index 9c000c3471..ac9a278dfd 100644 --- a/packages/cli/src/script-register.ts +++ b/packages/cli/src/script-register.ts @@ -10,6 +10,7 @@ import * as NodeModule from 'node:module'; +import { freezeScriptCache } from './script-cache.ts'; import { canRegisterSyncHooks, createRelay, loadOxcNode, threadWatchSink } from './script-hooks.ts'; // Look `register` up on the namespace: a named import would fail to link on every @@ -28,6 +29,7 @@ if (!canRegisterSyncHooks(process.versions.node)) { data: { tsconfig: oxcNode.freezeTsconfig(process.cwd()), transformDeps: oxcNode.freezeTransformDeps(), + cache: freezeScriptCache(), watchPort, }, transferList: watchPort ? [watchPort] : [], diff --git a/packages/cli/src/script-watch-cache.ts b/packages/cli/src/script-watch-cache.ts new file mode 100644 index 0000000000..6d2eb51b8d --- /dev/null +++ b/packages/cli/src/script-watch-cache.ts @@ -0,0 +1,30 @@ +import { NODE_OPTIONS_WITH_VALUE, WATCH_OPTIONS_WITH_VALUE } from './vpx-script.ts'; + +/** Consume the watcher's cache flag, preserving options' values and script arguments. */ +export function watchCacheArgs(argv: readonly string[]): { args: string[]; disabled: boolean } { + const args: string[] = []; + let disabled = false; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--' || arg === '-' || !arg.startsWith('-')) { + args.push(...argv.slice(i)); + break; + } + const name = arg.split('=', 1)[0].replaceAll('_', '-'); + if (name === '--no-cache') { + disabled = true; + continue; + } + args.push(arg); + // Match vpx's treatment of a bare config flag across supported Node versions: + // only a .json token is its value; a script extension remains the entry. + const takesValue = + name === '--experimental-config-file' + ? argv[i + 1]?.endsWith('.json') && !argv[i + 1].startsWith('-') + : NODE_OPTIONS_WITH_VALUE.has(name) || WATCH_OPTIONS_WITH_VALUE.has(name); + if (!arg.includes('=') && takesValue && i + 1 < argv.length) { + args.push(argv[++i]); + } + } + return { args, disabled }; +} diff --git a/packages/cli/src/script-watch.ts b/packages/cli/src/script-watch.ts index 37867e381f..2e0da61ffb 100644 --- a/packages/cli/src/script-watch.ts +++ b/packages/cli/src/script-watch.ts @@ -16,10 +16,16 @@ import { fileURLToPath } from 'node:url'; // Upstream's declaration of `@oxc-node/cli/watch`; tsdown bundles the vendored sources // in its place (see `vendoredWatcherPlugin` in tsdown.config.ts). import { runWatch } from '../../../oxc-node/packages/cli/watch.js'; +import { watchCacheArgs } from './script-watch-cache.ts'; const dist = path.dirname(fileURLToPath(import.meta.url)); +const { args, disabled } = watchCacheArgs(process.argv.slice(2)); +if (disabled) { + // All restarts inherit this override, including when an env file opts in. + process.env.VPX_DISABLE_CACHE = '1'; +} -process.exitCode = await runWatch(process.argv.slice(2), { +process.exitCode = await runWatch(args, { name: 'vpx --watch', // As in script mode; see `vpx-bin.ts`. loaderArgs: [ diff --git a/packages/cli/tsdown.config.ts b/packages/cli/tsdown.config.ts index 46f8b0dfa7..e1730fe64d 100644 --- a/packages/cli/tsdown.config.ts +++ b/packages/cli/tsdown.config.ts @@ -104,7 +104,11 @@ export default defineConfig([ dts: true, clean: true, // NAPI binding uses a relative path that tsdown can't auto-detect from package.json - deps: { neverBundle: [/\.\.\/binding\/index\.(js|cjs)/] }, + deps: { + neverBundle: [/\.\.\/binding\/index\.(js|cjs)/], + // Loader cache coordination must not add package loading to every startup. + alwaysBundle: ['@voidzero-dev/vite-task-client'], + }, inputOptions: { resolve: { // Prefer ESM entry (module field) over CJS/UMD (main field) for bundled deps. @@ -129,7 +133,10 @@ export default defineConfig([ shims: true, dts: false, clean: false, - deps: { neverBundle: [/\.\.\/binding\/index\.(js|cjs)/] }, + deps: { + neverBundle: [/\.\.\/binding\/index\.(js|cjs)/], + alwaysBundle: ['@voidzero-dev/vite-task-client'], + }, outputOptions: { codeSplitting: false, }, diff --git a/packages/tools/patches/oxc-node-transform-cache.patch b/packages/tools/patches/oxc-node-transform-cache.patch new file mode 100644 index 0000000000..bca5228b4a --- /dev/null +++ b/packages/tools/patches/oxc-node-transform-cache.patch @@ -0,0 +1,771 @@ +--- a/src/lib.rs ++++ b/src/lib.rs +@@ -46,6 +46,11 @@ + }; + use oxc_sourcemap::{SourceMap, Token}; + use phf::Set; ++ ++// Prototype host cache hook; maintained by Vite+ until the upstream API is agreed. ++mod transform_cache; ++use transform_cache::CacheRequest; ++pub use transform_cache::{CachedTransform, TransformCache, set_transform_cache}; + + #[cfg(all( + not(target_arch = "x86"), +@@ -691,6 +696,7 @@ + /// and can select different tsconfigs, even though they share this native library. + #[derive(Default)] + struct LoaderContext { ++ transform_cache: Mutex>>, + resolver_and_tsconfig: OnceLock<(Resolver, TsconfigSource)>, + tsconfig_override: Mutex, + transform_deps: Mutex, +@@ -1698,6 +1704,8 @@ + pub struct Output { + code: String, + map: Option>, ++ // Warm cache hits keep maps serialized: decoding VLQ only to encode it again is wasted work. ++ cached_map_json: Option, + } + + #[cfg_attr(feature = "napi", napi)] +@@ -1713,7 +1721,7 @@ + /// Returns the source map as a JSON string + /// Cache the result of this function if you need to use it multiple times + pub fn source_map(&self) -> Option { +- self.map.as_ref().map(|source_map| source_map.to_json_string()) ++ self.cached_map_json.clone().or_else(|| self.map.as_ref().map(|map| map.to_json_string())) + } + } + +@@ -2262,7 +2270,6 @@ + helper_module_name: Option<&str>, + module_runner: Option<&mut ModuleRunner>, + ) -> Result<(Output, bool)> { +- let allocator = Allocator::default(); + // `.js`, `.jsx`, `.ts`, `.tsx` and extensionless files are ambiguous: oxc decides + // between a script and an ES module by looking for `import`/`export` syntax, so a + // file that has none is treated as a script and the helper loader emits `require()` +@@ -2272,6 +2279,27 @@ + let source_type = source_type_from_path(src_path).unwrap_or_default().with_module(is_es_module); + let source = code.to_source()?; + let source_str = strip_bom(&source); ++ // Module-runner transforms also mutate their caller's state, so leave that separate API uncached. ++ let cache = module_runner ++ .is_none() ++ .then(|| { ++ CacheRequest::new( ++ src_path, ++ source_str, ++ compiler_options, ++ module_target, ++ enable_top_level_await, ++ is_es_module, ++ helper_module_name, ++ b"transform", ++ ) ++ }) ++ .flatten(); ++ if let Some(cached) = cache.as_ref().and_then(CacheRequest::get) { ++ let module = cached.module; ++ return Ok((cached.into_output(), module)); ++ } ++ let allocator = Allocator::default(); + let allow_module_retry = !is_es_module + && matches!(module_target, Some(Module::Preserve)) + && source_type.is_unambiguous(); +@@ -2323,7 +2351,119 @@ + helper_module_name, + module_runner, + )?; ++ if let Some(cache) = cache { ++ cache.put(&CachedTransform::from_output(&output, flip_to_module)); ++ } + Ok((output, flip_to_module)) ++} ++ ++/// The same normalized compiler settings drive transformation and cache identity. ++fn transform_options( ++ compiler_options: Option<&CompilerOptions>, ++ module_target: Option, ++ enable_top_level_await: bool, ++ helper_module_name: Option<&str>, ++) -> TransformOptions { ++ let use_define_for_class_fields = use_define_for_class_fields(compiler_options); ++ // `useDefineForClassFields` selects `[[Define]]` semantics; oxc's `setPublicClassFields` ++ // assumption selects the opposite, `[[Set]]`, so it is the negation of it. ++ let set_public_class_fields = !use_define_for_class_fields; ++ let legacy_decorators = ++ compiler_options.and_then(|c| c.experimental_decorators).unwrap_or(false); ++ // Every Node.js release with `module.register()` runs class fields, `#private` members ++ // and static blocks natively, so they are only lowered where the emitted class has to ++ // differ from the source: `[[Set]]` field semantics, and legacy decorators, which are ++ // applied to the lowered class. ++ let lower_class_features = set_public_class_fields || legacy_decorators; ++ TransformOptions { ++ assumptions: CompilerAssumptions { set_public_class_fields, ..Default::default() }, ++ decorator: DecoratorOptions { ++ legacy: legacy_decorators, ++ emit_decorator_metadata: compiler_options ++ .and_then(|c| c.emit_decorator_metadata) ++ .unwrap_or(false), ++ strict_null_checks: compiler_options ++ .and_then(|c| c.strict_null_checks) ++ .unwrap_or(false), ++ }, ++ jsx: { ++ // `compilerOptions.jsx` holds `tsc`'s values (`react`, `react-jsx`, ++ // `react-jsxdev`, ...), not Babel's runtime names — mapping them is what ++ // `jsx_runtime` is for. `(JsxRuntime::Automatic, false)` is `Default`, so ++ // an unset option reproduces the automatic production runtime. ++ let (runtime, development) = compiler_options ++ .and_then(|c| c.jsx.as_deref()) ++ .map(jsx_runtime) ++ .unwrap_or_default(); ++ JsxOptions { ++ runtime, ++ development, ++ import_source: compiler_options.and_then(|c| c.jsx_import_source.clone()), ++ pragma: compiler_options.and_then(|c| c.jsx_factory.clone()), ++ pragma_frag: compiler_options.and_then(|c| c.jsx_fragment_factory.clone()), ++ ..Default::default() ++ } ++ }, ++ typescript: TypeScriptOptions { ++ // `TypeScriptOptions` holds `Cow<'static, str>`, and the compiler ++ // options are now borrowed per file rather than from a `OnceLock`, ++ // so these have to be owned. ++ jsx_pragma: compiler_options ++ .and_then(|c| c.jsx_factory.clone()) ++ .map(Cow::Owned) ++ .unwrap_or_default(), ++ jsx_pragma_frag: compiler_options ++ .and_then(|c| c.jsx_fragment_factory.clone()) ++ .map(Cow::Owned) ++ .unwrap_or_default(), ++ rewrite_import_extensions: compiler_options ++ .and_then(|c| c.rewrite_relative_import_extensions) ++ .unwrap_or_default() ++ .then_some(RewriteExtensionsMode::Rewrite), ++ // `verbatimModuleSyntax` makes `tsc` (and Node's own type stripping) ++ // emit every non-`type` import verbatim, even when the binding is ++ // unused — the module's side effects still have to run. That is ++ // exactly oxc's "only remove type imports" mode. ++ only_remove_type_imports: compiler_options ++ .and_then(|c| c.verbatim_module_syntax) ++ .unwrap_or(false), ++ // With `[[Set]]` semantics, `tsc` also drops class fields that have no ++ // initializer instead of assigning `undefined` through the prototype chain ++ // (which would fire an inherited setter). oxc only does that when asked. ++ remove_class_fields_without_initializer: set_public_class_fields, ++ ..Default::default() ++ }, ++ env: EnvOptions { ++ module: module_target.unwrap_or_default(), ++ es2022: ES2022Options { ++ class_static_block: lower_class_features, ++ // `loose` stays `false`: it would also lower `#private` fields to ++ // string-keyed properties, which `tsc` never does. The assumption ++ // above alone selects `[[Set]]` for public fields — the transformer ++ // ORs the two together. ++ class_properties: lower_class_features.then(ClassPropertiesOptions::default), ++ // Turn this on would throw error for all top-level awaits; the caller ++ // clears it for a file flipping to an ES module, which keeps them. ++ top_level_await: enable_top_level_await, ++ }, ++ es2026: ES2026Options { ++ explicit_resource_management: !NATIVE_EXPLICIT_RESOURCE_MANAGEMENT ++ .load(Ordering::Relaxed), ++ }, ++ ..Default::default() ++ }, ++ proposals: ProposalOptions {}, ++ helper_loader: HelperLoaderOptions { ++ // The module name carries a tag for the loading copy when one was given — ++ // `@oxc-node/core@/helpers/x` — so several registered copies each get ++ // their own helpers back instead of racing on one shared specifier. The ++ // resolve side maps it to `@oxc-node/core`'s real `exports` subpath. ++ module_name: helper_module_name ++ .map_or(Cow::Borrowed("@oxc-node/core"), |name| Cow::Owned(name.to_owned())), ++ ..Default::default() ++ }, ++ ..Default::default() ++ } + } + + /// Semantic analysis, transform and codegen for an already-parsed program. Split from +@@ -2370,109 +2510,15 @@ + // JavaScript on its way out. + let cts = is_cts(program.source_type); + +- let use_define_for_class_fields = use_define_for_class_fields(compiler_options); +- // `useDefineForClassFields` selects `[[Define]]` semantics; oxc's `setPublicClassFields` +- // assumption selects the opposite, `[[Set]]`, so it is the negation of it. +- let set_public_class_fields = !use_define_for_class_fields; +- let legacy_decorators = +- compiler_options.and_then(|c| c.experimental_decorators).unwrap_or(false); +- // Every Node.js release with `module.register()` runs class fields, `#private` members +- // and static blocks natively, so they are only lowered where the emitted class has to +- // differ from the source: `[[Set]]` field semantics, and legacy decorators, which are +- // applied to the lowered class. +- let lower_class_features = set_public_class_fields || legacy_decorators; + let TransformerReturn { diagnostics, .. } = Transformer::new( + allocator, + src_path, +- &TransformOptions { +- assumptions: CompilerAssumptions { set_public_class_fields, ..Default::default() }, +- decorator: DecoratorOptions { +- legacy: legacy_decorators, +- emit_decorator_metadata: compiler_options +- .and_then(|c| c.emit_decorator_metadata) +- .unwrap_or(false), +- strict_null_checks: compiler_options +- .and_then(|c| c.strict_null_checks) +- .unwrap_or(false), +- }, +- jsx: { +- // `compilerOptions.jsx` holds `tsc`'s values (`react`, `react-jsx`, +- // `react-jsxdev`, ...), not Babel's runtime names — mapping them is what +- // `jsx_runtime` is for. `(JsxRuntime::Automatic, false)` is `Default`, so +- // an unset option reproduces the automatic production runtime. +- let (runtime, development) = compiler_options +- .and_then(|c| c.jsx.as_deref()) +- .map(jsx_runtime) +- .unwrap_or_default(); +- JsxOptions { +- runtime, +- development, +- import_source: compiler_options.and_then(|c| c.jsx_import_source.clone()), +- pragma: compiler_options.and_then(|c| c.jsx_factory.clone()), +- pragma_frag: compiler_options.and_then(|c| c.jsx_fragment_factory.clone()), +- ..Default::default() +- } +- }, +- typescript: TypeScriptOptions { +- // `TypeScriptOptions` holds `Cow<'static, str>`, and the compiler +- // options are now borrowed per file rather than from a `OnceLock`, +- // so these have to be owned. +- jsx_pragma: compiler_options +- .and_then(|c| c.jsx_factory.clone()) +- .map(Cow::Owned) +- .unwrap_or_default(), +- jsx_pragma_frag: compiler_options +- .and_then(|c| c.jsx_fragment_factory.clone()) +- .map(Cow::Owned) +- .unwrap_or_default(), +- rewrite_import_extensions: compiler_options +- .and_then(|c| c.rewrite_relative_import_extensions) +- .unwrap_or_default() +- .then_some(RewriteExtensionsMode::Rewrite), +- // `verbatimModuleSyntax` makes `tsc` (and Node's own type stripping) +- // emit every non-`type` import verbatim, even when the binding is +- // unused — the module's side effects still have to run. That is +- // exactly oxc's "only remove type imports" mode. +- only_remove_type_imports: compiler_options +- .and_then(|c| c.verbatim_module_syntax) +- .unwrap_or(false), +- // With `[[Set]]` semantics, `tsc` also drops class fields that have no +- // initializer instead of assigning `undefined` through the prototype chain +- // (which would fire an inherited setter). oxc only does that when asked. +- remove_class_fields_without_initializer: set_public_class_fields, +- ..Default::default() +- }, +- env: EnvOptions { +- module: module_target.unwrap_or_default(), +- es2022: ES2022Options { +- class_static_block: lower_class_features, +- // `loose` stays `false`: it would also lower `#private` fields to +- // string-keyed properties, which `tsc` never does. The assumption +- // above alone selects `[[Set]]` for public fields — the transformer +- // ORs the two together. +- class_properties: lower_class_features.then(ClassPropertiesOptions::default), +- // Turn this on would throw error for all top-level awaits; the caller +- // clears it for a file flipping to an ES module, which keeps them. +- top_level_await: enable_top_level_await, +- }, +- es2026: ES2026Options { +- explicit_resource_management: !NATIVE_EXPLICIT_RESOURCE_MANAGEMENT +- .load(Ordering::Relaxed), +- }, +- ..Default::default() +- }, +- proposals: ProposalOptions {}, +- helper_loader: HelperLoaderOptions { +- // The module name carries a tag for the loading copy when one was given — +- // `@oxc-node/core@/helpers/x` — so several registered copies each get +- // their own helpers back instead of racing on one shared specifier. The +- // resolve side maps it to `@oxc-node/core`'s real `exports` subpath. +- module_name: helper_module_name +- .map_or(Cow::Borrowed("@oxc-node/core"), |name| Cow::Owned(name.to_owned())), +- ..Default::default() +- }, +- ..Default::default() +- }, ++ &transform_options( ++ compiler_options, ++ module_target, ++ enable_top_level_await, ++ helper_module_name, ++ ), + ) + .build_with_scoping(scoping, program); + +@@ -2534,9 +2580,9 @@ + let map = map.map(|source_map| source_map.into_owned()); + if module_runner.is_some() { + let (code, map) = retain_lines(code, map, esm); +- return Ok(Output { code, map }); +- } +- Ok(Output { code, map }) ++ return Ok(Output { code, map, cached_map_json: None }); ++ } ++ Ok(Output { code, map, cached_map_json: None }) + } + + /// What the module runner asks of [`transform_program`] on top of the normal +@@ -3362,12 +3408,36 @@ + } + }; + let source = strip_bom(source); ++ let cache = CacheRequest::new( ++ &path, ++ source, ++ resolved_compiler_options, ++ Some(Module::Esm), ++ false, ++ false, ++ helper_module_name.as_deref(), ++ b"classify-ambiguous", ++ ); ++ if let Some(cached) = cache.as_ref().and_then(CacheRequest::get) { ++ return Ok(cached.module.then(|| LoadFnOutput { ++ format: "module".to_owned(), ++ source: Some(Either4::B(Uint8Array::from_string(code_with_inline_map( ++ cached.into_output(), ++ )))), ++ response_url: Some(url.to_owned()), ++ })); ++ } + let allocator = Allocator::default(); + // `only_module_parse` covers the shapes oxc cannot resolve on its own, such as a + // top-level `for await`; `parsed_as_esm` covers the rest, including top-level await. + let Parsed { mut program, diagnostics, module_record, only_module_parse } = + parse_source(&allocator, source, source_type, true); + if !(only_module_parse || parsed_as_esm(&program, &module_record)) { ++ if diagnostics.is_empty() { ++ if let Some(cache) = cache { ++ cache.put(&CachedTransform::classification()); ++ } ++ } + return Ok(None); + } + // From here on the file is handed back as an ES module, so surface parse errors +@@ -3394,6 +3464,9 @@ + helper_module_name.as_deref(), + None, + )?; ++ if let Some(cache) = cache { ++ cache.put(&CachedTransform::from_output(&transformed, true)); ++ } + tracing::debug!("loaded {} format: module", url); + Ok(Some(LoadFnOutput { + format: "module".to_owned(), +@@ -3423,6 +3496,19 @@ + } + }; + let source = strip_bom(source); ++ let cache = CacheRequest::new( ++ path, ++ source, ++ resolved_compiler_options, ++ Some(Module::CommonJS), ++ true, ++ false, ++ None, ++ b"classify-cts", ++ ); ++ if cache.as_ref().and_then(CacheRequest::get).is_some() { ++ return Ok(None); ++ } + // Only the transform knows which `import`/`export` declarations are type-only, so a + // file with any declaration runs through it — the same options the `require()` path + // uses — and only its verdict is kept. +@@ -3437,10 +3523,13 @@ + return Ok(None); + } + if !has_module_declaration(&program) { ++ if let Some(cache) = cache { ++ cache.put(&CachedTransform::classification()); ++ } + return Ok(None); + } + // Any other error is the `require()` path's to report, with its own helper names. +- let _ = transform_program( ++ let transformed = transform_program( + &allocator, + path, + &mut program, +@@ -3451,14 +3540,26 @@ + None, + None, + ); +- if has_cts_module_declaration(&program) { Err(cts_module_syntax_error(path)) } else { Ok(None) } ++ if has_cts_module_declaration(&program) { ++ Err(cts_module_syntax_error(path)) ++ } else { ++ if transformed.is_ok() { ++ if let Some(cache) = cache { ++ cache.put(&CachedTransform::classification()); ++ } ++ } ++ Ok(None) ++ } + } + + /// The generated code with its source map appended as a data URL, if one was produced. + fn code_with_inline_map(output: Output) -> String { +- match output.map { +- Some(sm) => { +- let sm = sm.to_data_url(); ++ match output.source_map() { ++ Some(map) => { ++ let sm = format!( ++ "data:application/json;charset=utf-8;base64,{}", ++ base64_simd::STANDARD.encode_to_string(map.as_bytes()) ++ ); + const SOURCEMAP_PREFIX: &str = "\n//# sourceMappingURL="; + let len = sm.len() + output.code.len() + 22; + let mut output_code = String::with_capacity(len); +--- /dev/null ++++ b/src/transform_cache.rs +@@ -0,0 +1,320 @@ ++//! Synchronous embedder cache hook for the vpx prototype. ++//! ++//! The host owns persistence and build/runtime namespacing. The loader owns semantic ++//! identity and keeps configuration reads and CommonJS source-provenance checks live. ++ ++use super::{ ++ Arc, CompilerOptions, Module, Output, Path, PoisonError, loader_context, transform_options, ++}; ++ ++/// An Oxc result without an AST or decoded source map. ++/// ++/// `module` means a format flip for a transform request, or ES module classification ++/// for a classification request. Classification-only CommonJS records have empty code ++/// and no map; they never authorize skipping the caller's live source/file comparison. ++#[derive(Clone, Debug)] ++pub struct CachedTransform { ++ pub code: String, ++ pub source_map: Option, ++ pub module: bool, ++} ++ ++impl CachedTransform { ++ pub(crate) fn from_output(output: &Output, module: bool) -> Self { ++ Self { code: output.code.clone(), source_map: output.source_map(), module } ++ } ++ ++ pub(crate) fn classification() -> Self { ++ Self { code: String::new(), source_map: None, module: false } ++ } ++ ++ pub(crate) fn into_output(self) -> Output { ++ Output { code: self.code, map: None, cached_map_json: self.source_map } ++ } ++} ++ ++/// Optional host storage. Implementations must return `None` on invalid/unavailable ++/// records and must keep storage failures separate from compiler failures. ++/// ++/// Hash every key part with its length, in order. Scope storage to the exact loader, ++/// compiler/helper build and runtime. There is no JS callback or asynchronous wait. ++pub trait TransformCache: Send + Sync { ++ fn get(&self, key: &[&[u8]]) -> Option; ++ fn put(&self, key: &[&[u8]], value: &CachedTransform); ++} ++ ++/// Install a backend for this loader context. Captured contexts share it, while ++/// independent Node.js workers and off-thread ESM hooks initialize their own policy. ++pub fn set_transform_cache(cache: Option>) { ++ *loader_context().transform_cache.lock().unwrap_or_else(PoisonError::into_inner) = cache; ++} ++ ++pub(crate) struct CacheRequest<'a> { ++ cache: Arc, ++ path: &'a Path, ++ source: &'a str, ++ options: String, ++ kind: &'static [u8], ++ is_es_module: [u8; 1], ++} ++ ++impl<'a> CacheRequest<'a> { ++ #[allow(clippy::too_many_arguments)] ++ pub(crate) fn new( ++ path: &'a Path, ++ source: &'a str, ++ compiler_options: Option<&CompilerOptions>, ++ module: Option, ++ top_level_await: bool, ++ is_es_module: bool, ++ helper: Option<&str>, ++ kind: &'static [u8], ++ ) -> Option { ++ let cache = loader_context() ++ .transform_cache ++ .lock() ++ .unwrap_or_else(PoisonError::into_inner) ++ .clone()?; ++ // Use the same complete normalized options as the transformer. Debug is a ++ // build-scoped encoding, not a cross-version public wire format. These options ++ // contain values, not resolver state, addresses or unordered alias maps. ++ let options = ++ format!("{:?}", transform_options(compiler_options, module, top_level_await, helper)); ++ Some(Self { cache, path, source, options, kind, is_es_module: [u8::from(is_es_module)] }) ++ } ++ ++ fn key(&self) -> [&[u8]; 6] { ++ [ ++ b"oxc-node-transform-v1", ++ self.kind, ++ self.path.as_os_str().as_encoded_bytes(), ++ self.source.as_bytes(), ++ self.options.as_bytes(), ++ &self.is_es_module, ++ ] ++ } ++ ++ pub(crate) fn get(&self) -> Option { ++ self.cache.get(&self.key()) ++ } ++ ++ pub(crate) fn put(&self, value: &CachedTransform) { ++ self.cache.put(&self.key(), value); ++ } ++} ++ ++#[cfg(test)] ++mod tests { ++ use std::{ ++ collections::HashMap, ++ sync::{ ++ Mutex, ++ atomic::{AtomicUsize, Ordering}, ++ }, ++ }; ++ ++ use super::*; ++ use crate::{LoaderContext, LoaderContextGuard, oxc_transform}; ++ use napi::Either; ++ ++ #[derive(Default)] ++ struct MemoryCache { ++ entries: Mutex>, CachedTransform>>, ++ hits: AtomicUsize, ++ } ++ ++ impl TransformCache for MemoryCache { ++ fn get(&self, key: &[&[u8]]) -> Option { ++ let key: Vec<_> = key.iter().map(|part| part.to_vec()).collect(); ++ let value = self.entries.lock().unwrap().get(&key).cloned(); ++ if value.is_some() { ++ self.hits.fetch_add(1, Ordering::Relaxed); ++ } ++ value ++ } ++ ++ fn put(&self, key: &[&[u8]], value: &CachedTransform) { ++ self.entries ++ .lock() ++ .unwrap() ++ .insert(key.iter().map(|part| part.to_vec()).collect(), value.clone()); ++ } ++ } ++ ++ fn transform(source: &str, options: &CompilerOptions, helper: &str) -> Output { ++ oxc_transform( ++ Path::new("cache.ts"), ++ &Either::::B(source.as_bytes()), ++ Some(options), ++ Some(Module::Preserve), ++ false, ++ true, ++ Some(helper), ++ None, ++ ) ++ .unwrap() ++ .0 ++ } ++ ++ #[test] ++ fn cached_transform_preserves_maps_and_invalidates_source_options_and_helpers() { ++ let _guard = LoaderContextGuard::enter(Arc::new(LoaderContext::default())); ++ let cache = Arc::new(MemoryCache::default()); ++ set_transform_cache(Some(cache.clone())); ++ let source = "class Value { count: number = 1; } console.log(new Value().count);"; ++ let mut options = CompilerOptions::default(); ++ options.use_define_for_class_fields = Some(false); ++ let first = transform(source, &options, "helpers-a"); ++ let cached = transform(source, &options, "helpers-a"); ++ assert_eq!(cache.hits.load(Ordering::Relaxed), 1); ++ assert_eq!(first.source(), cached.source()); ++ assert_eq!(first.source_map(), cached.source_map()); ++ let changed = transform(&source.replace("= 1", "= 2"), &options, "helpers-a"); ++ assert_ne!(first.source(), changed.source()); ++ assert_eq!(cache.hits.load(Ordering::Relaxed), 1); ++ options.use_define_for_class_fields = Some(true); ++ let changed = transform(source, &options, "helpers-a"); ++ assert_ne!(first.source(), changed.source()); ++ assert_eq!(cache.hits.load(Ordering::Relaxed), 1); ++ transform(source, &options, "helpers-b"); ++ assert_eq!(cache.hits.load(Ordering::Relaxed), 1); ++ } ++ ++ #[test] ++ fn cache_policy_is_scoped_to_the_loader_context() { ++ let cache = Arc::new(MemoryCache::default()); ++ let _outer = LoaderContextGuard::enter(Arc::new(LoaderContext::default())); ++ set_transform_cache(Some(cache.clone())); ++ let source = "console.log(1);"; ++ let options = CompilerOptions::default(); ++ transform(source, &options, "helpers"); ++ { ++ let _inner = LoaderContextGuard::enter(Arc::new(LoaderContext::default())); ++ transform(source, &options, "helpers"); ++ assert_eq!(cache.hits.load(Ordering::Relaxed), 0); ++ } ++ transform(source, &options, "helpers"); ++ assert_eq!(cache.hits.load(Ordering::Relaxed), 1); ++ } ++ ++ #[test] ++ fn esm_format_flip_is_preserved_on_a_hit() { ++ let _guard = LoaderContextGuard::enter(Arc::new(LoaderContext::default())); ++ let cache = Arc::new(MemoryCache::default()); ++ set_transform_cache(Some(cache.clone())); ++ let source = "export const value: number = 1;"; ++ let run = || { ++ oxc_transform( ++ Path::new("cache.ts"), ++ &Either::::B(source.as_bytes()), ++ None, ++ Some(Module::Preserve), ++ true, ++ false, ++ Some("helpers"), ++ None, ++ ) ++ .unwrap() ++ }; ++ let (first, first_module) = run(); ++ let (cached, cached_module) = run(); ++ assert!(first_module && cached_module); ++ assert_eq!(cache.hits.load(Ordering::Relaxed), 1); ++ assert_eq!(first.source(), cached.source()); ++ assert_eq!(first.source_map(), cached.source_map()); ++ } ++ ++ #[test] ++ fn cts_classification_hits_but_never_caches_a_module_syntax_error() { ++ let _guard = LoaderContextGuard::enter(Arc::new(LoaderContext::default())); ++ let cache = Arc::new(MemoryCache::default()); ++ set_transform_cache(Some(cache.clone())); ++ let path = Path::new("cache.cts"); ++ for _ in 0..2 { ++ assert!( ++ crate::reject_cts_module_syntax(path, Some("const value: number = 1;"), None) ++ .unwrap() ++ .is_none() ++ ); ++ } ++ assert_eq!(cache.hits.load(Ordering::Relaxed), 1); ++ for _ in 0..2 { ++ assert!( ++ crate::reject_cts_module_syntax( ++ path, ++ Some("export const value: number = 1;"), ++ None ++ ) ++ .is_err() ++ ); ++ } ++ assert_eq!(cache.hits.load(Ordering::Relaxed), 1); ++ } ++ ++ #[test] ++ fn ambiguous_classification_reuses_esm_and_commonjs_results() { ++ use crate::{LoadFnOutput, TryAsStr, load_commonjs_esm}; ++ ++ let _guard = LoaderContextGuard::enter(Arc::new(LoaderContext::default())); ++ let cache = Arc::new(MemoryCache::default()); ++ set_transform_cache(Some(cache.clone())); ++ let output = ++ LoadFnOutput { format: "commonjs".to_owned(), source: None, response_url: None }; ++ let url = crate::path_to_file_url(&std::env::temp_dir().join("cache.ts").to_string_lossy()); ++ for _ in 0..2 { ++ let result = load_commonjs_esm( ++ &url, ++ &output, ++ Some("export const value: number = 1;"), ++ None, ++ None, ++ ) ++ .unwrap() ++ .unwrap(); ++ assert_eq!(result.format, "module"); ++ assert!(result.source.as_ref().unwrap().try_as_str().unwrap().contains("export")); ++ } ++ assert_eq!(cache.hits.load(Ordering::Relaxed), 1); ++ for _ in 0..2 { ++ assert!( ++ load_commonjs_esm(&url, &output, Some("const value: number = 1;"), None, None) ++ .unwrap() ++ .is_none() ++ ); ++ } ++ assert_eq!(cache.hits.load(Ordering::Relaxed), 2); ++ } ++ ++ #[test] ++ fn a_classification_hit_still_checks_the_current_disk_source() { ++ use crate::{Either4, LoadFnOutput, TryAsStr, transform_output}; ++ ++ struct FileGuard(std::path::PathBuf); ++ impl Drop for FileGuard { ++ fn drop(&mut self) { ++ let _ = std::fs::remove_file(&self.0); ++ } ++ } ++ ++ let _guard = LoaderContextGuard::enter(Arc::new(LoaderContext::default())); ++ let cache = Arc::new(MemoryCache::default()); ++ set_transform_cache(Some(cache.clone())); ++ let file = FileGuard( ++ std::env::temp_dir().join(format!("oxc-cache-provenance-{}.ts", std::process::id())), ++ ); ++ let url = crate::path_to_file_url(&file.0.to_string_lossy()); ++ let source = "module.exports = 1;"; ++ let output = || LoadFnOutput { ++ format: "commonjs".to_owned(), ++ source: Some(Either4::A(source.to_owned())), ++ response_url: None, ++ }; ++ std::fs::write(&file.0, source).unwrap(); ++ let first = transform_output(url.clone(), output(), None, None).unwrap(); ++ assert!(matches!(first.source, Some(Either4::D(_)))); ++ std::fs::write(&file.0, "module.exports = 2;").unwrap(); ++ let next = transform_output(url, output(), None, None).unwrap(); ++ assert!(cache.hits.load(Ordering::Relaxed) > 0); ++ assert!(next.source.as_ref().unwrap().try_as_str().unwrap().contains("= 1")); ++ } ++} +--- a/Cargo.toml ++++ b/Cargo.toml +@@ -68,6 +68,7 @@ + yarn_pnp = ["oxc_resolver/yarn_pnp"] + + [dependencies] ++base64-simd = "0.8.0" + memchr = "2" + napi = { version = "3.12.2", default-features = false, features = ["serde-json", "napi3"] } + napi-derive = { version = "3.6.3", default-features = false, features = ["type-def"] } diff --git a/packages/tools/src/__tests__/patch-oxc-node.spec.ts b/packages/tools/src/__tests__/patch-oxc-node.spec.ts index c14aeb06d3..ff2fb3248f 100644 --- a/packages/tools/src/__tests__/patch-oxc-node.spec.ts +++ b/packages/tools/src/__tests__/patch-oxc-node.spec.ts @@ -1,6 +1,10 @@ -import { describe, expect, test } from 'vitest'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; -import { setOxcVersion } from '../patch-oxc-node.ts'; +import { describe, expect, test } from 'vite-plus/test'; + +import { applyTransformCachePatch, setOxcVersion } from '../patch-oxc-node.ts'; describe('setOxcVersion', () => { const cargoToml = `[dependencies] @@ -21,3 +25,39 @@ oxc_resolver = { version = "11.24.3" } expect(() => setOxcVersion('[dependencies]\n', '0.152.0')).toThrow(/`oxc` dependency/); }); }); + +describe('applyTransformCachePatch', () => { + const patch = `--- a/src/lib.rs ++++ b/src/lib.rs +@@ -1 +1 @@ +-original ++cached +`; + + test('applies once to an archive checkout and accepts the already-patched state', () => { + const root = mkdtempSync(join(tmpdir(), 'vp-oxc-cache-patch-')); + try { + mkdirSync(join(root, 'oxc-node', 'src'), { recursive: true }); + const source = join(root, 'oxc-node', 'src', 'lib.rs'); + writeFileSync(source, 'original\n'); + expect(applyTransformCachePatch(root, patch)).toBe(true); + expect(readFileSync(source, 'utf8')).toBe('cached\n'); + expect(applyTransformCachePatch(root, patch)).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test('rejects upstream drift without rewriting source', () => { + const root = mkdtempSync(join(tmpdir(), 'vp-oxc-cache-patch-')); + try { + mkdirSync(join(root, 'oxc-node', 'src'), { recursive: true }); + const source = join(root, 'oxc-node', 'src', 'lib.rs'); + writeFileSync(source, 'upstream changed\n'); + expect(() => applyTransformCachePatch(root, patch)).toThrow(/pinned upstream/); + expect(readFileSync(source, 'utf8')).toBe('upstream changed\n'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/tools/src/patch-oxc-node.ts b/packages/tools/src/patch-oxc-node.ts index b8a507e0d0..3a311700d1 100644 --- a/packages/tools/src/patch-oxc-node.ts +++ b/packages/tools/src/patch-oxc-node.ts @@ -8,10 +8,12 @@ * * This script points the `oxc` dependency at the workspace version, so the binding links * one copy of oxc, and adapts an AST predicate for the workspace's Oxc version. + * The warm-start prototype also applies a host transform-cache hook patch. * It is idempotent. Keep it free of package imports: CI runs it right after checkout, * before dependencies are installed. */ +import { spawnSync } from 'node:child_process'; import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -40,6 +42,31 @@ export function setOxcVersion(cargoToml: string, version: string): string { return replaceCargoCrateVersion(cargoToml, 'oxc', version); } +/** Apply the prototype patch once, and fail clearly when the pinned upstream drifts. */ +export function applyTransformCachePatch(rootDir: string, patch: string): boolean { + const args = ['apply', '--directory=oxc-node', '--whitespace=error']; + const run = (extra: string[]) => + spawnSync('git', [...args, ...extra], { + cwd: rootDir, + input: patch, + encoding: 'utf8', + }); + const check = run(['--check']); + if (check.status === 0) { + const applied = run([]); + if (applied.status !== 0) { + throw new Error(`[patch-oxc-node] Could not apply transform cache patch: ${applied.stderr}`); + } + return true; + } + if (run(['--reverse', '--check']).status === 0) { + return false; + } + throw new Error( + `[patch-oxc-node] Transform cache patch does not match the pinned upstream: ${check.stderr}`, + ); +} + export function patchOxcNode(rootDir: string = process.cwd()): void { const version = oxcVersion(readFileSync(join(rootDir, 'Cargo.toml'), 'utf-8'), 'Cargo.toml'); const cargoTomlPath = join(rootDir, OXC_NODE_DIR, 'Cargo.toml'); @@ -61,6 +88,13 @@ export function patchOxcNode(rootDir: string = process.cwd()): void { writeFileSync(libPath, patchedSource, 'utf-8'); log('✓ lib.rs: compatible await-using detection'); } + const cachePatch = readFileSync( + new URL('../patches/oxc-node-transform-cache.patch', import.meta.url), + 'utf8', + ); + if (applyTransformCachePatch(rootDir, cachePatch)) { + log('✓ native transform-cache hook available to embedders (prototype)'); + } } if ( diff --git a/rfcs/vpx-script-execution.md b/rfcs/vpx-script-execution.md index 8744d14253..7e7df686f2 100644 --- a/rfcs/vpx-script-execution.md +++ b/rfcs/vpx-script-execution.md @@ -372,7 +372,7 @@ Package mode through the global `vpx` is unchanged. What changes: - **`vp migrate` rules for other runners**: `esno` and `jiti` remain unsupported. Migration handles compatible `tsx`, `oxnode`, and transpile-only `ts-node` commands; see the [migration rules](../docs/guide/migrate-rules.md#package-script-rewrite-rules). - **TypeScript eval and REPL**, once oxc-node transforms eval input. -- **Opt-in transform cache**, keyed like tsx's. +- **Persistent compilation caches**: [warm-start cache RFC](./vpx-warm-start-cache.md), including Vite Task integration and the 10× startup target. - **Programmatic API**: a `tsImport()` equivalent exported from `vite-plus`. ## References diff --git a/rfcs/vpx-warm-start-cache-results.md b/rfcs/vpx-warm-start-cache-results.md new file mode 100644 index 0000000000..28bec84da1 --- /dev/null +++ b/rfcs/vpx-warm-start-cache-results.md @@ -0,0 +1,338 @@ +# vpx Warm Start Cache Prototype Results + +- Status: Measured prototype; caching remains opt-in +- Date: 2026-10-27 +- Design: [Warm-start cache RFC](./vpx-warm-start-cache.md) + +The October 27 benchmark tables describe the original prototype. After the rebase onto `0bea91491`, a [November 4 profile](#warm-transform-cache-hits-after-the-rebase) measured the two 300-module graphs on the updated loader and compiler. A [follow-up experiment](#packing-small-records-by-source-directory) compared packed storage with individual files and caching disabled. + +## Outcome + +The first version does not reach the 10× end-to-end warm-start target. Large single-source fixtures benefit from the combined Oxc and Node caches, but the 300-module graphs become slower despite successful artifact hits. Keep the prototype disabled by default and profile the per-module cache overhead before considering rollout. + +When `vpx` selects the built prototype package, `VPX_CACHE=1` enables both new layers. `VPX_CACHE=transform` and `VPX_CACHE=compile` isolate them; `VPX_DISABLE_CACHE=1` disables the prototype. The installed global launcher must resolve that local package for these controls to apply. The existing local launcher's normal Node compile-cache behavior is preserved in every comparison. + +## Main results on Node.js 24.22.1 + +All five existing fixtures are retained: one small TypeScript file (`hello`), 300-module ESM and CommonJS graphs (`esm-app`, `cjs-app`), and roughly 1.8 MB single-source CommonJS and ESM cases (`large-cjs`, `large-esm`). The [shared fixture generator](../bench/vpx-fixtures.ts) also serves the historical benchmark. + +Times below are launch-to-ready milliseconds, shown as **p50 / p95**. Speedup is baseline p50 divided by combined-cache p50; less than 1× means a regression. The interval is a paired-block bootstrap 95% confidence interval for that ratio. + +### Global vpx + +| Fixture | Baseline ms | Combined ms | Speedup | 95% interval | +| ----------- | ------------- | ------------- | ------- | ------------ | +| `hello` | 35.07 / 38.66 | 35.86 / 39.81 | 0.98× | 0.96–1.00× | +| `esm-app` | 73.70 / 82.29 | 85.87 / 98.70 | 0.86× | 0.83–0.87× | +| `cjs-app` | 73.44 / 82.26 | 84.69 / 98.78 | 0.87× | 0.84–0.88× | +| `large-cjs` | 79.39 / 84.77 | 48.16 / 53.42 | 1.65× | 1.61–1.67× | +| `large-esm` | 72.34 / 80.40 | 46.65 / 51.46 | 1.55× | 1.51–1.58× | + +### Local vpx and direct loader + +Local rows include the project-local Node launcher. Direct-loader rows omit the global/local launcher and are diagnostic, not user-facing acceptance results. + +| Fixture / launcher | Baseline ms | Combined ms | Speedup | 95% interval | +| -------------------- | -------------- | --------------- | ------- | ------------ | +| `hello` / local | 56.85 / 62.52 | 56.72 / 62.43 | 1.00× | 0.98–1.03× | +| `esm-app` / local | 94.03 / 104.60 | 105.87 / 121.58 | 0.89× | 0.87–0.91× | +| `cjs-app` / local | 93.90 / 103.22 | 106.32 / 129.55 | 0.88× | 0.86–0.89× | +| `large-cjs` / local | 99.27 / 110.05 | 69.38 / 76.06 | 1.43× | 1.40–1.47× | +| `large-esm` / local | 93.17 / 101.33 | 68.81 / 76.47 | 1.35× | 1.32–1.38× | +| `hello` / direct | 29.35 / 32.66 | 30.11 / 32.92 | 0.97× | 0.95–1.00× | +| `esm-app` / direct | 66.87 / 78.95 | 80.95 / 96.90 | 0.83× | 0.81–0.85× | +| `cjs-app` / direct | 67.67 / 78.04 | 79.94 / 95.36 | 0.85× | 0.83–0.86× | +| `large-cjs` / direct | 73.82 / 79.09 | 42.88 / 47.08 | 1.72× | 1.68–1.75× | +| `large-esm` / direct | 66.57 / 71.68 | 41.87 / 46.59 | 1.59× | 1.57–1.62× | + +## Cache layers and regressions + +This table isolates the two cache layers on the global launcher. Cells are warm readiness p50 milliseconds. + +| Fixture | Baseline | Disabled | Transform | Compile | Combined | +| ----------- | -------- | -------- | --------- | ------- | -------- | +| `hello` | 35.07 | 35.98 | 35.66 | 35.63 | 35.86 | +| `esm-app` | 73.70 | 72.66 | 81.31 | 77.60 | 85.87 | +| `cjs-app` | 73.44 | 73.61 | 82.02 | 77.39 | 84.69 | +| `large-cjs` | 79.39 | 80.26 | 61.78 | 66.66 | 48.16 | +| `large-esm` | 72.34 | 73.62 | 60.14 | 60.41 | 46.65 | + +The graph regression is already present with transform caching alone; enabling only Node's compile cache also regresses these fixtures. The large-source cases benefit from each layer and improve further when both are enabled. These ablations identify which policy adds cost, not the time spent in any individual filesystem or compiler phase. + +With the prototype disabled, warm p50 changes range from -1.04 to +2.07 ms across all Node.js 24 rows (-1.4% to +2.7%). Warm p95 changes range from -3.4% to +3.9%. Small positive deltas should be investigated rather than described as zero overhead; these point estimates do not establish statistical significance. + +### Cold artifact costs + +Cold readiness p50 on the global launcher, in milliseconds: + +| Fixture | Baseline | Transform | Compile | Combined | Combined change | +| ----------- | -------- | --------- | ------- | -------- | ------------------ | +| `hello` | 38.34 | 39.83 | 40.55 | 39.77 | +1.43 ms / +3.7% | +| `esm-app` | 81.44 | 147.14 | 92.65 | 153.12 | +71.68 ms / +88.0% | +| `cjs-app` | 80.14 | 153.24 | 88.37 | 155.07 | +74.93 ms / +93.5% | +| `large-cjs` | 87.87 | 95.36 | 85.96 | 98.39 | +10.53 ms / +12.0% | +| `large-esm` | 77.59 | 86.54 | 83.99 | 91.21 | +13.61 ms / +17.5% | + +Across all three launchers, combined-cache cold graph medians increase by 63.4–95.3%. The cold penalty is already visible in transform-only mode. Cold runs have only five samples per row, so borderline threshold crossings need confirmation; the large graph regressions are not borderline. + +Cold launch-to-exit medians also increase: `esm-app` 83.85 → 199.45 ms; `cjs-app` 83.26 → 198.88 ms. These global-launcher results include work after readiness, which requires separate profiling. + +| RFC gate | Observed result | +| --------------------------------- | -------------------------------------------------------------------------------------------------- | +| 10× end-to-end warm startup | Not met by any measured global or local row. | +| Warm p95 regression budget of 5% | The `hello` rows change by -0.1% to +3.0%; graph rows regress by 16.2–25.5% and exceed the budget. | +| Cold p50 regression budget of 10% | 11 of 15 Node.js 24 combined-cache point estimates exceed it, including every graph row. | +| Default-on rollout | Blocked by the performance regressions; retain explicit opt-in. | + +## Supplemental runtime results + +The same builds and global launcher were measured on Node.js 22.18.0, which uses off-thread ESM hooks, and Node.js 26.12.0. Only baseline and combined policies were compared. Times remain warm readiness p50 / p95 milliseconds. + +### Node.js 22.18.0 + +| Fixture | Baseline ms | Combined ms | Speedup | 95% interval | +| ----------- | --------------- | -------------- | ------- | ------------ | +| `hello` | 47.77 / 51.17 | 48.40 / 54.06 | 0.99× | 0.98–0.99× | +| `esm-app` | 90.00 / 101.37 | 98.12 / 107.57 | 0.92× | 0.90–0.93× | +| `cjs-app` | 89.50 / 95.82 | 97.08 / 105.71 | 0.92× | 0.91–0.94× | +| `large-cjs` | 113.02 / 120.55 | 81.59 / 87.35 | 1.39× | 1.37–1.40× | +| `large-esm` | 97.76 / 101.74 | 72.09 / 75.96 | 1.36× | 1.33–1.36× | + +### Node.js 26.12.0 + +| Fixture | Baseline ms | Combined ms | Speedup | 95% interval | +| ----------- | ------------- | ------------- | ------- | ------------ | +| `hello` | 34.63 / 36.21 | 35.06 / 36.93 | 0.99× | 0.97–1.00× | +| `esm-app` | 69.56 / 74.08 | 80.30 / 88.06 | 0.87× | 0.85–0.89× | +| `cjs-app` | 70.14 / 77.05 | 80.77 / 87.07 | 0.87× | 0.86–0.89× | +| `large-cjs` | 77.36 / 82.83 | 46.81 / 51.54 | 1.65× | 1.63–1.68× | +| `large-esm` | 69.85 / 73.13 | 45.90 / 48.04 | 1.52× | 1.50–1.54× | + +## Cache diagnostics and validation + +Separate post-measurement diagnostic launches on Node.js 24.22.1 and 26.12.0 report 304 native hits for each graph, four for `hello` and `large-esm`, and five for `large-cjs`, with zero native misses, writes, or errors in the transform-enabled policies. These are cache operations, not module counts. The diagnostic launches are excluded from the timing summaries. + +The native counters are thread-local. On Node.js 22.18.0 the main-thread probe cannot observe off-thread ESM counters: it reports zero for most fixtures and one hit for `large-cjs`. These values do not prove missing ESM caching. No Node/V8 compile-cache hit counter was collected; activation and ablation timings are not proof of individual V8 hits. + +Validation completed: Rust check/test/lint; six artifact-store tests and six upstream cache tests; 2,807 TypeScript tests with one skipped; 54 CLI snapshots; `vp check`; and task-cache source/config invalidation smoke checks. The benchmark validates readiness, stdout, stderr, exit status, a fresh execution side effect, actual Node executable/version, and selected loader on every cold, priming, warm, and diagnostic launch. + +The main run contains 4,425 successful launches; the supplemental run contains 1,180. Neither report contains a failed sample. + +## Measurement method and artifact identity + +- Machine: Apple M4 Max, 16 logical CPUs, 48 GiB memory; macOS arm64, Darwin 27.0.0. The benchmark parent ran Node.js 22.23.4. +- Baseline: Vite+ source `a9cc41dedf612b2a991da2968cb4107b46bccf38`, built with the same optimized release configuration and dependency pins as the prototype. The candidate includes this task's cache changes. Both package versions are `1.4.0`; hashes distinguish the actual builds. The generated `binding/index.cjs`, local launcher, and bin shim are byte-identical across the builds. +- Each row uses five cold artifact runs, three completed priming runs, and 50 warm runs. Each launch starts a fresh process. Policies are interleaved in seeded randomized blocks (`--seed 1`), with separate cache roots and temporary directories. +- Cold clears persistent artifacts and temporary compile caches, not OS filesystem caches. Dependency installation and fixture generation are outside the timings. +- The global launcher is the same installed executable for both contenders. Its isolated config uses `nodeShimMode: "system_first"`, with the requested runtime first on `PATH`; each run verifies the actual executable and selected project loader. Managed runtime selection is not measured here. +- Readiness follows fixture imports and initialization. A small JavaScript probe under `node_modules` avoids adding an Oxc transform, observes readiness, and writes a unique execution token after the marker. Exit time includes that write and cache shutdown work. These instrumented readiness numbers are not directly comparable with the earlier uninstrumented `spawnSync` launch-to-exit table. +- Confidence intervals use 2,000 deterministic bootstrap resamples (`random.Random(20261027)`). Each resample draws 50 iteration indices with replacement and applies the same indices to baseline and combined samples. The interval is the 2.5th–97.5th percentile of the ratio of their medians. It describes sampling uncertainty for this machine/run, not cross-machine or application performance. + +The raw reports are local ignored artifacts, not checked-in files: `tmp/vpx-cache-v1-node24.json` and `tmp/vpx-cache-v1-node22-node26.json`. They retain chronological samples, all p50/p95 summaries, runtime identities, per-file package hashes, exact arguments, and diagnostics. + +| Artifact | SHA256 | +| ------------------------------- | ------------------------------------------------------------------ | +| Node.js 24.22.1 | `6f32069480ec14d52b6d7b1f3c7124cfbbc46203abf5ddf5c65b127435f3f329` | +| Node.js 22.18.0 | `9187ad22c98cea5b635a79db52fa32ab3f6aa9d41e3abf5da71437cfef1ca9de` | +| Node.js 26.12.0 | `12ccc7c7d8b9b33ac0740fc7c2590d2aba14ccfa0a40f3d3f7a6c4c60c87f07c` | +| Shared global launcher | `af04cd44d02ee80f170f47b37ab387c27db2a0528c73d697e851c8c09b8edc01` | +| Baseline native binding | `39e27f8031acbcc597cd4076ceac27628603edb995b640d949a71c0dbf57ecd4` | +| Candidate native binding | `a314b801365a0ac82c976b0a66696478ff580ee40a51e176f57b4438412e0c03` | +| Shared generated binding loader | `ac942895862b5986387e7bb2300ed8c6f5756cfcce1a4e8c2b2b3b3c6f622e6e` | + +The global command path is `/Users/fengmk2/.vite-plus/bin/vpx`, resolving to `/Users/fengmk2/.vite-plus/1.3.0+force.70583.1792569205250812000/bin/vp`. Runtime executable paths are `/Users/fengmk2/.vite-plus/js_runtime/node//bin/node` for the exact versions above. Package roots are `/Users/fengmk2/git/worktrees/vp/vpx-cache-baseline/packages/cli` and `/Users/fengmk2/.codex/worktrees/d925/vp/packages/cli`. + +## Warm transform-cache hits after the rebase + +On November 4, both 300-module graphs spent about 77% of native cache-hit time accessing artifact files. The table shows median milliseconds per global `vpx` launch, summed over 304 hits. Each warm cache-enabled run recorded zero misses, writes, and errors. + +| Work inside a native hit | ESM (ms) | CommonJS (ms) | +| --------------------------------------- | -------: | ------------: | +| Prepare key inputs, including options | 0.65 | 0.66 | +| Hash key inputs with BLAKE3 | 0.88 | 0.88 | +| Check directory and file metadata | 1.71 | 1.71 | +| Allocate buffer, open, read, close | 5.21 | 5.36 | +| Verify payload checksum with BLAKE3 | 0.38 | 0.38 | +| Complete native lookup, including above | 8.98 | 9.10 | + +Formatting the compiler options took about 0.63 ms per launch. The two BLAKE3 operations together took about 1.26 ms. The filesystem timings include warm OS cache access. The complete lookup also includes path construction, record decoding, and timer overhead. It starts at cache-request creation and ends when the store returns a hit. + +The uninstrumented release took 71.80 ms with caching disabled and 78.82 ms with transform caching on ESM (+7.02 ms). CommonJS rose from 72.06 to 78.53 ms (+6.47 ms). These startup differences include the transform work that hits skip. With timers enabled, startup medians were 78.56 and 78.37 ms, respectively. Bypassing the global launcher yielded the same 77% filesystem share on both graphs. + +The experiment used commit `19d7d80cb`, Node.js `24.22.2`, an Apple M4 Max, and the repository's optimized release profile for both bindings. Each fixture, launcher, and mode ran four priming processes and 50 measured processes in interleaved blocks. Node's compile cache was disabled. Temporary probes accumulated per-thread wall times and reported them after readiness. The local samples and probe script are in `tmp/vpx-profile-19d7d80cb/`. + +## Packing small records by source directory + +The [packed store](../packages/cli/binding/src/oxc_node/cache/packed.rs) loads related records together and indexes their full content keys in memory. Both fixtures put all 300 leaf modules in one source directory. Separate diagnostic launches traced file API calls under the transform-cache root on macOS. The counts below were identical for ESM and CommonJS across two warm processes each. + +| Per warm process | Individual files | Packed | +| -------------------- | ---------------: | -----: | +| File metadata checks | 612 | 6 | +| Open | 304 | 2 | +| Read, including EOF | 610 | 6 | +| Close | 304 | 2 | + +All 304 lookups hit in both layouts. The packed processes read about 496–498 kB of artifact data each, within 0.3% of the individual-file totals. + +The table shows warm global `vpx` launch-to-ready p50 in milliseconds. + +| Fixture | Run | Cache disabled | Individual files | Packed | Packed minus disabled | +| -------- | --: | -------------: | ---------------: | -----: | --------------------: | +| ESM | 1 | 80.33 | 89.93 | 80.01 | −0.32 | +| ESM | 2 | 73.84 | 81.74 | 72.71 | −1.13 | +| CommonJS | 1 | 82.01 | 91.49 | 81.23 | −0.78 | +| CommonJS | 2 | 74.70 | 81.12 | 73.83 | −0.87 | + +Each run used a fresh cache root, four priming processes, and 120 independently launched processes per mode. Each randomized block exercised all four modes, including cache-off controls for both binaries. The table compares packed storage with its own binary's cache-off control. Both release builds used Node.js `24.22.2` on the same M4 Max, with Node's compile cache disabled. + +Packing saved 7.3–10.3 ms versus individual files. Its advantage over cache-off was smaller. The 95% bootstrap intervals for each run's difference in p50 include zero. Comparing launches within the same block reduces the effect of changes in machine load. Across 240 blocks, the median paired saving was 1.02 ms for each graph (95% intervals of 0.72–1.36 ms for ESM and 0.56–1.49 ms for CommonJS). These intervals resample whole blocks within each run. + +Cold global p50 for the packed store was 101.71 ms on ESM and 96.54 ms on CommonJS. Cache-off took 81.00 and 81.46 ms, respectively, over five cold launches per mode. Warm p95 was faster with packed storage in run 1 and slower in run 2 for both graphs. + +The local timing samples are in `tmp/vpx-packed-bench/run1.json` and `tmp/vpx-packed-bench/repeat.json`. The file-operation traces are in `tmp/vpx-packed-io/`. The individual-file and packed native binding SHA256 digests start with `fadc32118c99` and `a4a69354de33`, respectively. + +## Next measurements + +Node compile-cache work on the 300-module fixtures still needs a separate profile. For cold starts, measure directory checks, publication, and per-record writes. Transform-only timings show that this path already contains most of the observed cold graph penalty. + +Measure packed lookups on projects that import from many directories or have accumulated revisions. Other experiments include reusing normalized option fingerprints and skipping transforms that cost less to recompute than to retrieve. + +This is one macOS machine and synthetic workloads only. Real scripts, watch-restart timing, Windows/Linux runtime validation, and cache garbage collection remain outstanding. The results do not justify narrowing the 10× objective or enabling the prototype by default. + +## Reproduction + +Build baseline and candidate packages with matching optimized release settings, following the repository's build and mbx instructions. Preserve each package's `dist/` and `binding/` files. Run the harness from the candidate checkout with real absolute executable/package paths: + +```sh +node bench/vpx-cache.ts \ + --baseline-package /absolute/baseline/packages/cli \ + --candidate-package /absolute/candidate/packages/cli \ + --node /absolute/node24/bin/node \ + --vpx /absolute/bin/vpx \ + --runs 50 --warmup 3 --cold-runs 5 --seed 1 \ + --diagnostics --json tmp/vpx-cache-v1-node24.json + +node bench/vpx-cache.ts \ + --baseline-package /absolute/baseline/packages/cli \ + --candidate-package /absolute/candidate/packages/cli \ + --node /absolute/node22/bin/node --node /absolute/node26/bin/node \ + --vpx /absolute/bin/vpx --launcher global \ + --mode baseline --mode combined \ + --runs 50 --warmup 3 --cold-runs 5 --seed 1 \ + --diagnostics --json tmp/vpx-cache-v1-node22-node26.json +``` + +## Complete timing matrices + +Every cell below is **p50 / p95 in milliseconds**. Cold and warm readiness and exit results are retained for every measured mode and launcher. Priming and diagnostic launches are excluded. + +
+Node.js 24.22.1 — all five modes and three launchers + +| Fixture | Launcher | Mode | Cold ready | Cold exit | Warm ready | Warm exit | +| ----------- | -------- | --------- | --------------- | --------------- | --------------- | --------------- | +| `hello` | global | baseline | 38.34 / 63.90 | 40.41 / 65.94 | 35.07 / 38.66 | 36.92 / 40.88 | +| `hello` | global | disabled | 37.02 / 38.42 | 39.45 / 40.83 | 35.98 / 39.97 | 38.14 / 43.08 | +| `hello` | global | transform | 39.83 / 40.43 | 41.99 / 42.75 | 35.66 / 39.55 | 37.99 / 41.91 | +| `hello` | global | compile | 40.55 / 41.01 | 43.97 / 44.82 | 35.63 / 39.72 | 37.75 / 42.01 | +| `hello` | global | combined | 39.77 / 40.48 | 42.90 / 43.60 | 35.86 / 39.81 | 38.08 / 41.97 | +| `hello` | local | baseline | 60.33 / 63.24 | 62.54 / 65.29 | 56.85 / 62.52 | 58.76 / 64.72 | +| `hello` | local | disabled | 62.13 / 64.58 | 64.13 / 66.64 | 57.22 / 61.41 | 59.28 / 63.78 | +| `hello` | local | transform | 65.24 / 66.87 | 67.88 / 69.23 | 56.46 / 61.87 | 58.73 / 64.06 | +| `hello` | local | compile | 63.88 / 65.50 | 67.05 / 68.51 | 57.66 / 62.71 | 59.93 / 65.45 | +| `hello` | local | combined | 65.71 / 66.34 | 68.80 / 69.74 | 56.72 / 62.43 | 58.86 / 64.74 | +| `hello` | direct | baseline | 31.40 / 33.01 | 33.37 / 35.01 | 29.35 / 32.66 | 31.17 / 34.73 | +| `hello` | direct | disabled | 32.77 / 35.58 | 35.31 / 38.03 | 30.13 / 32.90 | 32.35 / 35.01 | +| `hello` | direct | transform | 33.76 / 38.25 | 36.36 / 40.66 | 30.24 / 33.04 | 32.38 / 35.35 | +| `hello` | direct | compile | 32.73 / 32.96 | 36.02 / 36.28 | 30.33 / 33.27 | 32.65 / 35.53 | +| `hello` | direct | combined | 34.37 / 37.09 | 37.58 / 40.24 | 30.11 / 32.92 | 32.21 / 35.20 | +| `esm-app` | global | baseline | 81.44 / 83.23 | 83.85 / 86.09 | 73.70 / 82.29 | 76.08 / 84.81 | +| `esm-app` | global | disabled | 82.42 / 84.71 | 85.32 / 87.47 | 72.66 / 84.75 | 75.13 / 87.11 | +| `esm-app` | global | transform | 147.14 / 155.07 | 150.06 / 158.10 | 81.31 / 95.92 | 83.77 / 98.27 | +| `esm-app` | global | compile | 92.65 / 101.95 | 140.04 / 189.35 | 77.60 / 87.53 | 79.99 / 90.09 | +| `esm-app` | global | combined | 153.12 / 204.70 | 199.45 / 263.45 | 85.87 / 98.70 | 88.33 / 101.18 | +| `esm-app` | local | baseline | 108.11 / 114.51 | 110.76 / 116.94 | 94.03 / 104.60 | 96.42 / 107.08 | +| `esm-app` | local | disabled | 107.10 / 108.52 | 110.66 / 110.98 | 94.47 / 107.51 | 96.75 / 109.90 | +| `esm-app` | local | transform | 175.66 / 198.47 | 177.95 / 200.99 | 102.40 / 120.93 | 104.82 / 123.66 | +| `esm-app` | local | compile | 113.20 / 115.52 | 157.09 / 168.49 | 98.08 / 116.32 | 100.54 / 118.80 | +| `esm-app` | local | combined | 176.60 / 202.63 | 220.24 / 247.33 | 105.87 / 121.58 | 108.21 / 124.44 | +| `esm-app` | direct | baseline | 77.76 / 79.86 | 80.19 / 82.70 | 66.87 / 78.95 | 69.54 / 81.97 | +| `esm-app` | direct | disabled | 76.61 / 84.00 | 79.29 / 86.59 | 67.83 / 76.26 | 70.26 / 81.02 | +| `esm-app` | direct | transform | 141.04 / 149.62 | 143.43 / 152.35 | 75.91 / 100.37 | 78.34 / 102.98 | +| `esm-app` | direct | compile | 81.61 / 88.29 | 127.22 / 186.45 | 71.87 / 84.85 | 74.41 / 87.75 | +| `esm-app` | direct | combined | 151.86 / 178.83 | 195.52 / 229.13 | 80.95 / 96.90 | 83.45 / 101.38 | +| `cjs-app` | global | baseline | 80.14 / 82.86 | 83.26 / 85.78 | 73.44 / 82.26 | 76.22 / 84.90 | +| `cjs-app` | global | disabled | 80.56 / 90.26 | 83.41 / 93.01 | 73.61 / 85.47 | 76.15 / 88.20 | +| `cjs-app` | global | transform | 153.24 / 179.91 | 155.84 / 182.80 | 82.02 / 95.34 | 84.63 / 97.96 | +| `cjs-app` | global | compile | 88.37 / 94.98 | 131.78 / 140.64 | 77.39 / 86.88 | 80.18 / 89.56 | +| `cjs-app` | global | combined | 155.07 / 163.83 | 198.88 / 209.38 | 84.69 / 98.78 | 87.28 / 103.04 | +| `cjs-app` | local | baseline | 110.12 / 115.86 | 112.55 / 118.82 | 93.90 / 103.22 | 96.33 / 105.83 | +| `cjs-app` | local | disabled | 105.89 / 113.53 | 108.50 / 117.46 | 95.97 / 106.90 | 98.59 / 109.56 | +| `cjs-app` | local | transform | 178.93 / 203.28 | 181.60 / 207.05 | 101.16 / 113.30 | 103.73 / 115.68 | +| `cjs-app` | local | compile | 109.36 / 120.46 | 158.40 / 171.65 | 100.40 / 116.87 | 103.01 / 119.67 | +| `cjs-app` | local | combined | 181.31 / 190.24 | 224.29 / 237.22 | 106.32 / 129.55 | 108.85 / 132.21 | +| `cjs-app` | direct | baseline | 76.51 / 81.99 | 79.09 / 84.96 | 67.67 / 78.04 | 70.05 / 80.56 | +| `cjs-app` | direct | disabled | 78.43 / 105.82 | 81.28 / 109.06 | 67.94 / 79.70 | 70.53 / 82.49 | +| `cjs-app` | direct | transform | 141.01 / 149.55 | 143.60 / 152.28 | 75.31 / 89.51 | 77.90 / 92.12 | +| `cjs-app` | direct | compile | 82.03 / 87.68 | 133.58 / 138.24 | 72.20 / 85.66 | 74.98 / 88.34 | +| `cjs-app` | direct | combined | 147.50 / 155.03 | 191.29 / 199.79 | 79.94 / 95.36 | 82.52 / 98.03 | +| `large-cjs` | global | baseline | 87.87 / 89.92 | 91.07 / 95.96 | 79.39 / 84.77 | 82.14 / 88.05 | +| `large-cjs` | global | disabled | 87.18 / 90.30 | 90.71 / 93.75 | 80.26 / 86.21 | 82.89 / 88.98 | +| `large-cjs` | global | transform | 95.36 / 97.77 | 98.58 / 101.30 | 61.78 / 65.40 | 64.55 / 68.76 | +| `large-cjs` | global | compile | 85.96 / 86.66 | 91.41 / 93.81 | 66.66 / 72.53 | 69.42 / 75.54 | +| `large-cjs` | global | combined | 98.39 / 99.74 | 103.35 / 104.47 | 48.16 / 53.42 | 50.46 / 56.10 | +| `large-cjs` | local | baseline | 109.57 / 112.09 | 114.23 / 116.90 | 99.27 / 110.05 | 101.93 / 113.58 | +| `large-cjs` | local | disabled | 110.34 / 112.40 | 113.66 / 115.65 | 100.97 / 108.16 | 103.88 / 111.05 | +| `large-cjs` | local | transform | 118.58 / 169.88 | 121.72 / 173.52 | 82.03 / 91.49 | 84.51 / 94.62 | +| `large-cjs` | local | compile | 110.63 / 112.97 | 115.33 / 120.05 | 87.43 / 97.41 | 90.01 / 100.27 | +| `large-cjs` | local | combined | 118.25 / 127.89 | 123.41 / 132.66 | 69.38 / 76.06 | 71.78 / 78.68 | +| `large-cjs` | direct | baseline | 79.05 / 79.44 | 82.12 / 82.77 | 73.82 / 79.09 | 76.66 / 82.87 | +| `large-cjs` | direct | disabled | 81.39 / 83.88 | 84.38 / 87.51 | 74.40 / 79.79 | 77.16 / 82.77 | +| `large-cjs` | direct | transform | 87.38 / 89.29 | 90.45 / 93.04 | 56.13 / 61.76 | 59.22 / 64.52 | +| `large-cjs` | direct | compile | 82.85 / 83.25 | 87.92 / 88.40 | 60.72 / 65.87 | 63.52 / 69.03 | +| `large-cjs` | direct | combined | 89.12 / 91.21 | 94.28 / 96.91 | 42.88 / 47.08 | 45.27 / 49.66 | +| `large-esm` | global | baseline | 77.59 / 80.09 | 80.61 / 83.28 | 72.34 / 80.40 | 75.14 / 83.06 | +| `large-esm` | global | disabled | 77.18 / 77.80 | 80.49 / 81.23 | 73.62 / 78.59 | 76.33 / 82.10 | +| `large-esm` | global | transform | 86.54 / 87.14 | 89.67 / 90.57 | 60.14 / 65.02 | 62.57 / 67.68 | +| `large-esm` | global | compile | 83.99 / 85.32 | 88.81 / 90.45 | 60.41 / 67.33 | 63.11 / 70.01 | +| `large-esm` | global | combined | 91.21 / 97.33 | 98.56 / 102.89 | 46.65 / 51.46 | 49.01 / 54.08 | +| `large-esm` | local | baseline | 103.03 / 105.86 | 106.21 / 108.66 | 93.17 / 101.33 | 95.92 / 104.08 | +| `large-esm` | local | disabled | 103.29 / 105.90 | 106.86 / 108.91 | 93.29 / 102.13 | 95.91 / 106.03 | +| `large-esm` | local | transform | 111.87 / 114.20 | 114.91 / 117.28 | 80.89 / 90.70 | 83.41 / 93.76 | +| `large-esm` | local | compile | 109.86 / 111.85 | 114.95 / 116.44 | 81.51 / 89.82 | 84.21 / 92.96 | +| `large-esm` | local | combined | 116.40 / 133.95 | 121.73 / 140.71 | 68.81 / 76.47 | 71.22 / 79.08 | +| `large-esm` | direct | baseline | 71.83 / 73.43 | 75.21 / 76.24 | 66.57 / 71.68 | 69.17 / 75.22 | +| `large-esm` | direct | disabled | 71.68 / 73.15 | 75.01 / 76.23 | 67.27 / 72.72 | 70.09 / 75.70 | +| `large-esm` | direct | transform | 81.75 / 85.85 | 85.07 / 89.15 | 54.46 / 58.45 | 56.87 / 62.32 | +| `large-esm` | direct | compile | 79.54 / 82.52 | 87.09 / 89.62 | 54.78 / 59.74 | 57.37 / 62.89 | +| `large-esm` | direct | combined | 85.45 / 86.61 | 90.18 / 93.74 | 41.87 / 46.59 | 44.29 / 49.34 | + +
+ +
+Node.js 22.18.0 and 26.12.0 — global baseline and combined cache + +| Node.js | Fixture | Mode | Cold ready | Cold exit | Warm ready | Warm exit | +| ------- | ----------- | -------- | --------------- | --------------- | --------------- | --------------- | +| 22.18.0 | `hello` | baseline | 50.02 / 52.94 | 52.86 / 56.35 | 47.77 / 51.17 | 50.40 / 54.09 | +| 22.18.0 | `hello` | combined | 49.77 / 53.80 | 53.49 / 57.92 | 48.40 / 54.06 | 51.02 / 56.75 | +| 22.18.0 | `esm-app` | baseline | 90.95 / 98.11 | 94.83 / 102.41 | 90.00 / 101.37 | 93.70 / 105.29 | +| 22.18.0 | `esm-app` | combined | 153.30 / 154.67 | 197.78 / 201.45 | 98.12 / 107.57 | 101.64 / 110.85 | +| 22.18.0 | `cjs-app` | baseline | 90.09 / 95.83 | 93.82 / 99.57 | 89.50 / 95.82 | 93.15 / 99.09 | +| 22.18.0 | `cjs-app` | combined | 146.89 / 152.72 | 190.20 / 197.16 | 97.08 / 105.71 | 100.48 / 109.02 | +| 22.18.0 | `large-cjs` | baseline | 115.26 / 118.68 | 120.54 / 124.32 | 113.02 / 120.55 | 118.47 / 126.19 | +| 22.18.0 | `large-cjs` | combined | 121.31 / 123.21 | 128.46 / 130.54 | 81.59 / 87.35 | 86.32 / 92.52 | +| 22.18.0 | `large-esm` | baseline | 101.63 / 107.78 | 105.29 / 113.05 | 97.76 / 101.74 | 101.27 / 105.39 | +| 22.18.0 | `large-esm` | combined | 108.58 / 110.04 | 113.94 / 115.57 | 72.09 / 75.96 | 75.63 / 79.50 | +| 26.12.0 | `hello` | baseline | 34.28 / 35.98 | 35.96 / 38.69 | 34.63 / 36.21 | 36.55 / 38.03 | +| 26.12.0 | `hello` | combined | 37.56 / 38.64 | 40.13 / 41.27 | 35.06 / 36.93 | 36.88 / 39.03 | +| 26.12.0 | `esm-app` | baseline | 74.80 / 77.16 | 77.05 / 79.50 | 69.56 / 74.08 | 72.03 / 76.51 | +| 26.12.0 | `esm-app` | combined | 144.04 / 154.28 | 184.29 / 194.17 | 80.30 / 88.06 | 82.57 / 90.56 | +| 26.12.0 | `cjs-app` | baseline | 71.67 / 77.53 | 74.11 / 80.02 | 70.14 / 77.05 | 72.46 / 79.27 | +| 26.12.0 | `cjs-app` | combined | 137.89 / 146.02 | 178.54 / 186.20 | 80.77 / 87.07 | 82.96 / 89.22 | +| 26.12.0 | `large-cjs` | baseline | 77.64 / 83.38 | 80.21 / 86.84 | 77.36 / 82.83 | 80.08 / 85.50 | +| 26.12.0 | `large-cjs` | combined | 86.00 / 89.56 | 89.77 / 93.43 | 46.81 / 51.54 | 49.16 / 53.98 | +| 26.12.0 | `large-esm` | baseline | 69.24 / 74.46 | 71.92 / 77.07 | 69.85 / 73.13 | 72.49 / 75.85 | +| 26.12.0 | `large-esm` | combined | 82.36 / 85.87 | 86.39 / 92.17 | 45.90 / 48.04 | 48.35 / 50.55 | + +
diff --git a/rfcs/vpx-warm-start-cache.md b/rfcs/vpx-warm-start-cache.md new file mode 100644 index 0000000000..ae0216a5d4 --- /dev/null +++ b/rfcs/vpx-warm-start-cache.md @@ -0,0 +1,280 @@ +# RFC: Accelerate vpx Warm Starts with Vite Task Cache APIs + +- Status: Draft; opt-in v1 prototype implemented +- Related: [Script execution](./vpx-script-execution.md), [Task caching](../docs/guide/cache.md), [Directory layout](./directory-layout.md) +- Original benchmark baseline: Vite+ `a9cc41dedf612b2a991da2968cb4107b46bccf38`; Vite Task `a3c087a771a116f7c60220fc72738703eb45ac2e`; oxc-node `6d5376df15df5446a91d01d53f3d7965d8b9708e`. The current branch uses the newer pins in [Cargo.toml](../Cargo.toml) and [the upstream manifest](../packages/tools/.upstream-versions.json); see [the results](./vpx-warm-start-cache-results.md) for the measured build identities. + +## Proposal + +Persist the compilation work used by `vpx