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
13 changes: 13 additions & 0 deletions docs/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ Curated Cloudflare Workers configuration. Cloudflare-targeted apps require an ex
| `compatibility_date` | `string` | Cloudflare Workers compatibility date |
| `compatibility_flags` | `string[]` | Cloudflare Workers compatibility flags |
| `vars` | `object` | Plain-text worker variables |
| `limits` | `object` | Worker resource limit overrides |

```json
{
Expand All @@ -269,6 +270,18 @@ Curated Cloudflare Workers configuration. Cloudflare-targeted apps require an ex

`worker.vars` values must be strings. They are merged into Worker bindings before `.env` files are loaded, so project `.env` values override `worker.vars` for local dev/build. Do not put secrets here; use `env.ts` plus `void secret put` for production secrets.

`worker.limits.cpu_ms` caps Workers CPU time per request, in milliseconds. It must be an integer between 1 and 300000 (Cloudflare's hard maximum, 5 minutes). A deploy that requests more than your account plan's ceiling fails with an error naming both the requested value and the plan maximum. Only a rollback clamps: rolling back to a deployment whose configured limit now exceeds your plan applies the plan ceiling instead of failing. If your plan changes so that a previously valid value now exceeds the ceiling, deploys keep failing until you lower `cpu_ms` in `void.json`. On the self-hosted path (`void deploy --backend cloudflare`, or a bare `wrangler deploy` of the build output), the value is written into the generated `dist/ssr/wrangler.json` as `limits.cpu_ms` and enforced by Cloudflare directly. The Void plan ceiling does not apply there, but your Cloudflare account's own CPU-time allowance still does: the Workers Free plan caps CPU at 10 ms per request, and the Workers Paid plan allows up to 300000 ms. A value above what your Cloudflare account permits is constrained or rejected by Cloudflare, not by Void.

```json
{
"worker": {
"limits": {
"cpu_ms": 30000
}
}
}
```

### `routing`

Routing and edge configuration for headers, redirects, rewrites, and caching.
Expand Down
13 changes: 13 additions & 0 deletions packages/void/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,19 @@
"type": "object",
"description": "Environment variables (plain text, not secrets)",
"additionalProperties": { "type": "string" }
},
"limits": {
"type": "object",
"description": "Worker resource limit overrides.",
"properties": {
"cpu_ms": {
"type": "integer",
"minimum": 1,
"maximum": 300000,
"description": "Caps Workers CPU time per request, in milliseconds (max 300000 = 5 minutes). The platform plan may cap it lower."
}
},
"additionalProperties": false
}
},
"additionalProperties": false
Expand Down
2 changes: 2 additions & 0 deletions packages/void/src/cli/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5828,6 +5828,7 @@ async function runFrameworkDeploy(
workerMain: actualWorkerMain,
compatibilityDate: wranglerCompat?.compatibilityDate,
compatibilityFlags: wranglerCompat?.compatibilityFlags,
cpuMs: voidConfig.worker?.limits?.cpu_ms,
};

const manifest = buildFrameworkManifest({
Expand Down Expand Up @@ -6301,6 +6302,7 @@ async function runFullDeploy(
const voidPackageOptions = {
compatibilityDate: wranglerCompat?.compatibilityDate,
compatibilityFlags: wranglerCompat?.compatibilityFlags,
cpuMs: deployConfig.worker?.limits?.cpu_ms,
...(workerMain && { workerMain }),
...(sandboxConfig && { sandbox: sandboxConfig }),
...(liveConfig && { live: liveConfig }),
Expand Down
8 changes: 8 additions & 0 deletions packages/void/src/cli/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,8 @@ export type PackageOptions = {
compatibilityDate?: string;
/** Cloudflare compatibility_flags from framework's wrangler config. */
compatibilityFlags?: Array<string>;
/** Workers CPU-time limit (ms) from void.json `worker.limits.cpu_ms`. */
cpuMs?: number;
/** Cloudflare Sandbox container metadata for platform deploys. */
sandbox?: ResolvedSandboxConfig;
/** Resolved void/live Durable Object binding and class names. */
Expand Down Expand Up @@ -678,6 +680,9 @@ export function buildVoidManifest(opts: {
if (options?.compatibilityFlags && options.compatibilityFlags.length > 0) {
manifest.compatibilityFlags = options.compatibilityFlags;
}
if (options?.cpuMs) {
manifest.limits = { cpuMs: options.cpuMs };
}

if (hashedAssetsPrefix) {
manifest.hashedAssetsPrefix = hashedAssetsPrefix;
Expand Down Expand Up @@ -875,6 +880,9 @@ export function buildFrameworkManifest(opts: {
if (po?.compatibilityFlags && po.compatibilityFlags.length > 0) {
manifest.compatibilityFlags = po.compatibilityFlags;
}
if (po?.cpuMs) {
manifest.limits = { cpuMs: po.cpuMs };
}

if (opts.hashedAssetsPrefix) {
manifest.hashedAssetsPrefix = opts.hashedAssetsPrefix;
Expand Down
2 changes: 2 additions & 0 deletions packages/void/src/cli/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,8 @@ export type DeployManifest = {
workerMain?: string;
compatibilityDate?: string;
compatibilityFlags?: Array<string>;
/** Worker resource limit overrides from void.json `worker.limits`. */
limits?: { cpuMs?: number };
hashedAssetsPrefix?: string;
headerRules?: Array<HeaderRule>;
redirectRules?: Array<RedirectRule>;
Expand Down
28 changes: 28 additions & 0 deletions packages/void/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export type WranglerConfig = {
compatibility_date?: string;
compatibility_flags?: Array<string>;
vars?: Record<string, string>;
limits?: { cpu_ms?: number };
};

/**
Expand Down Expand Up @@ -119,6 +120,8 @@ export const VALID_NOT_FOUND_HANDLING: Set<string> = new Set([
*/
export const VALID_REDIRECT_STATUSES: Set<number> = new Set([301, 302, 303, 307, 308]);
const VALID_DATABASE_VALUES = new Set(['pg']);
/** Cloudflare's hard maximum for Workers CPU time per request (5 minutes). */
const MAX_WORKER_CPU_MS = 300000;
const VALID_SANDBOX_INSTANCE_TYPES = new Set<SandboxInstanceType>([
'dev',
'lite',
Expand Down Expand Up @@ -916,6 +919,31 @@ export function readConfig(root: string): VoidConfig {
}
}
}
if (w.limits !== undefined) {
if (typeof w.limits !== 'object' || w.limits === null || Array.isArray(w.limits)) {
throw new Error(`config: "worker.limits" in ${CONFIG_FILE} must be an object.`);
}
const limits = w.limits as Record<string, unknown>;
for (const key of Object.keys(limits)) {
if (key !== 'cpu_ms') {
throw new Error(
`config: Unknown key 'worker.limits.${key}' in ${CONFIG_FILE}. Valid: cpu_ms.`,
);
}
}
if (limits.cpu_ms !== undefined) {
if (
typeof limits.cpu_ms !== 'number' ||
!Number.isInteger(limits.cpu_ms) ||
limits.cpu_ms < 1 ||
limits.cpu_ms > MAX_WORKER_CPU_MS
) {
throw new Error(
`config: "worker.limits.cpu_ms" in ${CONFIG_FILE} must be an integer between 1 and ${MAX_WORKER_CPU_MS}.`,
);
}
}
}
}

// Validate database
Expand Down
8 changes: 8 additions & 0 deletions packages/void/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -957,6 +957,14 @@ export function voidPlugin(options?: {
workerConfig.compatibility_flags = wranglerCompat.compatibilityFlags;
}
}
// CPU cap from void.json `worker.limits.cpu_ms` (validated in config.ts as an
// integer 1..300000). The CF plugin passes `limits` through into the generated
// `dist/ssr/wrangler.json`, so a self-host `wrangler deploy` / `void deploy
// --backend cloudflare` enforces it directly. Managed `void deploy` ignores
// the generated wrangler config and carries the cap in the deploy manifest.
if (config.worker?.limits?.cpu_ms) {
workerConfig.limits = { cpu_ms: config.worker.limits.cpu_ms };
}
const inspectorPortEnv = process.env.VOID_INSPECTOR_PORT;
const inspectorPort =
inspectorPortEnv === 'false'
Expand Down
64 changes: 64 additions & 0 deletions packages/void/test/unit/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,57 @@ describe('readConfig', () => {
cleanup();
});

