Skip to content
Merged
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
4 changes: 3 additions & 1 deletion docs/guide/platform/installation/maintenance.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,9 @@ Repair recreates missing infrastructure that the installer owns. It does not res

### Upgrade the platform

Keep the platform runtime current when updating the Void CLI.
Keep the platform runtime current when updating the Void CLI. Upgrade the platform before your team updates its CLI: a newer CLI refuses to work with an older platform and asks for `void platform upgrade`. Install the new CLI on the administrator's machine, run the upgrade below, then have the team update.

If the platform will deploy apps that use `void/sandbox`, its runtime token needs **Account → Containers → Edit** and **Account → Cloudchamber → Edit**. A token made from an older setup link may lack them, and an upgrade does not add them. Edit the token in the Cloudflare dashboard, or set `VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN` to a new token when you upgrade. See [Cloudflare API Tokens](/guide/platform/installation/credentials#runtime-token-permissions).

Use the installed CLI's packaged runtime to upgrade:

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ Void-managed WebSocket route files (`*.ws.ts`) are Cloudflare-only because they

### `worker`

Curated Cloudflare Workers configuration. `void init` pins `compatibility_date` here. Binding arrays such as `d1_databases`, `kv_namespaces`, and `r2_buckets` go in [`cloudflare`](#cloudflare), where Void preserves custom settings and resolved IDs during deployment.
Curated Cloudflare Workers configuration. `void init` pins `compatibility_date` here. If a project has no config file and no date, the first dev server or build creates `void.config.ts` with only this date and adds `.void-wrangler.jsonc` to `.gitignore`. Astro, SvelteKit, Nuxt, and Analog apps get the date in a root `wrangler.jsonc` instead, which their adapter reads by default. A project with a `wrangler.toml` gets it in `void.json`. Binding arrays such as `d1_databases`, `kv_namespaces`, and `r2_buckets` go in [`cloudflare`](#cloudflare), where Void preserves custom settings and resolved IDs during deployment.

| Field | Type | Description |
| --------------------- | ---------- | -------------------------------------- |
Expand Down
2 changes: 1 addition & 1 deletion packages/deploy-cloudflare/src/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export type CloudflareTriggerConfig = Readonly<{
queueConsumers: ReadonlyArray<unknown>;
workflows: ReadonlyArray<unknown>;
/**
* Email Routing addresses (`addresses` in Wrangler config). Wrangler 4.131 applies its
* Email Routing addresses (`addresses` in Wrangler config). The Cloudflare CLI applies its
* Email Routing plan inside `triggers deploy` — `undefined` skips the plan entirely, while
* `[]` is a destructive plan that deletes every rule this Worker owns — so the two must stay
* distinct through the snapshot. Absent from annotations written before this field existed.
Expand Down
2 changes: 1 addition & 1 deletion packages/deploy-cloudflare/test/unit/version.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1713,7 +1713,7 @@ describe('Cloudflare version activation', () => {
).not.toHaveProperty('contentHash');
});

// Wrangler 4.131 applies its Email Routing plan inside `triggers deploy`, reading the
// The Cloudflare CLI applies its Email Routing plan inside `triggers deploy`, reading the
// config's `addresses`. The trigger-only config Void hands it must therefore carry the
// snapshot's addresses verbatim — and carry NOTHING when the snapshot has none, because an
// absent key skips the plan while `[]` deletes every rule the Worker owns.
Expand Down
25 changes: 13 additions & 12 deletions packages/void/src/cli/deploy-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,9 @@ export function scrubManagedDeployBuildMarkers(env: NodeJS.ProcessEnv): NodeJS.P
* top-level config this flow provisions + preflights. Scrubbed from every child env
* and fenced in the project dotenv (see below), on top of the provisioner's transport
* scrub/fence which owns the endpoint-redirect + `CLOUDFLARE_ENV` vars:
* - `WRANGLER_CI_OVERRIDE_NAME` — wrangler 4.131 applies it AFTER config load
* (`mergeSharedConfigArgs` → `getCIOverrideName()`), REPLACING the upload name, so a
* bare `wrangler deploy` uploads a worker whose name the generated-config check never
* validated.
* - `WRANGLER_CI_OVERRIDE_NAME` — the Cloudflare tooling (wrangler and the `cf` CLI)
* applies it AFTER config load (`getCIOverrideName()`), REPLACING the upload name, so a
* bare deploy uploads a worker whose name the generated-config check never validated.
* - `CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH` — the CF Vite plugin points the build at an
* ALTERNATE wrangler config, flattening a different binding set into
* `dist/ssr/wrangler.json`.
Expand All @@ -60,18 +59,20 @@ const DEPLOY_CONFIG_OVERRIDE_ENV_VARS = [
] as const;

/**
* The dotenv files wrangler 4.131 reloads into `process.env` (`override: true`) for
* the orchestrator's `--env=` calls (`getDefaultEnvFiles("")`), so a repo dotenv here
* would resurrect `WRANGLER_CI_OVERRIDE_NAME` at the DEPLOY step after the child-env
* scrub removed it.
* The dotenv files wrangler loads into `process.env` (`override: true`) when no
* `--env` is named (`getDefaultEnvFiles("")`). Void runs its deploy steps through the
* `cf` CLI, not wrangler, but this set is kept for a user who runs `wrangler deploy` on
* the same config. It also covers the `.env` / `.env.local` files `cf` reads. A repo
* dotenv here would bring back `WRANGLER_CI_OVERRIDE_NAME` after the child-env scrub
* removed it.
*/
const DEPLOY_WRANGLER_DOTENV_FILES = ['.env', '.env.local', '.env.', '.env..local'];

/**
* The dotenv files Vite's `loadEnv(mode, …)` reads for a production `vite build`
* (`.env`, `.env.local`, `.env.production`, `.env.production.local`), where the CF Vite
* plugin picks up `CLOUDFLARE_ENV` / `CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH` — a set
* DISJOINT from wrangler's `--env=` files, so it is fenced separately for the build.
* that differs from the wrangler dotenv files above, so it is fenced separately for the build.
*/
const DEPLOY_VITE_DOTENV_FILES = ['.env', '.env.local', '.env.production', '.env.production.local'];

Expand Down Expand Up @@ -133,7 +134,7 @@ export function findWranglerCiOverrideDotenvFile(
* FAIL CLOSED when the project's dotenv files declare a deploy-time config-override
* selector. wrangler / Vite reload dotenv on every command, so a repo dotenv would
* reintroduce a scrubbed var AFTER the child-env scrub — reselecting the upload worker
* name (wrangler `--env=` files) or the build's wrangler config (Vite production files).
* name (the wrangler dotenv files) or the build's wrangler config (Vite production files).
* The provisioner's transport fence ({@link assertNoTransportRedirectInProjectDotenv})
* owns `CLOUDFLARE_ENV` + the endpoint-redirect vars; this closes the deploy-specific
* name/config-path selectors it does not cover, across BOTH consumers' file sets.
Expand All @@ -143,7 +144,7 @@ export function assertNoConfigOverrideInProjectDotenv(
root: string,
platform: NodeJS.Platform = process.platform,
): void {
// WRANGLER_CI_OVERRIDE_NAME reaches the DEPLOY step via wrangler's `--env=` dotenv reload.
// WRANGLER_CI_OVERRIDE_NAME reaches a deploy through the wrangler dotenv reload.
const ciOverrideFile = findWranglerCiOverrideDotenvFile(root, platform);
if (ciOverrideFile) {
throw new Error(
Expand All @@ -154,7 +155,7 @@ export function assertNoConfigOverrideInProjectDotenv(
);
}
// CLOUDFLARE_ENV / CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH reach the BUILD via Vite's
// production `loadEnv`, whose file set is disjoint from wrangler's `--env=` files.
// production `loadEnv`, whose file set differs from the wrangler dotenv files.
for (const file of DEPLOY_VITE_DOTENV_FILES) {
const offender = findDotenvDeclaration(
root,
Expand Down
15 changes: 2 additions & 13 deletions packages/void/src/cli/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { log } from '@voidzero-dev/vite-plus-prompts';
import { parseTree, type Node } from 'jsonc-parser';
import { join } from 'pathslash';
import {
ignoreMaterializedCloudflareConfig,
seedCloudflareConfig,
stashCloudflareDeployPointer,
VOID_LOCK_FILE,
Expand Down Expand Up @@ -96,18 +97,6 @@ function isOrphanedCompatibilityConfig(
}
}

function ignoreGeneratedCloudflareConfig(root: string): void {
const path = join(root, '.gitignore');
const source = existsSync(path) ? readFileSync(path, 'utf8') : '';
if (source.split(/\r?\n/).some((line) => /^\/?\.void-wrangler\.jsonc$/.test(line.trim()))) {
return;
}
writeFileSync(
path,
`${source}${source && !source.endsWith('\n') ? '\n' : ''}.void-wrangler.jsonc\n`,
);
}

/** Move Void and Cloudflare JSON/JSONC settings into a typed, checked-in project config. */
export function migrateLegacyVoidConfig(root: string): boolean {
const legacyPath = join(root, LEGACY_FILE);
Expand Down Expand Up @@ -226,7 +215,7 @@ export function migrateLegacyVoidConfig(root: string): boolean {
if (wranglerName) {
unlinkSync(join(root, wranglerName));
}
ignoreGeneratedCloudflareConfig(root);
ignoreMaterializedCloudflareConfig(root);
stashCloudflareDeployPointer(root);
return true;
} finally {
Expand Down
19 changes: 10 additions & 9 deletions packages/void/src/cli/provision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,8 +267,9 @@ const defaultLogger: ProvisionLogger = {
};

/**
* Env vars that move wrangler's API transport to another endpoint or select another config
* env. Provisioning pins one base ({@link provisionEnv}) and rejects them in project dotenv.
* Env vars that move the Cloudflare CLI's API transport to another endpoint or select another
* config env. Provisioning pins one base ({@link provisionEnv}) and rejects them in project
* dotenv.
*/
const TRANSPORT_REDIRECT_ENV_VARS = [
'CLOUDFLARE_API_BASE_URL',
Expand All @@ -289,7 +290,7 @@ const OUTPUT_FILE_ENV_VARS = [

/**
* The one API base URL this provision uses, resolved like wrangler's `getCloudflareApiBaseUrl`
* so the Hyperdrive REST lister and wrangler agree:
* so the Hyperdrive REST lister and the Cloudflare CLI agree:
* 1. a present `CLOUDFLARE_API_BASE_URL` (then `CF_API_BASE_URL`) is used as-is, even empty;
* 2. otherwise the staging and compliance selectors derive it, and invalid values fail;
* 3. the config's `compliance_region` is always validated.
Expand All @@ -300,7 +301,7 @@ export function resolvePinnedApiBaseUrl(
): string {
const configRegion =
configComplianceRegion !== undefined
? assertComplianceChoice(configComplianceRegion, 'the wrangler config "compliance_region"')
? assertComplianceChoice(configComplianceRegion, 'the Cloudflare config "compliance_region"')
: undefined;
const direct =
env.CLOUDFLARE_API_BASE_URL !== undefined ? env.CLOUDFLARE_API_BASE_URL : env.CF_API_BASE_URL;
Expand All @@ -322,8 +323,8 @@ function resolveApiEnvironment(env: NodeJS.ProcessEnv): 'production' | 'staging'
if (raw !== 'production' && raw !== 'staging') {
throw new Error(
`provision: the WRANGLER_API_ENVIRONMENT environment variable must be one of ` +
`"production" or "staging", got ${JSON.stringify(raw)}. wrangler rejects this value, so ` +
`provisioning refuses to guess an endpoint; fix or unset it and retry.`,
`"production" or "staging", got ${JSON.stringify(raw)}. The Cloudflare tooling rejects ` +
`this value, so provisioning refuses to guess an endpoint; fix or unset it and retry.`,
);
}
return raw;
Expand Down Expand Up @@ -358,9 +359,9 @@ function resolveComplianceRegion(
throw new Error(
`provision: the Cloudflare compliance region is set to conflicting values — the ` +
`CLOUDFLARE_COMPLIANCE_REGION environment variable is ${JSON.stringify(envRegion)} but the ` +
`wrangler config "compliance_region" is ${JSON.stringify(configRegion)}. wrangler refuses to ` +
`run with a mismatched compliance region; reconcile them (remove one, or set them equal) and ` +
`retry.`,
`Cloudflare config "compliance_region" is ${JSON.stringify(configRegion)}. The Cloudflare ` +
`tooling refuses to run with a mismatched compliance region; reconcile them (remove one, or ` +
`set them equal) and retry.`,
);
}
return envRegion ?? configRegion ?? 'public';
Expand Down
58 changes: 38 additions & 20 deletions packages/void/src/cli/wrangler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ import {
} from 'jsonc-parser';
import { basename, dirname, join, normalize, resolve } from 'pathslash';
import {
ignoreMaterializedCloudflareConfig,
materializeCloudflareConfig,
MATERIALIZED_CLOUDFLARE_FILE,
persistCloudflareConfig,
} from '../cloudflare-config.ts';
import { TYPED_CONFIG_FILE } from '../config-loader.ts';
import { writeVoidProjectConfig } from '../config-write.ts';
import { readConfig, type VoidConfig } from '../config.ts';
import type { DurableObjectModule } from '../durable/scan.ts';
import type { InferredBindings } from '../inference/walk.ts';
Expand Down Expand Up @@ -209,7 +211,6 @@ export type WranglerCompat = {
};

type WranglerCompatFields = {
voidConfig: VoidConfig | undefined;
voidDate: string | undefined;
voidFlags: Array<string> | undefined;
wranglerDate: string | undefined;
Expand Down Expand Up @@ -372,17 +373,23 @@ export function readWranglerCompat(
return resolveWranglerCompat(readWranglerCompatFields(root, config, buildOutputDir));
}

/** Like `readWranglerCompat()`, but pins the default date when no source sets one. */
/**
* Like `readWranglerCompat()`, but pins the default date when no source sets one.
* With `rootWrangler`, a project with no config gets the date in a root wrangler.jsonc
* instead of a new void.config.ts, so a framework adapter that reads the root file by
* default still finds it.
*/
export function ensureWranglerCompat(
root: string,
config?: VoidConfig,
buildOutputDir?: string,
options?: { rootWrangler?: boolean },
): WranglerCompat {
const fields = readWranglerCompatFields(root, config, buildOutputDir);
const hasExplicitDate = fields.voidDate ?? fields.wranglerDate ?? fields.buildDate;
const defaultDate = hasExplicitDate
? undefined
: ensureVoidCompatibilityDate(root, fields.voidConfig);
: ensureVoidCompatibilityDate(root, options?.rootWrangler === true);
return resolveWranglerCompat(fields, defaultDate ? { defaultDate } : undefined);
}

Expand Down Expand Up @@ -442,7 +449,6 @@ function readWranglerCompatFields(
}

return {
voidConfig,
voidDate: voidConfig?.worker?.compatibility_date,
voidFlags: voidConfig?.worker?.compatibility_flags,
wranglerDate,
Expand Down Expand Up @@ -489,29 +495,41 @@ function readVoidConfigIfExists(root: string): VoidConfig | undefined {
: undefined;
}

function ensureVoidCompatibilityDate(root: string, config?: VoidConfig): string {
function ensureVoidCompatibilityDate(root: string, rootWrangler: boolean): string {
if (existsSync(join(root, TYPED_CONFIG_FILE))) {
writeProvisionedWranglerConfig(root, { compatibilityDate: LATEST_KNOWN_COMPAT_DATE });
return LATEST_KNOWN_COMPAT_DATE;
}
const filePath = join(root, VOID_CONFIG_FILE);
let nextConfig: VoidConfig = {};
if (!existsSync(filePath)) {
if (existsSync(join(root, 'wrangler.toml'))) {
// void.config.ts would point the Cloudflare plugin away from wrangler.toml and drop
// its bindings, so keep the date in void.json.
const pinned: VoidConfig = { worker: { compatibility_date: LATEST_KNOWN_COMPAT_DATE } };
writeFileSync(filePath, `${JSON.stringify(pinned, null, 2)}\n`);
} else if (rootWrangler || projectWranglerPaths(root).some((path) => existsSync(path))) {
// A root Wrangler config cannot sit next to void.config.ts, so pin the date in it.
writeProvisionedWranglerConfig(root, { compatibilityDate: LATEST_KNOWN_COMPAT_DATE });
} else {
// New projects get the date in void.config.ts, as `void init` writes it.
writeVoidProjectConfig(root, { worker: { compatibility_date: LATEST_KNOWN_COMPAT_DATE } });
ignoreMaterializedCloudflareConfig(root);
}
return LATEST_KNOWN_COMPAT_DATE;
}

if (existsSync(filePath)) {
try {
const parsed = JSON.parse(readFileSync(filePath, 'utf-8'));
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error(`config: ${VOID_CONFIG_FILE} must be a JSON object.`);
}
nextConfig = parsed as VoidConfig;
} catch (error) {
if (error instanceof SyntaxError) {
throw new Error(`config: Invalid JSON in ${VOID_CONFIG_FILE}.`);
}
throw error;
let nextConfig: VoidConfig;
try {
const parsed = JSON.parse(readFileSync(filePath, 'utf-8'));
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error(`config: ${VOID_CONFIG_FILE} must be a JSON object.`);
}
nextConfig = parsed as VoidConfig;
} catch (error) {
if (error instanceof SyntaxError) {
throw new Error(`config: Invalid JSON in ${VOID_CONFIG_FILE}.`);
}
} else if (config) {
nextConfig = { ...config };
throw error;
}

nextConfig.worker = {
Expand Down
17 changes: 16 additions & 1 deletion packages/void/src/cloudflare-config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { isDeepStrictEqual } from 'node:util';
import { isRecord } from '@void/deploy-core';
import { join } from 'pathslash';
Expand All @@ -11,6 +11,21 @@ export const MATERIALIZED_CLOUDFLARE_FILE = '.void-wrangler.jsonc';
export const VOID_LOCK_FILE = 'void.lock.json';
const PREVIEW_POINTER_FILE = 'preview-deploy-config.json';

/** Add the generated Cloudflare config to `.gitignore` unless a line already ignores it. */
export function ignoreMaterializedCloudflareConfig(root: string): void {
const path = join(root, '.gitignore');
const source = existsSync(path) ? readFileSync(path, 'utf8') : '';
const ignored = source.split('\n').some((line) => {
const entry = line.trim();
return entry === MATERIALIZED_CLOUDFLARE_FILE || entry === `/${MATERIALIZED_CLOUDFLARE_FILE}`;
});
if (ignored) {
return;
}
const separator = source && !source.endsWith('\n') ? '\n' : '';
writeFileSync(path, `${source}${separator}${MATERIALIZED_CLOUDFLARE_FILE}\n`);
}

type JsonObject = Record<string, unknown>;
type CloudflareState = {
version: 1;
Expand Down
11 changes: 8 additions & 3 deletions packages/void/src/plugin-framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,18 @@ export function createFrameworkPlugins(input: {
}
};

// Adapters read the root Wrangler file by default, so a project with no config gets
// the date there instead of in a new void.config.ts.
const ensureFrameworkCompat = () =>
ensureWranglerCompat(root, config, undefined, { rootWrangler: true });

// Ensure required compat flags are written before framework adapters
// initialize Cloudflare dev runtime / Miniflare.
const wranglerCompatPlugin: Plugin = {
name: 'void:wrangler-compat',
enforce: 'pre',
config(_, env) {
syncFrameworkCompat(ensureWranglerCompat(root, config));
syncFrameworkCompat(ensureFrameworkCompat());
if (env.command !== 'serve') {
return;
}
Expand All @@ -102,10 +107,10 @@ export function createFrameworkPlugins(input: {
}
},
buildStart() {
syncFrameworkCompat(ensureWranglerCompat(root, config));
syncFrameworkCompat(ensureFrameworkCompat());
},
configureServer() {
syncFrameworkCompat(ensureWranglerCompat(root, config));
syncFrameworkCompat(ensureFrameworkCompat());
},
};

Expand Down
2 changes: 1 addition & 1 deletion packages/void/test/unit/cloudflare-deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8716,7 +8716,7 @@ describe('checkWranglerMigrationApplySet', () => {
});
});

describe('compareWranglerMigrationPaths (wrangler 4.131 numeric-prefix comparator)', () => {
describe('compareWranglerMigrationPaths (wrangler numeric-prefix comparator)', () => {
it('sorts by leading numeric prefix (2 before 10), matching wrangler', () => {
const sorted = ['10_b.sql', '2_a.sql', '1_z.sql'].sort(compareWranglerMigrationPaths);
expect(sorted).toEqual(['1_z.sql', '2_a.sql', '10_b.sql']);
Expand Down
Loading
Loading