Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ dist
.DS_Store
.wrangler
.void-wrangler.jsonc
.cloudflare
cloudflare.config.ts
.env
.claude
plan.md
Expand Down
2 changes: 1 addition & 1 deletion .node-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
24.21.0
24.22.0
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/platform/installation/prerequisites.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions docs/reference/api/plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down
2 changes: 1 addition & 1 deletion packages/create-void/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/create-void/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,6 @@
"vite-plus": "catalog:"
},
"engines": {
"node": ">=24.21.0"
"node": "^24.22.0 || >=26.12.0"
}
}
2 changes: 2 additions & 0 deletions packages/void/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packages/void/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion packages/void/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down Expand Up @@ -427,6 +428,6 @@
}
},
"engines": {
"node": ">=24.21.0"
"node": "^24.22.0 || >=26.12.0"
}
}
55 changes: 55 additions & 0 deletions packages/void/src/cli/cf-build-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,61 @@ export async function prepareCfBuildOutput(
): Promise<CfBuildOutput | null> {
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'))) {
Expand Down
33 changes: 25 additions & 8 deletions packages/void/src/cli/cf-config-migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,8 @@ function assertHandledFollowUps(followUps: Array<MigrationFollowUp>): 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' ||
Expand Down Expand Up @@ -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<typeof InputConfigSchema.parse> {
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 {
Expand All @@ -395,6 +410,8 @@ export function migrateBuiltWranglerConfig(
migrationScript,
import.meta.resolve('@cloudflare/codemods'),
configPath,
import.meta.resolve('@cloudflare/config'),
mode,
],
{
cwd: directory,
Expand All @@ -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<JsonObject>).find(
const className = (effective.containers as Array<JsonObject>).find(
(entry) => entry.name === container.name,
)?.class_name;
const binding = durableBindings.find((entry) => record(entry)?.class_name === className);
Expand Down
3 changes: 3 additions & 0 deletions packages/void/src/cli/deploy-cloudflare-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 2 additions & 0 deletions packages/void/src/cli/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export const SCAFFOLD_GITIGNORE_LINES = [
'.void',
'.wrangler',
'.void-wrangler.jsonc',
'.cloudflare',
'cloudflare.config.ts',
'.env',
'.env.*',
'!.env.example',
Expand Down
6 changes: 3 additions & 3 deletions packages/void/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -88,7 +88,7 @@ const scheduledPreviewPointerCleanups = new Set<string>();
/** 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<AuxiliaryWorkerConfig>;
}): Array<Plugin> {
// Resolve root relative to CWD — this runs at config time
const root = process.cwd();
Expand Down Expand Up @@ -1011,7 +1011,6 @@ export function voidPlugin(options?: {
enabled: authEnabled,
configPath: authConfigPath,
}),
prerenderPlugin(root, config),
devTriggersHintPlugin(root, devTriggerToken),
...cloudflare({
configPath: cloudflareConfigPath,
Expand All @@ -1021,6 +1020,7 @@ export function voidPlugin(options?: {
viteEnvironment: { name: DEFAULT_WORKER_ENVIRONMENT_NAME },
config: mergeBindings,
}),
prerenderPlugin(root, config),
implicitDeployGuard,
];
}
Expand Down
Loading
Loading