diff --git a/.github/workflows/reusable-ecosystem-ci.yml b/.github/workflows/reusable-ecosystem-ci.yml index eb6fc99364..ae0c983f31 100644 --- a/.github/workflows/reusable-ecosystem-ci.yml +++ b/.github/workflows/reusable-ecosystem-ci.yml @@ -131,6 +131,7 @@ jobs: vp fmt vp test --api.port="$ECOSYSTEM_VITEST_PORT" vp run build + vp run test:dev # FIXME: TypeError: Failed to fetch dynamically imported module # - name: skeleton # node-version: 24 diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_dev_bundled_dev/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_dev_bundled_dev/package.json index fcf5005964..e6c5ab9df2 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_dev_bundled_dev/package.json +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_dev_bundled_dev/package.json @@ -1,4 +1,9 @@ { "name": "command-dev-bundled-dev-test", - "private": true + "private": true, + "type": "module", + "devDependencies": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vite-plus": "latest" + } } diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_dev_bundled_dev/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_dev_bundled_dev/snapshots.toml index 449c743099..cb7f128568 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_dev_bundled_dev/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_dev_bundled_dev/snapshots.toml @@ -2,13 +2,34 @@ # ENV_ENTRY to dist/vite/client/, but it left BUNDLED_DEV_CLIENT_ENTRY at # dist/client/. With `experimental.bundledDev` enabled, Vite reads that # path with fs.readFileSync, so `vp dev` fails at startup with ENOENT. +# Install packed packages for #3110: linking core to the checkout exposes its +# rolldown devDependency and hides missing runtime rewrites. Disable automatic +# peer installation so npm cannot add another rolldown through upstream Vite. [[case]] name = "command_dev_bundled_dev" -vp = "local" +vp = "global" skip-platforms = ["windows"] +local-registry = true +env = { ROLLDOWN_PACKAGE = "missing" } steps = [ - { argv = ["vp", "dev", "--host", "127.0.0.1", "--port", "0"], comment = "dev server with experimental.bundledDev enabled starts and serves the bundled dev client", continue-on-failure = true, interactions = [ - { "expect-milestone" = "dev-server:ready" }, + { argv = ["npm", "install", "--ignore-scripts", "--legacy-peer-deps", "--no-audit", "--no-fund"], snapshot = false }, + { argv = ["vp", "dev", "--host", "127.0.0.1", "--port", "0"], comment = "Bundled dev serves core's runtime without a separate rolldown package.", interactions = [ + { "expect-milestone" = "dev-server:runtime-verified" }, + { "write-key" = "ctrl-c" }, + ] }, +] + +[[case]] +name = "command_dev_bundled_dev_unrelated_rolldown" +vp = "global" +skip-platforms = ["windows"] +local-registry = true +env = { ROLLDOWN_PACKAGE = "unrelated" } +steps = [ + { argv = ["npm", "install", "--ignore-scripts", "--legacy-peer-deps", "--no-audit", "--no-fund"], snapshot = false }, + { argv = ["vpt", "cp", "-r", "unrelated-rolldown", "node_modules/rolldown"], snapshot = false }, + { argv = ["vp", "dev", "--host", "127.0.0.1", "--port", "0"], comment = "An unrelated rolldown package must not replace core's bundled runtime.", interactions = [ + { "expect-milestone" = "dev-server:runtime-verified" }, { "write-key" = "ctrl-c" }, ] }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_dev_bundled_dev/snapshots/command_dev_bundled_dev.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_dev_bundled_dev/snapshots/command_dev_bundled_dev.md index 070e0ded78..d76beefc8e 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_dev_bundled_dev/snapshots/command_dev_bundled_dev.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_dev_bundled_dev/snapshots/command_dev_bundled_dev.md @@ -1,28 +1,39 @@ # command_dev_bundled_dev +## `npm install --ignore-scripts --legacy-peer-deps --no-audit --no-fund` + + ## `vp dev --host 127.0.0.1 --port 0` -dev server with experimental.bundledDev enabled starts and serves the bundled dev client +Bundled dev serves core's runtime without a separate rolldown package. **Exit code:** 130 -**→ expect-milestone:** `dev-server:ready` +**→ expect-milestone:** `dev-server:runtime-verified` ``` +VITE+ - The Unified Toolchain for the Web VITE+ ready in - ➜ Local: http://127.0.0.1:/ + ➜ Local: http://127.0.0.1:/nested/ ➜ press h + enter to show help +Rolldown package: missing +Client imports /nested/@rolldown/experimental-runtime.mjs +Served runtime matches core ``` **← write-key:** `ctrl-c` ``` +VITE+ - The Unified Toolchain for the Web VITE+ ready in - ➜ Local: http://127.0.0.1:/ + ➜ Local: http://127.0.0.1:/nested/ ➜ press h + enter to show help +Rolldown package: missing +Client imports /nested/@rolldown/experimental-runtime.mjs +Served runtime matches core ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_dev_bundled_dev/snapshots/command_dev_bundled_dev_unrelated_rolldown.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_dev_bundled_dev/snapshots/command_dev_bundled_dev_unrelated_rolldown.md new file mode 100644 index 0000000000..9fc1fc7136 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_dev_bundled_dev/snapshots/command_dev_bundled_dev_unrelated_rolldown.md @@ -0,0 +1,42 @@ +# command_dev_bundled_dev_unrelated_rolldown + +## `npm install --ignore-scripts --legacy-peer-deps --no-audit --no-fund` + + +## `vpt cp -r unrelated-rolldown node_modules/rolldown` + + +## `vp dev --host 127.0.0.1 --port 0` + +An unrelated rolldown package must not replace core's bundled runtime. + +**Exit code:** 130 + +**→ expect-milestone:** `dev-server:runtime-verified` + +``` +VITE+ - The Unified Toolchain for the Web + + VITE+ ready in + + ➜ Local: http://127.0.0.1:/nested/ + ➜ press h + enter to show help +Rolldown package: unrelated +Client imports /nested/@rolldown/experimental-runtime.mjs +Served runtime matches core +``` + +**← write-key:** `ctrl-c` + +``` +VITE+ - The Unified Toolchain for the Web + + VITE+ ready in + + ➜ Local: http://127.0.0.1:/nested/ + ➜ press h + enter to show help +Rolldown package: unrelated +Client imports /nested/@rolldown/experimental-runtime.mjs +Served runtime matches core + +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_dev_bundled_dev/unrelated-rolldown/experimental-runtime.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_dev_bundled_dev/unrelated-rolldown/experimental-runtime.mjs new file mode 100644 index 0000000000..0c97657a10 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_dev_bundled_dev/unrelated-rolldown/experimental-runtime.mjs @@ -0,0 +1 @@ +throw new Error('Unrelated rolldown runtime'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_dev_bundled_dev/unrelated-rolldown/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_dev_bundled_dev/unrelated-rolldown/package.json new file mode 100644 index 0000000000..3ba1f0dea4 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_dev_bundled_dev/unrelated-rolldown/package.json @@ -0,0 +1,7 @@ +{ + "name": "rolldown", + "type": "module", + "exports": { + "./experimental/runtime": "./experimental-runtime.mjs" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_dev_bundled_dev/vite.config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_dev_bundled_dev/vite.config.ts index 971849d01a..cf8b02c2a3 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_dev_bundled_dev/vite.config.ts +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_dev_bundled_dev/vite.config.ts @@ -1,17 +1,62 @@ +import assert from 'node:assert/strict'; +import { readFileSync, realpathSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { join, sep } from 'node:path'; + export default { + base: '/nested/', clearScreen: false, experimental: { bundledDev: true, }, plugins: [ { - name: 'dev-server-ready-milestone', + name: 'verify-bundled-dev-runtime', configureServer(server) { + const projectRequire = createRequire(join(server.config.root, 'package.json')); + const cliRequire = createRequire(projectRequire.resolve('vite-plus/package.json')); + const corePackage = cliRequire.resolve('vite/package.json'); + // Reject a linked checkout: its development dependencies can mask #3110. + assert.ok(realpathSync(corePackage).startsWith(realpathSync('node_modules') + sep)); + const coreRequire = createRequire(corePackage); + assert.equal(coreRequire('./package.json').name, '@voidzero-dev/vite-plus-core'); + if (process.env.ROLLDOWN_PACKAGE === 'unrelated') { + assert.equal( + coreRequire.resolve('rolldown/experimental/runtime'), + realpathSync('node_modules/rolldown/experimental-runtime.mjs'), + ); + } else { + assert.throws(() => coreRequire.resolve('rolldown/experimental/runtime'), { + code: 'MODULE_NOT_FOUND', + }); + } + const expectedRuntime = readFileSync( + coreRequire.resolve('@voidzero-dev/vite-plus-core/rolldown/experimental/runtime'), + 'utf8', + ); server.httpServer?.once('listening', () => { // Let Vite print its startup banner after server.listen() resolves. - setImmediate(() => { - const name = Buffer.from('dev-server:ready').toString('base64url'); - process.stdout.write(`\x1b]2;vt-pty-terminal-test:${'0'.repeat(32)}:${name}\x1b\\`); + setImmediate(async () => { + try { + const base = `http://127.0.0.1:${server.httpServer.address().port}/nested/`; + const client = await fetch(base + 'bundledDevClient.mjs'); + assert.equal(client.status, 200); + assert.match( + await client.text(), + /from\s*['"]\/nested\/@rolldown\/experimental-runtime\.mjs['"]/, + ); + const runtime = await fetch(base + '@rolldown/experimental-runtime.mjs'); + assert.equal(runtime.status, 200); + assert.ok((await runtime.text()) === expectedRuntime, 'must serve core\'s runtime'); + console.log(`Rolldown package: ${process.env.ROLLDOWN_PACKAGE}`); + console.log('Client imports /nested/@rolldown/experimental-runtime.mjs'); + console.log('Served runtime matches core'); + const name = Buffer.from('dev-server:runtime-verified').toString('base64url'); + process.stdout.write(`\x1b]2;vt-pty-terminal-test:${'0'.repeat(32)}:${name}\x1b\\`); + } catch (error) { + console.error(error); + process.exit(1); + } }); }); }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/rolldown_webcontainer_fallback/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/rolldown_webcontainer_fallback/package.json new file mode 100644 index 0000000000..bb82239909 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/rolldown_webcontainer_fallback/package.json @@ -0,0 +1,8 @@ +{ + "name": "rolldown-webcontainer-fallback-test", + "private": true, + "type": "module", + "dependencies": { + "@voidzero-dev/vite-plus-core": "latest" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/rolldown_webcontainer_fallback/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/rolldown_webcontainer_fallback/snapshots.toml new file mode 100644 index 0000000000..b9295204b3 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/rolldown_webcontainer_fallback/snapshots.toml @@ -0,0 +1,12 @@ +# Execute the complete packed binding loader in a simulated WebContainer. +# Native bindings are unavailable; the WASM installer and cache are local fakes. +# A linked checkout would hide the missing rolldown/package.json dependency. +[[case]] +name = "rolldown_webcontainer_fallback" +vp = "global" +local-registry = true +steps = [ + { argv = ["npm", "install", "--ignore-scripts", "--legacy-peer-deps", "--no-audit", "--no-fund"], snapshot = false }, + { argv = ["node", "--experimental-vm-modules", "--disable-warning=ExperimentalWarning", "verify.mjs", "missing"], comment = "Without a standalone rolldown package, download the WASM binding at the bundled Rolldown version." }, + { argv = ["node", "--experimental-vm-modules", "--disable-warning=ExperimentalWarning", "verify.mjs", "cached"], comment = "Load an existing WASM binding without a package lookup or another download." }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/rolldown_webcontainer_fallback/snapshots/rolldown_webcontainer_fallback.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/rolldown_webcontainer_fallback/snapshots/rolldown_webcontainer_fallback.md new file mode 100644 index 0000000000..a777e6f700 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/rolldown_webcontainer_fallback/snapshots/rolldown_webcontainer_fallback.md @@ -0,0 +1,28 @@ +# rolldown_webcontainer_fallback + +## `npm install --ignore-scripts --legacy-peer-deps --no-audit --no-fund` + + +## `node --experimental-vm-modules --disable-warning=ExperimentalWarning verify.mjs missing` + +Without a standalone rolldown package, download the WASM binding at the bundled Rolldown version. + +``` +Standalone rolldown: missing +WASM cache: missing +[rolldown] Downloading @rolldown/binding-wasm32-wasi@ on WebContainer... +Installer selected the bundled Rolldown version +Loaded the WebContainer WASM binding +Downloads: 1 +``` + +## `node --experimental-vm-modules --disable-warning=ExperimentalWarning verify.mjs cached` + +Load an existing WASM binding without a package lookup or another download. + +``` +Standalone rolldown: missing +WASM cache: cached +Loaded the WebContainer WASM binding +Downloads: 0 +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/rolldown_webcontainer_fallback/verify.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/rolldown_webcontainer_fallback/verify.mjs new file mode 100644 index 0000000000..a4beaf509b --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/rolldown_webcontainer_fallback/verify.mjs @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync, realpathSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join, sep } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { createContext, SourceTextModule, SyntheticModule } from 'node:vm'; + +const mode = process.argv[2]; +assert.ok(mode === 'missing' || mode === 'cached'); +const projectRequire = createRequire(import.meta.url); +const corePackage = projectRequire.resolve('@voidzero-dev/vite-plus-core/package.json'); +assert.ok(realpathSync(corePackage).startsWith(realpathSync('node_modules') + sep)); +const coreRequire = createRequire(corePackage); +assert.throws(() => coreRequire.resolve('rolldown/package.json'), { code: 'MODULE_NOT_FOUND' }); +const core = coreRequire('./package.json'); +const version = core.bundledVersions.rolldown; +assert.notEqual(core.version, version, 'the fixture must distinguish core and Rolldown versions'); + +const sharedDir = join(dirname(corePackage), 'dist/rolldown/shared'); +const files = readdirSync(sharedDir).filter((file) => /^binding-[\w-]+\.mjs$/.test(file)); +assert.equal(files.length, 1); +const bindingUrl = pathToFileURL(join(sharedDir, files[0])).href; +const bindingRequire = createRequire(bindingUrl); +const source = readFileSync(new URL(bindingUrl), 'utf8'); +const bindingExport = source.match(/\brequire_binding as (\w+)/)?.[1]; +assert.ok(bindingExport, 'the emitted loader must export its binding initializer'); + +// Use WebContainer's POSIX paths even when the snapshot runs on Windows. +// No real /tmp files or package-manager processes are used by the loader. +const baseDir = `/tmp/rolldown-${version}`; +const bindingEntry = `${baseDir}/node_modules/@rolldown/binding-wasm32-wasi/rolldown-binding.wasi.cjs`; +const wasmBinding = {}; +let cached = mode === 'cached'; +let downloads = 0; +let cacheChecks = 0; +const fakeFs = { + readFileSync(file, encoding) { + assert.equal(file, corePackage, 'only core metadata may be read'); + return readFileSync(file, encoding); + }, + existsSync(file) { + assert.equal(file, bindingEntry); + cacheChecks++; + return cached; + }, + rmSync(dir, options) { + assert.equal(dir, baseDir); + assert.deepEqual({ ...options }, { recursive: true, force: true }); + assert.equal(cached, false); + }, + mkdirSync(dir, options) { + assert.equal(dir, baseDir); + assert.deepEqual({ ...options }, { recursive: true }); + }, +}; +const fakeChildProcess = { + execFileSync(command, args, options) { + assert.equal(command, 'pnpm'); + assert.deepEqual(Array.from(args), ['i', `@rolldown/binding-wasm32-wasi@${version}`]); + assert.equal(options.cwd, baseDir); + assert.equal(options.stdio, 'inherit'); + assert.equal(cached, false); + downloads++; + cached = true; + console.log('Installer selected the bundled Rolldown version'); + }, +}; + +function missingModule(specifier) { + return Object.assign(new Error(`Cannot find module '${specifier}'`), { + code: 'MODULE_NOT_FOUND', + }); +} + +function unavailableBinding(specifier) { + return ( + specifier.endsWith('.node') || + specifier.includes('binding-wasm32-wasi') || + specifier.endsWith('.wasi.cjs') + ); +} + +const isolatedRequire = Object.assign( + (specifier) => { + if (specifier === 'fs' || specifier === 'node:fs') return fakeFs; + if (specifier === 'child_process' || specifier === 'node:child_process') return fakeChildProcess; + if (specifier === bindingEntry) { + assert.equal(cached, true, 'the WASM binding must be cached or installed first'); + return wasmBinding; + } + if (unavailableBinding(specifier)) throw missingModule(specifier); + return bindingRequire(specifier); + }, + { + resolve(specifier) { + if (unavailableBinding(specifier)) throw missingModule(specifier); + // Keep real Node resolution for metadata: the old rolldown/package.json + // lookup must fail in this packed installation, not in a mocked resolver. + return bindingRequire.resolve(specifier); + }, + }, +); + +const context = createContext({ + console: { + log(message) { + // The installer and cache assertions above check the exact version. + console.log(message.replaceAll(`@${version}`, '@')); + }, + }, + process: { + env: { NAPI_RS_NATIVE_LIBRARY_PATH: '/unavailable-native-binding.node' }, + versions: { webcontainer: '1' }, + platform: 'linux', + arch: 'x64', + }, +}); +const loader = new SourceTextModule(source, { + context, + identifier: bindingUrl, + initializeImportMeta(meta) { + meta.url = bindingUrl; + }, +}); +await loader.link((specifier) => { + assert.equal(specifier, 'node:module'); + return new SyntheticModule( + ['createRequire'], + function () { + this.setExport('createRequire', (url) => { + assert.equal(url, bindingUrl); + return isolatedRequire; + }); + }, + { context }, + ); +}); +await loader.evaluate(); + +console.log('Standalone rolldown: missing'); +console.log(`WASM cache: ${mode}`); +const binding = loader.namespace[bindingExport](); +assert.equal(binding, wasmBinding); +assert.equal(binding.__napiBindingTarget, 'wasm32-wasi'); +assert.equal(cacheChecks, 1); +assert.equal(downloads, mode === 'missing' ? 1 : 0); +console.log('Loaded the WebContainer WASM binding'); +console.log(`Downloads: ${downloads}`); diff --git a/ecosystem-ci/repo.json b/ecosystem-ci/repo.json index 6f5d3177c8..1e2ae01307 100644 --- a/ecosystem-ci/repo.json +++ b/ecosystem-ci/repo.json @@ -13,7 +13,7 @@ "vibe-dashboard": { "repository": "https://github.com/voidzero-dev/vibe-dashboard.git", "branch": "main", - "hash": "92a143a57478723987e778439a355291835135ec", + "hash": "be10551d4b73b7bcb53491311b574fb214a38e1f", "forceFreshMigration": true, "playwright": true }, diff --git a/packages/core/BUNDLING.md b/packages/core/BUNDLING.md index ceb7c85b48..6a024b268b 100644 --- a/packages/core/BUNDLING.md +++ b/packages/core/BUNDLING.md @@ -42,6 +42,7 @@ await cp(join(rolldownPluginUtilsDir, 'dist'), join(projectDir, 'dist', 'pluginu - `@rolldown/pluginutils` → `@voidzero-dev/vite-plus-core/rolldown/pluginutils` - `rolldown/*` → `@voidzero-dev/vite-plus-core/rolldown/*` +- Replace the WebContainer fallback's `rolldown/package.json` lookup with the bundled Rolldown version. The WASM download must use that version, not the core package version. - In release builds: supported `@rolldown/binding-*` → `@voidzero-dev/vite-plus-*` **Input**: `rolldown/packages/rolldown/dist/` @@ -129,6 +130,16 @@ Located in `build-support/rewrite-module-specifiers.ts`, this utility rewrites s | `@rolldown/pluginutils` | `@voidzero-dev/vite-plus-core/rolldown/pluginutils` | | `@rolldown/pluginutils/*` | `@voidzero-dev/vite-plus-core/rolldown/pluginutils/*` | +### Detecting Missed Rewrites + +`__tests__/build-artifacts.spec.ts` snapshots the remaining bare `rolldown` and `@rolldown/pluginutils` names in all built JavaScript and declarations. It parses string literals and template literals, then records references to constants and their aliases. This includes package names passed to `require.resolve()` or `import.meta.resolve()`, which import-only checks miss. + +The snapshot allows CLI branding, embedded package metadata, and the browser runtime import that Vite rewrites before serving. Review new entries when upstream code changes; do not accept unresolved Node package lookups. Dynamically constructed names can escape static checks, so the installed-package bundled dev snapshots also verify runtime resolution without a separate Rolldown installation. + +Native `@rolldown/binding-*` references have separate rewrite checks, described below. + +The `rolldown_webcontainer_fallback` CLI snapshot executes the packed binding loader in a simulated WebContainer. It checks fresh and cached WASM bindings without a standalone `rolldown` package. The fixture uses real Node metadata resolution and a fake installer, and asserts that both paths use the bundled Rolldown version. + ### Release Build: Native Binding Rewriting During release builds (`RELEASE_BUILD=1`), `bundleRolldown()` rewrites Rolldown's native binding requires through `build-support/rewrite-rolldown-binding.ts`. For every platform in the CLI's `napi.targets` (mapped to napi platform suffixes with `parseTriple`), the loader's `@rolldown/binding-` requires become `@voidzero-dev/vite-plus-`, and that branch's version guard switches from the Rolldown version to core's version, which is what the Vite+ platform packages are published as. diff --git a/packages/core/__tests__/__snapshots__/build-artifacts.spec.ts.snap b/packages/core/__tests__/__snapshots__/build-artifacts.spec.ts.snap new file mode 100644 index 0000000000..ad846ae90b --- /dev/null +++ b/packages/core/__tests__/__snapshots__/build-artifacts.spec.ts.snap @@ -0,0 +1,42 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`build artifacts > snapshots remaining Rolldown package names and their uses in all built code 1`] = ` +[ + { + "code": "cac("rolldown")", + "file": "rolldown/cli.mjs", + }, + { + "code": "name: "rolldown"", + "file": "rolldown/shared/bindingify-input-options-[hash].mjs", + }, + { + "code": "keywords: [ "bundler", "esbuild", "parcel", "rolldown", "rollup", "webpack" ]", + "file": "rolldown/shared/bindingify-input-options-[hash].mjs", + }, + { + "code": ""rolldown": "./bin/cli.mjs"", + "file": "rolldown/shared/bindingify-input-options-[hash].mjs", + }, + { + "code": ""@rolldown/pluginutils": "catalog:"", + "file": "rolldown/shared/bindingify-input-options-[hash].mjs", + }, + { + "code": ""rolldown": "workspace:*"", + "file": "rolldown/shared/bindingify-input-options-[hash].mjs", + }, + { + "code": "import { DevRuntime } from "rolldown/experimental/runtime";", + "file": "vite/client/bundledDevClient.mjs", + }, + { + "code": "ROLLDOWN_DEV_RUNTIME_ENTRY = "rolldown/experimental/runtime"", + "file": "vite/node/chunks/node.js", + }, + { + "code": "new RegExp(\`(from\\\\s*['"])\${ROLLDOWN_DEV_RUNTIME_ENTRY}(['"])\`)", + "file": "vite/node/chunks/node.js", + }, +] +`; diff --git a/packages/core/__tests__/build-artifacts.spec.ts b/packages/core/__tests__/build-artifacts.spec.ts index d1fc9148c3..5f0946c750 100644 --- a/packages/core/__tests__/build-artifacts.spec.ts +++ b/packages/core/__tests__/build-artifacts.spec.ts @@ -3,13 +3,105 @@ import { createRequire } from 'node:module'; import path from 'node:path'; import url from 'node:url'; +import { Lang, parse as parseAst, type SgNode } from '@ast-grep/napi'; import { init, parse } from 'es-module-lexer'; +import { glob } from 'tinyglobby'; import { describe, expect, it } from 'vite-plus/test'; const coreDir = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..'); const distDir = path.join(coreDir, 'dist'); +const referenceContexts = new Set>([ + 'import_statement', + 'export_statement', + 'call_expression', + 'new_expression', + 'variable_declarator', + 'pair', + 'expression_statement', +]); + +function referenceContext(node: SgNode): string { + let parent = node.parent(); + while (parent && !referenceContexts.has(node.kind())) { + node = parent; + parent = node.parent(); + } + return node.text().replace(/\s+/g, ' '); +} + describe('build artifacts', () => { + it('snapshots remaining Rolldown package names and their uses in all built code', async () => { + const files = (await glob('**/*.{js,mjs,cjs,ts,mts,cts}', { cwd: distDir })).toSorted(); + expect(files.length).toBeGreaterThan(0); + const references: { file: string; code: string }[] = []; + + for (const file of files) { + const source = fs.readFileSync(path.join(distDir, file), 'utf8'); + const lang = /\.[cm]?ts$/.test(file) ? Lang.TypeScript : Lang.JavaScript; + const root = parseAst(lang, source).root(); + const record = (node: SgNode) => { + references.push({ + file: file.replace(/-[\w-]{8}(?=(?:\.d)?\.[cm]?[jt]s$)/, '-[hash]'), + code: referenceContext(node), + }); + }; + + // Inspect literals, not only imports: require.resolve, import.meta.resolve, + // templates, and new resolution APIs must not bypass this check. Comments + // are excluded by the parser. Native @rolldown/binding-* packages have a + // separate release rewrite and coverage in rewrite-rolldown-binding.spec.ts. + const literals = root.findAll({ + rule: { any: [{ kind: 'string' }, { kind: 'template_string' }] }, + }); + for (const literal of literals) { + if ( + !/^(?:rolldown|@rolldown\/pluginutils)(?:\/|$|\$\{)/.test(literal.text().slice(1, -1)) + ) { + continue; + } + record(literal); + + // The browser-import matching constant is intentional. Snapshot its uses + // too, so restoring import.meta.resolve(ROLLDOWN_DEV_RUNTIME_ENTRY) fails + // even though the declaration itself has not changed. Follow aliases. + const declaration = literal.parent(); + const name = declaration?.kind() === 'variable_declarator' && declaration.field('name'); + if (!name || name.kind() !== 'identifier') { + continue; + } + const bindings = new Set([name.text()]); + const identifiers = root.findAll({ rule: { kind: 'identifier' } }); + for (const binding of bindings) { + for (const identifier of identifiers) { + if (identifier.text() !== binding) { + continue; + } + const parent = identifier.parent(); + if ( + parent?.kind() === 'variable_declarator' && + parent.field('name')?.text() === binding + ) { + continue; + } + record(identifier); + if (parent?.kind() === 'variable_declarator') { + const alias = parent.field('name'); + if (alias?.kind() === 'identifier') { + bindings.add(alias.text()); + } + } + } + } + } + } + + // Only CLI branding, embedded package metadata, and the browser import that + // Vite rewrites before serving may remain. New entries require review; + // do not accept a snapshot containing an unresolved Node package lookup. + expect(references).toMatchSnapshot(); + }); + it('should include esm-shims.js in dist for tsdown shims support', () => { const shimsPath = path.join(distDir, 'esm-shims.js'); expect(fs.existsSync(shimsPath), `${shimsPath} should exist`).toBe(true); diff --git a/packages/core/build.ts b/packages/core/build.ts index a0fc8c2b88..203addbd8d 100644 --- a/packages/core/build.ts +++ b/packages/core/build.ts @@ -205,6 +205,18 @@ async function buildVite() { }; } } + if (id.endsWith(join('vite', 'src', 'node', 'plugins', 'clientInjections.ts'))) { + const { magicString } = meta; + if (magicString) { + // Resolve the runtime bundled with core. Keep ROLLDOWN_DEV_RUNTIME_ENTRY + // unchanged: it also matches the import in bundledDevClient.mjs. + magicString.replace( + 'import.meta.resolve(ROLLDOWN_DEV_RUNTIME_ENTRY)', + `import.meta.resolve('${pkgJson.name}/rolldown/experimental/runtime')`, + ); + return { code: magicString }; + } + } return undefined; }, }, @@ -336,6 +348,12 @@ async function bundleRolldownPluginutils() { async function bundleRolldown() { await mkdir(join(projectDir, 'dist/rolldown'), { recursive: true }); + const { version: rolldownVersion }: { version: string } = JSON.parse( + await readFile(join(rolldownSourceDir, 'package.json'), 'utf-8'), + ); + const webContainerVersionLookup = + 'JSON.parse(fs.readFileSync(__require.resolve("rolldown/package.json"), "utf-8")).version'; + let webContainerVersionRewrites = 0; const rolldownFiles = new Set(); await cp(join(rolldownSourceDir, 'dist'), join(projectDir, 'dist/rolldown'), { @@ -373,6 +391,12 @@ async function bundleRolldown() { file.endsWith('.d.ts') ) { let source = await readFile(file, 'utf-8'); + // WebContainer downloads the upstream WASM binding. Inline Rolldown's + // version so it needs neither an undeclared package nor Vite+'s version. + source = source.replaceAll(webContainerVersionLookup, () => { + webContainerVersionRewrites++; + return JSON.stringify(rolldownVersion); + }); const rules: ReplacementRule[] = [...createRolldownRewriteRules(pkgJson.name)]; if (vitePlusPlatformSuffixes) { const result = rewriteRolldownBindingRequires(source, { @@ -392,6 +416,13 @@ async function bundleRolldown() { } } + if (webContainerVersionRewrites !== 1) { + throw new Error( + `bundleRolldown: expected one WebContainer version lookup, found ${webContainerVersionRewrites}; ` + + 'review the upstream fallback and update or remove its version rewrite', + ); + } + // Every published platform suffix must find its loader branch, and each // redirected branch requires the platform package twice (the binding itself // and its package.json version guard) with one guard. A napi-rs upgrade