Skip to content

feat(vpx): add an opt-in bundle cache for script startup - #3438

Draft
fengmk2 wants to merge 4 commits into
mainfrom
feat/vpx-bundle-cache
Draft

fengmk2 wants to merge 4 commits into
mainfrom
feat/vpx-bundle-cache

Conversation

@fengmk2

@fengmk2 fengmk2 commented Nov 12, 2026 •

Copy link
Copy Markdown
Member

With VPX_BUNDLE_CACHE=1, vpx <script> bundles the script's ES modules with Rolldown after the first run. Later runs load the bundle while no input changed. The cache is opt-in and experimental. Without the variable, scripts run as before.

This replaces the per-module transform cache of #3343. That approach could not reach 2x, because the transform takes only about 28 µs per file. Most of the startup time is per-module work: V8 compile, module resolution, Node.js' module loader, and file system calls. Plain node with its own type stripping and compile cache is 1.34x faster than the current vpx on the same workload, which is the limit for any loader that keeps one file per module. rfcs/vpx-bundle-cache.md has the profile and the design.

Results

Knip 6.44.2 from source, scanning Marked 18.1.3 (the workload of #3343), complete run through the global vpx, on an Apple M4 Pro. Every mode gives the same report.

Node.js v1.6.0 Cache off Cache hit Speedup
22.23.4 358.0 ms 358.8 ms 189.0 ms 1.89x
24.22.2 307.4 ms 306.5 ms 153.7 ms 2.00x
26.13.1 311.8 ms 311.2 ms 165.6 ms 1.88x

The first run, which records the trace, takes 349.8 ms, compared with 348.6 ms with the cache off. The bundle builds in a separate process after the run exits.

The bench/vpx-script.ts fixtures on Node.js 24.22.2, through the loader:

Fixture Cache off VPX_BUNDLE_CACHE=1
hello (one file) 44.1 ms 37.0 ms
esm-app (300 modules) 79.5 ms 43.9 ms
cjs-app (300 modules) 79.3 ms 44.6 ms
large-esm (1.8 MB) 83.1 ms 41.8 ms
large-cjs (CommonJS, not bundled) 88.1 ms 89.9 ms

Commands from other projects on Node.js 24.22.2, through the loader. Every command gave the same output with and without the cache.

Command Modules bundled Cache off VPX_BUNDLE_CACHE=1 Speedup
A script that imports @aws-sdk/client-s3, rxjs, date-fns, and zod 400 of 400 542.0 ms 100.1 ms 5.41x
Knip, src/cli.ts 577 of 579 305.0 ms 149.2 ms 2.04x
taze 21.3.0, src/cli.ts --help 122 of 124 127.7 ms 82.3 ms 1.55x
tsdown 0.23.7, src/run.ts --help 124 of 127 59.1 ms 48.4 ms 1.22x
tsdown 0.23.7, build of a one-file library 69 of 127 96.2 ms 80.4 ms 1.20x
Vite+ CLI, packages/cli/src/bin.ts help 296 of 297 52.4 ms 46.8 ms 1.12x
dify, scripts/check-icon-dimensions.ts (one file) 1 of 1 42.0 ms 37.4 ms 1.12x
evcc, i18n/check.ts (one file, reads many JSON files) 1 of 1 88.4 ms 84.7 ms 1.04x
Prettier 3.9.10, bin/prettier.cjs --check (CommonJS) — 95.0 ms 95.7 ms 0.7 ms slower

The gain depends on how many modules the script loads at startup. Plain Node.js takes 536.2 ms for the library script, so its time comes from loading about 650 modules one at a time. The Vite+ CLI's help loads only about 40 of its 296 bundled modules. A one-file script saves only the native loader's startup, about 4–7 ms.

How it works

  1. First run. The script loads with the normal hooks. The run records how each import resolved and in which format each module loaded. At exit, a detached dist/script-bundle-build.js loads each ES module with oxc-node's own transform and bundles it with Rolldown into node_modules/.vite-plus/vpx-bundle/.

    A module stays out of the bundle and loads from its own file if it is not an ES module, if it uses import.meta other than url, filename, dirname, main, and resolve(), if it uses a direct eval, or if it uses require, module, or exports without a declaration. Rolldown would give such a module working CommonJS globals, which Node.js does not (node-fetch-native in taze takes its catch path).

  2. Later runs. The preload checks every input (the inode, size, modification time, and change time of each file), then runs the bundle with Node.js' compile cache. Chunks, built-ins, and JavaScript dependencies go straight to Node.js. The native binding loads only for a module outside the bundle. On Knip, it does not load.

    The inputs are the bundled modules, each import target, the directories of relative targets outside node_modules, the nearest package.json files, the node_modules lookups of bare specifiers, the tsconfig files, and the loader's own files. The key (Node.js version, platform, entry, working directory, execArgv, NODE_OPTIONS, tsconfig variables) selects the cache directory. The script's arguments are not in the key. One changed input makes the whole bundle expire: the next run records again at the speed without the cache. On Knip, the three runs after an edit to one file take 291.3 ms, 218.5 ms (new chunk paths, so V8 compiles them again), and 139.1 ms.

  3. Same behavior as before.

    • Each bundled module keeps its own import.meta.url, filename, and dirname, and process.argv[1] stays the script.
    • A computed import() or import.meta.resolve() resolves from the original module.
    • Stack traces show the source files with the same lines and columns.
    • Tree shaking keeps every module body and every /*#__PURE__*/ call, and keepNames keeps function names.
  4. No second instance of a module. A module that code outside the bundle imports or requires stays out of the bundle, and so do its imports. If a run still loads a bundled module from its own file (for example, a computed import() that the first run did not reach), the run prints a warning, and the next build leaves the module out.

  5. Same order as before. A chunk runs all its imports before its own code. Thus each module outside the bundle that startup code imports goes through a bundled proxy (export * from "<url>") in a chunk of its own, and the bundled modules between two such modules form a chunk. The entry has its own chunk and imports these chunks in their run order. A CommonJS dependency that reads process.env at load still sees what a bundled module before it set. A module that only import() reaches goes into a chunk that loads with that import(). If a startup dependency has a top-level await, the script runs without a bundle, because its chunk could wait for itself.

The cache is off in watch mode, under coverage, with another --require, --import, or --loader, with --test, --eval, or --preserve-symlinks, and on Node.js releases without in-thread module hooks (before 22.22.3 and 24.11.1). VPX_BUNDLE_CACHE_DEBUG=1 prints why each run uses the bundle or not, and builds before exit.

The preload requires dist/script-bundle-hooks.cjs only when VPX_BUNDLE_CACHE=1. The other change on the default path: the pirates matcher decides with a JavaScript extension check instead of a call to the binding, which gives the same answer under setTransformDeps('typescript'). Two vpx snapshots change only in the preload line number of a stack trace.

Known limitations

  • A run in which code that the first run did not reach loads a bundled module from its own file has two instances of that module. Later runs are correct.
  • Among modules that only import() reaches, the order is kept less strictly than at startup.
  • A library that reads structured call sites to find its caller's file sees the chunk's path.
  • A new file in a directory that holds a bundled module drops the bundle, even if the file does not change resolution.
  • Nothing removes old cache entries yet. A build keeps the three newest older outputs of its entry.

🤖 Generated with Claude Code

fengmk2 and others added 2 commits November 12, 2026 15:38
With VPX_BUNDLE_CACHE=1, the first run of a script records how each
import resolved and in which format each module loaded. At exit, a
detached builder bundles the script's ES modules with Rolldown into
node_modules/.vite-plus/vpx-bundle/. Later runs check every input and
load the bundle, with the native loader loaded only for a module that
the bundle does not contain.

Each bundled module keeps its own import.meta values, a computed
import() or import.meta.resolve() resolves from the module's own URL,
and a module that code outside the bundle imports stays out, so that no
module has two instances. A load of a bundled module from its own file
is reported once and leaves the module out of the next build.

The preload requires the cache code only when VPX_BUNDLE_CACHE=1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A chunk runs all its imports before its own code, so an external module
that a chunk imported directly ran before the bundled code that came
before it. Each external that startup code imports now goes through a
bundled proxy in a chunk of its own, between chunks of the bundled
modules around it, and the entry imports them in their run order. A
startup dependency with a top-level await keeps the script off the
bundle, where its chunk could wait for itself.

Also from review:
- A failed build records every file it read, so a fix brings the bundle back.
- The recorded run takes the build lock at exit, for the short build only.
- A build keeps the three newest older outputs; a run checks its chunks exist.
- A literal import() that does not resolve at build time resolves at run time.
- Every bare specifier records its node_modules lookup.
- The tsconfig variables and the TypeScript dependency rule have one source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Nov 12, 2026 •

Copy link
Copy Markdown

🚀 Deploying Preview to Cloudflare 🚀

Preview URL: https://feat-vpx-bundle-cache-viteplus-dev.voidzero-docs.workers.dev (commit c406bb4)

This URL reflects your latest Preview deployment

Preview Deployments by commit

Status Deployment URL Commit Updated (UTC) See this deployment's details
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://013e0266-viteplus-dev.voidzero-docs.workers.dev c406bb4 2026-11-12T09:20:21.163Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://2de23ee0-viteplus-dev.voidzero-docs.workers.dev d3543e4 2026-11-12T08:11:48.108Z Visit the dashboard ↗

@github-actions

github-actions Bot commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

✅ Staging deployment successful!

Preview: https://viteplus-staging.void.app/
Commit: c406bb4

@github-actions

github-actions Bot commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

CLI artifact sizes (c406bb4)

Final release artifacts built by the canonical build-upstream and build-windows-cli actions.
The dist rows use the Linux build. The core total excludes .node files to match the release artifact.

Artifact Format Base PR Change
packages/cli/dist Directory total 2.34 MiB 2.39 MiB +57.60 KiB (+2.41%)
packages/core/dist Directory total 4.09 MiB 4.09 MiB 0 B (0.00%)
Combined package dist Directory total 6.42 MiB 6.48 MiB +57.60 KiB (+0.88%)
vp (Linux x64) Binary 8.34 MiB 8.34 MiB 0 B (0.00%)
vp (Linux x64) gzip -9 3.81 MiB 3.81 MiB 0 B (0.00%)
NAPI (Linux x64) Binary 36.77 MiB 36.77 MiB 0 B (0.00%)
NAPI (Linux x64) gzip -9 14.69 MiB 14.69 MiB 0 B (0.00%)
vp (macOS ARM64) Binary 5.46 MiB 5.46 MiB 0 B (0.00%)
vp (macOS ARM64) gzip -9 3.25 MiB 3.25 MiB 0 B (0.00%)
NAPI (macOS ARM64) Binary 43.43 MiB 43.43 MiB 0 B (0.00%)
NAPI (macOS ARM64) gzip -9 18.86 MiB 18.86 MiB 0 B (0.00%)
vp (Windows x64) Binary 6.18 MiB 6.18 MiB 0 B (0.00%)
vp (Windows x64) gzip -9 2.86 MiB 2.86 MiB 0 B (0.00%)
NAPI (Windows x64) Binary 32.81 MiB 32.81 MiB 0 B (0.00%)
NAPI (Windows x64) gzip -9 13.45 MiB 13.45 MiB -1 B (-0.00%)
Trampoline (Windows x64) Binary 13.50 KiB 13.50 KiB 0 B (0.00%)
Trampoline (Windows x64) gzip -9 7.09 KiB 7.09 KiB 0 B (0.00%)
Installer (Windows x64) Binary 4.68 MiB 4.68 MiB 0 B (0.00%)
Installer (Windows x64) gzip -9 2.24 MiB 2.24 MiB +2 B (+0.00%)

fengmk2 and others added 2 commits November 12, 2026 17:19
Rolldown treats an ES module that uses `require`, `module`, or `exports`
without a declaration as CommonJS and gives it a working `require()`.
Node.js runs the same module without these globals, so code such as
node-fetch-native's `try { require(...) } catch` took the other path in
the bundle. The analysis now marks such a module as unsafe, and it loads
from its own file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add a section on the cache files, the key, the hit/off/miss decision,
how a bundle is made, what makes it expire, and when it is reused. Add
results from more projects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant