From 3a8902ea7f08cce3f1909dc67449e73e60dbb8e9 Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Thu, 12 Nov 2026 15:38:54 +0800 Subject: [PATCH 1/4] feat(vpx): add an opt-in bundle cache for script startup 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 --- .../vpx_script_module_formats.global.md | 16 +- .../vpx_script_module_formats.local.md | 16 +- .../vpx_script_tsconfig_transforms.global.md | 8 +- .../vpx_script_tsconfig_transforms.local.md | 8 +- .../computed.ts | 6 + .../command_vpx_script_bundle_cache/data.json | 1 + .../command_vpx_script_bundle_cache/lazy.ts | 6 + .../command_vpx_script_bundle_cache/lib.ts | 24 + .../command_vpx_script_bundle_cache/main.ts | 33 + .../node_modules/dep-cjs/index.js | 1 + .../node_modules/dep-cjs/package.json | 1 + .../node_modules/dep-esm/index.js | 9 + .../node_modules/dep-esm/package.json | 1 + .../package.json | 9 + .../command_vpx_script_bundle_cache/shared.ts | 1 + .../snapshots.toml | 47 ++ .../vpx_script_bundle_cache.global.md | 96 +++ .../vpx_script_bundle_cache.local.md | 96 +++ ...ipt_bundle_cache_computed_import.global.md | 54 ++ ...ript_bundle_cache_computed_import.local.md | 54 ++ ...ipt_bundle_cache_top_level_await.global.md | 23 + ...ript_bundle_cache_top_level_await.local.md | 23 + .../vpx_script_bundle_cache_unsupported.md | 23 + .../tla-main.ts | 3 + .../command_vpx_script_bundle_cache/tla.ts | 6 + .../tsconfig.json | 8 + docs/guide/vpx.md | 10 + .../cli/src/__tests__/script-bundle.spec.ts | 308 +++++++++ packages/cli/src/script-bundle-build.ts | 638 ++++++++++++++++++ packages/cli/src/script-bundle-hooks.ts | 296 ++++++++ packages/cli/src/script-bundle-plan.ts | 318 +++++++++ packages/cli/src/script-bundle.ts | 427 ++++++++++++ packages/cli/src/script-preload.ts | 94 ++- packages/cli/tsdown.config.ts | 19 + rfcs/vpx-bundle-cache.md | 123 ++++ 35 files changed, 2759 insertions(+), 47 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/computed.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/data.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/lazy.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/lib.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/main.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/node_modules/dep-cjs/index.js create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/node_modules/dep-cjs/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/node_modules/dep-esm/index.js create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/node_modules/dep-esm/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/shared.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/snapshots/vpx_script_bundle_cache.global.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/snapshots/vpx_script_bundle_cache.local.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/snapshots/vpx_script_bundle_cache_computed_import.global.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/snapshots/vpx_script_bundle_cache_computed_import.local.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/snapshots/vpx_script_bundle_cache_top_level_await.global.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/snapshots/vpx_script_bundle_cache_top_level_await.local.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/snapshots/vpx_script_bundle_cache_unsupported.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/tla-main.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/tla.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/tsconfig.json create mode 100644 packages/cli/src/__tests__/script-bundle.spec.ts create mode 100644 packages/cli/src/script-bundle-build.ts create mode 100644 packages/cli/src/script-bundle-hooks.ts create mode 100644 packages/cli/src/script-bundle-plan.ts create mode 100644 packages/cli/src/script-bundle.ts create mode 100644 rfcs/vpx-bundle-cache.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script/snapshots/vpx_script_module_formats.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script/snapshots/vpx_script_module_formats.global.md index d7a2fef3d0..7c20d04a25 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script/snapshots/vpx_script_module_formats.global.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script/snapshots/vpx_script_module_formats.global.md @@ -9,12 +9,12 @@ A .cts that uses export is CommonJS with ES module syntax; it fails with a clear **Exit code:** 1 ``` -/packages/cli/dist/script-preload.cjs:477 - const result = oxcNode.load(url, context, nextLoad, HELPER_MODULE_NAME); - ^ +/packages/cli/dist/script-preload.cjs:505 + const result = oxcNode().load(url, context, nextLoad, HELPER_MODULE_NAME); + ^ Error: Failed to transform /src/both.cts: a `.cts` file is CommonJS, but this one uses ES module syntax (`import`, `export` or `import.meta`), which oxc-node does not compile to CommonJS. Rename it to `.mts` to run it as an ES module, or write it as CommonJS with `import x = require("...")` and `export =`. - at load (/packages/cli/dist/script-preload.cjs:477:25) + at load (/packages/cli/dist/script-preload.cjs:505:27) at nextStep (node:internal/modules/customization_hooks:189:26) at loadWithHooks (node:internal/modules/customization_hooks:374:18) at #loadSync (node:internal/modules/esm/loader:865:14) @@ -37,12 +37,12 @@ Importing it reports the same error **Exit code:** 1 ``` -/packages/cli/dist/script-preload.cjs:477 - const result = oxcNode.load(url, context, nextLoad, HELPER_MODULE_NAME); - ^ +/packages/cli/dist/script-preload.cjs:505 + const result = oxcNode().load(url, context, nextLoad, HELPER_MODULE_NAME); + ^ Error: Failed to transform /src/both.cts: a `.cts` file is CommonJS, but this one uses ES module syntax (`import`, `export` or `import.meta`), which oxc-node does not compile to CommonJS. Rename it to `.mts` to run it as an ES module, or write it as CommonJS with `import x = require("...")` and `export =`. - at load (/packages/cli/dist/script-preload.cjs:477:25) + at load (/packages/cli/dist/script-preload.cjs:505:27) at nextStep (node:internal/modules/customization_hooks:189:26) at loadWithHooks (node:internal/modules/customization_hooks:374:18) at #loadSync (node:internal/modules/esm/loader:865:14) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script/snapshots/vpx_script_module_formats.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script/snapshots/vpx_script_module_formats.local.md index d7a2fef3d0..7c20d04a25 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script/snapshots/vpx_script_module_formats.local.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script/snapshots/vpx_script_module_formats.local.md @@ -9,12 +9,12 @@ A .cts that uses export is CommonJS with ES module syntax; it fails with a clear **Exit code:** 1 ``` -/packages/cli/dist/script-preload.cjs:477 - const result = oxcNode.load(url, context, nextLoad, HELPER_MODULE_NAME); - ^ +/packages/cli/dist/script-preload.cjs:505 + const result = oxcNode().load(url, context, nextLoad, HELPER_MODULE_NAME); + ^ Error: Failed to transform /src/both.cts: a `.cts` file is CommonJS, but this one uses ES module syntax (`import`, `export` or `import.meta`), which oxc-node does not compile to CommonJS. Rename it to `.mts` to run it as an ES module, or write it as CommonJS with `import x = require("...")` and `export =`. - at load (/packages/cli/dist/script-preload.cjs:477:25) + at load (/packages/cli/dist/script-preload.cjs:505:27) at nextStep (node:internal/modules/customization_hooks:189:26) at loadWithHooks (node:internal/modules/customization_hooks:374:18) at #loadSync (node:internal/modules/esm/loader:865:14) @@ -37,12 +37,12 @@ Importing it reports the same error **Exit code:** 1 ``` -/packages/cli/dist/script-preload.cjs:477 - const result = oxcNode.load(url, context, nextLoad, HELPER_MODULE_NAME); - ^ +/packages/cli/dist/script-preload.cjs:505 + const result = oxcNode().load(url, context, nextLoad, HELPER_MODULE_NAME); + ^ Error: Failed to transform /src/both.cts: a `.cts` file is CommonJS, but this one uses ES module syntax (`import`, `export` or `import.meta`), which oxc-node does not compile to CommonJS. Rename it to `.mts` to run it as an ES module, or write it as CommonJS with `import x = require("...")` and `export =`. - at load (/packages/cli/dist/script-preload.cjs:477:25) + at load (/packages/cli/dist/script-preload.cjs:505:27) at nextStep (node:internal/modules/customization_hooks:189:26) at loadWithHooks (node:internal/modules/customization_hooks:374:18) at #loadSync (node:internal/modules/esm/loader:865:14) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script/snapshots/vpx_script_tsconfig_transforms.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script/snapshots/vpx_script_tsconfig_transforms.global.md index 29e1e6de6a..0e09c7d21b 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script/snapshots/vpx_script_tsconfig_transforms.global.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script/snapshots/vpx_script_tsconfig_transforms.global.md @@ -34,12 +34,12 @@ Decorators without experimentalDecorators fail with a clear error **Exit code:** 1 ``` -/packages/cli/dist/script-preload.cjs:477 - const result = oxcNode.load(url, context, nextLoad, HELPER_MODULE_NAME); - ^ +/packages/cli/dist/script-preload.cjs:505 + const result = oxcNode().load(url, context, nextLoad, HELPER_MODULE_NAME); + ^ Error: Failed to transform file:///plain/decorators.ts: decorators require `"experimentalDecorators": true` in a tsconfig.json that includes this file; standard (TC39) decorators are not supported yet - at load (/packages/cli/dist/script-preload.cjs:477:25) + at load (/packages/cli/dist/script-preload.cjs:505:27) at nextStep (node:internal/modules/customization_hooks:189:26) at loadWithHooks (node:internal/modules/customization_hooks:374:18) at #loadSync (node:internal/modules/esm/loader:865:14) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script/snapshots/vpx_script_tsconfig_transforms.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script/snapshots/vpx_script_tsconfig_transforms.local.md index 29e1e6de6a..0e09c7d21b 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script/snapshots/vpx_script_tsconfig_transforms.local.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script/snapshots/vpx_script_tsconfig_transforms.local.md @@ -34,12 +34,12 @@ Decorators without experimentalDecorators fail with a clear error **Exit code:** 1 ``` -/packages/cli/dist/script-preload.cjs:477 - const result = oxcNode.load(url, context, nextLoad, HELPER_MODULE_NAME); - ^ +/packages/cli/dist/script-preload.cjs:505 + const result = oxcNode().load(url, context, nextLoad, HELPER_MODULE_NAME); + ^ Error: Failed to transform file:///plain/decorators.ts: decorators require `"experimentalDecorators": true` in a tsconfig.json that includes this file; standard (TC39) decorators are not supported yet - at load (/packages/cli/dist/script-preload.cjs:477:25) + at load (/packages/cli/dist/script-preload.cjs:505:27) at nextStep (node:internal/modules/customization_hooks:189:26) at loadWithHooks (node:internal/modules/customization_hooks:374:18) at #loadSync (node:internal/modules/esm/loader:865:14) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/computed.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/computed.ts new file mode 100644 index 0000000000..453737bfca --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/computed.ts @@ -0,0 +1,6 @@ +import { counter } from './shared.ts'; + +export function computed(): string { + counter.value += 10; + return 'loaded'; +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/data.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/data.json new file mode 100644 index 0000000000..d15ea79ca1 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/data.json @@ -0,0 +1 @@ +{ "name": "fixture-data" } diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/lazy.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/lazy.ts new file mode 100644 index 0000000000..a908a5d0e5 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/lazy.ts @@ -0,0 +1,6 @@ +import { counter } from './shared.ts'; + +export function lazy(): string { + counter.value++; + return 'loaded'; +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/lib.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/lib.ts new file mode 100644 index 0000000000..00dc425223 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/lib.ts @@ -0,0 +1,24 @@ +import path from 'node:path'; + +export enum Color { + Red = 'red', + Blue = 'blue', +} + +export class Store { + items: number[] = []; + + constructor(private readonly name: string) {} + + add(id: number): void { + this.items.push(id); + } + + fail(): never { + throw new Error(`store ${this.name} failed`); + } +} + +export function where(): string { + return path.basename(import.meta.dirname); +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/main.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/main.ts new file mode 100644 index 0000000000..cec2c06a7d --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/main.ts @@ -0,0 +1,33 @@ +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import cjs from 'dep-cjs'; +import { esm } from 'dep-esm'; + +import data from './data.json' with { type: 'json' }; +import { Color, Store, where } from './lib.ts'; +import { counter } from './shared.ts'; + +const store = new Store('main'); +store.add(1); +counter.value++; +console.log( + `color=${Color.Red} items=${store.items.length} esm=${esm()} cjs=${cjs.cjs()} json=${data.name}`, +); +const isMain = import.meta.url === pathToFileURL(process.argv[1]!).href; +const filename = fileURLToPath(import.meta.url) === import.meta.filename; +console.log(`main=${isMain} filename=${filename} dirname=${where()} class=${Store.name}`); + +const mode = process.argv[2]; +if (mode === 'lazy') { + // A literal import() that the recorded run did not reach. + const { lazy } = await import('./lazy.ts'); + console.log(`lazy=${lazy()} counter=${counter.value}`); +} else if (mode === 'computed') { + // A computed import(), which the run resolves from this file. + const name = 'computed'; + const { computed } = await import(`./${name}.ts`); + console.log(`computed=${computed()} counter=${counter.value}`); + console.log(`resolve=${import.meta.resolve('dep-esm').endsWith('/dep-esm/index.js')}`); +} else if (mode === 'throw') { + store.fail(); +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/node_modules/dep-cjs/index.js b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/node_modules/dep-cjs/index.js new file mode 100644 index 0000000000..06112381c8 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/node_modules/dep-cjs/index.js @@ -0,0 +1 @@ +exports.cjs = () => `cjs:${require('node:path').basename(__dirname)}`; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/node_modules/dep-cjs/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/node_modules/dep-cjs/package.json new file mode 100644 index 0000000000..db18c3114c --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/node_modules/dep-cjs/package.json @@ -0,0 +1 @@ +{ "name": "dep-cjs", "version": "1.0.0", "main": "index.js" } diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/node_modules/dep-esm/index.js b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/node_modules/dep-esm/index.js new file mode 100644 index 0000000000..6e13de07e1 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/node_modules/dep-esm/index.js @@ -0,0 +1,9 @@ +import { createRequire } from 'node:module'; + +// Reads its own package.json next to this file, which the bundle must keep finding. +const require = createRequire(import.meta.url); +const { version } = require('./package.json'); + +export function esm() { + return `esm@${version}`; +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/node_modules/dep-esm/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/node_modules/dep-esm/package.json new file mode 100644 index 0000000000..00e1a7940c --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/node_modules/dep-esm/package.json @@ -0,0 +1 @@ +{ "name": "dep-esm", "version": "1.0.0", "type": "module", "exports": "./index.js" } diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/package.json new file mode 100644 index 0000000000..6c2f6b4de9 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/package.json @@ -0,0 +1,9 @@ +{ + "name": "command-vpx-script-bundle-cache", + "version": "1.0.0", + "private": true, + "type": "module", + "engines": { + "node": "22.23.4" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/shared.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/shared.ts new file mode 100644 index 0000000000..c2e2e2dca6 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/shared.ts @@ -0,0 +1 @@ +export const counter = { value: 0 }; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/snapshots.toml new file mode 100644 index 0000000000..03f461bb22 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/snapshots.toml @@ -0,0 +1,47 @@ +[[case]] +name = "vpx_script_bundle_cache" +vp = ["local", "global"] +env = { VPX_BUNDLE_CACHE = "1", VPX_BUNDLE_CACHE_DEBUG = "1" } +comment = "VPX_BUNDLE_CACHE=1 records a run, bundles the script's ES modules, and runs the bundle until an input changes. The debug lines show each decision; the builder runs before exit under VPX_BUNDLE_CACHE_DEBUG=1." +steps = [ + { argv = ["vpx", "./main.ts"], comment = "The first run loads each module and records the run; the builder then bundles its ES modules" }, + { argv = ["vpx", "./main.ts"], comment = "Later runs load the bundle without the native loader; import.meta, CommonJS and ES module dependencies, and JSON behave as before" }, + { argv = ["vpx", "./main.ts", "lazy"], comment = "A literal import() the recorded run did not reach runs from the bundle, with one instance of shared.ts" }, + { argv = ["vpx", "./main.ts", "throw"], comment = "Stack traces map to the source files", continue-on-failure = true }, + { argv = ["vpt", "replace-file-content", "lib.ts", "Red = 'red'", "Red = 'crimson'"], snapshot = false }, + { argv = ["vpx", "./main.ts"], comment = "An edited source file drops the bundle" }, + { argv = ["vpx", "./main.ts"], comment = "The rebuilt bundle has the edit" }, + { argv = ["vpx", "./main.ts"], envs = [["VPX_BUNDLE_CACHE", "0"]], comment = "Without VPX_BUNDLE_CACHE=1, the script runs as before" }, +] + +[[case]] +name = "vpx_script_bundle_cache_computed_import" +vp = ["local", "global"] +env = { VPX_BUNDLE_CACHE = "1", VPX_BUNDLE_CACHE_DEBUG = "1" } +comment = "A computed import() resolves from its own module. A module that only it loads, and that imports a bundled module, makes a second instance once; the run warns, and the next build leaves the bundled module out." +steps = [ + { argv = ["vpx", "./main.ts"], comment = "The recorded run does not reach the computed import()" }, + { argv = ["vpx", "./main.ts", "computed"], comment = "computed.ts loads from its own file and imports shared.ts from its file too: two instances, so counter=1" }, + { argv = ["vpx", "./main.ts", "computed"], comment = "The next run records again, and the build leaves shared.ts out" }, + { argv = ["vpx", "./main.ts", "computed"], comment = "From then on, shared.ts has one instance" }, +] + +[[case]] +name = "vpx_script_bundle_cache_top_level_await" +vp = ["local", "global"] +env = { VPX_BUNDLE_CACHE = "1", VPX_BUNDLE_CACHE_DEBUG = "1" } +comment = "A dependency that awaits an import() at its top level, whose target imports a module it shares with the startup code, does not wait for itself in the bundle." +steps = [ + { argv = ["vpx", "./tla-main.ts"], comment = "The recorded run" }, + { argv = ["vpx", "./tla-main.ts"], comment = "The bundled run gives the same result" }, +] + +[[case]] +name = "vpx_script_bundle_cache_unsupported" +vp = "local" +env = { VPX_BUNDLE_CACHE = "1", VPX_BUNDLE_CACHE_DEBUG = "1" } +comment = "Options that load other modules or hooks, or change how the script runs, turn the cache off." +steps = [ + { argv = ["vpx", "--import", "./shared.ts", "./main.ts"], comment = "Another --import preload could register hooks that the bundle would bypass" }, + { argv = ["vpx", "./main.ts"], envs = [["NODE_V8_COVERAGE", "${workspace}/coverage"]], comment = "Coverage reports source files, not a bundle" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/snapshots/vpx_script_bundle_cache.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/snapshots/vpx_script_bundle_cache.global.md new file mode 100644 index 0000000000..878b523210 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/snapshots/vpx_script_bundle_cache.global.md @@ -0,0 +1,96 @@ +# vpx_script_bundle_cache + +VPX_BUNDLE_CACHE=1 records a run, bundles the script's ES modules, and runs the bundle until an input changes. The debug lines show each decision; the builder runs before exit under VPX_BUNDLE_CACHE_DEBUG=1. + +## `vpx ./main.ts` + +The first run loads each module and records the run; the builder then bundles its ES modules + +``` +vpx bundle cache: miss (no bundle); recording +color=red items=1 esm=esm@1.0.0 cjs=cjs:dep-cjs json=fixture-data +main=true filename=true dirname=workspace class=Store +vpx bundle cache: bundled 6 of 6 ES modules in +``` + +## `vpx ./main.ts` + +Later runs load the bundle without the native loader; import.meta, CommonJS and ES module dependencies, and JSON behave as before + +``` +vpx bundle cache: hit (6 modules) +color=red items=1 esm=esm@1.0.0 cjs=cjs:dep-cjs json=fixture-data +main=true filename=true dirname=workspace class=Store +vpx bundle cache: the native loader did not load +``` + +## `vpx ./main.ts lazy` + +A literal import() the recorded run did not reach runs from the bundle, with one instance of shared.ts + +``` +vpx bundle cache: hit (6 modules) +color=red items=1 esm=esm@1.0.0 cjs=cjs:dep-cjs json=fixture-data +main=true filename=true dirname=workspace class=Store +lazy=loaded counter=2 +vpx bundle cache: the native loader did not load +``` + +## `vpx ./main.ts throw` + +Stack traces map to the source files + +**Exit code:** 1 + +``` +vpx bundle cache: hit (6 modules) +color=red items=1 esm=esm@1.0.0 cjs=cjs:dep-cjs json=fixture-data +main=true filename=true dirname=workspace class=Store +vpx bundle cache: the native loader did not load +/lib.ts:18 + throw new Error(`store ${this.name} failed`); + ^ + +Error: store main failed + at Store.fail (/lib.ts:18:11) + at (/main.ts:32:9) + at ModuleJob.run (node:internal/modules/esm/module_job:343:25) + at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:681:26) + at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:117:5) + +Node.js +``` + +## `vpt replace-file-content lib.ts 'Red = '\''red'\''' 'Red = '\''crimson'\'''` + + +## `vpx ./main.ts` + +An edited source file drops the bundle + +``` +vpx bundle cache: miss (/lib.ts changed); recording +color=crimson items=1 esm=esm@1.0.0 cjs=cjs:dep-cjs json=fixture-data +main=true filename=true dirname=workspace class=Store +vpx bundle cache: bundled 6 of 6 ES modules in +``` + +## `vpx ./main.ts` + +The rebuilt bundle has the edit + +``` +vpx bundle cache: hit (6 modules) +color=crimson items=1 esm=esm@1.0.0 cjs=cjs:dep-cjs json=fixture-data +main=true filename=true dirname=workspace class=Store +vpx bundle cache: the native loader did not load +``` + +## `VPX_BUNDLE_CACHE=0 vpx ./main.ts` + +Without VPX_BUNDLE_CACHE=1, the script runs as before + +``` +color=crimson items=1 esm=esm@1.0.0 cjs=cjs:dep-cjs json=fixture-data +main=true filename=true dirname=workspace class=Store +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/snapshots/vpx_script_bundle_cache.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/snapshots/vpx_script_bundle_cache.local.md new file mode 100644 index 0000000000..878b523210 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/snapshots/vpx_script_bundle_cache.local.md @@ -0,0 +1,96 @@ +# vpx_script_bundle_cache + +VPX_BUNDLE_CACHE=1 records a run, bundles the script's ES modules, and runs the bundle until an input changes. The debug lines show each decision; the builder runs before exit under VPX_BUNDLE_CACHE_DEBUG=1. + +## `vpx ./main.ts` + +The first run loads each module and records the run; the builder then bundles its ES modules + +``` +vpx bundle cache: miss (no bundle); recording +color=red items=1 esm=esm@1.0.0 cjs=cjs:dep-cjs json=fixture-data +main=true filename=true dirname=workspace class=Store +vpx bundle cache: bundled 6 of 6 ES modules in +``` + +## `vpx ./main.ts` + +Later runs load the bundle without the native loader; import.meta, CommonJS and ES module dependencies, and JSON behave as before + +``` +vpx bundle cache: hit (6 modules) +color=red items=1 esm=esm@1.0.0 cjs=cjs:dep-cjs json=fixture-data +main=true filename=true dirname=workspace class=Store +vpx bundle cache: the native loader did not load +``` + +## `vpx ./main.ts lazy` + +A literal import() the recorded run did not reach runs from the bundle, with one instance of shared.ts + +``` +vpx bundle cache: hit (6 modules) +color=red items=1 esm=esm@1.0.0 cjs=cjs:dep-cjs json=fixture-data +main=true filename=true dirname=workspace class=Store +lazy=loaded counter=2 +vpx bundle cache: the native loader did not load +``` + +## `vpx ./main.ts throw` + +Stack traces map to the source files + +**Exit code:** 1 + +``` +vpx bundle cache: hit (6 modules) +color=red items=1 esm=esm@1.0.0 cjs=cjs:dep-cjs json=fixture-data +main=true filename=true dirname=workspace class=Store +vpx bundle cache: the native loader did not load +/lib.ts:18 + throw new Error(`store ${this.name} failed`); + ^ + +Error: store main failed + at Store.fail (/lib.ts:18:11) + at (/main.ts:32:9) + at ModuleJob.run (node:internal/modules/esm/module_job:343:25) + at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:681:26) + at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:117:5) + +Node.js +``` + +## `vpt replace-file-content lib.ts 'Red = '\''red'\''' 'Red = '\''crimson'\'''` + + +## `vpx ./main.ts` + +An edited source file drops the bundle + +``` +vpx bundle cache: miss (/lib.ts changed); recording +color=crimson items=1 esm=esm@1.0.0 cjs=cjs:dep-cjs json=fixture-data +main=true filename=true dirname=workspace class=Store +vpx bundle cache: bundled 6 of 6 ES modules in +``` + +## `vpx ./main.ts` + +The rebuilt bundle has the edit + +``` +vpx bundle cache: hit (6 modules) +color=crimson items=1 esm=esm@1.0.0 cjs=cjs:dep-cjs json=fixture-data +main=true filename=true dirname=workspace class=Store +vpx bundle cache: the native loader did not load +``` + +## `VPX_BUNDLE_CACHE=0 vpx ./main.ts` + +Without VPX_BUNDLE_CACHE=1, the script runs as before + +``` +color=crimson items=1 esm=esm@1.0.0 cjs=cjs:dep-cjs json=fixture-data +main=true filename=true dirname=workspace class=Store +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/snapshots/vpx_script_bundle_cache_computed_import.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/snapshots/vpx_script_bundle_cache_computed_import.global.md new file mode 100644 index 0000000000..bda4c57f92 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/snapshots/vpx_script_bundle_cache_computed_import.global.md @@ -0,0 +1,54 @@ +# vpx_script_bundle_cache_computed_import + +A computed import() resolves from its own module. A module that only it loads, and that imports a bundled module, makes a second instance once; the run warns, and the next build leaves the bundled module out. + +## `vpx ./main.ts` + +The recorded run does not reach the computed import() + +``` +vpx bundle cache: miss (no bundle); recording +color=red items=1 esm=esm@1.0.0 cjs=cjs:dep-cjs json=fixture-data +main=true filename=true dirname=workspace class=Store +vpx bundle cache: bundled 6 of 6 ES modules in +``` + +## `vpx ./main.ts computed` + +computed.ts loads from its own file and imports shared.ts from its file too: two instances, so counter=1 + +``` +vpx bundle cache: hit (6 modules) +color=red items=1 esm=esm@1.0.0 cjs=cjs:dep-cjs json=fixture-data +main=true filename=true dirname=workspace class=Store +vpx: warning: /shared.ts is in the bundle cache but also loaded from its own file, so it has two instances in this run; the next run rebuilds the cache without it +computed=loaded counter=1 +resolve=true +vpx bundle cache: the native loader loaded +``` + +## `vpx ./main.ts computed` + +The next run records again, and the build leaves shared.ts out + +``` +vpx bundle cache: miss (no bundle); recording +color=red items=1 esm=esm@1.0.0 cjs=cjs:dep-cjs json=fixture-data +main=true filename=true dirname=workspace class=Store +computed=loaded counter=11 +resolve=true +vpx bundle cache: bundled 5 of 6 ES modules in +``` + +## `vpx ./main.ts computed` + +From then on, shared.ts has one instance + +``` +vpx bundle cache: hit (5 modules) +color=red items=1 esm=esm@1.0.0 cjs=cjs:dep-cjs json=fixture-data +main=true filename=true dirname=workspace class=Store +computed=loaded counter=11 +resolve=true +vpx bundle cache: the native loader loaded +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/snapshots/vpx_script_bundle_cache_computed_import.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/snapshots/vpx_script_bundle_cache_computed_import.local.md new file mode 100644 index 0000000000..bda4c57f92 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/snapshots/vpx_script_bundle_cache_computed_import.local.md @@ -0,0 +1,54 @@ +# vpx_script_bundle_cache_computed_import + +A computed import() resolves from its own module. A module that only it loads, and that imports a bundled module, makes a second instance once; the run warns, and the next build leaves the bundled module out. + +## `vpx ./main.ts` + +The recorded run does not reach the computed import() + +``` +vpx bundle cache: miss (no bundle); recording +color=red items=1 esm=esm@1.0.0 cjs=cjs:dep-cjs json=fixture-data +main=true filename=true dirname=workspace class=Store +vpx bundle cache: bundled 6 of 6 ES modules in +``` + +## `vpx ./main.ts computed` + +computed.ts loads from its own file and imports shared.ts from its file too: two instances, so counter=1 + +``` +vpx bundle cache: hit (6 modules) +color=red items=1 esm=esm@1.0.0 cjs=cjs:dep-cjs json=fixture-data +main=true filename=true dirname=workspace class=Store +vpx: warning: /shared.ts is in the bundle cache but also loaded from its own file, so it has two instances in this run; the next run rebuilds the cache without it +computed=loaded counter=1 +resolve=true +vpx bundle cache: the native loader loaded +``` + +## `vpx ./main.ts computed` + +The next run records again, and the build leaves shared.ts out + +``` +vpx bundle cache: miss (no bundle); recording +color=red items=1 esm=esm@1.0.0 cjs=cjs:dep-cjs json=fixture-data +main=true filename=true dirname=workspace class=Store +computed=loaded counter=11 +resolve=true +vpx bundle cache: bundled 5 of 6 ES modules in +``` + +## `vpx ./main.ts computed` + +From then on, shared.ts has one instance + +``` +vpx bundle cache: hit (5 modules) +color=red items=1 esm=esm@1.0.0 cjs=cjs:dep-cjs json=fixture-data +main=true filename=true dirname=workspace class=Store +computed=loaded counter=11 +resolve=true +vpx bundle cache: the native loader loaded +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/snapshots/vpx_script_bundle_cache_top_level_await.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/snapshots/vpx_script_bundle_cache_top_level_await.global.md new file mode 100644 index 0000000000..c09afbedab --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/snapshots/vpx_script_bundle_cache_top_level_await.global.md @@ -0,0 +1,23 @@ +# vpx_script_bundle_cache_top_level_await + +A dependency that awaits an import() at its top level, whose target imports a module it shares with the startup code, does not wait for itself in the bundle. + +## `vpx ./tla-main.ts` + +The recorded run + +``` +vpx bundle cache: miss (no bundle); recording +early=loaded:1 +vpx bundle cache: bundled 4 of 4 ES modules in +``` + +## `vpx ./tla-main.ts` + +The bundled run gives the same result + +``` +vpx bundle cache: hit (4 modules) +early=loaded:1 +vpx bundle cache: the native loader did not load +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/snapshots/vpx_script_bundle_cache_top_level_await.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/snapshots/vpx_script_bundle_cache_top_level_await.local.md new file mode 100644 index 0000000000..c09afbedab --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/snapshots/vpx_script_bundle_cache_top_level_await.local.md @@ -0,0 +1,23 @@ +# vpx_script_bundle_cache_top_level_await + +A dependency that awaits an import() at its top level, whose target imports a module it shares with the startup code, does not wait for itself in the bundle. + +## `vpx ./tla-main.ts` + +The recorded run + +``` +vpx bundle cache: miss (no bundle); recording +early=loaded:1 +vpx bundle cache: bundled 4 of 4 ES modules in +``` + +## `vpx ./tla-main.ts` + +The bundled run gives the same result + +``` +vpx bundle cache: hit (4 modules) +early=loaded:1 +vpx bundle cache: the native loader did not load +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/snapshots/vpx_script_bundle_cache_unsupported.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/snapshots/vpx_script_bundle_cache_unsupported.md new file mode 100644 index 0000000000..f1814ca622 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/snapshots/vpx_script_bundle_cache_unsupported.md @@ -0,0 +1,23 @@ +# vpx_script_bundle_cache_unsupported + +Options that load other modules or hooks, or change how the script runs, turn the cache off. + +## `vpx --import ./shared.ts ./main.ts` + +Another --import preload could register hooks that the bundle would bypass + +``` +vpx bundle cache: off (--import loads other modules or hooks) +color=red items=1 esm=esm@1.0.0 cjs=cjs:dep-cjs json=fixture-data +main=true filename=true dirname=workspace class=Store +``` + +## `NODE_V8_COVERAGE=${workspace}/coverage vpx ./main.ts` + +Coverage reports source files, not a bundle + +``` +vpx bundle cache: off (NODE_V8_COVERAGE is set) +color=red items=1 esm=esm@1.0.0 cjs=cjs:dep-cjs json=fixture-data +main=true filename=true dirname=workspace class=Store +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/tla-main.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/tla-main.ts new file mode 100644 index 0000000000..2c282db36d --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/tla-main.ts @@ -0,0 +1,3 @@ +import { early } from './tla.ts'; + +console.log(`early=${early}`); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/tla.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/tla.ts new file mode 100644 index 0000000000..b5e5b758bb --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/tla.ts @@ -0,0 +1,6 @@ +import { counter } from './shared.ts'; + +// A dependency that awaits an import() at its top level, whose target imports shared.ts +// from the same startup set. +const { lazy } = await import('./lazy.ts'); +export const early = `${lazy()}:${counter.value}`; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/tsconfig.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/tsconfig.json new file mode 100644 index 0000000000..29fcdfce13 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vpx_script_bundle_cache/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "nodenext", + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true + } +} diff --git a/docs/guide/vpx.md b/docs/guide/vpx.md index 0a50b2b25b..44f855805c 100644 --- a/docs/guide/vpx.md +++ b/docs/guide/vpx.md @@ -160,6 +160,16 @@ It watches every file the script imports or requires (in worker threads too, on `tsx watch`'s `--ignore` and `--no-cache` and its `CHOKIDAR_USEPOLLING` variable work too (`--no-cache` does nothing: `vpx` has no transform cache), and so do the Node.js watch mode options `--watch-preserve-output` and `--watch-kill-signal`, so scripts written for `tsx watch` or `node --watch` move over. `--watch-path`, which would start Node.js's watch mode, is an error that points to `--include`, and so are `--test`, `--eval`, and other options that run no file. Other Node.js options go before the file. +### Bundle Cache (Experimental) + +Set `VPX_BUNDLE_CACHE=1` to start a script that imports many modules faster. The first run loads the script as usual and records how it loaded. Then `vpx` bundles the script's ES modules into `node_modules/.vite-plus/vpx-bundle/`, after the run exits. Later runs load the bundle until a source file, a dependency, or a tsconfig changes. + +```bash +VPX_BUNDLE_CACHE=1 vpx ./scripts/report.ts +``` + +Stack traces, `import.meta.url`, `import.meta.dirname`, and `process.argv` show the source files, as without the cache. The cache is off in watch mode, under coverage (`NODE_V8_COVERAGE`), with another `--require` or `--import` preload, and on Node.js releases before 22.22.3 and 24.11.1. Set `VPX_BUNDLE_CACHE_DEBUG=1` to print why each run uses the bundle or not. See the [RFC](https://github.com/voidzero-dev/vite-plus/blob/main/rfcs/vpx-bundle-cache.md) for the design and the known limitations. + ### tsconfig `vpx` reads compiler options from a tsconfig but does not type-check with it. Each file uses the nearest `tsconfig.json` above it that includes it through `files`, `include`, `exclude`, or project `references`, as `tsc` does, and `extends` is followed. Files in `node_modules` use no tsconfig, and neither does a file that no tsconfig includes, so it gets the defaults below. Such a file still resolves `paths` aliases through the nearest `tsconfig.json` above it, as tsx does: with a root tsconfig whose `include` is `["src"]`, a script in `scripts/` can import the same aliases as `src/`. Because the config depends on the file's location, not on the working directory, `vpx` compiles a file the same way wherever it runs from. diff --git a/packages/cli/src/__tests__/script-bundle.spec.ts b/packages/cli/src/__tests__/script-bundle.spec.ts new file mode 100644 index 0000000000..0cb8ebda70 --- /dev/null +++ b/packages/cli/src/__tests__/script-bundle.spec.ts @@ -0,0 +1,308 @@ +import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { parseAst } from 'vite/rolldown/parseAst'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + analyzeModule, + bundledSet, + metaReplacement, + staticallyReachable, +} from '../script-bundle-plan.ts'; +import { + BUNDLE_CACHE_VERSION, + type BundleCacheContext, + bundleKey, + changedInput, + checkManifest, + findCacheRoot, + isPlainDependency, + isPlainDependencyImport, + MANIFEST, + parseResolveFrom, + stamp, + unsupportedReason, + writeJsonAtomic, +} from '../script-bundle.ts'; + +const dirs: string[] = []; + +function project(files: Record): string { + const dir = mkdtempSync(path.join(tmpdir(), 'vpx-bundle-')); + dirs.push(dir); + for (const [name, content] of Object.entries(files)) { + const file = path.join(dir, name); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, content); + } + return dir; +} + +afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function analyze(code: string) { + return analyzeModule(parseAst(code, { lang: 'js', sourceType: 'module' }) as never); +} + +describe('analyzeModule', () => { + it('collects static, re-exported, and literal dynamic specifiers', () => { + const analysis = analyze( + [ + "import a from './a.js';", + "import './side-effect.js';", + "export { b } from './b.js';", + "export * from 'dep';", + "const c = await import('./c.js');", + 'const d = await import(`./d.js`);', + ].join('\n'), + ); + expect(analysis.specifiers).toEqual([ + './a.js', + './side-effect.js', + './b.js', + 'dep', + './c.js', + './d.js', + ]); + expect(analysis.dynamicSpecifiers).toEqual(['./c.js', './d.js']); + expect(analysis.unsafe).toBeUndefined(); + expect(analysis.resolveEdits).toEqual([]); + }); + + it('marks import.meta reads it can replace', () => { + const code = + 'console.log(import.meta.url, import.meta.filename, import.meta.dirname, import.meta.main);'; + const analysis = analyze(code); + expect(analysis.unsafe).toBeUndefined(); + expect( + analysis.metaEdits.map((edit) => [edit.property, code.slice(edit.start, edit.end)]), + ).toEqual([ + ['url', 'import.meta.url'], + ['filename', 'import.meta.filename'], + ['dirname', 'import.meta.dirname'], + ['main', 'import.meta.main'], + ]); + }); + + it('marks the specifiers only the run knows', () => { + const code = "const m = await import(name + '.js'); const u = import.meta.resolve(dep);"; + const analysis = analyze(code); + expect(analysis.unsafe).toBeUndefined(); + expect(analysis.resolveEdits.map((edit) => code.slice(edit.start, edit.end))).toEqual([ + "name + '.js'", + 'dep', + ]); + }); + + it.each([ + ['await ready;', true], + ['for await (const x of xs) {}', true], + ['await using handle = open();', true], + ['async function f() { await ready; }', false], + ['const f = async () => { for await (const x of xs) {} };', false], + ])('detects top-level await in %s', (code, expected) => { + expect(analyze(code).topLevelAwait).toBe(expected); + }); + + it.each([ + ['const meta = import.meta;', 'import.meta'], + ['console.log(import.meta.env);', 'import.meta.env'], + ['const resolve = import.meta.resolve;', 'import.meta.resolve'], + ["eval('1');", 'a direct eval()'], + ])('leaves out %s', (code, reason) => { + expect(analyze(code).unsafe).toBe(reason); + }); +}); + +describe('metaReplacement', () => { + it('replaces each read with the module’s own value', () => { + const url = 'file:///p/src/a.ts'; + expect(metaReplacement('url', url, '/p/src/a.ts', '/p/src', false)).toBe(JSON.stringify(url)); + expect(metaReplacement('filename', url, '/p/src/a.ts', '/p/src', false)).toBe('"/p/src/a.ts"'); + expect(metaReplacement('dirname', url, '/p/src/a.ts', '/p/src', false)).toBe('"/p/src"'); + }); + + it('keeps import.meta.main only for the entry', () => { + expect(metaReplacement('main', 'file:///p/a.ts', '/p/a.ts', '/p', true)).toBeUndefined(); + expect(metaReplacement('main', 'file:///p/b.ts', '/p/b.ts', '/p', false)).toBe( + '(import.meta.main && false)', + ); + }); +}); + +function graph(modules: Record) { + return new Map(Object.entries(modules)); +} + +describe('bundledSet', () => { + it('bundles the safe ES modules reachable from the entry', () => { + const modules = graph({ + entry: { imports: ['a', 'cjs'] }, + a: { imports: ['b'] }, + b: { imports: [] }, + unreachable: { imports: [] }, + }); + expect(bundledSet('entry', modules, [], new Set())).toEqual(new Set(['a', 'b', 'entry'])); + }); + + it('leaves out every module that code outside the bundle imports, and what it imports', () => { + const modules = graph({ + entry: { imports: ['a', 'unsafe', 'shared'] }, + a: { imports: ['shared'] }, + unsafe: { unsafe: 'import.meta', imports: ['shared'] }, + shared: { imports: ['leaf'] }, + leaf: { imports: [] }, + }); + expect(bundledSet('entry', modules, [], new Set())).toEqual(new Set(['a', 'entry'])); + }); + + it('applies run edges and exclusions', () => { + const modules = graph({ + entry: { imports: ['a', 'b'] }, + a: { imports: [] }, + b: { imports: [] }, + }); + // A `require()` of `a` in the run, and `b` reported as loaded twice. + expect([...bundledSet('entry', modules, [['', 'a']], new Set(['b']))]).toEqual(['entry']); + }); + + it('bundles nothing when the entry cannot be bundled', () => { + const modules = graph({ entry: { imports: [] } }); + expect(bundledSet('entry', modules, [['', 'entry']], new Set()).size).toBe(0); + expect(bundledSet('other', modules, [], new Set()).size).toBe(0); + }); +}); + +describe('staticallyReachable', () => { + it('keeps the modules only import() reaches out of the startup chunk', () => { + const staticImports = new Map([ + ['entry', ['shared']], + ['lazy', ['shared', 'lazy-only']], + ]); + const bundled = new Set(['entry', 'shared', 'lazy', 'lazy-only']); + expect(staticallyReachable('entry', staticImports, bundled)).toEqual( + new Set(['entry', 'shared']), + ); + expect(staticallyReachable('missing', staticImports, bundled).size).toBe(0); + }); +}); + +describe('parseResolveFrom', () => { + it('splits the module index from the specifier', () => { + expect(parseResolveFrom('vpx-from:12:./plugins/a.ts')).toEqual([12, './plugins/a.ts']); + expect(parseResolveFrom('vpx-from:3:file:///p/a.js')).toEqual([3, 'file:///p/a.js']); + expect(parseResolveFrom('./a.ts')).toBeUndefined(); + expect(parseResolveFrom('vpx-from:x:./a.ts')).toBeUndefined(); + }); +}); + +describe('unsupportedReason', () => { + const own = { preload: '/vp/dist/script-preload.cjs', register: '/vp/dist/script-register.js' }; + const loader = ['--require', own.preload, '--import', pathToFileURL(own.register).href]; + + it('accepts the loader’s own entries and other options', () => { + expect(unsupportedReason({}, [...loader, '--conditions', 'dev'], own)).toBeUndefined(); + }); + + it.each([ + [{}, ['--require', './setup.ts'], '--require loads other modules or hooks'], + [{}, ['--import=tsx'], '--import loads other modules or hooks'], + [{ NODE_OPTIONS: '--import ./x.mjs' }, [], 'NODE_OPTIONS loads other modules or hooks'], + [{ NODE_V8_COVERAGE: '/tmp/coverage' }, [], 'NODE_V8_COVERAGE is set'], + [{}, ['--test'], '--test is not supported'], + [ + {}, + ['--experimental-config-file=node.config.json'], + '--experimental-config-file is not supported', + ], + ])('rejects %j %j', (env, execArgv, reason) => { + expect(unsupportedReason(env, [...loader, ...execArgv], own)).toBe(reason); + }); +}); + +describe('cache files', () => { + it('finds the nearest node_modules above the script', () => { + const dir = project({ 'node_modules/.keep': '', 'scripts/deep/a.ts': '' }); + expect(findCacheRoot(path.join(dir, 'scripts/deep/a.ts'))).toBe( + path.join(dir, 'node_modules/.vite-plus/vpx-bundle'), + ); + expect(findCacheRoot('/no-project-here/a.ts')).toBeUndefined(); + }); + + it('detects an edit that restores the size and the modification time', () => { + const dir = project({ 'a.ts': 'one' }); + const file = path.join(dir, 'a.ts'); + const before = stamp(file); + const time = new Date('2026-01-01T00:00:00Z'); + utimesSync(file, time, time); + const restored = stamp(file); + writeFileSync(file, 'two'); + utimesSync(file, time, time); + expect(stamp(file)).not.toBe(restored); + expect(changedInput([[file, before]])).toBe(file); + expect(changedInput([[path.join(dir, 'missing.ts'), '']])).toBeUndefined(); + }); + + it('reports a hit, a disabled entry, and a miss', () => { + const dir = project({ 'a.ts': '' }); + const file = path.join(dir, 'a.ts'); + const context: BundleCacheContext = { + entryPath: file, + entryURL: pathToFileURL(file).href, + cwd: dir, + key: bundleKey(file, dir, {}, []), + root: dir, + dir: path.join(dir, 'cache'), + }; + const write = (manifest: object) => + writeJsonAtomic(path.join(context.dir, MANIFEST), { + version: BUNDLE_CACHE_VERSION, + key: context.key, + inputs: [[file, stamp(file)]], + ...manifest, + }); + expect(checkManifest(context)).toEqual({ kind: 'miss', reason: 'no bundle' }); + write({ out: 'out-1', entry: 'entry.mjs', modules: [] }); + expect(checkManifest(context).kind).toBe('hit'); + write({ disabled: 'the entry is not an ES module' }); + expect(checkManifest(context)).toEqual({ + kind: 'disabled', + reason: 'the entry is not an ES module', + }); + write({ key: 'another', out: 'out-1', entry: 'entry.mjs' }); + expect(checkManifest(context)).toEqual({ kind: 'miss', reason: 'another configuration' }); + write({ out: 'out-1', entry: 'entry.mjs' }); + writeFileSync(file, 'edited'); + expect(checkManifest(context)).toEqual({ kind: 'miss', reason: `${file} changed` }); + }); +}); + +describe('dependency fast paths', () => { + it('treats JavaScript in node_modules as Node.js resolves and loads it', () => { + expect(isPlainDependency('file:///p/node_modules/dep/index.js')).toBe(true); + expect(isPlainDependency('file:///p/node_modules/dep/index.ts')).toBe(false); + expect(isPlainDependency('file:///p/node_modules/dep/data.json')).toBe(false); + expect(isPlainDependency('file:///p/src/index.js')).toBe(false); + }); + + it('takes a relative import in a dependency only when the named file exists', () => { + const dir = project({ + 'node_modules/dep/index.js': '', + 'node_modules/dep/lib.js': '', + 'node_modules/dep/types.ts': '', + }); + const parent = pathToFileURL(path.join(dir, 'node_modules/dep/index.js')).href; + expect(isPlainDependencyImport('./lib.js', parent)).toBe(true); + expect(isPlainDependencyImport('./lib', parent)).toBe(false); + expect(isPlainDependencyImport('./missing.js', parent)).toBe(false); + expect(isPlainDependencyImport('./types.ts', parent)).toBe(false); + expect(isPlainDependencyImport('dep2', parent)).toBe(false); + }); +}); diff --git a/packages/cli/src/script-bundle-build.ts b/packages/cli/src/script-bundle-build.ts new file mode 100644 index 0000000000..abdf55f8e5 --- /dev/null +++ b/packages/cli/src/script-bundle-build.ts @@ -0,0 +1,638 @@ +/** + * Builds the `vpx` bundle cache for one script (see `script-bundle.ts`). The preload + * starts it at the end of a recorded run as `node dist/script-bundle-build.js `, in + * the script's working directory and environment, so the native loader selects the same + * tsconfig and transforms each module the same way as the run did. + * + * 1. Starting at the entry, load every ES module with the loader's own transform, parse + * it, and resolve its imports: from the trace when the run made the import, and with + * the loader's resolver otherwise (an `import()` the run did not reach). + * 2. Pick the modules the bundle may contain (`bundledSet`). + * 3. Bundle them with Rolldown. Each module keeps its own `import.meta.url`, `filename`, + * and `dirname`; every other import stays external, at its resolved `file:` URL. + * 4. Write the chunks and a manifest of every input, stamped before it was read. + */ + +import { readdirSync, readFileSync, renameSync, rmSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { ignoreInput, ignoreOutput } from '@voidzero-dev/vite-task-client'; +import { RolldownMagicString, build } from 'vite/rolldown'; +import { parseAst } from 'vite/rolldown/parseAst'; + +import type { LoadFnOutput, ResolveFnOutput } from '../binding/index.js'; +import { + analyzeModule, + bundledSet, + type GraphModule, + type MetaEdit, + metaReplacement, + staticallyReachable, + type ResolveEdit, +} from './script-bundle-plan.ts'; +import { + type BundleManifest, + type BundleTrace, + BUNDLE_CACHE_VERSION, + debug, + EXCLUDED, + hash, + isBuiltinSpecifier, + MANIFEST, + readJson, + releaseBuildLock, + RESOLVE_FROM_PREFIX, + stamp, + TRACE, + writeJsonAtomic, +} from './script-bundle.ts'; +import { + HELPER_MODULE_NAME, + loadOxcNode, + runtimeHelperSpecifier, + threadScriptConfig, +} from './script-hooks.ts'; + +interface Target { + url: string; + format: string | null; +} + +interface ModuleRecord extends GraphModule { + code: string; + map: unknown; + json: boolean; + metaEdits: MetaEdit[]; + resolveEdits: ResolveEdit[]; + /** Specifier → resolved module, for the specifiers in the code. */ + targets: Map; + /** The resolved URLs of the static imports and re-exports. */ + staticImports: string[]; + topLevelAwait: boolean; + /** The module file and its source map, stamped before they were read. */ + stamps: [string, string][]; +} + +const dir = process.argv[2]; +const started = performance.now(); +const inputs = new Map(); + +/** Record `file` as an input, stamped the first time it is seen. */ +function addInput(file: string, fileStamp?: string): void { + if (!inputs.has(file)) { + inputs.set(file, fileStamp ?? stamp(file)); + } +} + +function writeManifest(manifest: Omit): void { + writeJsonAtomic(path.join(dir, MANIFEST), { + version: BUNDLE_CACHE_VERSION, + ...manifest, + inputs: [...inputs], + } satisfies BundleManifest); +} + +const require = createRequire(import.meta.url); + +async function buildBundle(trace: BundleTrace): Promise { + const oxcNode = loadOxcNode(); + // Report the tsconfig files the transforms read, which are inputs too. + oxcNode.enableWatchReport(); + const tsconfig = threadScriptConfig(); + oxcNode.initializeLoader(tsconfig.path, tsconfig.source); + oxcNode.setTransformDeps('typescript'); + + const observed = new Map(); + const importFormat = new Map(); + const requireFormat = new Map(); + const loadedFormat = new Map(); + const conditionsOf = new Map(); + const runEdges: [parent: string, specifier: string, kind: string, url: string][] = []; + for (const [parent, specifier, kind, conditions, url, format] of trace.resolves) { + // `load` gets the format an `import` resolved to; a `require()` answer is the fallback. + const formats = kind === 'import' ? importFormat : requireFormat; + if (!formats.has(url)) { + formats.set(url, format); + } + if (parent === null) { + continue; + } + runEdges.push([parent, specifier, kind, url]); + if (kind === 'import') { + observed.set(`${parent}\n${specifier}`, { url, format }); + if (!conditionsOf.has(parent)) { + conditionsOf.set(parent, trace.conditions[conditions]); + } + } + } + for (const [url, format] of trace.loads) { + loadedFormat.set(url, format); + } + const entryConditions = + trace.resolves.find(([parent, , kind]) => parent === null && kind === 'import')?.[3] ?? 0; + const defaultConditions = trace.conditions[entryConditions] ?? ['node', 'import']; + // oxc-node sets up its resolver, which `load` also needs, on the first resolve; a run + // resolves the entry first too. + void oxcNode.createResolve( + trace.entry, + { conditions: defaultConditions, importAttributes: {} }, + (url) => ({ url }), + ); + + /** An import the run did not make, resolved as the loader would resolve it. */ + const resolveUnobserved = (parentURL: string, specifier: string): Target | undefined => { + if (isBuiltinSpecifier(specifier)) { + return { url: specifier, format: 'builtin' }; + } + const helper = runtimeHelperSpecifier(specifier); + if (helper !== undefined) { + // The loader resolves runtime helpers from this package, as `import` does here. + return { url: import.meta.resolve(helper), format: null }; + } + let result; + try { + result = oxcNode.createResolve( + specifier, + { + parentURL, + conditions: conditionsOf.get(parentURL) ?? defaultConditions, + importAttributes: {}, + }, + (url) => ({ url }), + ) as ResolveFnOutput; + } catch { + return undefined; + } + // A specifier oxc-node leaves to Node.js comes back unresolved. + if (!result.url.startsWith('file:')) { + return undefined; + } + const format = typeof result.format === 'string' ? result.format : null; + if (!importFormat.has(result.url)) { + importFormat.set(result.url, format); + } + return { url: result.url, format }; + }; + + const decoder = new TextDecoder(); + /** The module as the loader runs it, or `undefined` when it is not an ES module. */ + const loadModule = ( + url: string, + ): { code: string; format: string; map: unknown; stamps: [string, string][] } | undefined => { + if (!url.startsWith('file:') || url.includes('?') || url.includes('#')) { + return undefined; + } + const file = fileURLToPath(url); + const stamps: [string, string][] = [[file, stamp(file)]]; + if (url.includes('/node_modules/') && !/\.(?:[cm]?ts|tsx|json)$/.test(url)) { + // Dependencies run as published; only their format needs deciding. + const format = loadedFormat.get(url) ?? dependencyFormat(file); + if (format !== 'module') { + return undefined; + } + const code = readFileSync(file, 'utf8').replace(/^/, ''); + return { code, format, map: dependencyMap(code, file, stamps), stamps }; + } + const format = importFormat.get(url) ?? requireFormat.get(url); + if (typeof format !== 'string') { + // Without a format, the loader would hand the file to Node.js untransformed. + return undefined; + } + const result = oxcNode.load( + url, + { format, conditions: conditionsOf.get(url) ?? defaultConditions, importAttributes: {} }, + (loadURL, loadContext) => ({ + format: typeof loadContext?.format === 'string' ? loadContext.format : format, + source: readFileSync(fileURLToPath(loadURL)), + }), + HELPER_MODULE_NAME, + ) as LoadFnOutput; + const source = result.source; + if ((result.format !== 'module' && result.format !== 'json') || source == null) { + return undefined; + } + const text = typeof source === 'string' ? source : decoder.decode(source); + return { ...splitInlineMap(text), format: result.format, stamps }; + }; + + const excluded = new Set((readJson(path.join(dir, EXCLUDED)) as string[] | undefined) ?? []); + const modules = new Map(); + const visited = new Set(); + const queue = [trace.entry]; + while (queue.length > 0) { + const url = queue.shift()!; + if (visited.has(url)) { + continue; + } + visited.add(url); + const loaded = loadModule(url); + if (loaded === undefined) { + continue; + } + const json = loaded.format === 'json'; + const record: ModuleRecord = { + code: loaded.code, + map: loaded.map, + json, + metaEdits: [], + resolveEdits: [], + imports: [], + targets: new Map(), + staticImports: [], + topLevelAwait: false, + stamps: loaded.stamps, + }; + modules.set(url, record); + if (json) { + continue; + } + let analysis; + try { + analysis = analyzeModule( + parseAst(loaded.code, { lang: 'js', sourceType: 'module' }) as never, + ); + } catch (error) { + record.unsafe = `it does not parse: ${(error as Error).message.split('\n', 1)[0]}`; + continue; + } + record.unsafe = analysis.unsafe; + record.metaEdits = analysis.metaEdits; + record.resolveEdits = analysis.resolveEdits; + record.topLevelAwait = analysis.topLevelAwait; + for (const specifier of analysis.specifiers) { + const target = observed.get(`${url}\n${specifier}`) ?? resolveUnobserved(url, specifier); + if (target === undefined) { + record.unsafe ??= `it imports ${specifier}, which does not resolve`; + continue; + } + record.targets.set(specifier, target); + if (record.unsafe === undefined && target.url.startsWith('file:')) { + queue.push(target.url); + } + } + record.imports = Array.from(record.targets.values(), (target) => target.url); + // A specifier is a static import when the code names it more often than in `import()`. + const dynamicCount = new Map(); + for (const specifier of analysis.dynamicSpecifiers) { + dynamicCount.set(specifier, (dynamicCount.get(specifier) ?? 0) + 1); + } + for (const specifier of analysis.specifiers) { + const count = dynamicCount.get(specifier) ?? 0; + const target = record.targets.get(specifier); + if (count > 0) { + dynamicCount.set(specifier, count - 1); + } else if (target !== undefined) { + record.staticImports.push(target.url); + } + } + } + + // The run's own edges. A `require()`, and an import whose specifier is not in the code + // (a computed `import()`), resolve outside the bundle's control at run time, so they + // count as edges from outside (`''`) and keep their targets out of the bundle. + const edges: [string, string][] = runEdges.map(([parent, specifier, kind, url]) => + kind === 'import' && modules.get(parent)?.targets.has(specifier) !== false + ? [parent, url] + : ['', url], + ); + const bundled = bundledSet(trace.entry, modules, edges, excluded); + for (const [url, record] of modules) { + if (record.unsafe !== undefined) { + debug(`left out ${fileURLToPath(url)}: ${record.unsafe}`); + } + } + if (bundled.size === 0) { + throw new Error(modules.get(trace.entry)?.unsafe ?? 'the entry is not an ES module'); + } + + // What each bundled module is (stamped before it was read), then what resolving its + // imports depended on. + for (const url of bundled) { + for (const [file, fileStamp] of modules.get(url)!.stamps) { + addInput(file, fileStamp); + } + } + for (const url of bundled) { + const record = modules.get(url)!; + const file = fileURLToPath(url); + addPackageScope(file); + for (const [specifier, target] of record.targets) { + if (!target.url.startsWith('file:')) { + continue; + } + const targetFile = fileURLToPath(target.url); + const dependency = target.url.includes('/node_modules/'); + addInput(targetFile); + addPackageScope(targetFile); + if (specifier.startsWith('.') || specifier.startsWith('/')) { + // A new file beside the target can take its place (`x.ts` for `./x.js`). An + // installed package changes only through an install, which the package's own + // files and the `node_modules` lookups below record. + if (!dependency) { + addInput(path.dirname(targetFile)); + } + } else if (dependency) { + addPackageLookup(path.dirname(file), specifier); + } + } + } + for (const file of oxcNode.takeWatchDependencies()) { + addInput(file); + } + + const out = `out-${hash(`${process.pid}:${Date.now()}`)}`; + const temp = path.join(dir, `${out}.tmp`); + const entryFile = fileURLToPath(trace.entry); + // The manifest lists the bundled modules in this order; rewritten specifiers refer to it. + const order = [...bundled]; + const moduleIndex = new Map(order.map((url, index) => [url, index])); + const startup = staticallyReachable( + trace.entry, + new Map(Array.from(modules, ([url, record]) => [url, record.staticImports])), + bundled, + ); + // The startup chunk holds the entry's static dependencies, which finish before the + // entry runs, so an `import()` the entry awaits can import from it. A dependency that + // awaits at its top level would hold that chunk open while its own `import()` waits for + // it; one file with a wrapper per lazy module avoids that. + const startupFiles = new Set( + Array.from(startup, (url) => fileURLToPath(url)).filter((file) => file !== entryFile), + ); + const split = + startup.size < bundled.size && + [...startup].every((url) => url === trace.entry || !modules.get(url)!.topLevelAwait); + await build({ + input: { entry: entryFile }, + cwd: trace.cwd, + platform: 'node', + logLevel: 'silent', + // Keep every module body and every call Node.js would run. + treeshake: { moduleSideEffects: true, annotations: false, unknownGlobalSideEffects: true }, + plugins: [ + { + name: 'vpx-bundle-cache', + resolveId(specifier, importer) { + if (importer === undefined) { + return specifier === entryFile ? { id: entryFile, moduleSideEffects: true } : null; + } + if (specifier.startsWith(RESOLVE_FROM_PREFIX)) { + // A rewritten specifier that Rolldown folded to a constant: still the run's. + return { id: specifier, external: true }; + } + const record = modules.get(pathToFileURL(importer).href); + if (record === undefined) { + // Rolldown's own runtime module. + return null; + } + const target = record.targets.get(specifier); + if (target === undefined) { + throw new Error(`${importer} imports ${specifier}, which the plan does not cover`); + } + if (bundled.has(target.url)) { + return { id: fileURLToPath(target.url), moduleSideEffects: true }; + } + return { id: target.url, external: true }; + }, + load(id) { + const record = modules.get(pathToFileURL(id).href); + if (record === undefined) { + return null; + } + return { + code: record.code, + map: record.map as never, + moduleType: record.json ? 'json' : 'js', + }; + }, + transform(code, id) { + const url = pathToFileURL(id).href; + const record = modules.get(url); + if ( + record === undefined || + (record.metaEdits.length === 0 && record.resolveEdits.length === 0) + ) { + return null; + } + if (code !== record.code) { + throw new Error(`${id} changed between load and transform`); + } + const magic = new RolldownMagicString(code); + for (const edit of record.metaEdits) { + const replacement = metaReplacement( + edit.property, + url, + id, + path.dirname(id), + url === trace.entry, + ); + if (replacement !== undefined) { + magic.overwrite(edit.start, edit.end, replacement); + } + } + const prefix = JSON.stringify(`${RESOLVE_FROM_PREFIX}${moduleIndex.get(url)}:`); + for (const edit of record.resolveEdits) { + magic.prependLeft(edit.start, `${prefix} + (`); + magic.appendRight(edit.end, ')'); + } + // A map for every character, so that composing it with the loader's map keeps + // stack trace columns. + const map = magic.generateMap({ hires: true, source: id, includeContent: false }); + return { code: magic.toString(), map: map.toString() }; + }, + }, + ], + output: { + dir: temp, + format: 'esm', + entryFileNames: 'entry.mjs', + chunkFileNames: 'chunk-[hash].mjs', + // What runs at startup stays in source order without a wrapper per module; a module + // only `import()` reaches goes to a chunk that loads when the `import()` runs. + codeSplitting: split + ? { groups: [{ name: 'startup', test: (id) => startupFiles.has(id) }] } + : false, + sourcemap: true, + sourcemapExcludeSources: true, + keepNames: true, + }, + }); + renameSync(temp, path.join(dir, out)); + writeManifest({ key: trace.key, out, entry: 'entry.mjs', modules: order }); + for (const name of readdirSync(dir)) { + if (name.startsWith('out-') && name !== out) { + rmSync(path.join(dir, name), { recursive: true, force: true }); + } + } + debug( + `bundled ${bundled.size} of ${modules.size} ES modules in ${Math.round(performance.now() - started)} ms`, + ); +} + +/** The format Node.js gives a dependency file it runs as published. */ +function dependencyFormat(file: string): string { + if (file.endsWith('.mjs')) { + return 'module'; + } + if (!file.endsWith('.js')) { + return 'commonjs'; + } + for (let current = path.dirname(file); ;) { + const manifest = readJson(path.join(current, 'package.json')) as { type?: string } | undefined; + if (manifest !== undefined) { + // A package without `"type"` may still hold ES modules that Node.js detects by + // syntax; leaving them to Node.js keeps the answer exact. + return manifest.type === 'module' ? 'module' : 'commonjs'; + } + const parent = path.dirname(current); + if (parent === current) { + return 'commonjs'; + } + current = parent; + } +} + +const INLINE_MAP = '\n//# sourceMappingURL=data:application/json;'; + +/** Split oxc-node's inline source map from the code it transformed. */ +function splitInlineMap(source: string): { code: string; map: unknown } { + const index = source.lastIndexOf(INLINE_MAP); + if (index < 0) { + return { code: source, map: null }; + } + const data = source.slice(index + INLINE_MAP.length); + const base64 = data.slice(data.indexOf('base64,') + 'base64,'.length).trim(); + try { + const map = JSON.parse(Buffer.from(base64, 'base64').toString('utf8')) as { + sources?: string[]; + }; + // oxc-node names each source by its `file:` URL, which Rolldown would take for a + // relative path. + map.sources = map.sources?.map((name) => + name.startsWith('file:') ? fileURLToPath(name) : name, + ); + return { code: source.slice(0, index), map }; + } catch { + return { code: source.slice(0, index), map: null }; + } +} + +/** A dependency's own source map, so stack traces still reach its sources. */ +function dependencyMap(code: string, file: string, stamps: [string, string][]): unknown { + const match = /\/\/# sourceMappingURL=(\S+)\s*$/.exec(code); + if (match === null) { + return null; + } + try { + const url = match[1]; + if (url.startsWith('data:')) { + return JSON.parse( + Buffer.from(url.slice(url.indexOf('base64,') + 7), 'base64').toString('utf8'), + ); + } + const mapFile = path.resolve(path.dirname(file), decodeURIComponent(url)); + stamps.push([mapFile, stamp(mapFile)]); + return JSON.parse(readFileSync(mapFile, 'utf8')); + } catch { + return null; + } +} + +/** + * The nearest `package.json` above `file`, which decides its format and its package's + * `exports` and `imports`, and the project directories in between, where a new + * `package.json` would take its place. + */ +const scopedDirectories = new Set(); +function addPackageScope(file: string): void { + const between: string[] = []; + // A directory seen before has its scope recorded already. + for (let current = path.dirname(file); !scopedDirectories.has(current);) { + scopedDirectories.add(current); + const manifest = path.join(current, 'package.json'); + if (stamp(manifest) !== '') { + addInput(manifest); + break; + } + between.push(current); + const parent = path.dirname(current); + if (parent === current) { + break; + } + current = parent; + } + for (const directory of between) { + if (!directory.includes(`${path.sep}node_modules${path.sep}`)) { + addInput(directory); + } + } +} + +/** + * The `node_modules` lookup for a bare specifier from `directory`: the package directory + * it found, which a reinstall replaces, and each closer location that must stay empty. + */ +function addPackageLookup(directory: string, specifier: string): void { + const parts = specifier.split('/'); + const name = specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]; + for (let current = directory; ;) { + if (path.basename(current) !== 'node_modules') { + const candidate = path.join(current, 'node_modules', name); + addInput(candidate); + if (inputs.get(candidate) !== '') { + return; + } + } + const parent = path.dirname(current); + if (parent === current) { + return; + } + current = parent; + } +} + +// Under `vp run`, the cache is the loader's own state, as for the run that started this. +ignoreInput(path.dirname(dir)); +ignoreOutput(path.dirname(dir)); + +// The loader's own files: a reinstall or an upgrade changes them. +for (const file of [ + fileURLToPath(new URL('../package.json', import.meta.url)), + fileURLToPath(new URL('./script-preload.cjs', import.meta.url)), + fileURLToPath(new URL('./script-bundle-hooks.cjs', import.meta.url)), + fileURLToPath(import.meta.url), + require.resolve('vite/package.json'), +]) { + addInput(file); +} + +const trace = readJson(path.join(dir, TRACE)) as BundleTrace | undefined; +try { + if (trace === undefined || trace.version !== BUNDLE_CACHE_VERSION) { + throw new Error('no trace to build from'); + } + await buildBundle(trace); +} catch (error) { + const reason = `the bundle could not be built: ${describeError(error)}`; + debug(reason); + if (trace !== undefined) { + addInput(fileURLToPath(trace.entry)); + writeManifest({ key: trace.key, disabled: reason }); + } +} finally { + releaseBuildLock(dir); +} + +/** The first line that says what failed; Rolldown starts with "Build failed with …". */ +function describeError(error: unknown): string { + const first = (error as { errors?: { message?: string }[] }).errors?.[0]?.message; + const message = first ?? (error instanceof Error ? error.message : String(error)); + const line = message + // oxlint-disable-next-line no-control-regex -- ANSI color codes + .replace(/\u001b\[[0-9;]*m/g, '') + .split('\n') + .map((text) => text.trim()) + .find((text) => text !== '' && !/^Build failed with \d+ errors?:$/.test(text)); + return line ?? 'unknown error'; +} diff --git a/packages/cli/src/script-bundle-hooks.ts b/packages/cli/src/script-bundle-hooks.ts new file mode 100644 index 0000000000..c22841b54d --- /dev/null +++ b/packages/cli/src/script-bundle-hooks.ts @@ -0,0 +1,296 @@ +/** + * The run side of the `vpx` bundle cache (see `script-bundle.ts`): the hooks that run a + * cached bundle, and the recorder that feeds `script-bundle-build.ts`. Used by the + * preload, which registers what `hooks()` returns around its own hooks. + */ + +import { spawn, spawnSync } from 'node:child_process'; +import Module, { type LoadFnOutput, type LoadHookSync, type ResolveHookSync } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { isMainThread } from 'node:worker_threads'; + +import { ignoreInput, ignoreOutput } from '@voidzero-dev/vite-task-client'; + +import { + acquireBuildLock, + type BundleCacheContext, + bundleCacheContext, + type BundleManifest, + type BundleTrace, + BUNDLE_CACHE_VERSION, + checkManifest, + debug, + debugEnabled, + excludeModule, + isBuiltinSpecifier, + isPlainDependency, + isPlainDependencyImport, + parseResolveFrom, + releaseBuildLock, + TRACE, + unsupportedReason, + writeJsonAtomic, +} from './script-bundle.ts'; +import { canRegisterSyncHooks, isCommonJsRequire } from './script-hooks.ts'; + +export interface LoaderHooks { + resolve: ResolveHookSync; + load: LoadHookSync; +} + +export interface BundleCacheRun { + /** The run uses a cached bundle, so the native binding can load when first needed. */ + warm: boolean; + /** The hooks to register in place of the loader's own `full` hooks. */ + hooks(full: LoaderHooks): LoaderHooks; + /** Under `VPX_BUNDLE_CACHE_DEBUG=1`, report at exit whether the binding loaded. */ + reportBinding(loaded: () => boolean): void; +} + +/** + * Start the bundle cache for this process, or return `undefined` when it does not apply: + * without `VPX_BUNDLE_CACHE=1`, off the main thread, on a Node.js release without + * in-thread hooks, under an option the cache does not support, or for a script outside + * a project. `dist` is the directory of the preload and the other loader entries. + */ +export function startBundleCache(dist: string): BundleCacheRun | undefined { + const env = process.env; + if (env.VPX_BUNDLE_CACHE !== '1' || !isMainThread) { + return undefined; + } + if (!canRegisterSyncHooks(process.versions.node)) { + debug(`off (Node.js ${process.version} has no in-thread module hooks)`); + return undefined; + } + const unsupported = unsupportedReason(env, process.execArgv, { + preload: path.join(dist, 'script-preload.cjs'), + register: path.join(dist, 'script-register.js'), + }); + if (unsupported !== undefined) { + debug(`off (${unsupported})`); + return undefined; + } + const context = bundleCacheContext(process.argv[1], process.cwd(), env, process.execArgv); + if (context === undefined) { + debug('off (no node_modules directory above the script)'); + return undefined; + } + // Under `vp run`, the cache is this loader's own state, not a task input or output. The + // checks below still stat every source file, which keeps those files task inputs. + ignoreInput(context.root); + ignoreOutput(context.root); + const state = checkManifest(context); + if (state.kind === 'hit') { + debug(`hit (${state.manifest.modules?.length ?? 0} modules)`); + return warmRun(context, state.manifest, dist); + } + if (state.kind === 'disabled') { + debug(`off (${state.reason})`); + return undefined; + } + if (!acquireBuildLock(context.dir)) { + debug(`miss (${state.reason}); another run is building the bundle`); + return undefined; + } + debug(`miss (${state.reason}); recording`); + return recordRun(context, dist); +} + +/** Node.js' own compile cache for the bundle, unless the user configured one. */ +function enableCompileCache(root: string): void { + const env = process.env; + if (env.NODE_COMPILE_CACHE !== undefined || env.NODE_DISABLE_COMPILE_CACHE !== undefined) { + return; + } + Module.enableCompileCache?.(path.join(root, 'v8')); +} + +/** + * Run the bundle: the main entry resolves to the entry chunk, and the chunks and the + * loader's own files go straight to Node.js. Everything else goes to the loader's hooks, + * which load the native binding on first use. The bundle never loads a module from its + * own file, so a load of a bundled module's URL means a second instance of it, for + * example from a computed `import()` the recorded run did not make. The run reports it + * once and drops the bundle, and the next build leaves the module out. + */ +function warmRun( + context: BundleCacheContext, + manifest: BundleManifest, + dist: string, +): BundleCacheRun { + const outURL = `${pathToFileURL(path.join(context.dir, manifest.out!)).href}/`; + const entryChunkURL = outURL + manifest.entry!; + const distURL = `${pathToFileURL(dist).href}/`; + const modules = manifest.modules ?? []; + const bundled = new Set(modules); + enableCompileCache(context.root); + + let reported = false; + const checkDuplicate = (url: string) => { + if (reported || !bundled.has(url)) { + return; + } + reported = true; + excludeModule(context.dir, url); + process.stderr.write( + `vpx: warning: ${fileURLToPath(url)} is in the bundle cache but also loaded from its own file, so it has two instances in this run; the next run rebuilds the cache without it\n`, + ); + }; + + return { + warm: true, + reportBinding(loaded) { + if (debugEnabled()) { + process.once('exit', () => { + debug(`the native loader ${loaded() ? 'loaded' : 'did not load'}`); + }); + } + }, + hooks(full) { + const resolve: ResolveHookSync = (specifier, hookContext, nextResolve) => { + const parentURL = hookContext.parentURL; + if (!isCommonJsRequire(hookContext)) { + if (parentURL === undefined && specifier === context.entryURL) { + return { url: entryChunkURL, format: 'module', shortCircuit: true }; + } + if (isBuiltinSpecifier(specifier)) { + // The loader short-circuits built-ins to Node.js too. + return nextResolve(specifier, hookContext); + } + if (parentURL?.startsWith(outURL)) { + const from = parseResolveFrom(specifier); + if (from !== undefined && modules[from[0]] !== undefined) { + // A computed `import()` or `import.meta.resolve()` in a bundled module: + // resolved from that module's own URL, as without the bundle. + return full.resolve( + from[1], + { ...hookContext, parentURL: modules[from[0]] }, + nextResolve, + ); + } + // Other chunks, and dependencies resolved at build time to the `file:` URL that + // Node.js resolves the same way. + if (specifier.startsWith('./') || isPlainDependency(specifier)) { + return nextResolve(specifier, hookContext); + } + } else if (parentURL?.startsWith(distURL) || isOwnRegister(specifier, distURL)) { + return nextResolve(specifier, hookContext); + } else if (parentURL !== undefined && isPlainDependencyImport(specifier, parentURL)) { + // A dependency outside the bundle importing its own files. + return nextResolve(specifier, hookContext); + } + } + return full.resolve(specifier, hookContext, nextResolve); + }; + const load: LoadHookSync = (url, hookContext, nextLoad) => { + if (url.startsWith('node:') || url.startsWith(outURL) || url.startsWith(distURL)) { + return nextLoad(url, hookContext); + } + checkDuplicate(url); + if (!isCommonJsRequire(hookContext) && isPlainDependency(url)) { + // What the loader does for these: Node.js' answer, and no source for CommonJS, + // which the CommonJS loader reads itself (nodejs/node#62920). + const result = nextLoad(url, hookContext); + return result.format === 'commonjs' + ? ({ format: 'commonjs', source: null, responseURL: url } as unknown as LoadFnOutput) + : result; + } + return full.load(url, hookContext, nextLoad); + }; + return { resolve, load }; + }, + }; +} + +/** The `--import` entry, whichever spelling of its URL the CLI passed. */ +function isOwnRegister(specifier: string, distURL: string): boolean { + if (!specifier.startsWith('file:') || !specifier.endsWith('/script-register.js')) { + return false; + } + try { + return pathToFileURL(path.dirname(fileURLToPath(specifier))).href + '/' === distURL; + } catch { + return false; + } +} + +/** + * Run the script through the loader's hooks and record how every import resolved and + * every module loaded. At exit, write the trace and start the builder: detached, so the + * script's exit does not wait for it, or in this process under `VPX_BUNDLE_CACHE_DEBUG=1`. + */ +function recordRun(context: BundleCacheContext, dist: string): BundleCacheRun { + const env = { ...process.env }; + const conditions: string[][] = []; + const conditionIndex = new Map(); + const resolves: BundleTrace['resolves'] = []; + const loads: BundleTrace['loads'] = []; + + process.once('exit', () => { + try { + const trace: BundleTrace = { + version: BUNDLE_CACHE_VERSION, + key: context.key, + entry: context.entryURL, + cwd: context.cwd, + conditions, + resolves, + loads, + }; + writeJsonAtomic(path.join(context.dir, TRACE), trace); + const args = [path.join(dist, 'script-bundle-build.js'), context.dir]; + if (debugEnabled(env)) { + spawnSync(process.execPath, args, { + cwd: context.cwd, + env, + stdio: ['ignore', 'ignore', 'inherit'], + }); + } else { + spawn(process.execPath, args, { + cwd: context.cwd, + env, + stdio: 'ignore', + detached: true, + windowsHide: true, + }).unref(); + } + } catch (error) { + releaseBuildLock(context.dir); + debug(`could not start the build: ${(error as Error).message}`, env); + } + }); + + return { + warm: false, + reportBinding() {}, + hooks(full) { + return { + resolve(specifier, hookContext, nextResolve) { + const result = full.resolve(specifier, hookContext, nextResolve); + const list = hookContext.conditions ?? []; + const key = list.join('\0'); + let index = conditionIndex.get(key); + if (index === undefined) { + index = conditions.push([...list]) - 1; + conditionIndex.set(key, index); + } + resolves.push([ + hookContext.parentURL ?? null, + specifier, + isCommonJsRequire(hookContext) ? 'require' : 'import', + index, + result.url, + result.format ?? null, + ]); + return result; + }, + load(url, hookContext, nextLoad) { + const result = full.load(url, hookContext, nextLoad); + loads.push([url, result.format ?? null]); + return result; + }, + }; + }, + }; +} diff --git a/packages/cli/src/script-bundle-plan.ts b/packages/cli/src/script-bundle-plan.ts new file mode 100644 index 0000000000..219e4af600 --- /dev/null +++ b/packages/cli/src/script-bundle-plan.ts @@ -0,0 +1,318 @@ +/** + * Pure parts of the `vpx` bundle cache builder (`script-bundle-build.ts`): what one + * module needs from the bundler, and which modules the bundle may contain. + */ + +/** The ESTree subset this analysis reads; Rolldown's `parseAst` produces it. */ +interface AstNode { + type: string; + start: number; + end: number; + [key: string]: unknown; +} + +/** An `import.meta.` read to replace with this module's own value. */ +export interface MetaEdit { + start: number; + end: number; + property: 'url' | 'filename' | 'dirname' | 'main'; +} + +/** + * A specifier that only the run knows: a computed `import()`, or the argument of + * `import.meta.resolve()`. The bundle prefixes it with `RESOLVE_FROM_PREFIX`, so the run + * resolves it from this module's own URL instead of the bundle's. + */ +export interface ResolveEdit { + start: number; + end: number; +} + +export interface ModuleAnalysis { + /** Every specifier the module imports: static imports, re-exports, literal `import()`. */ + specifiers: string[]; + /** The specifiers of literal `import()` calls, which a static import may also name. */ + dynamicSpecifiers: string[]; + metaEdits: MetaEdit[]; + resolveEdits: ResolveEdit[]; + /** Whether the module awaits at its top level, which a shared chunk cannot do. */ + topLevelAwait: boolean; + /** Why the module cannot be bundled, or `undefined`. */ + unsafe?: string; +} + +const META_PROPERTIES = new Set(['url', 'filename', 'dirname', 'main']); + +function isNode(value: unknown): value is AstNode { + return typeof value === 'object' && value !== null && typeof (value as AstNode).type === 'string'; +} + +/** The value of a string literal or a template literal without substitutions. */ +function staticString(node: unknown): string | undefined { + if (!isNode(node)) { + return undefined; + } + if (node.type === 'Literal' && typeof node.value === 'string') { + return node.value; + } + if (node.type === 'TemplateLiteral' && (node.expressions as unknown[]).length === 0) { + const [quasi] = node.quasis as { value: { cooked: string | null } }[]; + return quasi?.value.cooked ?? undefined; + } + return undefined; +} + +/** Whether `node` is `import.meta`. */ +function isImportMeta(node: unknown): node is AstNode { + return ( + isNode(node) && + node.type === 'MetaProperty' && + (node.meta as { name: string }).name === 'import' + ); +} + +/** + * Read what bundling one ES module depends on. A module is unsafe to bundle when its + * behavior depends on its own location in a way the bundle cannot keep: a use of + * `import.meta` other than reading `url`, `filename`, `dirname`, or `main`, or calling + * `resolve()`; and a direct `eval`, which sees the renamed bindings of a scope-hoisted + * bundle. + */ +export function analyzeModule(program: AstNode): ModuleAnalysis { + const specifiers: string[] = []; + const dynamicSpecifiers: string[] = []; + const metaEdits: MetaEdit[] = []; + const resolveEdits: ResolveEdit[] = []; + /** `import.meta` nodes already handled as the object of `import.meta.resolve()`. */ + const handled = new WeakSet(); + let unsafe: string | undefined; + let topLevelAwait = false; + let functionDepth = 0; + + const visit = (node: AstNode, parent: AstNode | undefined): void => { + switch (node.type) { + case 'ImportDeclaration': + case 'ExportAllDeclaration': + case 'ExportNamedDeclaration': { + const source = staticString(node.source); + if (source !== undefined) { + specifiers.push(source); + } + break; + } + case 'ImportExpression': { + const source = staticString(node.source); + if (source === undefined) { + const expression = node.source as AstNode; + resolveEdits.push({ start: expression.start, end: expression.end }); + } else { + specifiers.push(source); + dynamicSpecifiers.push(source); + } + break; + } + case 'CallExpression': { + const callee = node.callee as AstNode; + const [argument] = node.arguments as AstNode[]; + if (callee.type === 'Identifier' && callee.name === 'eval') { + unsafe ??= 'a direct eval()'; + } else if ( + callee.type === 'MemberExpression' && + isImportMeta(callee.object) && + !callee.computed && + (callee.property as { name?: string }).name === 'resolve' && + argument !== undefined && + argument.type !== 'SpreadElement' + ) { + handled.add(callee.object); + resolveEdits.push({ start: argument.start, end: argument.end }); + } + break; + } + case 'MetaProperty': { + if (!isImportMeta(node) || handled.has(node)) { + break; + } + const property = + parent?.type === 'MemberExpression' && parent.object === node && !parent.computed + ? (parent.property as { name?: string }).name + : undefined; + if (property !== undefined && META_PROPERTIES.has(property)) { + metaEdits.push({ + start: parent!.start, + end: parent!.end, + property: property as MetaEdit['property'], + }); + } else { + unsafe ??= property === undefined ? 'import.meta' : `import.meta.${property}`; + } + break; + } + case 'AwaitExpression': + topLevelAwait ||= functionDepth === 0; + break; + case 'ForOfStatement': + topLevelAwait ||= functionDepth === 0 && node.await === true; + break; + case 'VariableDeclaration': + topLevelAwait ||= functionDepth === 0 && node.kind === 'await using'; + break; + } + const isFunction = FUNCTIONS.has(node.type); + if (isFunction) { + functionDepth++; + } + for (const key in node) { + if (key === 'type' || key === 'start' || key === 'end') { + continue; + } + const value = node[key]; + if (Array.isArray(value)) { + for (const child of value) { + if (isNode(child)) { + visit(child, node); + } + } + } else if (isNode(value)) { + visit(value, node); + } + } + if (isFunction) { + functionDepth--; + } + }; + visit(program, undefined); + return { specifiers, dynamicSpecifiers, metaEdits, resolveEdits, topLevelAwait, unsafe }; +} + +const FUNCTIONS = new Set(['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression']); + +/** The replacement for one `import.meta.` read in the module at `url`. */ +export function metaReplacement( + property: MetaEdit['property'], + url: string, + filename: string, + dirname: string, + isEntry: boolean, +): string | undefined { + if (property === 'main') { + // The entry module is in the entry chunk, which Node.js runs as the main module. Any + // other module is not the main one: `false`, or `undefined` where Node.js has no + // `import.meta.main`. + return isEntry ? undefined : '(import.meta.main && false)'; + } + return JSON.stringify(property === 'url' ? url : property === 'filename' ? filename : dirname); +} + +export interface GraphModule { + /** Why this module cannot be bundled, or `undefined`. */ + unsafe?: string; + /** The resolved URLs of the module's imports. */ + imports: readonly string[]; +} + +/** + * The modules the bundle may contain: the ES modules reachable from `entry` through + * safe modules, less every module that something outside the bundle imports or + * requires. A module outside the bundle loads from its own file at run time, so a + * bundled copy of what it imports would be a second instance. Removing a module puts + * its own imports outside the bundle's control too, so the removal repeats until no + * edge enters the set from outside. + * + * `modules` holds the analyzed ES modules; anything else is outside the bundle. + * `edges` adds `[parent, target]` pairs the analysis cannot see, such as the + * `require()` calls a CommonJS module made in the recorded run. + */ +export function bundledSet( + entry: string, + modules: ReadonlyMap, + edges: Iterable, + excluded: ReadonlySet, +): Set { + const candidate = (url: string) => { + const module = modules.get(url); + return module !== undefined && module.unsafe === undefined && !excluded.has(url); + }; + const set = new Set(); + if (!candidate(entry)) { + return set; + } + const queue = [entry]; + set.add(entry); + while (queue.length > 0) { + for (const target of modules.get(queue.pop()!)!.imports) { + if (!set.has(target) && candidate(target)) { + set.add(target); + queue.push(target); + } + } + } + + const targets = new Map>(); + const addEdge = (parent: string, target: string) => { + let list = targets.get(parent); + if (list === undefined) { + targets.set(parent, (list = new Set())); + } + list.add(target); + }; + for (const [url, module] of modules) { + for (const target of module.imports) { + addEdge(url, target); + } + } + for (const [parent, target] of edges) { + addEdge(parent, target); + } + + // Every target of a parent outside the set leaves it, and so do that target's own + // targets, until the set is closed. + const removed: string[] = []; + for (const [parent, list] of targets) { + if (!set.has(parent)) { + for (const target of list) { + if (set.delete(target)) { + removed.push(target); + } + } + } + } + while (removed.length > 0) { + for (const target of targets.get(removed.pop()!) ?? []) { + if (set.delete(target)) { + removed.push(target); + } + } + } + if (!set.has(entry)) { + set.clear(); + } + return set; +} + +/** + * The bundled modules that `entry` reaches through static imports alone. They all run at + * startup, so the bundle keeps them in one chunk, in their source order; a module that + * only `import()` reaches goes to a chunk of its own and runs when the `import()` does. + */ +export function staticallyReachable( + entry: string, + staticImports: ReadonlyMap, + bundled: ReadonlySet, +): Set { + const reached = new Set(); + if (!bundled.has(entry)) { + return reached; + } + const queue = [entry]; + reached.add(entry); + while (queue.length > 0) { + for (const target of staticImports.get(queue.pop()!) ?? []) { + if (bundled.has(target) && !reached.has(target)) { + reached.add(target); + queue.push(target); + } + } + } + return reached; +} diff --git a/packages/cli/src/script-bundle.ts b/packages/cli/src/script-bundle.ts new file mode 100644 index 0000000000..9bd097fc74 --- /dev/null +++ b/packages/cli/src/script-bundle.ts @@ -0,0 +1,427 @@ +/** + * The opt-in bundle cache for `vpx