diff --git a/.gitignore b/.gitignore index 552fa27fc..45f452739 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ dist .DS_Store .wrangler .void-wrangler.jsonc +.cloudflare +cloudflare.config.ts .env .claude plan.md diff --git a/.node-version b/.node-version index df6ae3370..a43a5b943 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -24.21.0 +24.22.0 diff --git a/README.md b/README.md index ee6e6c54a..8a504e110 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ The SDK's features are optional and composable. Use React, Vue, Svelte, or Solid ## Getting Started -Use Node.js 24.21.0 or later. In an empty directory, run: +Use Node.js 24.22.0 or Node.js 26.12.0 or later. In an empty directory, run: ```sh npm install -D void diff --git a/docs/guide/platform/installation/prerequisites.md b/docs/guide/platform/installation/prerequisites.md index 06d59f2f7..a51d8aeb9 100644 --- a/docs/guide/platform/installation/prerequisites.md +++ b/docs/guide/platform/installation/prerequisites.md @@ -34,7 +34,7 @@ The platform stores request metrics and logs in Analytics Engine. If installatio ## Install the CLI and Preview -With Node.js 24.21.0 or later, install the CLI: +With Node.js 24.22.0 or Node.js 26.12.0 or later, install the CLI: ```sh pnpm add --global void diff --git a/docs/reference/api/plugin.md b/docs/reference/api/plugin.md index fb6e32e1e..3298bdfee 100644 --- a/docs/reference/api/plugin.md +++ b/docs/reference/api/plugin.md @@ -16,10 +16,10 @@ export default defineConfig({ }); ``` -| Option | Type | Description | -| ------------------ | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `persistTo` | `string` | Directory path for persisting local dev state (D1, KV, R2). Defaults to `.void/` in the project root. | -| `auxiliaryWorkers` | `AuxiliaryWorkerConfig[]` | Additional workers to run inside the same Miniflare instance during dev. Passed through to `@cloudflare/vite-plugin`. Useful for running multiple workers that share bindings (e.g. a separate API worker alongside a dashboard). | +| Option | Type | Description | +| ------------------ | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `persistTo` | `string` | Directory path for persisting local dev state (D1, KV, R2). Defaults to `.void/` in the project root. | +| `auxiliaryWorkers` | `AuxiliaryWorkerConfig[]` | Additional Workers to run alongside your app, such as a separate API Worker and dashboard that share bindings. Each entry accepts `configPath` (JSON, JSONC, or TOML), `config` (settings or a callback), `viteEnvironment`, and `devOnly`. | ## Project configuration {#project-configuration} diff --git a/packages/create-void/README.md b/packages/create-void/README.md index 48fa89f13..e203f3d3f 100644 --- a/packages/create-void/README.md +++ b/packages/create-void/README.md @@ -4,7 +4,7 @@ Create a full-stack Vite app with [Void](https://void.cloud), ready for local de ## Getting Started -Use Node.js 24.21.0 or later: +Use Node.js 24.22.0 or Node.js 26.12.0 or later: ```sh npm create void@latest my-app diff --git a/packages/create-void/package.json b/packages/create-void/package.json index b0cd5ab7b..c4df25cfd 100644 --- a/packages/create-void/package.json +++ b/packages/create-void/package.json @@ -35,6 +35,6 @@ "vite-plus": "catalog:" }, "engines": { - "node": ">=24.21.0" + "node": "^24.22.0 || >=26.12.0" } } diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index 473867ba7..8814d8769 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -21,6 +21,8 @@ Project settings live in `void.config.ts` through `void/config` and `defineConfi Build-output config translation uses the public `@cloudflare/codemods` API with a compatible dependency range. Keep the lockfile's Cloudflare toolchain aligned with `cf`, while allowing compatible consumer updates. Migration and generated-config evaluation share one disposable, uncredentialed subprocess; unresolved blocking follow-ups must still fail closed. Keep real `cf` prebuilt dry-run coverage. +The Cloudflare Vite v2 bridge owns the ignored root `cloudflare.config.ts` and `.cloudflare/output`. Preserve Void's existing public config and `dist` artifacts; never overwrite an authored native config. Variable values stay out of the generated root config and codemod input files. Preview uses an isolated artifact copy with local-only secret declarations and removes it when the server closes. Keep output mirroring after framework post-build hooks and prerender after mirroring. Docker image builds remain deferred until deploy preparation: Container builds carry `void-containers.json` next to the native root config, pointing to their legacy Worker directory. Keep that directory with the native output until preparation completes. The normal CLI deploy uploads the Worker artifact covered by its existing validation; do not select the entire native output tree and implicitly authorize additional Workers or settings. Mock Void's bridge in binding-merge unit tests; exercise the real upstream plugin in bridge and build-output integration tests. + Unlinked projects with a root `wrangler.jsonc` or `wrangler.json` get a link-and-deploy prompt from `cli/cloudflare-link.ts` before the hosted fallback in `runDeployInner`. Honor explicit platform/project selections and saved destinations. Acceptance verifies the existing Worker, saves Cloudflare, and continues in the same invocation. `.void/cloudflare-link.json` keeps the handoff guard across failed builds; remove it only after successful activation. The handoff reuses resource IDs and remote secrets, blocks unapproved resource additions and schema/secret changes, and skips trigger/settings publication. `@void/deploy-cloudflare` validates the active and uploaded bindings and handler sets. Because bundled Wrangler inherits secrets from the latest upload, the handoff requires the active version to be latest before upload and verifies that no other candidate appeared during upload. Later deployments with a newer inactive upload and ordinary revisions with inherited active secrets use the version-aware API to inherit opaque secrets from the inspected active version, and upload assets explicitly. The active version must still own all traffic before upload. Deployments without remote secrets and explicit secret replacements retain installed `cf` uploads. The first handoff still fails closed. Later code-only deployments carry the opaque trigger-preservation marker forward; adding a trigger requires a known traffic snapshot or explicit root `routes`, `workers_dev`, and `preview_urls`. Older versions without complete trigger metadata use traffic-only rollback, retaining current domains and schedules. Existing TOML configs are detected and rejected before generating a competing config; named environments remain unsupported. Assets-only Workers report null or empty handlers alongside `script_runtime.assets`. Accept that shape during handoff; only the static adapter may add its `ASSETS` binding and sole `fetch` handler. Preserve every existing binding and reject name collisions. Missing handler metadata without verified assets must not bypass the handler checks. diff --git a/packages/void/README.md b/packages/void/README.md index 1ffc6c098..8ccd09804 100644 --- a/packages/void/README.md +++ b/packages/void/README.md @@ -12,7 +12,7 @@ The SDK's features are optional and composable. Use React, Vue, Svelte, or Solid ## Getting Started -Use Node.js 24.21.0 or later. In an empty directory, run: +Use Node.js 24.22.0 or Node.js 26.12.0 or later. In an empty directory, run: ```sh npm install -D void diff --git a/packages/void/package.json b/packages/void/package.json index c93d252a7..3573180fa 100644 --- a/packages/void/package.json +++ b/packages/void/package.json @@ -379,6 +379,7 @@ "pg": "^8.23.1", "postal-mime": "catalog:", "proper-lockfile": "^4.1.2", + "smol-toml": "^1.9.1", "terminal-link": "^5.0.0", "yaml": "^2.9.1" }, @@ -427,6 +428,6 @@ } }, "engines": { - "node": ">=24.21.0" + "node": "^24.22.0 || >=26.12.0" } } diff --git a/packages/void/src/cli/cf-build-output.ts b/packages/void/src/cli/cf-build-output.ts index e67ee17e4..ed34c78d4 100644 --- a/packages/void/src/cli/cf-build-output.ts +++ b/packages/void/src/cli/cf-build-output.ts @@ -129,6 +129,61 @@ export async function prepareCfBuildOutput( ): Promise { const source = resolve(workerDirectory); if (existsSync(getRootConfigPath(source))) { + const deferredContainers = join(dirname(getRootConfigPath(source)), 'void-containers.json'); + if (existsSync(deferredContainers)) { + const built = await readBuildOutput(source); + if ( + (accountId !== undefined && + built.rootConfig.accountId !== undefined && + accountId !== built.rootConfig.accountId) || + (mode !== undefined && mode !== built.rootConfig.buildContext.mode) + ) { + return null; + } + const metadata = record(JSON.parse(readFileSync(deferredContainers, 'utf8'))); + const path = metadata?.workerDirectory; + if ( + typeof path !== 'string' || + isAbsolute(path) || + path.includes('\\') || + path.includes(':') || + !isInside(source, resolve(source, path)) || + resolve(source, path) === source + ) { + throw new Error('Invalid Void Container build output directory.'); + } + const directory = realpathSync(resolve(source, path)); + const canonicalRoot = realpathSync(source); + if (directory === canonicalRoot || !isInside(canonicalRoot, directory)) { + throw new Error('Void Container output must stay inside the project.'); + } + const prepared = await prepareCfBuildOutput( + directory, + accountId, + mode ?? built.rootConfig.buildContext.mode, + ); + if (!prepared) { + return null; + } + try { + // Container preparation replaces only the entry Worker. Auxiliary Workers + // remain part of the native build and must travel with the final upload. + for (const [name, worker] of Object.entries(built.workers)) { + if (name !== 'default') { + cpSync( + dirname(worker.configPath), + dirname(getWorkerConfigPath(prepared.directory, name)), + { recursive: true }, + ); + } + } + await readBuildOutput(prepared.directory); + return prepared; + } catch (error) { + await prepared.dispose(); + throw error; + } + } return prepareNativeBuildOutput(source, accountId, mode); } if (!existsSync(join(source, 'wrangler.json'))) { diff --git a/packages/void/src/cli/cf-config-migrate.ts b/packages/void/src/cli/cf-config-migrate.ts index b455f3a43..933663bf7 100644 --- a/packages/void/src/cli/cf-config-migrate.ts +++ b/packages/void/src/cli/cf-config-migrate.ts @@ -195,7 +195,8 @@ function assertHandledFollowUps(followUps: Array): void { } const path = item.sourcePath ?? item.code; const handled = - (path === 'assets.directory' && item.message.includes('assets directory')) || + ((path === 'assets.directory' || /^env\.[^.]+\.assets\.directory$/.test(path)) && + item.message.includes('assets directory')) || (/^d1_databases\.\d+$/.test(path) && /`migrations_(?:dir|table)`/.test(item.message)) || /^durable_objects\.bindings\.\d+$/.test(path) || path === 'migrations' || @@ -363,13 +364,27 @@ if (executable === generated && migration.status !== 'complete') { } const executablePath = join(directory, 'cloudflare.config.mjs'); writeFileSync(executablePath, executable, { mode: 0o600 }); -const config = (await import(pathToFileURL(executablePath).href)).default; +const input = (await import(pathToFileURL(executablePath).href)).default; +const { resolveAndParseConfig } = await import(process.argv[3]); +const parsed = await resolveAndParseConfig(input, { isPreview: false, mode: process.argv[4] ?? 'production' }); +if (!parsed.success) throw new Error('cf did not generate a valid Worker configuration.'); +const config = parsed.data; process.stdout.write(JSON.stringify({ config, followUps: migration.followUps })); `; export function migrateBuiltWranglerConfig( raw: JsonObject, + mode = 'production', ): ReturnType { + const environment = record(record(raw.env)?.[mode]); + const effective = environment ? { ...raw, ...environment } : raw; + if (environment) { + for (const key of ['durable_objects', 'workflows', 'containers']) { + if (!(key in environment)) { + delete effective[key]; + } + } + } const cfPackage = fileURLToPath(import.meta.resolve('cf/package.json')); const directory = mkdtempSync(join(tmpdir(), 'void-cf-config-')); try { @@ -395,6 +410,8 @@ export function migrateBuiltWranglerConfig( migrationScript, import.meta.resolve('@cloudflare/codemods'), configPath, + import.meta.resolve('@cloudflare/config'), + mode, ], { cwd: directory, @@ -417,18 +434,18 @@ export function migrateBuiltWranglerConfig( if (!result || !worker) { throw new Error('cf did not generate a Worker configuration.'); } - applyPreservationFlags(raw, worker); - applyWorkflows(raw, worker); - const exports = { ...record(worker.exports), ...durableObjectExports(raw) }; - const containers = containersFromWrangler(raw, exports); + applyPreservationFlags(effective, worker); + applyWorkflows(effective, worker); + const exports = { ...record(worker.exports), ...durableObjectExports(effective) }; + const containers = containersFromWrangler(effective, exports); if (containers.length > 0) { const envBindings = record(worker.env); - const durableBindings = record(raw.durable_objects)?.bindings; + const durableBindings = record(effective.durable_objects)?.bindings; if (!envBindings || !Array.isArray(durableBindings)) { throw new Error('A Container Durable Object binding was not found in the built config.'); } for (const container of containers) { - const className = (raw.containers as Array).find( + const className = (effective.containers as Array).find( (entry) => entry.name === container.name, )?.class_name; const binding = durableBindings.find((entry) => record(entry)?.class_name === className); diff --git a/packages/void/src/cli/deploy-cloudflare-build.ts b/packages/void/src/cli/deploy-cloudflare-build.ts index e5a6d4cba..d59a5de96 100644 --- a/packages/void/src/cli/deploy-cloudflare-build.ts +++ b/packages/void/src/cli/deploy-cloudflare-build.ts @@ -324,6 +324,9 @@ export async function buildCloudflareWorker(input: { const generatedConfigPath = ( deps.resolveGeneratedConfigPath ?? resolveGeneratedDeployConfigPathDefault )(root); + // Deploy guards validate this Worker's generated config. The native output + // tree can also contain auxiliary Workers and settings those guards have not + // approved, so the CLI must keep preparing the validated Worker artifact. const prepareBuildOutput = () => frameworkPreset?.cloudflareArtifact === 'worker-output' ? prepareCfFrameworkBuildOutput({ diff --git a/packages/void/src/cli/template.ts b/packages/void/src/cli/template.ts index ba69109ef..9fae72f3c 100644 --- a/packages/void/src/cli/template.ts +++ b/packages/void/src/cli/template.ts @@ -4,6 +4,8 @@ export const SCAFFOLD_GITIGNORE_LINES = [ '.void', '.wrangler', '.void-wrangler.jsonc', + '.cloudflare', + 'cloudflare.config.ts', '.env', '.env.*', '!.env.example', diff --git a/packages/void/src/index.ts b/packages/void/src/index.ts index ff44bb337..f93d5834c 100644 --- a/packages/void/src/index.ts +++ b/packages/void/src/index.ts @@ -1,5 +1,4 @@ import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'; -import { cloudflare, type PluginConfig, type WorkerConfig } from '@cloudflare/vite-plugin'; import { join, resolve } from 'pathslash'; import pc from 'picocolors'; import { type Plugin } from 'vite'; @@ -35,6 +34,7 @@ import { pagesDevCssAssetsPlugin } from './pages/dev-css-assets.ts'; import { readVoidDevCredentials, runAiSetupPrompt } from './plugin-ai-setup.ts'; import { bindingRuntimePlugin } from './plugin-binding-runtime.ts'; import { createClientStubsPlugin } from './plugin-client-stubs.ts'; +import { cloudflare, type AuxiliaryWorkerConfig, type WorkerConfig } from './plugin-cloudflare.ts'; import { afterServerReady, warnIfPackageJsonIsNotEsm, @@ -88,7 +88,7 @@ const scheduledPreviewPointerCleanups = new Set(); /** Returns the Vite plugins that build and serve a Void app for its configured target. */ export function voidPlugin(options?: { persistTo?: string; - auxiliaryWorkers?: PluginConfig['auxiliaryWorkers']; + auxiliaryWorkers?: Array; }): Array { // Resolve root relative to CWD — this runs at config time const root = process.cwd(); @@ -1011,7 +1011,6 @@ export function voidPlugin(options?: { enabled: authEnabled, configPath: authConfigPath, }), - prerenderPlugin(root, config), devTriggersHintPlugin(root, devTriggerToken), ...cloudflare({ configPath: cloudflareConfigPath, @@ -1021,6 +1020,7 @@ export function voidPlugin(options?: { viteEnvironment: { name: DEFAULT_WORKER_ENVIRONMENT_NAME }, config: mergeBindings, }), + prerenderPlugin(root, config), implicitDeployGuard, ]; } diff --git a/packages/void/src/plugin-cloudflare-config.ts b/packages/void/src/plugin-cloudflare-config.ts new file mode 100644 index 000000000..bde4cc433 --- /dev/null +++ b/packages/void/src/plugin-cloudflare-config.ts @@ -0,0 +1,137 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { convertToWranglerConfig, type ParsedInputConfig } from '@cloudflare/config'; +import type { PluginConfig } from '@cloudflare/vite-plugin'; +import { resolve, dirname, basename, join } from 'pathslash'; +import { parse as parseToml } from 'smol-toml'; +import { migrateBuiltWranglerConfig } from './cli/cf-config-migrate.ts'; +import { readGeneratedWranglerConfigFile } from './cli/generated-wrangler-config.ts'; + +/** Void keeps its Wrangler-shaped public options independently of the upstream plugin. */ +export type WorkerConfig = ReturnType; + +type AuxiliaryConfig = + | Partial + | (( + config: WorkerConfig, + context: { entryWorkerConfig: WorkerConfig }, + ) => Partial | void); +export type AuxiliaryWorkerConfig = ( + | { configPath: string; config?: AuxiliaryConfig } + | { configPath?: string; config: AuxiliaryConfig } +) & { + viteEnvironment?: PluginConfig['viteEnvironment']; + devOnly?: boolean | (() => boolean); +}; + +/** Match the old plugin's patch semantics, including its array concatenation. */ +export function mergeCloudflareConfig(patch: object, fallback: object): Record { + const result: Record = { ...fallback }; + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) { + continue; + } + const previous = (result as Record)[key]; + result[key] = + Array.isArray(value) && Array.isArray(previous) + ? [...value, ...previous] + : value && + previous && + typeof value === 'object' && + typeof previous === 'object' && + !Array.isArray(value) && + !Array.isArray(previous) + ? mergeCloudflareConfig( + value as Record, + previous as Record, + ) + : value; + } + return result; +} + +/** Use Cloudflare's public migration API rather than maintaining a second binding translator. */ +export function toNativeCloudflareConfig(raw: WorkerConfig, root: string): ParsedInputConfig { + const packagePath = join(root, 'package.json'); + const packageName = existsSync(packagePath) + ? JSON.parse(readFileSync(packagePath, 'utf8')).name + : undefined; + const name = + String(packageName ?? basename(root)) + .replaceAll('_', '-') + .replace(/[^a-z0-9- ]/g, '-') + .replace(/^(-+)|(-+)$/g, '') + .slice(0, 63) || 'my-worker'; + const { env, vars, ...base } = raw; + const selected = process.env.CLOUDFLARE_ENV ? env?.[process.env.CLOUDFLARE_ENV] : undefined; + const values = selected ? selected.vars : vars; + // Wrangler environments are selected explicitly, independently of Vite's mode. + // Give the codemod distinct mode names so `env.production` cannot accidentally + // replace the top-level configuration during an ordinary production build. + const environments = + env && + Object.fromEntries( + Object.entries(env).map(([name, { vars: _vars, ...config }]) => [ + `void-environment-${name}`, + config, + ]), + ); + const config = migrateBuiltWranglerConfig( + { + name, + compatibility_date: '2026-02-24', + ...base, + ...(environments && process.env.CLOUDFLARE_ENV ? { env: environments } : {}), + }, + process.env.CLOUDFLARE_ENV + ? `void-environment-${process.env.CLOUDFLARE_ENV}` + : 'void-top-level', + ); + if (!config.worker) { + throw new Error('Void requires a Cloudflare Worker configuration.'); + } + // The codemod uses disposable files. Keep all variable values, including + // local development credentials, out of those files as well as the root config. + for (const [name, value] of Object.entries(values ?? {})) { + config.worker.env ??= {}; + config.worker.env[name] = + typeof value === 'string' ? { type: 'text', value } : { type: 'json', value }; + } + const main = selected?.main ?? raw.main; + if (main) { + config.worker.entrypoint = main.startsWith('virtual:') ? main : resolve(root, main); + } + return config; +} + +export function nativeAuxiliaryWorkers( + workers: Array | undefined, + root: string, +): PluginConfig['auxiliaryWorkers'] { + return workers?.map(({ configPath, config, ...options }) => ({ + ...options, + config({ entryWorkerConfig }) { + const path = configPath ? resolve(root, configPath) : undefined; + const base = ( + path + ? path.endsWith('.toml') + ? parseToml(readFileSync(path, 'utf8')) + : readGeneratedWranglerConfigFile(path) + : {} + ) as WorkerConfig; + const patch = + typeof config === 'function' + ? config(base, { + entryWorkerConfig: convertToWranglerConfig({ + worker: entryWorkerConfig, + containers: [], + }), + }) + : config; + const native = toNativeCloudflareConfig( + mergeCloudflareConfig(patch ?? {}, base), + path ? dirname(path) : root, + ); + return native.worker!; + }, + })); +} diff --git a/packages/void/src/plugin-cloudflare-preview.ts b/packages/void/src/plugin-cloudflare-preview.ts new file mode 100644 index 000000000..a192b7870 --- /dev/null +++ b/packages/void/src/plugin-cloudflare-preview.ts @@ -0,0 +1,65 @@ +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { getRootConfigPath, getWorkerConfigPath } from '@cloudflare/build-output-utils'; +import { dirname, join } from 'pathslash'; +import type { Plugin } from 'vite'; +import { writeFileAtomic } from './shared/atomic-write.ts'; +import { loadLocalEnv } from './shared/env.ts'; + +/** Give preview local bindings without changing the deployable build or storing their values. */ +export function withLocalPreviewBindings(plugins: Array, root: string): Array { + let directory: string | undefined; + const cleanup = () => { + if (directory) { + rmSync(directory, { recursive: true, force: true }); + directory = undefined; + } + process.removeListener('exit', cleanup); + }; + // Only the entry plugin reads Build Output during config resolution. Its other + // hooks and the remaining plugins continue to see the application's real root. + const entry = plugins.find((plugin) => plugin.name === 'vite-plugin-cloudflare'); + if (!entry?.config) { + throw new Error('The Cloudflare plugin no longer exposes its configuration hook.'); + } + const hook = typeof entry.config === 'function' ? entry.config : entry.config.handler; + entry.config = async function (config, environment) { + if (!environment.isPreview) { + return hook.call(this, config, environment); + } + cleanup(); + const parent = join(root, '.void'); + mkdirSync(parent, { recursive: true }); + directory = mkdtempSync(join(parent, 'preview-')); + process.once('exit', cleanup); + try { + const output = dirname(getRootConfigPath(root)); + cpSync(output, dirname(getRootConfigPath(directory)), { recursive: true }); + const path = getWorkerConfigPath(directory); + const worker = JSON.parse(readFileSync(path, 'utf8')); + worker.env ??= {}; + for (const name of Object.keys(loadLocalEnv(root))) { + const binding = worker.env[name]; + if (!binding || ['text', 'json', 'secret'].includes(binding.type)) { + worker.env[name] = { type: 'secret' }; + } + } + writeFileAtomic(path, JSON.stringify(worker)); + // The upstream preview resolves declared secrets from this env directory. + // A private artifact copy isolates local-only declarations from deployment + // and from other previews, including when the original build had no .env. + return await hook.call(this, { ...config, root: directory, envDir: root }, environment); + } catch (error) { + cleanup(); + throw error; + } + }; + return [ + ...plugins, + { + name: 'void:cloudflare-preview-cleanup', + configurePreviewServer(server) { + server.httpServer.once('close', cleanup); + }, + }, + ]; +} diff --git a/packages/void/src/plugin-cloudflare.ts b/packages/void/src/plugin-cloudflare.ts new file mode 100644 index 000000000..199174910 --- /dev/null +++ b/packages/void/src/plugin-cloudflare.ts @@ -0,0 +1,260 @@ +import { cpSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'; +import { + getRootConfigPath, + getWorkerAssetsDir, + readBuildOutput, +} from '@cloudflare/build-output-utils'; +import { convertToWranglerConfig } from '@cloudflare/config'; +import { cloudflare as cloudflareV2, type PluginConfig } from '@cloudflare/vite-plugin'; +import { dirname, isAbsolute, join, relative, resolve } from 'pathslash'; +import type { Plugin } from 'vite'; +import { readGeneratedWranglerConfigFile } from './cli/generated-wrangler-config.ts'; +import { + mergeCloudflareConfig, + nativeAuxiliaryWorkers, + toNativeCloudflareConfig, + type AuxiliaryWorkerConfig, + type WorkerConfig, +} from './plugin-cloudflare-config.ts'; +import { withLocalPreviewBindings } from './plugin-cloudflare-preview.ts'; +import { writeFileAtomic } from './shared/atomic-write.ts'; +import { CLOUDFLARE_DEPLOY_BUILD_ENV } from './shared/env.ts'; + +export type { WorkerConfig, AuxiliaryWorkerConfig } from './plugin-cloudflare-config.ts'; + +const GENERATED_CONFIG_HEADER = '// Generated by Void. Configure Cloudflare in void.config.ts.\n'; + +function ignoreGeneratedFiles(root: string): void { + const path = join(root, '.gitignore'); + const source = existsSync(path) ? readFileSync(path, 'utf8') : ''; + const entries = new Set(source.split(/\r?\n/).map((line) => line.trim().replace(/^\//, ''))); + const missing = ['.cloudflare', 'cloudflare.config.ts'].filter((name) => !entries.has(name)); + if (missing.length > 0) { + writeFileAtomic( + path, + `${source}${source && !source.endsWith('\n') ? '\n' : ''}${missing.join('\n')}\n`, + ); + } +} + +function isInside(directory: string, path: string): boolean { + const child = relative(directory, path); + return child !== '..' && !child.startsWith('../') && !isAbsolute(child); +} + +/** Keep Void's public configuration and artifact layout while running the v2 plugin. */ +export function cloudflare(options: { + configPath?: string; + config: (resolved: WorkerConfig) => Partial; + auxiliaryWorkers?: Array; + inspectorPort?: PluginConfig['inspectorPort']; + persistState?: PluginConfig['persistState']; + viteEnvironment: NonNullable; +}): Array { + const root = process.cwd(); + let legacy: WorkerConfig; + let native: ReturnType; + let pluginNative: ReturnType; + const outputDirectories = new Map(); + const bridge: Plugin = { + name: 'void:cloudflare-config', + config(_userConfig, environment) { + if (environment.isPreview) { + return; + } + let base = ( + options.configPath ? readGeneratedWranglerConfigFile(options.configPath) : {} + ) as WorkerConfig; + if (process.env.CLOUDFLARE_ENV && base.env) { + const selected = base.env[process.env.CLOUDFLARE_ENV]; + if (!selected) { + throw new Error( + `Cloudflare environment ${process.env.CLOUDFLARE_ENV} is not configured.`, + ); + } + const original = base; + base = convertToWranglerConfig(toNativeCloudflareConfig(base, root)); + base.main = selected.main ?? original.main; + base.migrations = selected.migrations ?? original.migrations; + base.assets = { + ...base.assets, + directory: selected.assets?.directory ?? original.assets?.directory, + }; + } + legacy = mergeCloudflareConfig(options.config(base), base); + native = toNativeCloudflareConfig(legacy, root); + pluginNative = structuredClone(native); + if (environment.command === 'build' && native.containers.length > 0) { + // Void builds images during deployment preparation. Managed builds consume + // the Worker bundle and must not acquire a local Docker prerequisite. + pluginNative.containers = []; + for (const declaration of Object.values(pluginNative.worker?.exports ?? {})) { + if ('container' in declaration) { + delete declaration.container; + } + } + } + const configPath = join(root, 'cloudflare.config.ts'); + if ( + existsSync(configPath) && + !readFileSync(configPath, 'utf8').startsWith(GENERATED_CONFIG_HEADER) + ) { + throw new Error( + 'Void found an authored cloudflare.config.ts. Move its settings into void.config.ts before using the Void Cloudflare integration.', + ); + } + // Local env values and proxy credentials belong only to the in-memory config callback. + const stored = structuredClone(pluginNative); + if (stored.worker) { + stored.worker.env = {}; + } + const source = `${GENERATED_CONFIG_HEADER}// @ts-nocheck\nconst config = ${JSON.stringify(stored, null, 2)};\nfor (const declaration of Object.values(config.worker?.exports ?? {})) {\n if (typeof declaration.container === 'string') declaration.container = config.containers.find(container => container.name === declaration.container);\n}\nexport default config;\n`; + ignoreGeneratedFiles(root); + if (!existsSync(configPath) || readFileSync(configPath, 'utf8') !== source) { + writeFileAtomic(configPath, source); + } + }, + }; + const directories: Plugin = { + name: 'void:cloudflare-output-directories', + enforce: 'post', + config(userConfig) { + const environmentNames = [ + options.viteEnvironment.name ?? 'ssr', + ...(options.viteEnvironment.childEnvironments ?? []), + ]; + for (const name of [...environmentNames, 'client']) { + outputDirectories.set( + name, + resolve( + root, + userConfig.environments?.[name]?.build?.outDir ?? + join(userConfig.build?.outDir ?? 'dist', name), + ), + ); + } + }, + }; + const output: Plugin = { + name: 'void:cloudflare-build-output', + enforce: 'post', + writeBundle: { + order: 'post', + handler() { + if (this.environment?.name !== 'client') { + return; + } + // Frameworks such as React Router read the completed client manifest while + // building their server, before the final Build Output hook runs. + const source = resolve(root, this.environment.config.build.outDir); + const destination = outputDirectories.get('client'); + if (destination && source !== destination && existsSync(source)) { + mkdirSync(destination, { recursive: true }); + cpSync(source, destination, { recursive: true }); + } + }, + }, + buildApp: { + order: 'post', + async handler(builder) { + if (Object.values(native.worker?.env ?? {}).some((binding) => binding.type === 'assets')) { + // API-only apps still expose ASSETS. Keep an empty asset service so + // native preview can resolve that binding even without a client build. + mkdirSync(getWorkerAssetsDir(root), { recursive: true }); + } + const built = await readBuildOutput(root); + const worker = built.workers.default; + const environment = options.viteEnvironment.name ?? 'ssr'; + const destination = outputDirectories.get(environment)!; + const client = outputDirectories.get('client')!; + if (!worker.bundleDir || !worker.config.manifest) { + throw new Error('Void requires a built Cloudflare Worker.'); + } + for (const directory of [destination, client]) { + // Match Vite's default cleanup for output inside the project. Never erase the + // project itself, an external directory, or the native output being copied. + if ( + directory !== root && + isInside(root, directory) && + !isInside(directory, worker.bundleDir) + ) { + rmSync(directory, { recursive: true, force: true }); + } + } + mkdirSync(destination, { recursive: true }); + cpSync(worker.bundleDir, destination, { recursive: true }); + if (worker.assetsDir) { + mkdirSync(client, { recursive: true }); + cpSync(worker.assetsDir, client, { recursive: true }); + } + const config: WorkerConfig = { + ...legacy, + name: worker.config.name, + account_id: native.accountId, + compatibility_date: worker.config.compatibilityDate, + compatibility_flags: worker.config.compatibilityFlags, + main: worker.config.manifest.mainModule, + no_bundle: true, + ...(process.env.CLOUDFLARE_ENV ? { targetEnvironment: process.env.CLOUDFLARE_ENV } : {}), + ...(legacy.migrations ? { migrations: legacy.migrations } : {}), + ...(legacy.secrets ? { secrets: legacy.secrets } : {}), + ...(legacy.assets || worker.assetsDir + ? { assets: { ...legacy.assets, directory: relative(destination, client) } } + : {}), + }; + delete config.env; + writeFileAtomic(join(destination, 'wrangler.json'), `${JSON.stringify(config, null, 2)}\n`); + if (native.containers.length > 0) { + writeFileAtomic( + join(dirname(getRootConfigPath(root)), 'void-containers.json'), + JSON.stringify({ workerDirectory: relative(root, destination) }), + ); + } + // Existing framework tooling still locates its built Worker through this pointer. + const pointer = join(root, '.wrangler/deploy/config.json'); + const pointerDestination = + existsSync(join(root, 'void.config.ts')) && + process.env[CLOUDFLARE_DEPLOY_BUILD_ENV] !== '1' + ? join(root, '.void/preview-deploy-config.json') + : pointer; + mkdirSync(dirname(pointerDestination), { recursive: true }); + writeFileAtomic( + pointerDestination, + JSON.stringify({ + configPath: relative(dirname(pointer), join(destination, 'wrangler.json')), + }), + ); + for (const name of options.viteEnvironment.childEnvironments ?? []) { + const source = builder.environments[name]?.config.build.outDir; + const target = outputDirectories.get(name); + if (source && target && existsSync(source)) { + cpSync(source, target, { recursive: true }); + } + } + }, + }, + }; + return [ + bridge, + directories, + ...withLocalPreviewBindings( + cloudflareV2({ + inspectorPort: options.inspectorPort, + persistState: options.persistState, + viteEnvironment: options.viteEnvironment, + auxiliaryWorkers: nativeAuxiliaryWorkers(options.auxiliaryWorkers, root), + types: { generate: false }, + config(resolved) { + // The generated config is Void-owned. Return one complete config so upstream's + // fallback merge cannot concatenate bindings or migration histories twice. + for (const key of Object.keys(resolved)) { + delete (resolved as unknown as Record)[key]; + } + return pluginNative.worker; + }, + }), + root, + ), + output, + ]; +} diff --git a/packages/void/src/plugin-prerender.ts b/packages/void/src/plugin-prerender.ts index dba2da003..3f52a78c6 100644 --- a/packages/void/src/plugin-prerender.ts +++ b/packages/void/src/plugin-prerender.ts @@ -1,3 +1,5 @@ +import { cpSync, mkdirSync } from 'node:fs'; +import { getWorkerAssetsDir } from '@cloudflare/build-output-utils'; import { join } from 'pathslash'; import type { Plugin } from 'vite'; import { resolveDistDir, resolveWorkerDirName } from './cli/build-output-dir.ts'; @@ -9,64 +11,80 @@ import { resolveRemoteMode } from './shared/env.ts'; /** * Vite plugin that prerenders pages at build time when `output: "static"`. * - * Runs in the `closeBundle` hook so prerender logs appear after Vite's - * asset summary. Gated on the "client" environment so it only runs once. + * Cloudflare prerender runs after Build Output and the compatible dist artifacts + * are complete. Other targets run once when the client bundle closes. */ export function prerenderPlugin(root: string, config: VoidConfig): Plugin { - return { - name: 'void:prerender', - apply: 'build', - - async closeBundle() { - if (config.output !== 'static') { - return; - } - // A managed deploy runs prerender in the trusted parent CLI after the - // build. This keeps every platform credential out of arbitrary Vite - // plugins while preserving the same built-worker execution path. - if (process.env.VOID_DEPLOY_BUILD === '1') { - return; - } - if (this.environment?.name !== 'client') { - return; - } - setShowTimestamp(false); + const run = async () => { + if (config.output !== 'static') { + return; + } + // A managed deploy runs prerender in the trusted parent CLI after the + // build. This keeps every platform credential out of arbitrary Vite + // plugins while preserving the same built-worker execution path. + if (process.env.VOID_DEPLOY_BUILD === '1') { + return; + } + setShowTimestamp(false); - // Prerender only applies to void apps (not meta-frameworks), so no preset needed - const distDir = resolveDistDir(root); - const clientDir = join(distDir, 'client'); + // Prerender only applies to void apps (not meta-frameworks), so no preset needed + const distDir = resolveDistDir(root); + const clientDir = join(distDir, 'client'); - let result; + let result; - if (isNodeTarget(config.target)) { - result = await prerenderPagesNode({ - root, - ssrDir: join(distDir, 'ssr'), - clientDir, - }); - } else { - const persistDir = join(root, '.void'); - const workerDirName = resolveWorkerDirName(distDir); - const workerDir = join(distDir, workerDirName); - const remoteMode = resolveRemoteMode(config.remote); + if (isNodeTarget(config.target)) { + result = await prerenderPagesNode({ + root, + ssrDir: join(distDir, 'ssr'), + clientDir, + }); + } else { + const persistDir = join(root, '.void'); + const workerDirName = resolveWorkerDirName(distDir); + const workerDir = join(distDir, workerDirName); + const remoteMode = resolveRemoteMode(config.remote); - result = await prerenderPages({ - root, - workerDir, - clientDir, - persistDir, - remoteMode, - }); - } + result = await prerenderPages({ + root, + workerDir, + clientDir, + persistDir, + remoteMode, + }); + } - if (result.paths.length === 0) { - voidLog('No pages to prerender'); - return; - } + if (result.paths.length === 0) { + voidLog('No pages to prerender'); + return; + } - if (result.failed.length > 0) { - voidWarn(`${result.failed.length} page(s) failed to prerender`); + if (result.failed.length > 0) { + voidWarn(`${result.failed.length} page(s) failed to prerender`); + } + }; + return { + name: 'void:prerender', + enforce: 'post', + apply: 'build', + async closeBundle() { + if (isNodeTarget(config.target) && this.environment?.name === 'client') { + await run(); } }, + buildApp: { + order: 'post', + async handler() { + if (isNodeTarget(config.target)) { + return; + } + await run(); + if (config.output === 'static' && process.env.VOID_DEPLOY_BUILD !== '1') { + const assets = getWorkerAssetsDir(root); + mkdirSync(assets, { recursive: true }); + cpSync(join(resolveDistDir(root), 'client'), assets, { recursive: true }); + } + }, + }, }; } diff --git a/packages/void/src/plugin-worker-bindings.ts b/packages/void/src/plugin-worker-bindings.ts index 37578de00..19fa0d22b 100644 --- a/packages/void/src/plugin-worker-bindings.ts +++ b/packages/void/src/plugin-worker-bindings.ts @@ -1,4 +1,3 @@ -import { type WorkerConfig } from '@cloudflare/vite-plugin'; import { appendSqliteClassMigration } from '@void/deploy-core'; import { type ApplicationAnalysis } from './application-analysis.ts'; import { SEND_EMAIL_BINDING } from './cli/email-cloudflare.ts'; @@ -17,6 +16,7 @@ import { readVoidDevCredentials, } from './plugin-ai-setup.ts'; import { hasBinding, shouldInjectEmailDevInboxVar } from './plugin-binding-utils.ts'; +import { type WorkerConfig } from './plugin-cloudflare-config.ts'; import { type EnvSchemaFacts } from './plugin-env-schema.ts'; import { isRouteEnabledForEnvironment, type RouteEnvironment } from './router/parse-filename.ts'; import { DEV_INBOX_ENV_VAR, DEV_INBOX_TOKEN_ENV_VAR } from './runtime/email/dev-inbox.ts'; diff --git a/packages/void/test/integration/kitchen-sink.test.ts b/packages/void/test/integration/kitchen-sink.test.ts index 42696b2a4..b7eeb20de 100644 --- a/packages/void/test/integration/kitchen-sink.test.ts +++ b/packages/void/test/integration/kitchen-sink.test.ts @@ -1,6 +1,7 @@ import { execSync } from 'node:child_process'; import { readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; +import { getWorkerConfigPath } from '@cloudflare/build-output-utils'; import spawn from 'cross-spawn'; import { chromium, type Browser, type Page } from 'playwright'; import { describe, expect, it, beforeAll, afterAll } from 'vite-plus/test'; @@ -474,5 +475,10 @@ function preparePreviewWranglerConfig(pg: string): string { }; delete wranglerConfig.ai; writeFileSync(wranglerPath, `${JSON.stringify(wranglerConfig, null, 2)}\n`); + const nativePath = getWorkerConfigPath(pg); + const native = JSON.parse(readFileSync(nativePath, 'utf8')); + native.env.BETTER_AUTH_SECRET = { type: 'text', value: 'void-preview-test-secret-1234567890' }; + delete native.env.AI; + writeFileSync(nativePath, `${JSON.stringify(native, null, 2)}\n`); return databaseId; } diff --git a/packages/void/test/unit/application-analysis-consumers.test.ts b/packages/void/test/unit/application-analysis-consumers.test.ts index af07de712..5fda07fa8 100644 --- a/packages/void/test/unit/application-analysis-consumers.test.ts +++ b/packages/void/test/unit/application-analysis-consumers.test.ts @@ -11,7 +11,7 @@ type CloudflarePluginConfig = { config: (resolved: Record) => Record; }; -vi.mock('@cloudflare/vite-plugin', () => import('../helpers/cloudflare-vite-plugin.ts')); +vi.mock('../../src/plugin-cloudflare.ts', () => import('../helpers/cloudflare-vite-plugin.ts')); const roots: Array = []; diff --git a/packages/void/test/unit/cf-build-output.test.ts b/packages/void/test/unit/cf-build-output.test.ts index fd3b4e4f3..1a7a26a0e 100644 --- a/packages/void/test/unit/cf-build-output.test.ts +++ b/packages/void/test/unit/cf-build-output.test.ts @@ -395,14 +395,9 @@ it( const root = mkdtempSync(join(tmpdir(), 'void-cf-additional-modules-')); roots.push(root); writeFileSync(join(root, 'package.json'), JSON.stringify({ type: 'module' })); - const configPath = join(root, 'wrangler.json'); writeFileSync( - configPath, - JSON.stringify({ - name: 'void-additional-modules', - main: 'index.js', - compatibility_date: '2026-09-29', - }), + join(root, 'cloudflare.config.ts'), + `export default { worker: { name: 'void-additional-modules', entrypoint: ${JSON.stringify(join(root, 'index.js'))}, compatibilityDate: '2026-09-29' } };`, ); const assets = { 'message.txt': 'hello', @@ -431,16 +426,16 @@ export default { fetch() { root, configFile: false, logLevel: 'silent', - plugins: [cloudflare({ configPath })], + plugins: [cloudflare()], build: { sourcemap: true }, }); await builder.buildApp(); - const output = join(root, 'dist', 'void_additional_modules'); + const output = getWorkerBundleDir(root); expect( readdirSync(output, { recursive: true }).some((name) => String(name).endsWith('.css')), ).toBe(true); await withPrepared( - prepareCfBuildOutput(output, { accountId: 'account-id' }), + prepareCfBuildOutput(root, { accountId: 'account-id' }), async (prepared) => { const built = await readBuildOutput(prepared.directory); const manifest = built.workers.default.config.manifest!; diff --git a/packages/void/test/unit/plugin-class-a-wrapper.test.ts b/packages/void/test/unit/plugin-class-a-wrapper.test.ts index ccf37b590..9a0a4e91d 100644 --- a/packages/void/test/unit/plugin-class-a-wrapper.test.ts +++ b/packages/void/test/unit/plugin-class-a-wrapper.test.ts @@ -18,7 +18,7 @@ import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { getOriginalFrameworkEntry } from '../../src/plugin-framework-entry.ts'; import { frameworkTriggersDispatchPath } from '../../src/plugin-framework-triggers.ts'; -vi.mock('@cloudflare/vite-plugin', () => import('../helpers/cloudflare-vite-plugin.ts')); +vi.mock('../../src/plugin-cloudflare.ts', () => import('../helpers/cloudflare-vite-plugin.ts')); const roots: Array = []; diff --git a/packages/void/test/unit/plugin-cloudflare.test.ts b/packages/void/test/unit/plugin-cloudflare.test.ts new file mode 100644 index 000000000..4d73c67f6 --- /dev/null +++ b/packages/void/test/unit/plugin-cloudflare.test.ts @@ -0,0 +1,245 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { getWorkerAssetsDir, readBuildOutput } from '@cloudflare/build-output-utils'; +import { createBuilder, preview, resolveConfig } from 'vite'; +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +import { prepareCfBuildOutput } from '../../src/deploy.ts'; +import { + nativeAuxiliaryWorkers, + toNativeCloudflareConfig, +} from '../../src/plugin-cloudflare-config.ts'; +import { cloudflare } from '../../src/plugin-cloudflare.ts'; + +const roots: Array = []; +function project(): string { + const root = mkdtempSync(join(tmpdir(), 'void-cf-v2-')); + roots.push(root); + writeFileSync(join(root, 'package.json'), JSON.stringify({ type: 'module', name: 'void-cf-v2' })); + vi.spyOn(process, 'cwd').mockReturnValue(root); + return root; +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + for (const root of roots.splice(0)) { + rmSync(root, { force: true, recursive: true }); + } +}); + +describe('Cloudflare v2 compatibility', () => { + it('selects named environments explicitly without letting production mode select one', () => { + const root = project(); + expect(toNativeCloudflareConfig({}, root).worker?.name).toBe('void-cf-v2'); + const raw = { + name: 'base', + vars: { COLOR: 'base' }, + env: { + production: { name: 'production-app', vars: { COLOR: 'production' } }, + staging: { name: 'staging-app', vars: { COLOR: 'staging' } }, + }, + }; + vi.stubEnv('CLOUDFLARE_ENV', undefined); + expect(toNativeCloudflareConfig(raw, root).worker?.name).toBe('base'); + vi.stubEnv('CLOUDFLARE_ENV', 'staging'); + expect(toNativeCloudflareConfig(raw, root).worker).toMatchObject({ + name: 'staging-app', + env: { COLOR: { value: 'staging' } }, + }); + vi.stubEnv('CLOUDFLARE_ENV', 'production'); + expect(toNativeCloudflareConfig(raw, root).worker?.name).toBe('production-app'); + }); + + it('keeps Container builds deferred and prepares their native declarations without Docker', async () => { + const root = project(); + writeFileSync( + join(root, 'worker.js'), + "export class Box {}\nexport default { fetch: () => new Response('ok') };\n", + ); + writeFileSync( + join(root, 'helper.js'), + "export default { fetch: () => new Response('helper') };", + ); + const builder = await createBuilder({ + root, + configFile: false, + logLevel: 'silent', + plugins: cloudflare({ + config: () => ({ + name: 'container-app', + main: 'worker.js', + compatibility_date: '2026-02-24', + durable_objects: { bindings: [{ name: 'BOX', class_name: 'Box' }] }, + migrations: [{ tag: 'v1', new_sqlite_classes: ['Box'] }], + containers: [{ name: 'box', class_name: 'Box', image: 'docker.io/example/box:1' }], + }), + viteEnvironment: { name: 'ssr' }, + auxiliaryWorkers: [ + { + config: { name: 'helper', main: 'helper.js', compatibility_date: '2026-02-24' }, + viteEnvironment: { name: 'helper' }, + }, + ], + }), + }); + await builder.buildApp(); + const artifact = await prepareCfBuildOutput(root); + expect(artifact).not.toBeNull(); + try { + const prepared = await readBuildOutput(artifact!.directory); + expect(Object.values(prepared.workers).map((worker) => worker.config.name)).toContain( + 'helper', + ); + expect(prepared.containers).toHaveLength(1); + expect(prepared.containers[0]?.config).toMatchObject({ + name: 'box', + image: { reference: 'docker.io/example/box:1' }, + }); + } finally { + await artifact?.dispose(); + } + const metadataPath = join(root, '.cloudflare/output/v0/void-containers.json'); + writeFileSync(metadataPath, JSON.stringify({ workerDirectory: '../outside' })); + await expect(prepareCfBuildOutput(root)).rejects.toThrow('Invalid Void Container'); + }); + + it('preserves resource identities, required secrets and compatibility settings', () => { + const root = project(); + const config = toNativeCloudflareConfig( + { + name: 'existing-app', + main: './worker.ts', + account_id: 'account-id', + compatibility_date: '2026-02-24', + compatibility_flags: ['nodejs_compat'], + d1_databases: [{ binding: 'DB', database_name: 'existing', database_id: 'existing-id' }], + kv_namespaces: [{ binding: 'CACHE', id: 'existing-cache' }], + secrets: { required: ['API_KEY'] }, + }, + root, + ); + expect(config.accountId).toBe('account-id'); + expect(config.worker).toMatchObject({ + name: 'existing-app', + entrypoint: join(root, 'worker.ts'), + compatibilityFlags: ['nodejs_compat'], + env: { + DB: { id: 'existing-id' }, + CACHE: { id: 'existing-cache' }, + API_KEY: { type: 'secret' }, + }, + }); + }); + + it('adapts file and callback auxiliary worker options without executing the entry', () => { + const root = project(); + mkdirSync(join(root, 'aux')); + writeFileSync( + join(root, 'aux/wrangler.toml'), + 'name = "helper"\nmain = "./worker.ts"\ncompatibility_date = "2026-02-24"\n', + ); + const callback = vi.fn((_config, { entryWorkerConfig }) => ({ + vars: { OWNER: entryWorkerConfig.name }, + })); + const workers = nativeAuxiliaryWorkers( + [{ configPath: 'aux/wrangler.toml', config: callback, devOnly: true }], + root, + )!; + const config = ( + workers[0]!.config as (input: { + entryWorkerConfig: { + name: string; + compatibilityDate: string; + compatibilityFlags: Array; + }; + }) => unknown + )({ + entryWorkerConfig: { name: 'entry', compatibilityDate: '2026-02-24', compatibilityFlags: [] }, + }); + expect(config).toMatchObject({ + name: 'helper', + entrypoint: join(root, 'aux/worker.ts'), + env: { OWNER: { type: 'text', value: 'entry' } }, + }); + expect(workers[0]!.devOnly).toBe(true); + }); + + it('keeps local values in memory and never overwrites an authored native config', async () => { + const root = project(); + const plugins = () => + cloudflare({ + config: () => ({ + name: 'app', + main: 'virtual:entry', + vars: { LOCAL_SECRET: 'local-only' }, + }), + viteEnvironment: { name: 'ssr' }, + }); + await resolveConfig({ root, configFile: false, plugins: plugins() }, 'serve'); + const generated = readFileSync(join(root, 'cloudflare.config.ts'), 'utf8'); + expect(generated).not.toContain('local-only'); + writeFileSync(join(root, 'cloudflare.config.ts'), 'export default { authored: true };'); + await expect( + resolveConfig({ root, configFile: false, plugins: plugins() }, 'serve'), + ).rejects.toThrow('authored cloudflare.config.ts'); + expect(readFileSync(join(root, 'cloudflare.config.ts'), 'utf8')).toBe( + 'export default { authored: true };', + ); + }); + + it('builds native output and the existing dist layout, then previews the native artifact', async () => { + const root = project(); + writeFileSync( + join(root, 'worker.js'), + "export default { fetch: (_request, env) => new Response(env.PREVIEW_VALUE ?? 'built-worker') };", + ); + writeFileSync(join(root, 'index.html'), '

asset

'); + const plugins = () => + cloudflare({ + config: () => ({ + name: 'app', + main: './worker.js', + compatibility_date: '2026-02-24', + assets: { binding: 'ASSETS', directory: './public', run_worker_first: true }, + }), + inspectorPort: false, + viteEnvironment: { name: 'ssr' }, + }); + const builder = await createBuilder({ + root, + configFile: false, + plugins: plugins(), + logLevel: 'silent', + }); + await builder.buildApp(); + const output = await readBuildOutput(root); + expect(output.workers.default.config.manifest?.mainModule).toBe('index.js'); + expect(existsSync(join(root, 'dist/ssr/index.js'))).toBe(true); + expect(existsSync(join(root, 'dist/client/index.html'))).toBe(true); + expect(existsSync(join(getWorkerAssetsDir(root), 'index.html'))).toBe(true); + expect(readFileSync(join(root, 'dist/ssr/wrangler.json'), 'utf8')).toContain( + '"main": "index.js"', + ); + writeFileSync(join(root, '.env'), 'PREVIEW_VALUE=local-preview-only'); + // Exercise the native plugin's Worker runtime as well as its build output. + const server = await preview({ + root, + configFile: false, + plugins: plugins(), + logLevel: 'silent', + server: { port: 0 }, + }); + try { + const response = await fetch(server.resolvedUrls!.local[0]!); + expect(await response.text()).toBe('local-preview-only'); + expect((await readBuildOutput(root)).workers.default.config.env).not.toHaveProperty( + 'PREVIEW_VALUE', + ); + } finally { + await new Promise((resolve, reject) => + server.httpServer.close((error) => (error ? reject(error) : resolve())), + ); + } + }); +}); diff --git a/packages/void/test/unit/plugin-database-bindings.test.ts b/packages/void/test/unit/plugin-database-bindings.test.ts index 743d1c21c..222cc8a29 100644 --- a/packages/void/test/unit/plugin-database-bindings.test.ts +++ b/packages/void/test/unit/plugin-database-bindings.test.ts @@ -8,7 +8,7 @@ type MockCloudflarePluginConfig = { config: (resolved: Record) => Record; }; -vi.mock('@cloudflare/vite-plugin', () => import('../helpers/cloudflare-vite-plugin.ts')); +vi.mock('../../src/plugin-cloudflare.ts', () => import('../helpers/cloudflare-vite-plugin.ts')); const roots: Array = []; diff --git a/packages/void/test/unit/plugin-dev-asset-routing.test.ts b/packages/void/test/unit/plugin-dev-asset-routing.test.ts index 30b044a7d..8d473c8ac 100644 --- a/packages/void/test/unit/plugin-dev-asset-routing.test.ts +++ b/packages/void/test/unit/plugin-dev-asset-routing.test.ts @@ -16,7 +16,7 @@ type MockCloudflarePlugin = Plugin & { }; }; -vi.mock('@cloudflare/vite-plugin', () => import('../helpers/cloudflare-vite-plugin.ts')); +vi.mock('../../src/plugin-cloudflare.ts', () => import('../helpers/cloudflare-vite-plugin.ts')); const tempDirs: Array = []; diff --git a/packages/void/test/unit/plugin-email-config.test.ts b/packages/void/test/unit/plugin-email-config.test.ts index f6bc3b748..22d0de726 100644 --- a/packages/void/test/unit/plugin-email-config.test.ts +++ b/packages/void/test/unit/plugin-email-config.test.ts @@ -25,7 +25,7 @@ type MockCloudflarePlugin = Plugin & { }; }; -vi.mock('@cloudflare/vite-plugin', () => import('../helpers/cloudflare-vite-plugin.ts')); +vi.mock('../../src/plugin-cloudflare.ts', () => import('../helpers/cloudflare-vite-plugin.ts')); const tempDirs: Array = []; diff --git a/packages/void/test/unit/plugin-framework-ai.test.ts b/packages/void/test/unit/plugin-framework-ai.test.ts index a9708cb62..cfc3fb892 100644 --- a/packages/void/test/unit/plugin-framework-ai.test.ts +++ b/packages/void/test/unit/plugin-framework-ai.test.ts @@ -5,7 +5,7 @@ import type { ResolvedConfig } from 'vite'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; // Framework adapters own the Cloudflare runtime, so these hooks never call the real plugin. -vi.mock('@cloudflare/vite-plugin', () => import('../helpers/cloudflare-vite-plugin.ts')); +vi.mock('../../src/plugin-cloudflare.ts', () => import('../helpers/cloudflare-vite-plugin.ts')); const roots: Array = []; diff --git a/packages/void/test/unit/plugin-prerender-managed.test.ts b/packages/void/test/unit/plugin-prerender-managed.test.ts index cccd5b7c2..7de9486b0 100644 --- a/packages/void/test/unit/plugin-prerender-managed.test.ts +++ b/packages/void/test/unit/plugin-prerender-managed.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vite-plus/test'; +import { afterEach, describe, it } from 'vite-plus/test'; import { prerenderPlugin } from '../../src/plugin-prerender.ts'; describe('managed static prerender isolation', () => { @@ -15,10 +15,8 @@ describe('managed static prerender isolation', () => { it('defers prerender instead of running inside the Vite build child', async () => { process.env.VOID_DEPLOY_BUILD = '1'; const plugin = prerenderPlugin('/path/that/must/not/be-read', { output: 'static' }); - expect(typeof plugin.closeBundle).toBe('function'); - - await (plugin.closeBundle as () => Promise).call({ - environment: { name: 'client' }, - }); + const hook = plugin.buildApp; + const handler = typeof hook === 'function' ? hook : hook?.handler; + await handler!.call({} as never, {} as never); }); }); diff --git a/packages/void/test/unit/plugin-route-binding-environment.test.ts b/packages/void/test/unit/plugin-route-binding-environment.test.ts index fb7d53a5b..783501ded 100644 --- a/packages/void/test/unit/plugin-route-binding-environment.test.ts +++ b/packages/void/test/unit/plugin-route-binding-environment.test.ts @@ -8,7 +8,7 @@ type MockCloudflareConfig = { config: (resolved: Record) => Record; }; -vi.mock('@cloudflare/vite-plugin', () => import('../helpers/cloudflare-vite-plugin.ts')); +vi.mock('../../src/plugin-cloudflare.ts', () => import('../helpers/cloudflare-vite-plugin.ts')); // Binding inference must not consult a developer's system keychain. vi.mock('../../src/cli/auth.ts', async (importOriginal) => ({ diff --git a/packages/void/test/unit/plugin-version-metadata.test.ts b/packages/void/test/unit/plugin-version-metadata.test.ts index 98f428c60..1204af979 100644 --- a/packages/void/test/unit/plugin-version-metadata.test.ts +++ b/packages/void/test/unit/plugin-version-metadata.test.ts @@ -11,7 +11,7 @@ import { } from '../../src/shared/version-metadata.ts'; type Config = { config: (resolved: Record) => Record }; -vi.mock('@cloudflare/vite-plugin', () => import('../helpers/cloudflare-vite-plugin.ts')); +vi.mock('../../src/plugin-cloudflare.ts', () => import('../helpers/cloudflare-vite-plugin.ts')); vi.mock('../../src/cli/auth.ts', async (original) => ({ ...(await original()), getToken: () => null, diff --git a/packages/void/test/unit/plugin-vite-environment.test.ts b/packages/void/test/unit/plugin-vite-environment.test.ts index 71fddb1a9..7b10448cb 100644 --- a/packages/void/test/unit/plugin-vite-environment.test.ts +++ b/packages/void/test/unit/plugin-vite-environment.test.ts @@ -12,11 +12,12 @@ import module, { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; -import { cloudflare } from '@cloudflare/vite-plugin'; +import { getWorkerBundleDir } from '@cloudflare/build-output-utils'; import { createBuilder, createServer, normalizePath, resolveConfig } from 'vite'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { voidPlugin } from '../../src/index.ts'; import { findVoidDependentPackages } from '../../src/installed-packages.ts'; +import { cloudflare } from '../../src/plugin-cloudflare.ts'; import { getHookHandler } from '../helpers/vite-hooks.ts'; const roots: Array = []; @@ -88,7 +89,7 @@ describe('voidPlugin Vite environments', () => { environments: { void_worker: { build: { - outDir: 'dist/ssr', + outDir: getWorkerBundleDir(realpathSync(root)), }, }, }, @@ -123,7 +124,7 @@ describe('voidPlugin Vite environments', () => { expect(config.environments.void_worker.resolve.conditions).toContain('workerd'); expect(config.environments.void_worker.build).toMatchObject({ emptyOutDir: false, - outDir: 'dist/ssr', + outDir: getWorkerBundleDir(realpathSync(root)), }); }); @@ -202,6 +203,7 @@ describe('voidPlugin Vite environments', () => { // @astrojs/cloudflare runs Astro's ssr environment in workerd this way. const adapter = cloudflare({ configPath: join(root, 'wrangler.jsonc'), + config: () => ({}), viteEnvironment: { name: 'ssr' }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55bbb5b7c..59dc8c3a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -226,8 +226,8 @@ catalogs: specifier: ^1.0.2 version: 1.0.2 '@cloudflare/vite-plugin': - specifier: ^1.64.0 - version: 1.64.0 + specifier: ^2.0.0-beta.v.3 + version: 2.0.0-beta.v.3 '@cloudflare/workers-auth': specifier: ^0.14.1 version: 0.14.1 @@ -280,8 +280,8 @@ catalogs: specifier: ^1.7.11 version: 1.7.11 cf: - specifier: '>=1.0.0-beta.18 <1.0.0-beta.19' - version: 1.0.0-beta.18 + specifier: ^1.0.0-beta.20 + version: 1.0.0-beta.20 lucide-react: specifier: 1.59.0 version: 1.59.0 @@ -289,8 +289,8 @@ catalogs: specifier: ^1.4.3 version: 1.4.3 miniflare: - specifier: ^5.20261012.1-beta - version: 5.20261012.1-beta + specifier: ^5.20261019.1-beta + version: 5.20261019.1-beta nuxt: specifier: ^4.6.2 version: 4.6.2 @@ -695,7 +695,7 @@ importers: version: 1.0.2 '@cloudflare/vite-plugin': specifier: 'catalog:' - version: 1.64.0(@types/node@26.6.5)(@voidzero-dev/vite-plus-core@1.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(typescript@7.0.2)(yaml@2.9.1))(wrangler@4.151.3(@cloudflare/workers-types@5.20261019.1)(@types/node@26.6.5)) + version: 2.0.0-beta.v.3(@types/node@26.6.5)(@voidzero-dev/vite-plus-core@1.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(typescript@7.0.2)(yaml@2.9.1))(workerd@1.20261019.1) '@cloudflare/workers-auth': specifier: 'catalog:' version: 0.14.1(vitest@5.0.5) @@ -734,7 +734,7 @@ importers: version: 1.1.0 cf: specifier: 'catalog:' - version: 1.0.0-beta.18(@types/node@26.6.5) + version: 1.0.0-beta.20(@types/node@26.6.5) drizzle-arktype: specifier: ^0.1.3 version: 0.1.3(arktype@2.2.8)(drizzle-orm@0.45.4(@cloudflare/workers-types@5.20261019.1)(@electric-sql/pglite@0.5.10)(@types/better-sqlite3@9.6.0)(@types/pg@8.23.1)(better-sqlite3@13.0.3)(kysely@0.29.7)(mysql2@3.24.6(@types/node@26.6.5))(pg@8.23.1)) @@ -770,7 +770,7 @@ importers: version: 4.1.1 miniflare: specifier: 'catalog:' - version: 5.20261012.1-beta(@types/node@26.6.5) + version: 5.20261019.1-beta(@types/node@26.6.5) minimatch: specifier: ^10.2.6 version: 10.2.6 @@ -786,6 +786,9 @@ importers: proper-lockfile: specifier: ^4.1.2 version: 4.1.2 + smol-toml: + specifier: ^1.9.1 + version: 1.9.1 terminal-link: specifier: ^5.0.0 version: 5.0.0 @@ -1007,7 +1010,7 @@ importers: version: 0.31.11 miniflare: specifier: 'catalog:' - version: 5.20261012.1-beta(@types/node@26.6.5) + version: 5.20261019.1-beta(@types/node@26.6.5) typescript: specifier: 'catalog:' version: 7.0.2 @@ -2723,8 +2726,8 @@ packages: resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} engines: {node: '>=22.0.0'} - '@cloudflare/runtime-types@0.1.12': - resolution: {integrity: sha512-eYF5teHQ+Jss4sbSVINM5tNPHxo/kJ2gKcMs7omyy7gO9PEkgUbGPZCYl06SxbBWTcJFF2vrX4epB775QTE4BQ==} + '@cloudflare/runtime-types@0.1.14': + resolution: {integrity: sha512-Hx4/RGjTNKPTB0z6q9MRv6GzvnL496EBvvBaUpANilSLfQJFXVkmClrbiMkzTFjzVRTgMsOhO8exwwkZu3OM8w==} '@cloudflare/sandbox@1.0.2': resolution: {integrity: sha512-JPPASKBQ5PE1XLy18zzljh3oIyDleu8lK3jW/HaSGq+eh9aJMl5zwAgobuP7I2FLuRMlqA18te5MIPEBF56jlg==} @@ -2745,6 +2748,12 @@ packages: vite: ^6.1.0 || ^7.0.0 || ^8.0.0 wrangler: ^4.151.0 + '@cloudflare/vite-plugin@2.0.0-beta.v.3': + resolution: {integrity: sha512-bfSEKaSXmRuoP5d73vJ2OAnaqgHuKkIoA7YlcIh/MS5H8lpOcKANYNy9x9FG0CntPiJnfLIq+qZlInMktEtr2Q==} + hasBin: true + peerDependencies: + vite: ^7.0.0 || ^8.0.0 + '@cloudflare/workerd-darwin-64@1.20261012.1': resolution: {integrity: sha512-dkTL9aY3V4D40b+jBpGPCistWUPFQ/M61wlWi6i1o8MvHFFgEw7EQkcaYEv6Yd3bkPDD/3TFT4hRsMb2xVZSwQ==} engines: {node: '>=16'} @@ -6522,9 +6531,9 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} - cf@1.0.0-beta.18: - resolution: {integrity: sha512-SG9NqB6xEZf/WZm6+E2v0OIY5JA0b9DUgl1VOOlF0D5Y+Nk5PyhHonJOd6K2/g6f261h+Z0FwTEK69c9bfIq4g==} - engines: {node: '>=22'} + cf@1.0.0-beta.20: + resolution: {integrity: sha512-sBg/zok+znpGvI00XwsUfuVz+5MyfnJcFY8YUwRXcl4eNg9qQWtnCf1YMq9rsWjcbr7BwjM1xbNJPTDhtkCRnw==} + engines: {node: ^22.23.0 || ^24.22.0 || ^26.12.0} hasBin: true chai@6.3.0: @@ -8142,10 +8151,6 @@ packages: resolution: {integrity: sha512-Cjrs+vHHQ7krLO4HYELYpYBJBuMhcnNZD/Z5z/UcQSnFqWSQY3DhtTCFalXgI1/yO1iK+YrRKK8EYkumrEopxg==} engines: {node: '>=22.0.0'} - miniflare@5.20261012.1-beta: - resolution: {integrity: sha512-8Canhy6g9+vAhqMc0o80YphqYIwpiA3aAp8aX+uDXU7J/qbUeoZzcII9c2V1P4uT/eHD4SB93qet1o1bRY3BuQ==} - engines: {node: '>=22.0.0'} - miniflare@5.20261019.1-beta: resolution: {integrity: sha512-1n7MN2UqZnPniyYAqNuV1YEAaja66ozKGVCc5ZTmYQhTubnoAwGLccwb+ZWYLOajuw3pOaJ70u//bZMuURlRew==} engines: {node: '>=22.0.0'} @@ -11039,10 +11044,10 @@ snapshots: '@cloudflare/kv-asset-handler@0.5.0': {} - '@cloudflare/runtime-types@0.1.12(@types/node@26.6.5)': + '@cloudflare/runtime-types@0.1.14(@types/node@26.6.5)': dependencies: - miniflare: 5.20261012.1-beta(@types/node@26.6.5) - workerd: 1.20261012.1 + miniflare: 5.20261019.1-beta(@types/node@26.6.5) + workerd: 1.20261019.1 transitivePeerDependencies: - '@types/node' - bufferutil @@ -11079,6 +11084,22 @@ snapshots: - bufferutil - utf-8-validate + '@cloudflare/vite-plugin@2.0.0-beta.v.3(@types/node@26.6.5)(@voidzero-dev/vite-plus-core@1.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(typescript@7.0.2)(yaml@2.9.1))(workerd@1.20261019.1)': + dependencies: + '@cloudflare/build-output-utils': 0.9.3 + '@cloudflare/config': 0.27.0 + '@cloudflare/runtime-types': 0.1.14(@types/node@26.6.5) + '@cloudflare/unenv-preset': 2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261019.1) + miniflare: 5.20261019.1-beta(@types/node@26.6.5) + unenv: 2.0.0-rc.24 + vite: '@voidzero-dev/vite-plus-core@1.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(typescript@7.0.2)(yaml@2.9.1)' + ws: 8.21.0 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + - workerd + '@cloudflare/workerd-darwin-64@1.20261012.1': optional: true @@ -14577,13 +14598,13 @@ snapshots: ccount@2.0.1: {} - cf@1.0.0-beta.18(@types/node@26.6.5): + cf@1.0.0-beta.20(@types/node@26.6.5): dependencies: '@cloudflare/build-output-utils': 0.9.3 '@cloudflare/config': 0.27.0 - '@cloudflare/runtime-types': 0.1.12(@types/node@26.6.5) + '@cloudflare/runtime-types': 0.1.14(@types/node@26.6.5) blake3-wasm: 2.1.5 - miniflare: 5.20261012.1-beta(@types/node@26.6.5) + miniflare: 5.20261019.1-beta(@types/node@26.6.5) transitivePeerDependencies: - '@types/node' - bufferutil @@ -15991,19 +16012,6 @@ snapshots: - bufferutil - utf-8-validate - miniflare@5.20261012.1-beta(@types/node@26.6.5): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - sharp: 0.35.5(@types/node@26.6.5) - undici: 7.29.1 - workerd: 1.20261012.1 - ws: 8.21.0 - youch: 4.1.0-beta.10 - transitivePeerDependencies: - - '@types/node' - - bufferutil - - utf-8-validate - miniflare@5.20261019.1-beta(@types/node@26.6.5): dependencies: '@cspotcode/source-map-support': 0.8.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index cb97b671c..8d8179b79 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -33,9 +33,8 @@ catalog: vite-plus: 1.3.0 vitest: 5.0.5 wrangler: ^4.151.3 - # beta.19 adds a second workerd release beside the stable Vite plugin's runtime. - # Recheck packed install size before widening this range. - cf: '>=1.0.0-beta.18 <1.0.0-beta.19' + # Keep cf, Miniflare and the Vite plugin on the same workerd release. + cf: ^1.0.0-beta.20 oauth4webapi: ^3.8.8 '@astryxdesign/core': 0.7.1 '@astryxdesign/theme-neutral': 0.7.1 @@ -45,15 +44,14 @@ catalog: '@cloudflare/build-output-utils': ^0.9.3 '@cloudflare/codemods': ^0.5.1 '@cloudflare/config': ^0.27.0 - '@cloudflare/vite-plugin': ^1.64.0 + '@cloudflare/vite-plugin': ^2.0.0-beta.v.3 '@cloudflare/workers-auth': ^0.14.1 '@nkzw/fate': 1.9.0 '@vitejs/plugin-react': ^6.1.2 '@vitejs/plugin-rsc': ^0.5.38 astro: ^7.4.0 magic-string: ^1.4.3 - # Match cf beta.18; the stable Vite plugin shares this workerd release. - miniflare: ^5.20261012.1-beta + miniflare: ^5.20261019.1-beta nuxt: ^4.6.2 playwright: ^1.64.1 postal-mime: ^4.0.11