it('accepts valid worker.limits.cpu_ms', () => {
setup(JSON.stringify({ worker: { limits: { cpu_ms: 50 } } }));
const config = readConfig(tmpDir);
expect(config.worker?.limits).toEqual({ cpu_ms: 50 });
cleanup();
});

it('accepts worker.limits.cpu_ms boundary values (1 and 300000)', () => {
setup(JSON.stringify({ worker: { limits: { cpu_ms: 1 } } }));
expect(readConfig(tmpDir).worker?.limits?.cpu_ms).toBe(1);
setup(JSON.stringify({ worker: { limits: { cpu_ms: 300000 } } }));
expect(readConfig(tmpDir).worker?.limits?.cpu_ms).toBe(300000);
cleanup();
});

it('throws on non-object worker.limits', () => {
setup(JSON.stringify({ worker: { limits: 50 } }));
expect(() => readConfig(tmpDir)).toThrow('"worker.limits" in void.json must be an object');
cleanup();
});

it('throws on unknown key inside worker.limits', () => {
setup(JSON.stringify({ worker: { limits: { cpuMs: 50 } } }));
expect(() => readConfig(tmpDir)).toThrow("Unknown key 'worker.limits.cpuMs'");
cleanup();
});

it('throws on worker.limits.cpu_ms of 0', () => {
setup(JSON.stringify({ worker: { limits: { cpu_ms: 0 } } }));
expect(() => readConfig(tmpDir)).toThrow('"worker.limits.cpu_ms"');
cleanup();
});

it('throws on negative worker.limits.cpu_ms', () => {
setup(JSON.stringify({ worker: { limits: { cpu_ms: -5 } } }));
expect(() => readConfig(tmpDir)).toThrow('"worker.limits.cpu_ms"');
cleanup();
});

