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
2 changes: 2 additions & 0 deletions docs/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,8 @@ Curated Cloudflare Workers configuration. `void init` pins `compatibility_date`

`worker.vars` values must be strings. Root `.env` values override them during local development only. Production builds do not load `.env`, and reject any `worker.vars` name declared as a server key in `env.ts`. Use `void secret put` for production server values.

Managed deployments include `worker.vars` as plain-text bindings. A remote secret with the same name takes precedence. To use the configured variable instead, remove that remote secret and deploy again. Secret deletion changes the running Worker immediately, so plan this migration during a maintenance window.

`worker.limits.cpu_ms` sets the CPU time limit per request, from 1 to 300000 ms. On a Void platform, deploy fails if the limit exceeds the account plan; lower it in `void.config.ts` before retrying. Rollback instead caps the old limit at the current plan ceiling. On direct deploys, [Cloudflare enforces the value](https://developers.cloudflare.com/workers/platform/limits/#cpu-time): Workers Free allows up to 10 ms and Workers Paid up to 300000 ms per request.

```json
Expand Down
4 changes: 3 additions & 1 deletion packages/void/src/cli/deploy-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -543,13 +543,14 @@ export function toManifestSchedulesAndQueues(

/**
* If the project has an `env.ts`, validate its schema against the union of
* production env files and remote secret names. Hard-error before upload so
* build-shell public values and remote secret names. Hard-error before upload so
* deploys never ship missing required env vars.
*/
export async function assertEnvSchemaValid(
root: string,
client: PlatformClient,
projectId: string,
workerVars: Record<string, string> | undefined,
): Promise<void> {
// Skip the remote secret round-trip when the project has no typed env schema.
if (findEnvFile(root) == null) {
Expand All @@ -560,6 +561,7 @@ export async function assertEnvSchemaValid(
const { hasSchema, report } = await validateProdEnv(root, {
productionOnly: true,
remoteSecrets,
workerVars,
});
if (!hasSchema || report.valid) {
return;
Expand Down
3 changes: 2 additions & 1 deletion packages/void/src/cli/deploy-managed-framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ export async function runFrameworkDeploy(

const { schedules, queues } = toManifestSchedulesAndQueues(rawJobs, rawQueues);

await assertEnvSchemaValid(root, client, config.projectId);
await assertEnvSchemaValid(root, client, config.projectId, voidConfig.worker?.vars);

const s = createSpinner();
const onProgress = (msg: string) => s.message(msg);
Expand Down Expand Up @@ -202,6 +202,7 @@ export async function runFrameworkDeploy(
revalidate,
revalidateQueryAllowlist: voidConfig.routing?.revalidateQueryAllowlist,
prerender,
vars: voidConfig.worker?.vars,
// Not `routing.notFound`; see `warnIgnoredNotFoundForFramework`.
assetConfig: { not_found_handling: 'none' },
packageOptions,
Expand Down
3 changes: 2 additions & 1 deletion packages/void/src/cli/deploy-managed-full.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,7 @@ export async function runFullDeploy(
);
const workerMain = resolveDeployWorkerMain(voidConfig, join(distDir, workerDirName));

await assertEnvSchemaValid(root, client, config.projectId);
await assertEnvSchemaValid(root, client, config.projectId, voidConfig.worker?.vars);

const s = createSpinner();
const onProgress = (msg: string) => s.message(msg);
Expand Down Expand Up @@ -421,6 +421,7 @@ export async function runFullDeploy(
ssr: isSsr || hasRoutes || hasMiddleware || hasWebSockets || authEnabled,
framework: isFrameworkMode ? detected!.name : undefined,
revalidate,
vars: voidConfig.worker?.vars,
queues,
prerender: uniquePrerenderPaths,
assetConfig,
Expand Down
11 changes: 11 additions & 0 deletions packages/void/src/cli/env-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ export type ValidateProdEnvOptions = {
productionOnly: boolean;
/** Optional remote secret list (names only). Treated as "value present". */
remoteSecrets?: Array<string>;
/** Explicit public vars included in a managed deployment. Never satisfy server schema keys. */
workerVars?: Record<string, string>;
};

export type ProdEnvValidationResult = {
Expand Down Expand Up @@ -131,6 +133,15 @@ export async function validateProdEnv(
if (reservedInSchema.length > 0) {
throw new Error(`env: ${reservedEnvKeysMessage('env.ts', reservedInSchema)}`);
}
const serverVarKeys = Object.keys(mod._getRegisteredSchema() ?? {}).filter(
(key) => !isClientEnvKey(key) && Object.hasOwn(options.workerVars ?? {}, key),
);
if (serverVarKeys.length > 0) {
throw new Error(
`env: Server env keys cannot be Worker plaintext vars: ${serverVarKeys.join(', ')}. ` +
'Remove them from worker.vars and set them with the remote secret store.',
);
}

const local = options.productionOnly
? {}
Expand Down
10 changes: 10 additions & 0 deletions packages/void/src/cli/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,7 @@ export function buildVoidManifest(opts: {
revalidate?: number | Record<string, number>;
pages?: Array<{ pattern: string; revalidate?: number }>;
pathRevalidate?: Record<string, number>;
vars?: Record<string, string>;
queues?: Array<QueueMeta>;
prerender?: Array<string>;
assetConfig?: AssetConfig;
Expand All @@ -463,6 +464,7 @@ export function buildVoidManifest(opts: {
revalidate,
pages,
pathRevalidate,
vars,
queues,
prerender,
assetConfig,
Expand Down Expand Up @@ -569,6 +571,10 @@ export function buildVoidManifest(opts: {
manifest.assetConfig = resolvedAssetConfig;
}

if (vars && Object.keys(vars).length > 0) {
manifest.vars = vars;
}

if (options?.workerMain) {
manifest.workerMain = options.workerMain;
}
Expand Down Expand Up @@ -693,6 +699,7 @@ export function buildFrameworkManifest(opts: {
revalidate?: number | Record<string, number>;
revalidateQueryAllowlist?: Record<string, Array<string>>;
prerender?: Array<string>;
vars?: Record<string, string>;
assetConfig?: AssetConfig;
packageOptions?: Omit<PackageOptions, 'workerMain'>;
hashedAssetsPrefix?: string;
Expand Down Expand Up @@ -735,6 +742,9 @@ export function buildFrameworkManifest(opts: {
if (opts.assetConfig) {
manifest.assetConfig = opts.assetConfig;
}
if (opts.vars && Object.keys(opts.vars).length > 0) {
manifest.vars = opts.vars;
}

manifest.ssr = true;
manifest.framework = opts.frameworkName;
Expand Down
58 changes: 55 additions & 3 deletions packages/void/test/integration/full-deploy-manifest.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import {
copyFileSync,
cpSync,
existsSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { join } from 'node:path';
import spawn from 'cross-spawn';
import {
Expand Down Expand Up @@ -89,7 +97,13 @@ let savedToken: string | undefined;
let savedProject: string | undefined;

type Captured = {
finalizeBody: { manifest?: { pages?: unknown; bindings?: Record<string, unknown> } } | null;
finalizeBody: {
manifest?: {
pages?: unknown;
bindings?: Record<string, unknown>;
vars?: Record<string, string>;
};
} | null;
};

// Every asset is reported as present, so the deploy skips uploads and goes straight to finalize.
Expand Down Expand Up @@ -176,7 +190,7 @@ function ndjson(events: Array<unknown>): Response {
});
}

describe('runFullDeploy emits manifest.pages (Void pages app, end-to-end)', () => {
describe('managed deployment manifests (built Worker)', () => {
beforeAll(() => {
playground = createIsolatedPlayground(ROOT, SOURCE_PLAYGROUND);
fixture = playground.root;
Expand Down Expand Up @@ -318,6 +332,44 @@ describe('runFullDeploy emits manifest.pages (Void pages app, end-to-end)', () =
}
});

it.each([
['Void', undefined],
['Class A', { name: 'vinext-pages', class: 'a' }],
['Class C', { name: 'astro', class: 'c' }],
] as const)(
'includes configured worker.vars in the %s deployment request',
async (_, detected) => {
mockDetectFramework.mockReturnValue(detected);
const configPath = join(fixture, 'void.config.ts');
const original = readFileSync(configPath, 'utf8');
const dotenvPath = join(fixture, '.env');
const originalDotenv = existsSync(dotenvPath) ? readFileSync(dotenvPath) : undefined;
const vars = { API_URL: 'https://api.example.test', EMPTY_SETTING: '' };
writeFileSync(
configPath,
original.replace('worker: {', `worker: { vars: ${JSON.stringify(vars)},`),
);
writeFileSync(dotenvPath, 'API_URL=https://local.example.test\nADMIN_TOKEN=local-secret\n');
if (detected?.name === 'astro') {
// Reuse the built Worker at the Class C preset's output paths.
cpSync(join(fixture, 'dist/ssr'), join(fixture, 'dist/server'), { recursive: true });
copyFileSync(join(fixture, 'dist/server/index.js'), join(fixture, 'dist/server/entry.mjs'));
}
try {
const captured = installPlatformMocks();
await runDeploy(fixture, { skipBuild: true });
expect(captured.finalizeBody?.manifest?.vars).toEqual(vars);
} finally {
writeFileSync(configPath, original);
if (originalDotenv === undefined) {
rmSync(dotenvPath, { force: true });
} else {
writeFileSync(dotenvPath, originalDotenv);
}
}
},
);

it('uses the alternate project for Worker packaging and prerendering while retaining the saved link', async () => {
const original = readFileSync(projectJson, 'utf8');
const saved = JSON.stringify({
Expand Down
24 changes: 24 additions & 0 deletions packages/void/test/unit/env-prod-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,30 @@ afterEach(() => {
});

describe('validateProdEnv — deploy-time schema enforcement', () => {
it('rejects schema-declared server keys in managed Worker vars even when a remote secret exists', async () => {
const root = createTempProject();
defineEnv({ ADMIN_TOKEN: string().optional() });

await expect(
validateProdEnv(root, {
productionOnly: true,
remoteSecrets: ['ADMIN_TOKEN'],
workerVars: { ADMIN_TOKEN: 'must-not-be-uploaded' },
}),
).rejects.toThrow('Server env keys cannot be Worker plaintext vars: ADMIN_TOKEN');
});

it('permits public Worker vars outside the server schema', async () => {
const root = createTempProject();
defineEnv({ ADMIN_TOKEN: string() });
const { report } = await validateProdEnv(root, {
productionOnly: true,
remoteSecrets: ['ADMIN_TOKEN'],
workerVars: { API_URL: 'https://api.example.test' },
});
expect(report.valid).toBe(true);
});

it('returns hasSchema=false when the project has no env.ts (skips validation)', async () => {
const root = createTempProject({ withEnvTs: false });
const result = await validateProdEnv(root, { productionOnly: true });
Expand Down
23 changes: 10 additions & 13 deletions platform/packages/api/src/deploy/upload-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,14 +274,9 @@ export async function uploadDeploymentWorker(input: {
// route can reject before persisting; see the invariant test there.
const userVarNames = Object.keys(manifest.vars ?? {});
const userSecretNames = secretBindings.map((s) => s.name);
// Check BOTH vars and project secrets. A secret is uploaded in the
// same `metadata.bindings` array, so a secret named e.g. `SEND_EMAIL`
// or `__VOID_PROJECT_SLUG` either shadows a platform binding or is
// shadowed by one — CF resolves duplicates last-wins with no error.
// The secrets route rejects these at PUT time, but older stored rows
// can still reach here. Failing the deploy with
// a named binding beats shipping a worker that silently reads the
// wrong value.
// Vars and secrets share metadata.bindings with platform bindings. Reject
// reserved names before Cloudflare rejects the duplicate binding. The secrets
// route rejects these at PUT time, but older stored rows can still reach here.
const reserved = [
...userVarNames.filter((n) => RESERVED_BINDING_NAMES.has(n)).map((n) => `vars.${n}`),
...userSecretNames.filter((n) => RESERVED_BINDING_NAMES.has(n)).map((n) => `secret.${n}`),
Expand All @@ -294,11 +289,13 @@ export async function uploadDeploymentWorker(input: {
if (collisions.length > 0) {
logger.warn('worker_upload', 'var_secret_collisions', { collisions });
}
const plainTextBindings = Object.entries(manifest.vars ?? {}).map(([name, text]) => ({
type: 'plain_text' as const,
name,
text,
}));
const plainTextBindings = Object.entries(manifest.vars ?? {})
.filter(([name]) => !secretNames.has(name))
.map(([name, text]) => ({
type: 'plain_text' as const,
name,
text,
}));
const durableObjects = await prepareManagedDurableObjects(
context.env,
context.get('db'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,18 +69,18 @@ function buildDeployRequest(projectId: string, jwt: string, manifest: object): R
}

describe('manifest var / secret collision warning', () => {
it('logs warning when manifest vars collide with project secrets', async () => {
it('uploads each binding once and warns when a secret overrides a manifest var', async () => {
const user = await createTestUser(env.db);
const project = await createTestProject(env.db, user.id, 'collision-app');
await createTestProjectSecret(env.db, env, project.id, 'API_KEY', 'secret-value');
mockCfApi();
const fetchMock = mockCfApi();
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});

const res = await app.fetch(
buildDeployRequest(project.id, user.jwt, {
version: 3,
bindings: {},
vars: { API_KEY: 'from-manifest' },
vars: { API_KEY: 'from-manifest', API_URL: 'https://api.example.test' },
}),
env,
);
Expand All @@ -93,5 +93,25 @@ describe('manifest var / secret collision warning', () => {
// in `collisions`.
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('var_secret_collisions'));
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('API_KEY'));

const uploadCall = fetchMock.mock.calls.find(
([url, init]) =>
String(url).includes('/workers/dispatch/') && (init as RequestInit)?.method === 'PUT',
);
expect(uploadCall).toBeDefined();
const form = (uploadCall![1] as RequestInit).body as FormData;
const metadata = JSON.parse(await (form.get('metadata') as Blob).text()) as {
bindings: Array<{ name: string; type: string; text?: string }>;
};
expect(metadata.bindings.filter(({ name }) => name === 'API_KEY')).toEqual([
{ type: 'secret_text', name: 'API_KEY', text: 'secret-value' },
]);
expect(metadata.bindings).toContainEqual({
type: 'plain_text',
name: 'API_URL',
text: 'https://api.example.test',
});
const names = metadata.bindings.map(({ name }) => name);
expect(new Set(names).size).toBe(names.length);
});
});
Loading