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
100 changes: 58 additions & 42 deletions packages/void/src/cli/cf-version-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import {
getWorkerBundleDir,
readBuildOutput,
} from '@cloudflare/build-output-utils';
import type { ParsedOutputWorkerConfig } from '@cloudflare/config';
import {
convertToWranglerConfig,
type ParsedInputConfig,
type ParsedOutputWorkerConfig,
} from '@cloudflare/config';
import type { CloudflareCommandResult } from '@void/deploy-cloudflare';
import ignore from 'ignore';
import mime from 'mime';
Expand Down Expand Up @@ -244,15 +248,30 @@ function bindingsFromOutput(
return bindings;
}

function exportsFromOutput(exports: unknown, bindings: Array<Json>): Json | undefined {
const durableObjectStates = new Set([
'created',
'deleted',
'renamed',
'transferred',
'expecting-transfer',
]);

/**
* The `exports` that cf uploads for the same Build Output. cf converts them with
* `@cloudflare/config`'s `convertToWranglerConfig` (cf `build-*.mjs`), and its
* `resolveExportsUploadPayload` (cf `containers-*.mjs`) sends Worker entrypoints,
* then Durable Objects, then Workflows reduced to their identity. Cloudflare
* stages a version at 0% only when its exports are identical to the live
* version's, and that version may have been uploaded by cf.
*/
export function exportsFromOutput(exports: unknown, bindings: Array<Json>): Json | undefined {
if (exports === undefined) {
return undefined;
}
const entries = object(exports);
if (!entries) {
throw new Error('Cloudflare Durable Object exports were not readable.');
}
const result: Json = {};
const workflows = new Map<string, string>();
for (const [name, raw] of Object.entries(entries)) {
const value = object(raw);
Expand All @@ -262,53 +281,30 @@ function exportsFromOutput(exports: unknown, bindings: Array<Json>): Json | unde
throw new Error(`Cloudflare Workflow ${workflowName} has multiple local exports.`);
}
workflows.set(workflowName, name);
// cf uploads identity only. Execution settings stay in the immutable
// Build Output and are applied by its separate typed trigger deployment.
result[name] = { type: 'workflow', name: workflowName };
continue;
}
if (value?.type === 'worker') {
result[name] = {
type: 'worker',
...(value.cache === undefined ? {} : { cache: value.cache }),
};
continue;
}
if (!value || value.type !== 'durable-object') {
throw new Error(`Cloudflare export ${name} cannot be uploaded with pinned secrets.`);
}
if (value.state === 'deleted') {
result[name] = { type: 'durable-object', state: 'deleted' };
} else if (value.state === 'renamed' || value.state === 'transferred') {
const renamed = value.state === 'renamed';
result[name] = {
type: 'durable-object',
state: value.state,
[renamed ? 'renamed_to' : 'transferred_to']: requiredString(
value[renamed ? 'renamedTo' : 'transferredTo'],
name,
),
};
} else if (value.storage === 'sqlite' || value.storage === 'legacy-kv') {
if (
value.state !== undefined &&
!['created', 'expecting-transfer'].includes(String(value.state))
) {
throw new Error(`Cloudflare Durable Object export ${name} has an unsupported state.`);
const state = value.state ?? 'created';
if (!durableObjectStates.has(String(state))) {
throw new Error(`Cloudflare Durable Object export ${name} has an unsupported state.`);
}
if (state === 'renamed' || state === 'transferred') {
requiredString(value[state === 'renamed' ? 'renamedTo' : 'transferredTo'], name);
} else if (state !== 'deleted') {
if (value.storage !== 'sqlite' && value.storage !== 'legacy-kv') {
throw new Error(`Cloudflare Durable Object export ${name} has no storage type.`);
}
if (value.container !== undefined) {
requiredString(value.container, name);
}
if (state === 'expecting-transfer') {
requiredString(value.transferFrom, name);
}
result[name] = {
type: 'durable-object',
storage: value.storage,
state: value.state ?? 'created',
...(value.container === undefined
? {}
: { container: requiredString(value.container, name) }),
...(value.state === 'expecting-transfer'
? { transfer_from: requiredString(value.transferFrom, name) }
: {}),
};
} else {
throw new Error(`Cloudflare Durable Object export ${name} has no storage type.`);
}
}
for (const binding of bindings) {
Expand All @@ -325,7 +321,27 @@ function exportsFromOutput(exports: unknown, bindings: Array<Json>): Json | unde
);
}
}
return result;
// The converter reads only `worker.exports` for this field.
const converted =
object(
convertToWranglerConfig({
worker: { exports: entries },
containers: [],
} as unknown as ParsedInputConfig).exports,
) ?? {};
const result: Json = {};
for (const type of ['worker', 'durable-object', 'workflow']) {
for (const [name, value] of Object.entries(converted)) {
const entry = object(value);
if (entry?.type !== type) {
continue;
}
// cf uploads Workflow identity only. Execution settings stay in the immutable
// Build Output and are applied by its separate typed trigger deployment.
result[name] = type === 'workflow' ? { type, name: entry.name } : entry;
}
}
return Object.keys(result).length > 0 ? result : undefined;
}

async function preflightWorkflowExports(options: {
Expand Down
12 changes: 10 additions & 2 deletions packages/void/src/cli/deploy-cloudflare-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -534,8 +534,16 @@ export function formatCloudflareActivationError(
'Set CLOUDFLARE_WORKERS_SUBDOMAIN to your account subdomain (for example "my-team")',
'and retry. Void caches it after a successful workers.dev deployment; production traffic is unchanged.',
].join('\n');
case 'readiness-staging-failed':
return `deploy: Cloudflare could not stage the uploaded Worker Version at zero-percent traffic${status}; production traffic is unchanged.${cloudflareCode}${diagnostic}${result.cloudflareErrorCode === 10210 && /Durable Object migration/i.test(result.diagnostic ?? '') ? '\n\nReview the Durable Object migration, then run `void deploy --platform cloudflare --atomic`. For future deploys, set deploy.cloudflare.mode to "atomic" in void.config.ts.' : ''}`;
case 'readiness-staging-failed': {
const hint =
result.cloudflareErrorCode === 10210 &&
(result.diagnostic ?? '').toLowerCase().includes('durable object migration')
? '\n\nReview the Durable Object migration, then run `void deploy --platform cloudflare --atomic`. For future deploys, set deploy.cloudflare.mode to "atomic" in void.config.ts.'
: result.cloudflareErrorCode === 100405
? '\n\nThe new version declares different exports from the live version (for example a Durable Object or Workflow class), so Cloudflare cannot split traffic between them. Run `void deploy --platform cloudflare --atomic` once to publish it at 100% directly; it receives traffic before readiness is checked.'
: '';
return `deploy: Cloudflare could not stage the uploaded Worker Version at zero-percent traffic${status}; production traffic is unchanged.${cloudflareCode}${diagnostic}${hint}`;
}
case 'atomic-failed':
return `deploy: Cloudflare could not publish the Worker atomically${status}; inspect production status before retrying.${cloudflareCode}${diagnostic}`;
case 'atomic-output-invalid':
Expand Down
Loading
Loading