diff --git a/.github/workflows/reusable-release-build.yml b/.github/workflows/reusable-release-build.yml index 35432573f5..ebb81e3461 100644 --- a/.github/workflows/reusable-release-build.yml +++ b/.github/workflows/reusable-release-build.yml @@ -69,11 +69,23 @@ jobs: for f in crates/vite_global_cli/Cargo.toml packages/cli/binding/Cargo.toml; do node -e 'const fs=require("fs"),f=process.argv[1];fs.writeFileSync(f,fs.readFileSync(f,"utf8").replace(/^version = "[^"]*"/m,`version = "${process.env.VERSION}"`))' "$f" done - # Stamp packages/cli/package.json too so the bundled JS dist inlines - # ${VERSION} as cliPkg.version (VITE_PLUS_VERSION). Without this a preview - # `vp migrate` pins the build-time release placeholder instead of the - # test build, and skips the registry-bridge .npmrc. - pnpm exec json-edit packages/cli/package.json '_.version = process.env.VERSION' + # Stamp the package.json files too: + # - packages/cli: the bundled JS dist inlines ${VERSION} as + # cliPkg.version (VITE_PLUS_VERSION). Without this a preview + # `vp migrate` pins the build-time release placeholder instead of + # the test build, and skips the registry-bridge .npmrc. + # - packages/core: the release build bakes ${VERSION} into bundled + # Rolldown's binding version guards, matching the + # @voidzero-dev/vite-plus- packages published as + # ${VERSION}. A release checkout already carries ${VERSION}; this + # only changes preview builds. + # The list feeds the verify step below, so a stamped file cannot + # silently drop out of verification. + PKG_JSON_STAMP_FILES="packages/cli/package.json packages/core/package.json" + for f in $PKG_JSON_STAMP_FILES; do + pnpm exec json-edit "$f" '_.version = process.env.VERSION' + done + echo "PKG_JSON_STAMP_FILES=$PKG_JSON_STAMP_FILES" >> "$GITHUB_ENV" - name: Verify version replacement shell: bash @@ -84,9 +96,11 @@ jobs: echo "ERROR: version stamp failed for $f"; head -5 "$f"; exit 1 fi done - if [ "$(node -p "require('./packages/cli/package.json').version")" != "${VERSION}" ]; then - echo "ERROR: version stamp failed for packages/cli/package.json"; exit 1 - fi + for f in ${PKG_JSON_STAMP_FILES}; do + if [ "$(node -p "require('./$f').version")" != "${VERSION}" ]; then + echo "ERROR: version stamp failed for $f"; exit 1 + fi + done echo "Version stamp verified: ${VERSION}" - name: Build diff --git a/packages/cli/BUNDLING.md b/packages/cli/BUNDLING.md index b4f23f1b2a..f6e73abb2d 100644 --- a/packages/cli/BUNDLING.md +++ b/packages/cli/BUNDLING.md @@ -154,14 +154,16 @@ packages/cli/ The CLI builds native bindings for the following platform targets: -| Target | Platform | Architecture | Output File | -| --------------------------- | -------- | ------------ | --------------------------------- | -| `aarch64-apple-darwin` | macOS | ARM64 | `vite-plus.darwin-arm64.node` | -| `x86_64-apple-darwin` | macOS | x64 | `vite-plus.darwin-x64.node` | -| `aarch64-unknown-linux-gnu` | Linux | ARM64 | `vite-plus.linux-arm64-gnu.node` | -| `x86_64-unknown-linux-gnu` | Linux | x64 | `vite-plus.linux-x64-gnu.node` | -| `aarch64-pc-windows-msvc` | Windows | ARM64 | `vite-plus.win32-arm64-msvc.node` | -| `x86_64-pc-windows-msvc` | Windows | x64 | `vite-plus.win32-x64-msvc.node` | +| Target | Platform | Architecture | Output File | +| ---------------------------- | -------- | ------------ | --------------------------------- | +| `aarch64-apple-darwin` | macOS | ARM64 | `vite-plus.darwin-arm64.node` | +| `x86_64-apple-darwin` | macOS | x64 | `vite-plus.darwin-x64.node` | +| `aarch64-unknown-linux-gnu` | Linux | ARM64 glibc | `vite-plus.linux-arm64-gnu.node` | +| `aarch64-unknown-linux-musl` | Linux | ARM64 musl | `vite-plus.linux-arm64-musl.node` | +| `x86_64-unknown-linux-gnu` | Linux | x64 glibc | `vite-plus.linux-x64-gnu.node` | +| `x86_64-unknown-linux-musl` | Linux | x64 musl | `vite-plus.linux-x64-musl.node` | +| `aarch64-pc-windows-msvc` | Windows | ARM64 | `vite-plus.win32-arm64-msvc.node` | +| `x86_64-pc-windows-msvc` | Windows | x64 | `vite-plus.win32-x64-msvc.node` | These targets are defined in `package.json` under the `napi.targets` field. @@ -220,29 +222,21 @@ await cli.build({ ### Module Specifier Rewriting -During release builds, the core package rewrites all `@rolldown/binding-*` imports to point to `vite-plus/binding`: - -```typescript -// In packages/core/build.ts -if (process.env.RELEASE_BUILD) { - // @rolldown/binding-darwin-arm64 → vite-plus/binding - source = source.replace(/@rolldown\/binding-([a-z0-9-]+)/g, 'vite-plus/binding'); -} -``` +During release builds, the core package rewrites each supported `@rolldown/binding-*` import to the matching Vite+ platform package (see `packages/core/build-support/rewrite-rolldown-binding.ts`): **Transformation examples**: -| Original Import | After Rewrite | -| ---------------------------------- | ------------------- | -| `@rolldown/binding-darwin-arm64` | `vite-plus/binding` | -| `@rolldown/binding-linux-x64-gnu` | `vite-plus/binding` | -| `@rolldown/binding-win32-x64-msvc` | `vite-plus/binding` | +| Original Import | After Rewrite | +| ---------------------------------- | ---------------------------------------- | +| `@rolldown/binding-darwin-arm64` | `@voidzero-dev/vite-plus-darwin-arm64` | +| `@rolldown/binding-linux-x64-gnu` | `@voidzero-dev/vite-plus-linux-x64-gnu` | +| `@rolldown/binding-win32-x64-msvc` | `@voidzero-dev/vite-plus-win32-x64-msvc` | This means: -1. The bundled rolldown code in `@voidzero-dev/vite-plus-core/rolldown` resolves native bindings from `vite-plus/binding` +1. The bundled rolldown code in `@voidzero-dev/vite-plus-core/rolldown` resolves native bindings through core's own declared optional dependencies (injected at publish time by `publish-native-addons.ts`) 2. Users don't need to install separate `@rolldown/binding-*` platform packages -3. The single `.node` file contains both vite-plus task runner and rolldown bindings +3. The platform `.node` file contains both vite-plus task runner and rolldown bindings ### Native Binding Contents @@ -259,23 +253,27 @@ When compiled with `RELEASE_BUILD=1`, the `.node` file contains: User imports 'vite-plus/rolldown' → packages/cli re-exports from @voidzero-dev/vite-plus-core/rolldown → packages/core/dist/rolldown/index.mjs - → Native binding: vite-plus/binding (rewritten from @rolldown/binding-*) - → binding/vite-plus.darwin-arm64.node (contains rolldown_binding) + → Native binding: @voidzero-dev/vite-plus-darwin-arm64 + (rewritten from @rolldown/binding-darwin-arm64) + → vite-plus.darwin-arm64.node (contains rolldown_binding) ``` ### Platform-Specific Publishing Native bindings are published as separate platform packages for optimal install size: -| Platform | Published Package | -| ----------- | ----------------------------------------- | -| macOS ARM64 | `@voidzero-dev/vite-plus-darwin-arm64` | -| macOS x64 | `@voidzero-dev/vite-plus-darwin-x64` | -| Linux ARM64 | `@voidzero-dev/vite-plus-linux-arm64-gnu` | -| Linux x64 | `@voidzero-dev/vite-plus-linux-x64-gnu` | -| Windows x64 | `@voidzero-dev/vite-plus-win32-x64-msvc` | - -These are automatically installed via `optionalDependencies` based on the user's platform. +| Platform | Published Package | +| ----------------- | ------------------------------------------ | +| macOS ARM64 | `@voidzero-dev/vite-plus-darwin-arm64` | +| macOS x64 | `@voidzero-dev/vite-plus-darwin-x64` | +| Linux ARM64 glibc | `@voidzero-dev/vite-plus-linux-arm64-gnu` | +| Linux ARM64 musl | `@voidzero-dev/vite-plus-linux-arm64-musl` | +| Linux x64 glibc | `@voidzero-dev/vite-plus-linux-x64-gnu` | +| Linux x64 musl | `@voidzero-dev/vite-plus-linux-x64-musl` | +| Windows ARM64 | `@voidzero-dev/vite-plus-win32-arm64-msvc` | +| Windows x64 | `@voidzero-dev/vite-plus-win32-x64-msvc` | + +These are automatically installed via `optionalDependencies` based on the user's platform. `publish-native-addons.ts` injects the exact-pinned entries into both `vite-plus` (via napi-rs prePublish) and `@voidzero-dev/vite-plus-core` during publish; the committed package.json files carry none of them. See `publish-native-addons.ts` for the publishing pipeline. diff --git a/packages/cli/package.json b/packages/cli/package.json index 55f4fbe401..176a6f951b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -62,11 +62,6 @@ "./bin": { "import": "./dist/bin.js" }, - "./binding": { - "types": "./binding/index.d.cts", - "import": "./binding/index.cjs", - "require": "./binding/index.cjs" - }, "./fmt": { "types": "./dist/fmt.d.ts", "import": "./dist/fmt.js" diff --git a/packages/cli/publish-native-addons.ts b/packages/cli/publish-native-addons.ts index 74c0bcdecd..881adb3bea 100644 --- a/packages/cli/publish-native-addons.ts +++ b/packages/cli/publish-native-addons.ts @@ -1,13 +1,5 @@ import { execSync } from 'node:child_process'; -import { - copyFileSync, - existsSync, - chmodSync, - mkdirSync, - readFileSync, - rmSync, - writeFileSync, -} from 'node:fs'; +import { copyFileSync, existsSync, chmodSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { readdir } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -15,7 +7,7 @@ import { fileURLToPath } from 'node:url'; import { NapiCli, parseTriple } from '@napi-rs/cli'; import pkg from './package.json' with { type: 'json' }; -import { editJsonFile } from './src/utils/json.ts'; +import { editJsonFile, readJsonFile } from './src/utils/json.ts'; const cli = new NapiCli(); @@ -94,6 +86,41 @@ for (const dir of platformDirs) { })); } +// Fresh read: napi-rs prePublish rewrote this package.json on disk, so the +// top-level `pkg` import is stale for injected fields. +const cliPackageJson = readJsonFile(join(currentDir, 'package.json')) as { + version: string; + repository?: unknown; + optionalDependencies?: Record; +}; + +// napi-rs prePublish injects the platform packages into this package's +// `optionalDependencies`. Release builds of core rewrite bundled Rolldown's +// binding requires to the same platform packages (see +// packages/core/build-support/rewrite-rolldown-binding.ts), so core must +// declare them too; napi-rs manages a single package, so mirror the injected +// entries into core with identical pins. Like the CLI's entries, these live +// only in the publish working tree, never in the committed package.json. +const nativePlatformPins: Record = {}; +for (const target of pkg.napi.targets) { + const packageName = `${pkg.napi.packageName}-${parseTriple(target).platformArchABI}`; + const pin = cliPackageJson.optionalDependencies?.[packageName]; + if (!pin) { + console.error( + `napi prePublish did not inject ${packageName} into packages/cli/package.json optionalDependencies`, + ); + process.exit(1); + } + nativePlatformPins[packageName] = pin; +} +editJsonFile(join(repoRoot, 'packages', 'core', 'package.json'), (corePkgJson) => ({ + ...corePkgJson, + optionalDependencies: { + ...(corePkgJson.optionalDependencies as Record | undefined), + ...nativePlatformPins, + }, +})); + // Publish each NAPI platform package (without vp binary) const npmTag = process.env.NPM_TAG || 'latest'; if (!skipNpmPublish) { @@ -121,8 +148,7 @@ if (!skipNpmPublish) { } } -// Read version from packages/cli/package.json for lockstep versioning -const cliPackageJson = JSON.parse(readFileSync(join(currentDir, 'package.json'), 'utf-8')); +// Lockstep versioning: the CLI platform packages publish at the same version. const cliVersion = cliPackageJson.version; // Create and publish separate @voidzero-dev/vite-plus-cli-{platform} packages diff --git a/packages/core/BUNDLING.md b/packages/core/BUNDLING.md index 40472e5654..34d6969231 100644 --- a/packages/core/BUNDLING.md +++ b/packages/core/BUNDLING.md @@ -43,7 +43,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/*` -- In release builds: `@rolldown/binding-*` → `vite-plus/binding` +- In release builds: supported `@rolldown/binding-*` → `@voidzero-dev/vite-plus-*` **Input**: `rolldown/packages/rolldown/dist/` **Output**: `dist/rolldown/` @@ -142,45 +142,34 @@ Located in `build-support/rewrite-module-specifiers.ts`, this utility rewrites s ### Release Build: Native Binding Rewriting -During release builds (`RELEASE_BUILD=1`), an additional critical transformation occurs for Rolldown's native bindings: +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. -```typescript -// In bundleRolldown() -if (process.env.RELEASE_BUILD) { - // @rolldown/binding-darwin-arm64 → vite-plus/binding - source = source.replace(/@rolldown\/binding-([a-z0-9-]+)/g, 'vite-plus/binding'); - // Sync version strings - source = source.replaceAll(`${rolldownBindingVersion}`, pkgJson.version); -} -``` +**Platform-specific binding rewrites**, one per `napi.targets` entry in `packages/cli/package.json` (see the target table in [CLI Package Bundling](../cli/BUNDLING.md#napi-targets)), for example: -**Platform-specific binding rewrites**: +| Original Import | Rewritten Import | +| ---------------------------------- | ---------------------------------------- | +| `@rolldown/binding-darwin-arm64` | `@voidzero-dev/vite-plus-darwin-arm64` | +| `@rolldown/binding-linux-x64-musl` | `@voidzero-dev/vite-plus-linux-x64-musl` | -| Original Import | Rewritten Import | -| ----------------------------------- | ------------------- | -| `@rolldown/binding-darwin-arm64` | `vite-plus/binding` | -| `@rolldown/binding-darwin-x64` | `vite-plus/binding` | -| `@rolldown/binding-linux-arm64-gnu` | `vite-plus/binding` | -| `@rolldown/binding-linux-x64-gnu` | `vite-plus/binding` | -| `@rolldown/binding-win32-x64-msvc` | `vite-plus/binding` | +Specifiers for platforms Vite+ does not ship (android, freebsd, the `wasm32-wasi` fallback, `darwin-universal`, ...) stay on `@rolldown/binding-*` and keep their upstream version guards. The build fails if the rewritten specifier and guard counts diverge from the napi-rs loader shape, so a loader format change cannot ship a partial rewrite. **Why this matters**: 1. **Self-contained distribution** - Users don't need to install separate `@rolldown/binding-*` packages -2. **Version alignment** - The rolldown binding version is synced to the vite-plus version -3. **Single native module** - The `vite-plus/binding` export points to the CLI's compiled `.node` file which includes `rolldown_binding` when built with `RELEASE_BUILD=1` +2. **Declared dependency graph** - Core resolves the binding through its own `optionalDependencies` (injected during publish by `packages/cli/publish-native-addons.ts`), so pnpm's global virtual store and Yarn PnP work without hidden hoisting, and core no longer requires back into `vite-plus` +3. **Version alignment** - Each rewritten branch's guard checks the platform package version against core's version; both are published lockstep **Resolution chain**: ``` User code imports '@voidzero-dev/vite-plus-core/rolldown' → dist/rolldown/index.mjs - → imports 'vite-plus/binding' (rewritten from @rolldown/binding-*) - → vite-plus CLI package ./binding export - → binding/vite-plus.darwin-arm64.node (contains rolldown_binding) + → requires '@voidzero-dev/vite-plus-' + (rewritten from '@rolldown/binding-') + → vite-plus..node (contains rolldown_binding) ``` -See [CLI Package Bundling](../cli/BUNDLING.md#rolldown-native-binding-integration) for details on how the CLI compiles rolldown bindings. +See [CLI Package Bundling](../cli/BUNDLING.md#rolldown-native-binding-integration) for details on how the CLI compiles and publishes the platform packages, and `rfcs/core-binding-resolution.md` for the design. --- @@ -350,7 +339,7 @@ dist/ # Build the core package pnpm -C packages/core build -# Release build (rewrites @rolldown/binding-* to vite-plus/binding) +# Release build (rewrites supported @rolldown/binding-* to @voidzero-dev/vite-plus-*) RELEASE_BUILD=1 pnpm -C packages/core build ``` diff --git a/packages/core/__tests__/binding-resolution-layout.spec.ts b/packages/core/__tests__/binding-resolution-layout.spec.ts new file mode 100644 index 0000000000..7b76cec852 --- /dev/null +++ b/packages/core/__tests__/binding-resolution-layout.spec.ts @@ -0,0 +1,189 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterAll, describe, expect, it } from 'vitest'; + +import { rewriteRolldownBindingRequires } from '../build-support/rewrite-rolldown-binding.ts'; + +// Reproduces issue #2054 at the module resolution layer. pnpm's +// enable-global-virtual-store installs packages at a realpath outside the +// project, where only declared dependencies are resolvable siblings; Node +// resolves from the realpath, so the project's own node_modules is never on +// the walk-up path. These tests rebuild that layout with stub packages and +// prove the collapsed `vite-plus/binding` rewrite fails there while the +// per-platform transform output resolves. Full-stack coverage with real +// release artifacts lives in the preview pipeline (see +// rfcs/core-binding-resolution.md). + +const PLATFORM_SUFFIX = 'linux-x64-gnu'; +const PLATFORM_PACKAGE = `@voidzero-dev/vite-plus-${PLATFORM_SUFFIX}`; +const CORE_VERSION = '0.0.0-layout-test'; + +// The napi-rs loader shape for one platform branch, before any release +// rewrite. Resolution does not depend on the host platform, so the branch is +// unconditional here. +const upstreamLoader = ` +const binding = require("@rolldown/binding-${PLATFORM_SUFFIX}"); +const bindingPackageVersion = require("@rolldown/binding-${PLATFORM_SUFFIX}/package.json").version; +if (bindingPackageVersion !== "1.2.1" && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== "0") throw new Error(\`Native binding package version mismatch, expected 1.2.1 but got \${bindingPackageVersion}. You can reinstall dependencies to fix this issue.\`); +module.exports = binding; +`; + +// The collapsed rewrite shipped before this fix (packages/core/build.ts). +const collapsedLoader = upstreamLoader.replace( + /@rolldown\/binding-([a-z0-9-]+)/g, + 'vite-plus/binding', +); + +const rewrittenLoader = rewriteRolldownBindingRequires(upstreamLoader, { + packageName: '@voidzero-dev/vite-plus', + platformSuffixes: new Set([PLATFORM_SUFFIX]), + version: CORE_VERSION, +}).source; + +const tmpRoots: string[] = []; +afterAll(() => { + for (const root of tmpRoots) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +function write(file: string, content: string) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); +} + +function writeJson(file: string, data: unknown) { + write(file, JSON.stringify(data, null, 2) + '\n'); +} + +/** + * Builds the pnpm enable-global-virtual-store layout: + * + * /global-store/.pnpm//node_modules/ package realpaths + * /project/node_modules/ symlinks into the store + * + * Core's realpath directory contains only its declared dependencies; the + * project's node_modules (including the undeclared `vite-plus`) is not on + * core's resolution path, exactly like pnpm's global virtual store. + */ +function buildGlobalVirtualStoreLayout(options: { + loader: string; + platformPackageVersion?: string; +}): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-binding-layout-')); + tmpRoots.push(root); + + const storeCoreDir = path.join( + root, + 'global-store/.pnpm/core@0.0.0/node_modules/@voidzero-dev/vite-plus-core', + ); + writeJson(path.join(storeCoreDir, 'package.json'), { + name: '@voidzero-dev/vite-plus-core', + version: CORE_VERSION, + main: './dist/rolldown-loader.cjs', + // Layout fidelity: a real published core declares the platform package it + // loads (injected at publish time, pinned to core's own version). + ...(options.platformPackageVersion + ? { optionalDependencies: { [PLATFORM_PACKAGE]: CORE_VERSION } } + : {}), + }); + write(path.join(storeCoreDir, 'dist/rolldown-loader.cjs'), options.loader); + + if (options.platformPackageVersion) { + const storePlatformDir = path.join( + root, + `global-store/.pnpm/platform@0.0.0/node_modules/${PLATFORM_PACKAGE}`, + ); + writeJson(path.join(storePlatformDir, 'package.json'), { + name: PLATFORM_PACKAGE, + version: options.platformPackageVersion, + main: './binding.cjs', + }); + write(path.join(storePlatformDir, 'binding.cjs'), `module.exports = { marker: 'native' };\n`); + // Declared dependency: a resolvable sibling of core's realpath, i.e. + // under the store entry's node_modules directory (two levels above the + // scoped core package directory). + const storeModulesDir = path.dirname(path.dirname(storeCoreDir)); + const link = path.join(storeModulesDir, PLATFORM_PACKAGE); + fs.mkdirSync(path.dirname(link), { recursive: true }); + fs.symlinkSync(storePlatformDir, link, 'junction'); + } + + // The project depends on vite-plus and core; vite-plus (with its ./binding + // export) sits in the project's node_modules like a real install, but core + // resolves from its store realpath and cannot see it. + const projectModules = path.join(root, 'project/node_modules'); + const vitePlusDir = path.join(projectModules, 'vite-plus'); + writeJson(path.join(vitePlusDir, 'package.json'), { + name: 'vite-plus', + version: CORE_VERSION, + exports: { + './binding': './binding/index.cjs', + './package.json': './package.json', + }, + }); + write(path.join(vitePlusDir, 'binding/index.cjs'), `module.exports = { marker: 'native' };\n`); + const coreLink = path.join(projectModules, '@voidzero-dev/vite-plus-core'); + fs.mkdirSync(path.dirname(coreLink), { recursive: true }); + fs.symlinkSync(storeCoreDir, coreLink, 'junction'); + + write( + path.join(root, 'project/main.cjs'), + `console.log(require('@voidzero-dev/vite-plus-core').marker);\n`, + ); + return root; +} + +function runProject(root: string, env: Record = {}) { + try { + const stdout = execFileSync(process.execPath, [path.join(root, 'project/main.cjs')], { + env: { ...process.env, NAPI_RS_ENFORCE_VERSION_CHECK: '', ...env }, + encoding: 'utf-8', + timeout: 30_000, + }); + return { ok: true as const, stdout }; + } catch (error) { + return { ok: false as const, stderr: String((error as { stderr?: string }).stderr ?? error) }; + } +} + +describe('binding resolution under the pnpm global virtual store layout', () => { + // Shared by the resolution and enforcement-pass tests below. + const matchingRoot = buildGlobalVirtualStoreLayout({ + loader: rewrittenLoader, + platformPackageVersion: CORE_VERSION, + }); + + it('reproduces #2054: the collapsed vite-plus/binding rewrite cannot resolve', () => { + const root = buildGlobalVirtualStoreLayout({ loader: collapsedLoader }); + expect(runProject(root)).toMatchObject({ + ok: false, + stderr: expect.stringContaining("Cannot find module 'vite-plus/binding'"), + }); + }); + + it('resolves through the declared platform package after the rewrite', () => { + expect(runProject(matchingRoot)).toEqual({ ok: true, stdout: 'native\n' }); + }); + + it('re-arms the version guard: enforcement rejects a mismatched platform package', () => { + const root = buildGlobalVirtualStoreLayout({ + loader: rewrittenLoader, + platformPackageVersion: '0.0.0-stale', + }); + expect(runProject(root, { NAPI_RS_ENFORCE_VERSION_CHECK: '1' })).toMatchObject({ + ok: false, + stderr: expect.stringContaining( + `Native binding package version mismatch, expected ${CORE_VERSION} but got 0.0.0-stale`, + ), + }); + // Matching versions pass with enforcement on. + expect(runProject(matchingRoot, { NAPI_RS_ENFORCE_VERSION_CHECK: '1' })).toEqual({ + ok: true, + stdout: 'native\n', + }); + }); +}); diff --git a/packages/core/__tests__/fixtures/rolldown-binding-loader.txt b/packages/core/__tests__/fixtures/rolldown-binding-loader.txt new file mode 100644 index 0000000000..fb50881d20 --- /dev/null +++ b/packages/core/__tests__/fixtures/rolldown-binding-loader.txt @@ -0,0 +1,124 @@ +// Captured excerpts from rolldown/packages/rolldown/dist/shared/binding-*.mjs +// (Rolldown 1.2.1, napi-rs generated loader). Input fixture for +// rewrite-rolldown-binding.spec.ts; stored as .txt because the stitched +// excerpts are not standalone-parseable JS and the code never executes. + +// #region webcontainer fallback +//#region src/webcontainer-fallback.cjs +var require_webcontainer_fallback = /* @__PURE__ */ __commonJSMin(((exports, module) => { + const fs = __require("node:fs"); + const childProcess = __require("node:child_process"); + const version = JSON.parse(fs.readFileSync(__require.resolve("rolldown/package.json"), "utf-8")).version; + const baseDir = `/tmp/rolldown-${version}`; + const bindingEntry = `${baseDir}/node_modules/@rolldown/binding-wasm32-wasi/rolldown-binding.wasi.cjs`; + if (!fs.existsSync(bindingEntry)) { + const bindingPkg = `@rolldown/binding-wasm32-wasi@${version}`; + fs.rmSync(baseDir, { + recursive: true, + force: true + }); + fs.mkdirSync(baseDir, { recursive: true }); + console.log(`[rolldown] Downloading ${bindingPkg} on WebContainer...`); + childProcess.execFileSync("pnpm", ["i", bindingPkg], { + cwd: baseDir, + stdio: "inherit" + }); + } + module.exports = __require(bindingEntry); +})); +// #endregion + +// #region darwin-x64 and darwin-arm64 branches (supported platforms) + if (process.arch === "x64") { + try { + return __require("./rolldown-binding.darwin-x64.node"); + } catch (e) { + loadErrors.push(e); + } + try { + const binding = __require("@rolldown/binding-darwin-x64"); + const bindingPackageVersion = __require("@rolldown/binding-darwin-x64/package.json").version; + if (bindingPackageVersion !== "1.2.1" && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== "0") throw new Error(`Native binding package version mismatch, expected 1.2.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`); + return binding; + } catch (e) { + loadErrors.push(e); + } + } else if (process.arch === "arm64") { + try { + return __require("../rolldown-binding.darwin-arm64.node"); + } catch (e) { + loadErrors.push(e); + } + try { + const binding = __require("@rolldown/binding-darwin-arm64"); + const bindingPackageVersion = __require("@rolldown/binding-darwin-arm64/package.json").version; + if (bindingPackageVersion !== "1.2.1" && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== "0") throw new Error(`Native binding package version mismatch, expected 1.2.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`); + return binding; + } catch (e) { + loadErrors.push(e); + } + } else loadErrors.push(/* @__PURE__ */ new Error(`Unsupported architecture on macOS: ${process.arch}`)); + } else if (process.platform === "freebsd") if (process.arch === "x64") { + try { + return __require("./rolldown-binding.freebsd-x64.node"); + } catch (e) { + loadErrors.push(e); + } +// #endregion + +// #region freebsd-x64 branch (unsupported platform) + try { + const binding = __require("@rolldown/binding-freebsd-x64"); + const bindingPackageVersion = __require("@rolldown/binding-freebsd-x64/package.json").version; + if (bindingPackageVersion !== "1.2.1" && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== "0") throw new Error(`Native binding package version mismatch, expected 1.2.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`); + return binding; + } catch (e) { + loadErrors.push(e); + } + } else if (process.arch === "arm64") { + try { + return __require("./rolldown-binding.freebsd-arm64.node"); + } catch (e) { + loadErrors.push(e); + } +// #endregion + +// #region linux-x64-musl branch (supported platform) + else if (process.platform === "linux") if (process.arch === "x64") if (isMusl()) { + try { + return __require("./rolldown-binding.linux-x64-musl.node"); + } catch (e) { + loadErrors.push(e); + } + try { + const binding = __require("@rolldown/binding-linux-x64-musl"); + const bindingPackageVersion = __require("@rolldown/binding-linux-x64-musl/package.json").version; + if (bindingPackageVersion !== "1.2.1" && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== "0") throw new Error(`Native binding package version mismatch, expected 1.2.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`); + return binding; + } catch (e) { + loadErrors.push(e); + } + } else { +// #endregion + +// #region wasi package fallback + if (!wasiBindingLoaded && (!__napiWasiFlavorRequested || __napiWasiFlavor === "wasm32-wasi")) { + let candidateError = null; + let candidateFailed = false; + try { + candidateError = __napiWasiResolveCandidate("@rolldown/binding-wasm32-wasi", true, void 0); + candidateFailed = candidateError !== null; + if (!candidateFailed) { + if (process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== "0") { + const bindingPackageVersion = __require("@rolldown/binding-wasm32-wasi/package.json").version; + if (bindingPackageVersion !== "1.2.1") throw new Error(`WASI binding package version mismatch, expected 1.2.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`); + } + wasiBinding = __require("@rolldown/binding-wasm32-wasi"); + nativeBinding = wasiBinding; + wasiBindingLoaded = true; + } + } catch (err) { + candidateError = err; + candidateFailed = true; + } +// #endregion diff --git a/packages/core/__tests__/rewrite-rolldown-binding.spec.ts b/packages/core/__tests__/rewrite-rolldown-binding.spec.ts new file mode 100644 index 0000000000..136088e57f --- /dev/null +++ b/packages/core/__tests__/rewrite-rolldown-binding.spec.ts @@ -0,0 +1,143 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import url from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import cliPkgJson from '../../cli/package.json' with { type: 'json' }; +import { rewriteRolldownBindingRequires } from '../build-support/rewrite-rolldown-binding.ts'; +import corePkgJson from '../package.json' with { type: 'json' }; + +const fixturePath = path.join( + path.dirname(url.fileURLToPath(import.meta.url)), + 'fixtures', + 'rolldown-binding-loader.txt', +); +const loaderFixture = fs.readFileSync(fixturePath, 'utf-8'); + +const PACKAGE_NAME = '@voidzero-dev/vite-plus'; + +// The suffixes the fixture's supported branches use; must stay a subset of the +// CLI's napi.targets-derived set the release build passes in. +const platformSuffixes = new Set([ + 'darwin-arm64', + 'darwin-x64', + 'linux-arm64-gnu', + 'linux-arm64-musl', + 'linux-x64-gnu', + 'linux-x64-musl', + 'win32-arm64-msvc', + 'win32-x64-msvc', +]); + +const VERSION = '9.9.9'; + +describe('rewriteRolldownBindingRequires', () => { + const result = rewriteRolldownBindingRequires(loaderFixture, { + packageName: PACKAGE_NAME, + platformSuffixes, + version: VERSION, + }); + + it('rewrites supported platform branches to Vite+ platform packages', () => { + // Fixture contains three supported branches: darwin-x64, darwin-arm64, + // linux-x64-musl. Each requires the package and its package.json. + for (const suffix of ['darwin-x64', 'darwin-arm64', 'linux-x64-musl']) { + expect(result.source).toContain(`__require("@voidzero-dev/vite-plus-${suffix}")`); + expect(result.source).toContain( + `__require("@voidzero-dev/vite-plus-${suffix}/package.json")`, + ); + expect(result.source).not.toContain(`@rolldown/binding-${suffix}`); + } + expect(result.rewrittenSuffixes).toEqual( + new Set(['darwin-x64', 'darwin-arm64', 'linux-x64-musl']), + ); + expect(result.specifierRewrites).toBe(6); + }); + + it('rewrites version guards only for rewritten branches', () => { + expect(result.guardRewrites).toBe(3); + const guardMatches = result.source.match(/bindingPackageVersion !== "9\.9\.9"/g); + expect(guardMatches).toHaveLength(3); + const messageMatches = result.source.match(/expected 9\.9\.9 but got/g); + expect(messageMatches).toHaveLength(3); + }); + + it('leaves unsupported platform branches on the Rolldown packages and version', () => { + expect(result.source).toContain('__require("@rolldown/binding-freebsd-x64")'); + expect(result.source).toContain('__require("@rolldown/binding-freebsd-x64/package.json")'); + // The freebsd guard still compares against the Rolldown version. + const freebsdGuard = result.source.slice( + result.source.indexOf('@rolldown/binding-freebsd-x64/package.json'), + ); + expect(freebsdGuard).toContain('bindingPackageVersion !== "1.2.1"'); + expect(freebsdGuard).toContain('expected 1.2.1 but got'); + }); + + it('leaves the WASI and WebContainer fallbacks untouched', () => { + expect(result.source).toContain('__napiWasiResolveCandidate("@rolldown/binding-wasm32-wasi"'); + expect(result.source).toContain('__require("@rolldown/binding-wasm32-wasi/package.json")'); + expect(result.source).toContain( + '`${baseDir}/node_modules/@rolldown/binding-wasm32-wasi/rolldown-binding.wasi.cjs`', + ); + expect(result.source).toContain('`@rolldown/binding-wasm32-wasi@${version}`'); + }); + + it('leaves version strings outside rewritten guards untouched', () => { + // Rolldown's public version stays its own; only guard comparisons of + // rewritten branches change. + const source = `const VERSION = "1.2.1";\nexport { VERSION };\n`; + const rewritten = rewriteRolldownBindingRequires(source, { + packageName: PACKAGE_NAME, + platformSuffixes, + version: VERSION, + }); + expect(rewritten.source).toBe(source); + expect(rewritten.specifierRewrites).toBe(0); + expect(rewritten.guardRewrites).toBe(0); + }); + + it('handles single-quoted loader output', () => { + const source = [ + `const binding = require('@rolldown/binding-darwin-arm64');`, + `const bindingPackageVersion = require('@rolldown/binding-darwin-arm64/package.json').version;`, + `if (bindingPackageVersion !== '1.2.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') throw new Error(\`Native binding package version mismatch, expected 1.2.1 but got \${bindingPackageVersion}. You can reinstall dependencies to fix this issue.\`);`, + ].join('\n'); + const rewritten = rewriteRolldownBindingRequires(source, { + packageName: PACKAGE_NAME, + platformSuffixes, + version: VERSION, + }); + expect(rewritten.source).toContain(`require('@voidzero-dev/vite-plus-darwin-arm64')`); + expect(rewritten.source).toContain(`bindingPackageVersion !== '9.9.9'`); + expect(rewritten.source).toContain('expected 9.9.9 but got'); + expect(rewritten.specifierRewrites).toBe(2); + expect(rewritten.guardRewrites).toBe(1); + }); + + it('is stable when applied twice', () => { + const second = rewriteRolldownBindingRequires(result.source, { + packageName: PACKAGE_NAME, + platformSuffixes, + version: VERSION, + }); + expect(second.source).toBe(result.source); + expect(second.specifierRewrites).toBe(0); + }); + + it('covers every CLI napi target with a platform suffix', async () => { + const { parseTriple } = await import('@napi-rs/cli'); + expect(cliPkgJson.napi.packageName).toBe(PACKAGE_NAME); + const suffixes = cliPkgJson.napi.targets.map((target) => parseTriple(target).platformArchABI); + expect(new Set(suffixes)).toEqual(platformSuffixes); + }); + + it('keeps the committed core package.json free of platform pins', () => { + // Platform pins are injected at publish time + // (packages/cli/publish-native-addons.ts), never committed. + const committedNativePins = Object.keys(corePkgJson.optionalDependencies).filter((name) => + name.startsWith(`${PACKAGE_NAME}-`), + ); + expect(committedNativePins).toEqual([]); + }); +}); diff --git a/packages/core/build-support/rewrite-rolldown-binding.ts b/packages/core/build-support/rewrite-rolldown-binding.ts new file mode 100644 index 0000000000..f5d1bb35ef --- /dev/null +++ b/packages/core/build-support/rewrite-rolldown-binding.ts @@ -0,0 +1,82 @@ +// Release builds rewrite bundled Rolldown's native binding requires from +// `@rolldown/binding-` to the Vite+ platform packages +// (`-`), whose merged `.node` contains +// `rolldown_binding`. Core declares those packages as `optionalDependencies` +// at publish time (see packages/cli/publish-native-addons.ts), so the bundled +// loader resolves through declared dependencies instead of requiring back into +// `vite-plus`. See rfcs/core-binding-resolution.md. +// +// This is a text transform rather than a `rewriteModuleSpecifiers` rule +// because the version-guard rewrite is not a module specifier and must stay +// paired with its branch's specifier rewrite, so one module owns both and the +// caller can assert the pairing. + +export interface RewriteRolldownBindingOptions { + /** The napi `packageName` platform packages derive from, e.g. `@voidzero-dev/vite-plus`. */ + packageName: string; + /** + * napi platform suffixes Vite+ ships native packages for, e.g. + * `darwin-arm64`, `linux-x64-musl` (derived from the CLI's `napi.targets`). + * Specifiers for other platforms, including the `wasm32-wasi` fallback, + * are left on `@rolldown/binding-*` and keep their upstream version guards. + */ + platformSuffixes: ReadonlySet; + /** Version the platform packages are published as (core's own version). */ + version: string; +} + +export interface RewriteRolldownBindingResult { + source: string; + /** Platform suffixes whose loader branch was redirected. */ + rewrittenSuffixes: Set; + /** `@rolldown/binding-` occurrences rewritten (two per branch). */ + specifierRewrites: number; + /** Version guards rewritten (one per branch). */ + guardRewrites: number; +} + +const SPECIFIER_RE = /@rolldown\/binding-([a-z0-9-]+)/g; + +// A rewritten platform branch's version guard, generated by napi-rs: +// +// const bindingPackageVersion = __require("-/package.json").version; +// if (bindingPackageVersion !== "" && ...) throw new Error(`... expected but got ...`); +// +// Anchoring on the rewritten specifier scopes the version replacement to +// branches this build actually redirects; guards of platforms left on +// `@rolldown/binding-*` keep comparing against the Rolldown version. Matching +// bare `require(` also covers the bundled chunks' `__require(` calls. +function buildGuardRe(packageName: string): RegExp { + const escaped = packageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp( + `(require\\(["']${escaped}-[a-z0-9-]+\\/package\\.json["']\\)\\.version;\\s*if \\(bindingPackageVersion !== )(["'])[^"']+\\2([\\s\\S]{0,400}?expected )\\S+( but got)`, + 'g', + ); +} + +export function rewriteRolldownBindingRequires( + source: string, + { packageName, platformSuffixes, version }: RewriteRolldownBindingOptions, +): RewriteRolldownBindingResult { + const rewrittenSuffixes = new Set(); + let specifierRewrites = 0; + let rewritten = source.replace(SPECIFIER_RE, (specifier, suffix: string) => { + if (!platformSuffixes.has(suffix)) { + return specifier; + } + rewrittenSuffixes.add(suffix); + specifierRewrites += 1; + return `${packageName}-${suffix}`; + }); + + let guardRewrites = 0; + rewritten = rewritten.replace( + buildGuardRe(packageName), + (_match, head: string, quote: string, mid: string, tail: string) => { + guardRewrites += 1; + return `${head}${quote}${version}${quote}${mid}${version}${tail}`; + }, + ); + + return { source: rewritten, rewrittenSuffixes, specifierRewrites, guardRewrites }; +} diff --git a/packages/core/build.ts b/packages/core/build.ts index 33fbc01752..d7a43b58f3 100644 --- a/packages/core/build.ts +++ b/packages/core/build.ts @@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'; import { copyFile, cp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import path from 'node:path'; -import { dirname, join, parse, resolve, relative } from 'node:path'; +import { dirname, join, parse, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { format } from 'oxfmt'; @@ -18,6 +18,7 @@ import { glob } from 'tinyglobby'; import { generateLicenseFile } from '../../scripts/generate-license.js'; import viteRolldownConfig from '../../vite/packages/vite/rolldown.config.js'; +import cliPkgJson from '../cli/package.json' with { type: 'json' }; import { buildCjsDeps } from './build-support/build-cjs-deps.js'; import { replaceThirdPartyCjsRequires } from './build-support/find-create-require.js'; import { RewriteImportsPlugin } from './build-support/rewrite-imports.js'; @@ -27,6 +28,7 @@ import { rewriteModuleSpecifiers, type ReplacementRule, } from './build-support/rewrite-module-specifiers.js'; +import { rewriteRolldownBindingRequires } from './build-support/rewrite-rolldown-binding.js'; import pkgJson from './package.json' with { type: 'json' }; const projectDir = join(fileURLToPath(import.meta.url), '..'); @@ -370,6 +372,22 @@ async function bundleRolldown() { }, }); + // Platform suffixes Vite+ publishes native packages for, e.g. `darwin-arm64` + // from `aarch64-apple-darwin`. `@rolldown/binding-*` uses the same napi + // suffix convention, so these are the loader branches release builds + // redirect to `-`. `@napi-rs/cli` loads lazily + // because only release builds need it (it costs ~120ms to import). + let vitePlusPlatformSuffixes: ReadonlySet | undefined; + if (process.env.RELEASE_BUILD) { + const { parseTriple } = await import('@napi-rs/cli'); + vitePlusPlatformSuffixes = new Set( + cliPkgJson.napi.targets.map((target) => parseTriple(target).platformArchABI), + ); + } + const rewrittenSuffixes = new Set(); + let bindingSpecifierRewrites = 0; + let bindingGuardRewrites = 0; + // Rewrite @rolldown/pluginutils imports in JS and type declaration files for (const file of rolldownFiles) { if ( @@ -380,20 +398,41 @@ async function bundleRolldown() { ) { let source = await readFile(file, 'utf-8'); const rules: ReplacementRule[] = [...createRolldownRewriteRules(pkgJson.name)]; - if (process.env.RELEASE_BUILD) { - const rolldownBindingVersion = ( - await import(toPosixPath(relative(projectDir, join(rolldownSourceDir, 'package.json'))), { - with: { type: 'json' }, - }) - ).default.version; - // @rolldown/binding-darwin-arm64 → @voidzero-dev/vite-plus-darwin-arm64/binding - source = source.replace(/@rolldown\/binding-([a-z0-9-]+)/g, 'vite-plus/binding'); - source = source.replaceAll(`${rolldownBindingVersion}`, pkgJson.version); + if (vitePlusPlatformSuffixes) { + const result = rewriteRolldownBindingRequires(source, { + packageName: cliPkgJson.napi.packageName, + platformSuffixes: vitePlusPlatformSuffixes, + version: pkgJson.version, + }); + source = result.source; + for (const suffix of result.rewrittenSuffixes) { + rewrittenSuffixes.add(suffix); + } + bindingSpecifierRewrites += result.specifierRewrites; + bindingGuardRewrites += result.guardRewrites; } const newSource = rewriteModuleSpecifiers(source, file, { rules }); await writeFile(file, newSource); } } + + // 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 + // that reshapes the generated loader, or a Rolldown loader that drops a + // branch, breaks these invariants; fail the release build instead of + // shipping a partial rewrite. + if (vitePlusPlatformSuffixes) { + const missing = [...vitePlusPlatformSuffixes].filter((s) => !rewrittenSuffixes.has(s)); + if (missing.length > 0 || bindingSpecifierRewrites !== bindingGuardRewrites * 2) { + throw new Error( + `bundleRolldown: unexpected Rolldown binding loader shape ` + + `(${bindingSpecifierRewrites} specifier rewrites, ${bindingGuardRewrites} guard rewrites` + + (missing.length > 0 ? `, missing platform branches: ${missing.join(', ')}` : '') + + `); update build-support/rewrite-rolldown-binding.ts for the current napi-rs loader format`, + ); + } + } } async function bundleTsdown() { diff --git a/packages/core/package.json b/packages/core/package.json index 7976a69163..6873596851 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -116,6 +116,7 @@ "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", + "@napi-rs/cli": "catalog:", "@oxc-node/cli": "catalog:", "@oxc-node/core": "catalog:", "@tsdown/css": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed9004caaa..f71400d6e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -605,6 +605,9 @@ importers: '@babel/types': specifier: ^7.28.5 version: 7.29.7 + '@napi-rs/cli': + specifier: 'catalog:' + version: 3.8.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@1.11.2)(@types/node@24.13.3)(node-addon-api@7.1.1) '@oxc-node/cli': specifier: 'catalog:' version: 0.1.0 @@ -10063,6 +10066,39 @@ snapshots: dependencies: '@braidai/lang': 1.1.2 + '@napi-rs/cli@3.8.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@1.11.2)(@types/node@24.13.3)(node-addon-api@7.1.1)': + dependencies: + '@inquirer/prompts': 8.5.2(@types/node@24.13.3) + '@napi-rs/cross-toolchain': 1.0.3(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@1.11.2) + '@napi-rs/wasm-tools': 1.0.1(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@1.11.2) + '@octokit/rest': 22.0.1 + clipanion: 4.0.0-rc.4(typanion@3.14.0) + colorette: 2.0.20 + emnapi: 2.0.0-alpha.3(node-addon-api@7.1.1) + es-toolkit: 1.47.1 + js-yaml: 4.3.0 + obug: 2.1.4 + semver: 7.8.5 + typanion: 3.14.0 + typescript: 6.0.3 + optionalDependencies: + '@emnapi/runtime': 1.11.2 + transitivePeerDependencies: + - '@emnapi/core' + - '@napi-rs/cross-toolchain-arm64-target-aarch64' + - '@napi-rs/cross-toolchain-arm64-target-armv7' + - '@napi-rs/cross-toolchain-arm64-target-ppc64le' + - '@napi-rs/cross-toolchain-arm64-target-s390x' + - '@napi-rs/cross-toolchain-arm64-target-x86_64' + - '@napi-rs/cross-toolchain-x64-target-aarch64' + - '@napi-rs/cross-toolchain-x64-target-armv7' + - '@napi-rs/cross-toolchain-x64-target-ppc64le' + - '@napi-rs/cross-toolchain-x64-target-s390x' + - '@napi-rs/cross-toolchain-x64-target-x86_64' + - '@types/node' + - node-addon-api + - supports-color + '@napi-rs/cli@3.8.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@1.9.1)(@types/node@24.10.3)(node-addon-api@7.1.1)': dependencies: '@inquirer/prompts': 8.5.2(@types/node@24.10.3) @@ -10162,6 +10198,16 @@ snapshots: - node-addon-api - supports-color + '@napi-rs/cross-toolchain@1.0.3(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@1.11.2)': + dependencies: + '@napi-rs/lzma': 1.4.5(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@1.11.2) + '@napi-rs/tar': 1.1.0(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@1.11.2) + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + - supports-color + '@napi-rs/cross-toolchain@1.0.3(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@1.9.1)': dependencies: '@napi-rs/lzma': 1.4.5(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@1.9.1) @@ -10221,6 +10267,14 @@ snapshots: '@napi-rs/lzma-linux-x64-musl@1.4.5': optional: true + '@napi-rs/lzma-wasm32-wasi@1.4.5(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@1.11.2)': + dependencies: + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@1.11.2) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + '@napi-rs/lzma-wasm32-wasi@1.4.5(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@1.9.1)': dependencies: '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@1.9.1) @@ -10246,6 +10300,29 @@ snapshots: '@napi-rs/lzma-win32-x64-msvc@1.4.5': optional: true + '@napi-rs/lzma@1.4.5(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@1.11.2)': + optionalDependencies: + '@napi-rs/lzma-android-arm-eabi': 1.4.5 + '@napi-rs/lzma-android-arm64': 1.4.5 + '@napi-rs/lzma-darwin-arm64': 1.4.5 + '@napi-rs/lzma-darwin-x64': 1.4.5 + '@napi-rs/lzma-freebsd-x64': 1.4.5 + '@napi-rs/lzma-linux-arm-gnueabihf': 1.4.5 + '@napi-rs/lzma-linux-arm64-gnu': 1.4.5 + '@napi-rs/lzma-linux-arm64-musl': 1.4.5 + '@napi-rs/lzma-linux-ppc64-gnu': 1.4.5 + '@napi-rs/lzma-linux-riscv64-gnu': 1.4.5 + '@napi-rs/lzma-linux-s390x-gnu': 1.4.5 + '@napi-rs/lzma-linux-x64-gnu': 1.4.5 + '@napi-rs/lzma-linux-x64-musl': 1.4.5 + '@napi-rs/lzma-wasm32-wasi': 1.4.5(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@1.11.2) + '@napi-rs/lzma-win32-arm64-msvc': 1.4.5 + '@napi-rs/lzma-win32-ia32-msvc': 1.4.5 + '@napi-rs/lzma-win32-x64-msvc': 1.4.5 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + '@napi-rs/lzma@1.4.5(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@1.9.1)': optionalDependencies: '@napi-rs/lzma-android-arm-eabi': 1.4.5 @@ -10328,6 +10405,14 @@ snapshots: '@napi-rs/tar-linux-x64-musl@1.1.0': optional: true + '@napi-rs/tar-wasm32-wasi@1.1.0(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@1.11.2)': + dependencies: + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@1.11.2) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + '@napi-rs/tar-wasm32-wasi@1.1.0(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@1.9.1)': dependencies: '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@1.9.1) @@ -10353,6 +10438,28 @@ snapshots: '@napi-rs/tar-win32-x64-msvc@1.1.0': optional: true + '@napi-rs/tar@1.1.0(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@1.11.2)': + optionalDependencies: + '@napi-rs/tar-android-arm-eabi': 1.1.0 + '@napi-rs/tar-android-arm64': 1.1.0 + '@napi-rs/tar-darwin-arm64': 1.1.0 + '@napi-rs/tar-darwin-x64': 1.1.0 + '@napi-rs/tar-freebsd-x64': 1.1.0 + '@napi-rs/tar-linux-arm-gnueabihf': 1.1.0 + '@napi-rs/tar-linux-arm64-gnu': 1.1.0 + '@napi-rs/tar-linux-arm64-musl': 1.1.0 + '@napi-rs/tar-linux-ppc64-gnu': 1.1.0 + '@napi-rs/tar-linux-s390x-gnu': 1.1.0 + '@napi-rs/tar-linux-x64-gnu': 1.1.0 + '@napi-rs/tar-linux-x64-musl': 1.1.0 + '@napi-rs/tar-wasm32-wasi': 1.1.0(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@1.11.2) + '@napi-rs/tar-win32-arm64-msvc': 1.1.0 + '@napi-rs/tar-win32-ia32-msvc': 1.1.0 + '@napi-rs/tar-win32-x64-msvc': 1.1.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + '@napi-rs/tar@1.1.0(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@1.9.1)': optionalDependencies: '@napi-rs/tar-android-arm-eabi': 1.1.0 @@ -10418,6 +10525,13 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@1.11.2)': + dependencies: + '@emnapi/core': 2.0.0-alpha.3 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.10.3 + optional: true + '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@1.9.1)': dependencies: '@emnapi/core': 2.0.0-alpha.3 @@ -10458,6 +10572,14 @@ snapshots: '@napi-rs/wasm-tools-linux-x64-musl@1.0.1': optional: true + '@napi-rs/wasm-tools-wasm32-wasi@1.0.1(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@1.11.2)': + dependencies: + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@1.11.2) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + '@napi-rs/wasm-tools-wasm32-wasi@1.0.1(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@1.9.1)': dependencies: '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@1.9.1) @@ -10483,6 +10605,25 @@ snapshots: '@napi-rs/wasm-tools-win32-x64-msvc@1.0.1': optional: true + '@napi-rs/wasm-tools@1.0.1(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@1.11.2)': + optionalDependencies: + '@napi-rs/wasm-tools-android-arm-eabi': 1.0.1 + '@napi-rs/wasm-tools-android-arm64': 1.0.1 + '@napi-rs/wasm-tools-darwin-arm64': 1.0.1 + '@napi-rs/wasm-tools-darwin-x64': 1.0.1 + '@napi-rs/wasm-tools-freebsd-x64': 1.0.1 + '@napi-rs/wasm-tools-linux-arm64-gnu': 1.0.1 + '@napi-rs/wasm-tools-linux-arm64-musl': 1.0.1 + '@napi-rs/wasm-tools-linux-x64-gnu': 1.0.1 + '@napi-rs/wasm-tools-linux-x64-musl': 1.0.1 + '@napi-rs/wasm-tools-wasm32-wasi': 1.0.1(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@1.11.2) + '@napi-rs/wasm-tools-win32-arm64-msvc': 1.0.1 + '@napi-rs/wasm-tools-win32-ia32-msvc': 1.0.1 + '@napi-rs/wasm-tools-win32-x64-msvc': 1.0.1 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + '@napi-rs/wasm-tools@1.0.1(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@1.9.1)': optionalDependencies: '@napi-rs/wasm-tools-android-arm-eabi': 1.0.1 diff --git a/rfcs/core-binding-resolution.md b/rfcs/core-binding-resolution.md new file mode 100644 index 0000000000..b9c2c905ed --- /dev/null +++ b/rfcs/core-binding-resolution.md @@ -0,0 +1,76 @@ +# RFC: Resolve Core's Rolldown Binding via Platform Packages + +- Issue: [#2054](https://github.com/voidzero-dev/vite-plus/issues/2054) +- Supersedes: [#2067](https://github.com/voidzero-dev/vite-plus/pull/2067) (same rewrite direction, different version wiring) + +## Problem + +Release builds of `@voidzero-dev/vite-plus-core` collapse every `@rolldown/binding-*` require in bundled Rolldown's loader to `vite-plus/binding`. This breaks three ways: + +1. Core never declares `vite-plus`, so the require resolves only through pnpm's hidden hoist and closes an undeclared cycle (`vite-plus -> core -> vite-plus/binding`). pnpm's `enable-global-virtual-store` and Yarn PnP enforce declared dependencies and fail with `Cannot find module 'vite-plus/binding'`. Repro: . +2. Core installed without `vite-plus` (the `"vite": "npm:@voidzero-dev/vite-plus-core@"` alias) cannot load Rolldown at all. +3. The collapsed rewrite turns every version guard into `require('vite-plus/binding/package.json')`, which throws `ERR_PACKAGE_PATH_NOT_EXPORTED` (`vite-plus` exports no such subpath). Every platform branch fails, and the binding loads only through the rewritten WASI fallback, skipping the version check. + +PR #2067 fixed 1-2 with a per-platform rewrite but committed exact-pinned platform `optionalDependencies` into `packages/core/package.json`. That fights the release model: `prepare_release.yml` bumps only `version` fields, `mergePackageJson()` plus the CI dirty-tree check fail on stale pins, the repo lockfile pulls published bindings into the dev workspace, and preview builds pin the previous release. + +The pipeline already solves this for `vite-plus` itself: napi-rs `prePublish` injects the exact-pinned `@voidzero-dev/vite-plus-` entries at publish time, and the committed package.json carries none. + +## Package graphs + +Published release artifacts; solid edges are declared dependencies, dashed is the undeclared runtime require. + +### Before + +```mermaid +flowchart TD + app["user project"] --> vp["vite-plus"] + vp -->|"dependency, exact pin"| core["@voidzero-dev/vite-plus-core
(bundled Rolldown)"] + vp -->|"optionalDependencies x8,
injected at publish"| plat["@voidzero-dev/vite-plus-*
platform packages (merged .node)"] + core -.->|"require('vite-plus/binding'),
undeclared"| vp + linkStyle 3 stroke:#d33,stroke-width:2px +``` + +### After + +```mermaid +flowchart TD + app["user project"] --> vp["vite-plus"] + vp -->|"dependency, exact pin"| core["@voidzero-dev/vite-plus-core
(bundled Rolldown)"] + vp -->|"optionalDependencies x8,
injected at publish"| plat["@voidzero-dev/vite-plus-*
platform packages (merged .node)"] + core -->|"optionalDependencies x8,
injected at publish"| plat +``` + +No new packages, no cycle, and core works standalone. Package managers dedupe the shared platform package, so nothing downloads twice. + +## Design + +All changes apply to release artifacts; dev builds keep `@rolldown/binding-*` and load the `.node` embedded in dist. + +1. **Per-platform rewrite** (`packages/core/build-support/rewrite-rolldown-binding.ts`): `@rolldown/binding-` becomes `@voidzero-dev/vite-plus-` for the suffixes derived from the CLI's `napi.targets` via napi-rs `parseTriple`. Requiring a platform package returns the merged `.node` via its `main`, the same shape as Rolldown's own binding packages. Other platforms (android, freebsd, `wasm32-wasi`, `darwin-universal`, the WebContainer fallback) stay on `@rolldown/binding-*`. +2. **Guard rewrite scoped to rewritten branches**: each loader branch pairs its require with a version guard (`bindingPackageVersion !== ""`, enforced under `NAPI_RS_ENFORCE_VERSION_CHECK`). The transform rewrites the guard's expected version to core's version in one pattern anchored on the rewritten specifier, so untouched branches keep upstream guards and Rolldown's public `VERSION` export stays the Rolldown version. Platform packages publish lockstep with core, making the guard a real check again. The build fails if any published platform suffix misses its loader branch or the specifier and guard rewrites diverge, so a napi-rs loader format change cannot ship a partial rewrite. +3. **Publish-time optionalDependencies injection** (`packages/cli/publish-native-addons.ts`): after napi-rs `prePublish` injects the CLI's platform pins, mirror the identical entries into `packages/core/package.json`, failing if any target is missing. The script runs in both flows before core is packed: release (`--mode npm`, platform packages publish first so pins resolve) and registry-bridge preview (`--mode pkg-pr-new`, pins match the bridge-served versions). Nothing version-pinned lands in committed files. +4. **Stamp core's version in release builds** (`reusable-release-build.yml`): stamp `packages/core/package.json` to `VERSION` next to the existing CLI stamp so baked guard versions match the published platform packages. A no-op for releases, a fix for previews. +5. **Export removal**: the `vite-plus/binding` export existed only for the collapsed rewrite. Nothing imports the specifier (the CLI loads its binding relatively; no repo, dist, or ecosystem references), so the export is removed. Old published cores that require it always pair with an old `vite-plus` through the exact version pin, so removal cannot strand them. + +Unchanged: dev builds, local registry and e2e (they install dev-built core), `prepare_release.yml`, `mergePackageJson()`, the repo lockfile. + +## Alternatives + +- **Committed pins (PR #2067)**: needs a release-time re-pin step, lockfile entries, and a pins-match-version test only to compensate for committing values the publish pipeline already knows. +- **Neutral loader package `@voidzero-dev/vite-plus-binding`**: cleanest graph and native napi-rs injection, but a ninth lockstep package plus relocating the napi packaging out of `packages/cli/binding/`, which the local CLI, bootstrap, pack-local, and preview flows all depend on. Buys no resolution property that declaring the existing platform packages lacks; revisit if a third package ever needs the binding. +- **Optional peer on `vite-plus` (#2053)**: declares the cycle instead of removing it; standalone core stays broken. +- **Ship the `.node` inside release core**: duplicates the native addon the platform packages exist to deduplicate. + +## Testing + +1. Unit tests for the transform against a captured loader excerpt: supported branches rewritten (specifier plus guard), unsupported and WASI branches untouched, stable on a second pass. +2. The publish script fails when the injected pins do not cover every `napi.target`. +3. Layout resolution spec (`binding-resolution-layout.spec.ts`): rebuilds the global-virtual-store shape with stub packages and asserts the collapsed rewrite fails with `Cannot find module 'vite-plus/binding'` while the transform output resolves and its guard rejects a mismatched platform package. A PTY snapshot case cannot cover this: snapshot installs use dev-built core, which embeds the `.node` in dist and never takes the rewritten path. +4. Full-stack regression: install packed release artifacts into a project with `enable-global-virtual-store=true` and bundle through `@voidzero-dev/vite-plus-core/rolldown`. Meaningful only for `RELEASE_BUILD` artifacts, so it belongs in the preview pipeline. The repro from #2054 is the acceptance test. + +Ships in a normal release; no consumer action. + +## Open questions + +1. Should napi-rs support injecting platform `optionalDependencies` into more than one package? Worth an upstream issue; it would replace the script-side mirror in Design 3. +2. Where the layout regression test runs: preview pipeline (proposed), a release-PR preflight, or an opt-in `RELEASE_BUILD=1` e2e leg.