it('throws on non-integer worker.limits.cpu_ms', () => {
setup(JSON.stringify({ worker: { limits: { cpu_ms: 50.5 } } }));
expect(() => readConfig(tmpDir)).toThrow('"worker.limits.cpu_ms"');
cleanup();
});

it('throws on worker.limits.cpu_ms above 300000', () => {
setup(JSON.stringify({ worker: { limits: { cpu_ms: 300001 } } }));
expect(() => readConfig(tmpDir)).toThrow('"worker.limits.cpu_ms"');
cleanup();
});

it('accepts routing.revalidate as a number', () => {
setup(JSON.stringify({ routing: { revalidate: 60 } }));
const config = readConfig(tmpDir);
Expand Down Expand Up @@ -788,6 +839,19 @@ describe('readConfig redirects', () => {
}
});

it('schema.json worker.limits.cpu_ms bounds match runtime validation', () => {
const schema = JSON.parse(readFileSync(join(import.meta.dirname, '../../schema.json'), 'utf8'));
const limits = schema.properties.worker.properties.limits;
expect(limits.type).toBe('object');
// Runtime (config.ts) rejects unknown keys inside limits.
expect(limits.additionalProperties).toBe(false);
expect(Object.keys(limits.properties)).toEqual(['cpu_ms']);
// Runtime accepts an integer between 1 and 300000 (Cloudflare's hard max).
expect(limits.properties.cpu_ms.type).toBe('integer');
expect(limits.properties.cpu_ms.minimum).toBe(1);
expect(limits.properties.cpu_ms.maximum).toBe(300000);
});

it('publishes schema.json as a package file and subpath export', () => {
const packageJson = JSON.parse(
readFileSync(join(import.meta.dirname, '../../package.json'), 'utf8'),
Expand Down
53 changes: 53 additions & 0 deletions packages/void/test/unit/package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { hash as blake3hash } from 'blake3-jit';
import { join } from 'pathslash';
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test';
import {
buildFrameworkManifest,
buildStaticManifest,
buildVoidManifest,
computeAssetHash,
Expand Down Expand Up @@ -168,6 +169,58 @@ describe('buildVoidManifest — self-host non-regression', () => {
});
});

describe('manifest limits (worker.limits.cpu_ms)', () => {
function makeBindings(overrides: Partial<InferredBindings> = {}): InferredBindings {
return {
needsD1: false,
needsKV: false,
needsR2: false,
needsAuth: false,
needsQueues: false,
needsAI: false,
needsSandbox: false,
...overrides,
};
}

it('emits manifest.limits when options.cpuMs is set (void manifest)', () => {
const manifest = buildVoidManifest({ bindings: makeBindings(), options: { cpuMs: 50 } });
expect(manifest.limits).toEqual({ cpuMs: 50 });
});

it('omits the limits key entirely when options.cpuMs is unset (void manifest)', () => {
const manifest = buildVoidManifest({ bindings: makeBindings(), options: {} });
expect('limits' in manifest).toBe(false);
});

it('emits manifest.limits when packageOptions.cpuMs is set (framework manifest)', () => {
const manifest = buildFrameworkManifest({
frameworkName: 'astro',
workerMain: 'index.mjs',
bindings: makeBindings(),
packageOptions: { cpuMs: 30000 },
});
expect(manifest.limits).toEqual({ cpuMs: 30000 });
});

it('omits the limits key entirely when packageOptions.cpuMs is unset (framework manifest)', () => {
const manifest = buildFrameworkManifest({
frameworkName: 'astro',
workerMain: 'index.mjs',
bindings: makeBindings(),
packageOptions: {},
});
expect('limits' in manifest).toBe(false);
});

it('static manifests never carry limits', () => {
for (const appType of ['static', 'spa'] as const) {
const manifest = buildStaticManifest({ appType });
expect('limits' in manifest).toBe(false);
}
});
});

describe('resolveSpaFallbackChain', () => {
it('returns SPA platform switch with no rules when user has no fallbacks (SPA)', () => {
const result = resolveSpaFallbackChain('spa', undefined);
Expand Down
22 changes: 22 additions & 0 deletions packages/void/test/unit/plugin-dev-asset-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,28 @@ describe('voidPlugin dev asset routing', () => {
expect(assets?.run_worker_first).toEqual(['/api', '/api/*', '/__void', '/__void/*']);
});

it('passes worker.limits.cpu_ms from void.json into the generated worker config', async () => {
const root = createProjectRoot();
writeFileSync(
join(root, 'void.json'),
JSON.stringify({
worker: { compatibility_date: '2026-02-24', limits: { cpu_ms: 120000 } },
}),
);

const config = await resolveCloudflareConfig(root);

expect(config.limits).toEqual({ cpu_ms: 120000 });
});

it('emits no limits key when worker.limits is not configured', async () => {
const root = createProjectRoot();

const config = await resolveCloudflareConfig(root);

expect('limits' in config).toBe(false);
});

it('routes document routes through the worker before SPA fallback HTML', async () => {
const root = createDocumentRouteProjectRoot();
const assets = await resolveVoidPluginAssets(root);
Expand Down