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
18 changes: 9 additions & 9 deletions docs/integrations/cloudflare.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,14 +133,14 @@ Apps using email need `email.from` in `void.config.ts`. Void reviews the domain'

Set these CI secrets and variables as needed:

| Name | When needed |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
| `CLOUDFLARE_API_TOKEN` | Required. Workers Scripts: Edit, read access to bound resources, and edit access to products Void provisions. |
| `CLOUDFLARE_ACCOUNT_ID` | When the token can access several accounts. |
| `DATABASE_URL` | PostgreSQL and MySQL provisioning and migrations. |
| `CLOUDFLARE_WORKERS_SUBDOMAIN` | When the Worker has no version preview URL. Use `my-team` or `my-team.workers.dev`. |
| `CF_ACCESS_CLIENT_ID`, `CF_ACCESS_CLIENT_SECRET` | When Cloudflare Access protects the readiness URL. |
| `CF_ACCESS_READINESS_ORIGIN` | When Access protects a route or Custom Domain and `workers_dev` is `false`. See [Cloudflare Access](#cloudflare-access). |
| Name | When needed |
| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `CLOUDFLARE_API_TOKEN` | Required. Workers Scripts: Edit, read access to bound resources, and edit access to products Void provisions. |
| `CLOUDFLARE_ACCOUNT_ID` | When the token can access several accounts. |
| `DATABASE_URL` | PostgreSQL and MySQL provisioning and migrations. |
| `CLOUDFLARE_WORKERS_SUBDOMAIN` | When workers.dev is enabled and the version has no preview URL. Use `my-team` or `my-team.workers.dev`. An account subdomain does not create a readiness URL when `workers_dev` is false. |
| `CF_ACCESS_CLIENT_ID`, `CF_ACCESS_CLIENT_SECRET` | When Cloudflare Access protects the readiness URL. |
| `CF_ACCESS_READINESS_ORIGIN` | When Access protects a route or Custom Domain and `workers_dev` is `false`. See [Cloudflare Access](#cloudflare-access). |

For email, run `void email setup --platform cloudflare` locally, commit `void.lock.json`, and pass `--require-email` in CI. The token also needs Email Routing Edit and Email Sending Edit.

Expand Down Expand Up @@ -174,7 +174,7 @@ Set production server values with `void secret put <NAME>`. Void preserves exist

### Readiness and rollback

Void normally verifies an uploaded version before sending it traffic. If readiness or trigger updates fail, it restores the previous deployment. Finish or cancel any gradual rollout in Cloudflare before deploying through Void.
Void normally verifies an uploaded version before sending it traffic. A Worker with workers.dev turned off and no route or Custom Domain has no URL Void can check, so that deploy skips the readiness check and activates the uploaded version. If readiness or trigger updates fail, it restores the previous deployment. Finish or cancel any gradual rollout in Cloudflare before deploying through Void.

For an existing Durable Object Worker that Cloudflare cannot stage, review the changes and use:

Expand Down
20 changes: 10 additions & 10 deletions docs/reference/cli/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,16 +86,16 @@ void deploy --platform cloudflare --require-email # fail instead of deploying

Void signs you in through your browser when needed and saves the selected account. In CI, set `CLOUDFLARE_API_TOKEN` and, if the token can access several accounts, `CLOUDFLARE_ACCOUNT_ID`.

| Option or setting | Cloudflare behavior |
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------- |
| `--dir`, `--spa` | Deploy static output through a small Worker and Workers Assets |
| `--skip-build` | Reuse existing static, SPA, or SSG output; unavailable for Worker apps |
| `--project` | Unavailable; the Worker and account come from `void.config.ts` and `void.lock.json` |
| Named environments | Unavailable; use the top-level `cloudflare` config |
| `CLOUDFLARE_WORKERS_SUBDOMAIN` | Needed in fresh CI when versions have no preview URL; cached locally after a deploy |
| `CF_ACCESS_READINESS_ORIGIN` | Lets Access credentials reach the readiness check on a route or Custom Domain, for example `https://app.example.com` |
| `--require-email` | Fail instead of deploying without email when the email step cannot run, as in CI |
| `DATABASE_URL` | Required in the deploy environment for PostgreSQL or MySQL provisioning and migrations |
| Option or setting | Cloudflare behavior |
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `--dir`, `--spa` | Deploy static output through a small Worker and Workers Assets |
| `--skip-build` | Reuse existing static, SPA, or SSG output; unavailable for Worker apps |
| `--project` | Unavailable; the Worker and account come from `void.config.ts` and `void.lock.json` |
| Named environments | Unavailable; use the top-level `cloudflare` config |
| `CLOUDFLARE_WORKERS_SUBDOMAIN` | Needed in fresh CI when a workers.dev Worker has no preview URL; cached locally after a deploy. It does not apply when `workers_dev` is false |
| `CF_ACCESS_READINESS_ORIGIN` | Lets Access credentials reach the readiness check on a route or Custom Domain, for example `https://app.example.com` |
| `--require-email` | Fail instead of deploying without email when the email step cannot run, as in CI |
| `DATABASE_URL` | Required in the deploy environment for PostgreSQL or MySQL provisioning and migrations |

The token needs Workers Scripts: Edit, read access to bound resources, and edit access to products Void provisions. Commit `void.lock.json` after the first deploy before using other machines or CI.

Expand Down
203 changes: 113 additions & 90 deletions packages/deploy-cloudflare/src/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export type CloudflareVersionActivation =
| {
ok: true;
versionId: string;
previewUrl: string;
previewUrl?: string;
previousVersionId: string | null;
workersDevSubdomain?: string;
preservedTriggers?: boolean;
Expand Down Expand Up @@ -133,6 +133,7 @@ export type CloudflareVersionActivation =
* credentials because `readinessHeadersOrigin` did not name the origin.
*/
customRouteReadinessOrigin?: string;
workersDev?: boolean;
};

type ProductionDeployment = Readonly<{
Expand Down Expand Up @@ -1055,6 +1056,7 @@ async function activateCloudflareVersionWithSecretsFile(
routes: previousMetadata.triggerConfig.routes,
}
: undefined);
const preserveRoutesRequested = desiredTriggerConfig.preserveRoutes === true;
if (continuesOpaqueTriggerPreservation) {
desiredTriggerConfig = { ...desiredTriggerConfig, preserve: true };
} else if (!options.preserveTriggers && desiredMetadata?.schema === 2) {
Expand Down Expand Up @@ -1344,72 +1346,11 @@ async function activateCloudflareVersionWithSecretsFile(
? workersDevReadinessUrl(options.workerName, options.workersDevSubdomain)
: customRouteReadinessUrl(desiredTriggerConfig.routes);
readinessUsesCustomRoute = !desiredTriggerConfig.workers_dev && readinessUrl !== undefined;
if (!readinessUrl) {
return {
ok: false,
reason: 'preview-unavailable',
versionId,
previousVersionId: previous.versionId,
};
}
const fallbackSubdomain = extractWorkersDevSubdomain(readinessUrl);
if (fallbackSubdomain && fallbackSubdomain === options.readinessHeadersWorkersDevSubdomain) {
authenticatedWorkersDevSubdomain = fallbackSubdomain;
}
const stagingFailure = await stageCandidateForReadiness(
options,
previous.versionId,
versionId,
message,
);
if (stagingFailure) {
return stagingFailure;
}
stagedVersionOverride = true;
}

const trustedReadinessOrigin = normalizeReadinessAccessOrigin(options.readinessHeadersOrigin);
// A route hostname is not guaranteed to reach this Worker. Send Access credentials to a custom
// route only when the operator named its exact origin.
const withholdsCustomRouteHeaders = (url: string) =>
readinessUsesCustomRoute && !isTrustedReadinessOrigin(url, trustedReadinessOrigin);
const probe = (url: string) =>
probeReadiness(
`${url.replace(/\/$/, '')}/__void/ready`,
options.workerName,
withholdsCustomRouteHeaders(url) ? undefined : options.readinessHeaders,
authenticatedWorkersDevSubdomain,
readinessUsesCustomRoute ? trustedReadinessOrigin : undefined,
stagedVersionOverride ? versionId : undefined,
options.fetch ?? globalThis.fetch,
options.sleep ?? delay,
options.readinessAttempts ?? 6,
Number.POSITIVE_INFINITY,
options.requireVersionMetadata ? versionId : undefined,
);
options.onProgress?.('Waiting for the Worker to become ready...');
let ready = await probe(readinessUrl);

// Account-wide Access default-deny commonly blocks Wrangler's generated preview alias even
// when the stable `<worker>.<account>.workers.dev` destination has an application admitting the
// operator or a service token. The preview response gives us the account subdomain, so preserve
// the same no-traffic readiness invariant by staging the uploaded version at 0% and selecting it
// explicitly through the stable, policy-covered hostname. This requires a prior active version
// whose traffic can remain untouched while the new version is staged; a truly empty first
// deployment has no safe zero-percent fallback.
if (
ready === 'access-denied' &&
!stagedVersionOverride &&
previous.versionId !== null &&
!options.requirePreviewForReadiness
) {
const stableReadinessUrl = desiredTriggerConfig.workers_dev
? workersDevReadinessUrl(
options.workerName,
extractWorkersDevSubdomain(readinessUrl) ?? options.workersDevSubdomain,
)
: customRouteReadinessUrl(desiredTriggerConfig.routes);
if (stableReadinessUrl && stableReadinessUrl !== readinessUrl) {
if (readinessUrl) {
const fallbackSubdomain = extractWorkersDevSubdomain(readinessUrl);
if (fallbackSubdomain && fallbackSubdomain === options.readinessHeadersWorkersDevSubdomain) {
authenticatedWorkersDevSubdomain = fallbackSubdomain;
}
const stagingFailure = await stageCandidateForReadiness(
options,
previous.versionId,
Expand All @@ -1419,28 +1360,97 @@ async function activateCloudflareVersionWithSecretsFile(
if (stagingFailure) {
return stagingFailure;
}
readinessUrl = stableReadinessUrl;
authenticatedWorkersDevSubdomain = extractWorkersDevSubdomain(stableReadinessUrl);
readinessUsesCustomRoute = !desiredTriggerConfig.workers_dev;
stagedVersionOverride = true;
ready = await probe(readinessUrl);
} else if (
desiredTriggerConfig.workers_dev ||
desiredTriggerConfig.routes.length > 0 ||
preserveRoutesRequested
) {
return {
ok: false,
reason: 'preview-unavailable',
versionId,
previousVersionId: previous.versionId,
workersDev: desiredTriggerConfig.workers_dev,
};
}
}
if (ready !== 'ready') {
return {
ok: false,
reason: ready === 'access-denied' ? 'readiness-access-denied' : 'readiness-failed',
versionId,
previousVersionId: previous.versionId,
...(ready === 'access-denied' && withholdsCustomRouteHeaders(readinessUrl)
? customRouteReadinessOriginField(readinessUrl)
: {}),
...(stagedVersionOverride
? await restorePreviousVersion(options, previous.versionId, 'readiness failed', [
stagedVersionPercentages(previous.versionId, versionId),
])
: { rollback: 'not-needed' as const }),
};

if (readinessUrl) {
const trustedReadinessOrigin = normalizeReadinessAccessOrigin(options.readinessHeadersOrigin);
// A route hostname is not guaranteed to reach this Worker. Send Access credentials to a custom
// route only when the operator named its exact origin.
const withholdsCustomRouteHeaders = (url: string) =>
readinessUsesCustomRoute && !isTrustedReadinessOrigin(url, trustedReadinessOrigin);
const probe = (url: string) =>
probeReadiness(
`${url.replace(/\/$/, '')}/__void/ready`,
options.workerName,
withholdsCustomRouteHeaders(url) ? undefined : options.readinessHeaders,
authenticatedWorkersDevSubdomain,
readinessUsesCustomRoute ? trustedReadinessOrigin : undefined,
stagedVersionOverride ? versionId : undefined,
options.fetch ?? globalThis.fetch,
options.sleep ?? delay,
options.readinessAttempts ?? 6,
Number.POSITIVE_INFINITY,
options.requireVersionMetadata ? versionId : undefined,
);
options.onProgress?.('Waiting for the Worker to become ready...');
let ready = await probe(readinessUrl);

// Account-wide Access default-deny commonly blocks Wrangler's generated preview alias even
// when the stable `<worker>.<account>.workers.dev` destination has an application admitting the
// operator or a service token. The preview response gives us the account subdomain, so preserve
// the same no-traffic readiness invariant by staging the uploaded version at 0% and selecting it
// explicitly through the stable, policy-covered hostname. This requires a prior active version
// whose traffic can remain untouched while the new version is staged; a truly empty first
// deployment has no safe zero-percent fallback.
if (
ready === 'access-denied' &&
!stagedVersionOverride &&
previous.versionId !== null &&
!options.requirePreviewForReadiness
) {
const stableReadinessUrl = desiredTriggerConfig.workers_dev
? workersDevReadinessUrl(
options.workerName,
extractWorkersDevSubdomain(readinessUrl) ?? options.workersDevSubdomain,
)
: customRouteReadinessUrl(desiredTriggerConfig.routes);
if (stableReadinessUrl && stableReadinessUrl !== readinessUrl) {
const stagingFailure = await stageCandidateForReadiness(
options,
previous.versionId,
versionId,
message,
);
if (stagingFailure) {
return stagingFailure;
}
readinessUrl = stableReadinessUrl;
authenticatedWorkersDevSubdomain = extractWorkersDevSubdomain(stableReadinessUrl);
readinessUsesCustomRoute = !desiredTriggerConfig.workers_dev;
stagedVersionOverride = true;
ready = await probe(readinessUrl);
}
}
if (ready !== 'ready') {
return {
ok: false,
reason: ready === 'access-denied' ? 'readiness-access-denied' : 'readiness-failed',
versionId,
previousVersionId: previous.versionId,
...(ready === 'access-denied' && withholdsCustomRouteHeaders(readinessUrl)
? customRouteReadinessOriginField(readinessUrl)
: {}),
...(stagedVersionOverride
? await restorePreviousVersion(options, previous.versionId, 'readiness failed', [
stagedVersionPercentages(previous.versionId, versionId),
])
: { rollback: 'not-needed' as const }),
};
}
}
if (!options.verifyUploader()) {
return {
Expand Down Expand Up @@ -1548,9 +1558,8 @@ async function activateCloudflareVersionWithSecretsFile(
ok: true,
versionId,
previousVersionId: previous.versionId,
previewUrl: publishedCloudflareUrl(options.workerName, desiredTriggerConfig, readinessUrl),
preservedTriggers: true,
...workersDevSubdomainResult(readinessUrl),
...activationUrlFields(options.workerName, desiredTriggerConfig, readinessUrl),
};
}
const knownTriggerConfigs: Record<string, CloudflareTriggerConfig> = {
Expand Down Expand Up @@ -1661,7 +1670,6 @@ async function activateCloudflareVersionWithSecretsFile(
return {
ok: true,
versionId,
previewUrl: publishedCloudflareUrl(options.workerName, desiredTriggerConfig, readinessUrl),
previousVersionId: previous.versionId,
...(desiredTriggerConfig.preserve ? { preservedTriggers: true } : {}),
...(!desiredTriggerConfig.preserve
Expand All @@ -1673,7 +1681,7 @@ async function activateCloudflareVersionWithSecretsFile(
},
}
: {}),
...workersDevSubdomainResult(readinessUrl),
...activationUrlFields(options.workerName, desiredTriggerConfig, readinessUrl),
};
}

Expand Down Expand Up @@ -2055,6 +2063,7 @@ async function bootstrapFirstDeployment(
reason: atomic ? 'atomic-output-invalid' : 'bootstrap-output-invalid',
previousVersionId,
...(versionId ? { versionId } : {}),
workersDev: desiredTriggerConfig.workers_dev,
};
}
if (!options.verifyUploader()) {
Expand Down Expand Up @@ -2822,6 +2831,20 @@ function workersDevReadinessUrl(workerName: string, value: string | undefined):
return `https://${workerName}.${subdomain}.workers.dev`;
}

function activationUrlFields(
workerName: string,
triggerConfig: CloudflareTriggerConfig,
readinessUrl: string | undefined,
): Pick<Extract<CloudflareVersionActivation, { ok: true }>, 'previewUrl' | 'workersDevSubdomain'> {
if (!readinessUrl) {
return {};
}
return {
previewUrl: publishedCloudflareUrl(workerName, triggerConfig, readinessUrl),
...workersDevSubdomainResult(readinessUrl),
};
}

/** Resolve the stable URL that owns production traffic after triggers are published. */
function publishedCloudflareUrl(
workerName: string,
Expand Down
Loading
Loading