Skip to content

Commit fc361a5

Browse files
Brooooooklynclaude
andcommitted
feat(config): add worker.limits.cpu_ms per-project CPU limit
Adds a curated wrangler field to void.json so a project can set its own Workers CPU cap: - config.ts / schema.json: validate worker.limits as a strict object (unknown keys rejected) with cpu_ms an integer in 1..300000 - deploy manifest: carry limits.cpuMs (manifest version stays 3, the field is optional and additive) - emit limits into buildVoidManifest and buildFrameworkManifest; static manifests never carry limits - also copy the value into the Cloudflare vite-plugin worker config so the generated dist/ssr/wrangler.json enforces it on `void deploy --backend cloudflare` (adversarial-review finding) - docs: reference/config.md documents managed-platform semantics (deploys above the plan ceiling reject; only rollback clamps) and the self-hosted passthrough Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DF8p6MCmgiE7e3LM1J8EQV
1 parent 870b8c1 commit fc361a5

10 files changed

Lines changed: 214 additions & 0 deletions

File tree

‎docs/reference/config.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ Curated Cloudflare Workers configuration. Cloudflare-targeted apps require an ex
254254
| `compatibility_date` | `string` | Cloudflare Workers compatibility date |
255255
| `compatibility_flags` | `string[]` | Cloudflare Workers compatibility flags |
256256
| `vars` | `object` | Plain-text worker variables |
257+
| `limits` | `object` | Worker resource limit overrides |
257258

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

270271
`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.
271272

273+
`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. 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 — no plan ceiling applies.
274+
275+
```json
276+
{
277+
"worker": {
278+
"limits": {
279+
"cpu_ms": 30000
280+
}
281+
}
282+
}
283+
```
284+
272285
### `routing`
273286

274287
Routing and edge configuration for headers, redirects, rewrites, and caching.

‎packages/void/schema.json‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,19 @@
355355
"type": "object",
356356
"description": "Environment variables (plain text, not secrets)",
357357
"additionalProperties": { "type": "string" }
358+
},
359+
"limits": {
360+
"type": "object",
361+
"description": "Worker resource limit overrides.",
362+
"properties": {
363+
"cpu_ms": {
364+
"type": "integer",
365+
"minimum": 1,
366+
"maximum": 300000,
367+
"description": "Caps Workers CPU time per request, in milliseconds (max 300000 = 5 minutes). The platform plan may cap it lower."
368+
}
369+
},
370+
"additionalProperties": false
358371
}
359372
},
360373
"additionalProperties": false

‎packages/void/src/cli/deploy.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5627,6 +5627,7 @@ async function runFrameworkDeploy(
56275627
workerMain: actualWorkerMain,
56285628
compatibilityDate: wranglerCompat?.compatibilityDate,
56295629
compatibilityFlags: wranglerCompat?.compatibilityFlags,
5630+
cpuMs: voidConfig.worker?.limits?.cpu_ms,
56305631
};
56315632

56325633
const manifest = buildFrameworkManifest({
@@ -6068,6 +6069,7 @@ async function runFullDeploy(
60686069
const voidPackageOptions = {
60696070
compatibilityDate: wranglerCompat?.compatibilityDate,
60706071
compatibilityFlags: wranglerCompat?.compatibilityFlags,
6072+
cpuMs: deployConfig.worker?.limits?.cpu_ms,
60716073
...(workerMain && { workerMain }),
60726074
...(sandboxConfig && { sandbox: sandboxConfig }),
60736075
...(liveConfig && { live: liveConfig }),

‎packages/void/src/cli/package.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,8 @@ export type PackageOptions = {
404404
compatibilityDate?: string;
405405
/** Cloudflare compatibility_flags from framework's wrangler config. */
406406
compatibilityFlags?: Array<string>;
407+
/** Workers CPU-time limit (ms) from void.json `worker.limits.cpu_ms`. */
408+
cpuMs?: number;
407409
/** Cloudflare Sandbox container metadata for platform deploys. */
408410
sandbox?: ResolvedSandboxConfig;
409411
/** Resolved void/live Durable Object binding and class names. */
@@ -655,6 +657,9 @@ export function buildVoidManifest(opts: {
655657
if (options?.compatibilityFlags && options.compatibilityFlags.length > 0) {
656658
manifest.compatibilityFlags = options.compatibilityFlags;
657659
}
660+
if (options?.cpuMs) {
661+
manifest.limits = { cpuMs: options.cpuMs };
662+
}
658663

659664
if (hashedAssetsPrefix) {
660665
manifest.hashedAssetsPrefix = hashedAssetsPrefix;
@@ -849,6 +854,9 @@ export function buildFrameworkManifest(opts: {
849854
if (po?.compatibilityFlags && po.compatibilityFlags.length > 0) {
850855
manifest.compatibilityFlags = po.compatibilityFlags;
851856
}
857+
if (po?.cpuMs) {
858+
manifest.limits = { cpuMs: po.cpuMs };
859+
}
852860

853861
if (opts.hashedAssetsPrefix) {
854862
manifest.hashedAssetsPrefix = opts.hashedAssetsPrefix;

‎packages/void/src/cli/types.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,8 @@ export type DeployManifest = {
200200
workerMain?: string;
201201
compatibilityDate?: string;
202202
compatibilityFlags?: Array<string>;
203+
/** Worker resource limit overrides from void.json `worker.limits`. */
204+
limits?: { cpuMs?: number };
203205
hashedAssetsPrefix?: string;
204206
headerRules?: Array<HeaderRule>;
205207
redirectRules?: Array<RedirectRule>;

‎packages/void/src/config.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export type WranglerConfig = {
2323
compatibility_date?: string;
2424
compatibility_flags?: Array<string>;
2525
vars?: Record<string, string>;
26+
limits?: { cpu_ms?: number };
2627
};
2728

2829
/** Routing configuration — headers, redirects, rewrites, fallbacks, revalidation, prerendering. */
@@ -90,6 +91,8 @@ const VALID_OUTPUTS = new Set(['server', 'static']);
9091
*/
9192
export const VALID_REDIRECT_STATUSES: Set<number> = new Set([301, 302, 303, 307, 308]);
9293
const VALID_DATABASE_VALUES = new Set(['pg']);
94+
/** Cloudflare's hard maximum for Workers CPU time per request (5 minutes). */
95+
const MAX_WORKER_CPU_MS = 300000;
9396
const VALID_SANDBOX_INSTANCE_TYPES = new Set<SandboxInstanceType>([
9497
'dev',
9598
'lite',
@@ -878,6 +881,31 @@ export function readConfig(root: string): VoidConfig {
878881
}
879882
}
880883
}
884+
if (w.limits !== undefined) {
885+
if (typeof w.limits !== 'object' || w.limits === null || Array.isArray(w.limits)) {
886+
throw new Error(`config: "worker.limits" in ${CONFIG_FILE} must be an object.`);
887+
}
888+
const limits = w.limits as Record<string, unknown>;
889+
for (const key of Object.keys(limits)) {
890+
if (key !== 'cpu_ms') {
891+
throw new Error(
892+
`config: Unknown key 'worker.limits.${key}' in ${CONFIG_FILE}. Valid: cpu_ms.`,
893+
);
894+
}
895+
}
896+
if (limits.cpu_ms !== undefined) {
897+
if (
898+
typeof limits.cpu_ms !== 'number' ||
899+
!Number.isInteger(limits.cpu_ms) ||
900+
limits.cpu_ms < 1 ||
901+
limits.cpu_ms > MAX_WORKER_CPU_MS
902+
) {
903+
throw new Error(
904+
`config: "worker.limits.cpu_ms" in ${CONFIG_FILE} must be an integer between 1 and ${MAX_WORKER_CPU_MS}.`,
905+
);
906+
}
907+
}
908+
}
881909
}
882910

883911
// Validate database

‎packages/void/src/index.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -937,6 +937,14 @@ export function voidPlugin(options?: {
937937
workerConfig.compatibility_flags = wranglerCompat.compatibilityFlags;
938938
}
939939
}
940+
// CPU cap from void.json `worker.limits.cpu_ms` (validated in config.ts as an
941+
// integer 1..300000). The CF plugin passes `limits` through into the generated
942+
// `dist/ssr/wrangler.json`, so a self-host `wrangler deploy` / `void deploy
943+
// --backend cloudflare` enforces it directly. Managed `void deploy` ignores
944+
// the generated wrangler config and carries the cap in the deploy manifest.
945+
if (config.worker?.limits?.cpu_ms) {
946+
workerConfig.limits = { cpu_ms: config.worker.limits.cpu_ms };
947+
}
940948
const inspectorPortEnv = process.env.VOID_INSPECTOR_PORT;
941949
const inspectorPort =
942950
inspectorPortEnv === 'false'

‎packages/void/test/unit/config.test.ts‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,57 @@ describe('readConfig', () => {
318318
cleanup();
319319
});
320320

321+
it('accepts valid worker.limits.cpu_ms', () => {
322+
setup(JSON.stringify({ worker: { limits: { cpu_ms: 50 } } }));
323+
const config = readConfig(tmpDir);
324+
expect(config.worker?.limits).toEqual({ cpu_ms: 50 });
325+
cleanup();
326+
});
327+
328+
it('accepts worker.limits.cpu_ms boundary values (1 and 300000)', () => {
329+
setup(JSON.stringify({ worker: { limits: { cpu_ms: 1 } } }));
330+
expect(readConfig(tmpDir).worker?.limits?.cpu_ms).toBe(1);
331+
setup(JSON.stringify({ worker: { limits: { cpu_ms: 300000 } } }));
332+
expect(readConfig(tmpDir).worker?.limits?.cpu_ms).toBe(300000);
333+
cleanup();
334+
});
335+
336+
it('throws on non-object worker.limits', () => {
337+
setup(JSON.stringify({ worker: { limits: 50 } }));
338+
expect(() => readConfig(tmpDir)).toThrow('"worker.limits" in void.json must be an object');
339+
cleanup();
340+
});
341+
342+
it('throws on unknown key inside worker.limits', () => {
343+
setup(JSON.stringify({ worker: { limits: { cpuMs: 50 } } }));
344+
expect(() => readConfig(tmpDir)).toThrow("Unknown key 'worker.limits.cpuMs'");
345+
cleanup();
346+
});
347+
348+
it('throws on worker.limits.cpu_ms of 0', () => {
349+
setup(JSON.stringify({ worker: { limits: { cpu_ms: 0 } } }));
350+
expect(() => readConfig(tmpDir)).toThrow('"worker.limits.cpu_ms"');
351+
cleanup();
352+
});
353+
354+
it('throws on negative worker.limits.cpu_ms', () => {
355+
setup(JSON.stringify({ worker: { limits: { cpu_ms: -5 } } }));
356+
expect(() => readConfig(tmpDir)).toThrow('"worker.limits.cpu_ms"');
357+
cleanup();
358+
});
359+
360+
it('throws on non-integer worker.limits.cpu_ms', () => {
361+
setup(JSON.stringify({ worker: { limits: { cpu_ms: 50.5 } } }));
362+
expect(() => readConfig(tmpDir)).toThrow('"worker.limits.cpu_ms"');
363+
cleanup();
364+
});
365+
366+
it('throws on worker.limits.cpu_ms above 300000', () => {
367+
setup(JSON.stringify({ worker: { limits: { cpu_ms: 300001 } } }));
368+
expect(() => readConfig(tmpDir)).toThrow('"worker.limits.cpu_ms"');
369+
cleanup();
370+
});
371+
321372
it('accepts routing.revalidate as a number', () => {
322373
setup(JSON.stringify({ routing: { revalidate: 60 } }));
323374
const config = readConfig(tmpDir);
@@ -748,6 +799,19 @@ describe('readConfig redirects', () => {
748799
}
749800
});
750801

802+
it('schema.json worker.limits.cpu_ms bounds match runtime validation', () => {
803+
const schema = JSON.parse(readFileSync(join(import.meta.dirname, '../../schema.json'), 'utf8'));
804+
const limits = schema.properties.worker.properties.limits;
805+
expect(limits.type).toBe('object');
806+
// Runtime (config.ts) rejects unknown keys inside limits.
807+
expect(limits.additionalProperties).toBe(false);
808+
expect(Object.keys(limits.properties)).toEqual(['cpu_ms']);
809+
// Runtime accepts an integer between 1 and 300000 (Cloudflare's hard max).
810+
expect(limits.properties.cpu_ms.type).toBe('integer');
811+
expect(limits.properties.cpu_ms.minimum).toBe(1);
812+
expect(limits.properties.cpu_ms.maximum).toBe(300000);
813+
});
814+
751815
it('publishes schema.json as a package file and subpath export', () => {
752816
const packageJson = JSON.parse(
753817
readFileSync(join(import.meta.dirname, '../../package.json'), 'utf8'),

‎packages/void/test/unit/package.test.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { hash as blake3hash } from 'blake3-jit';
33
import { join } from 'pathe';
44
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test';
55
import {
6+
buildFrameworkManifest,
7+
buildStaticManifest,
68
buildVoidManifest,
79
computeAssetHash,
810
collectAndHashAssets,
@@ -167,6 +169,58 @@ describe('buildVoidManifest — self-host non-regression', () => {
167169
});
168170
});
169171

172+
describe('manifest limits (worker.limits.cpu_ms)', () => {
173+
function makeBindings(overrides: Partial<InferredBindings> = {}): InferredBindings {
174+
return {
175+
needsD1: false,
176+
needsKV: false,
177+
needsR2: false,
178+
needsAuth: false,
179+
needsQueues: false,
180+
needsAI: false,
181+
needsSandbox: false,
182+
...overrides,
183+
};
184+
}
185+
186+
it('emits manifest.limits when options.cpuMs is set (void manifest)', () => {
187+
const manifest = buildVoidManifest({ bindings: makeBindings(), options: { cpuMs: 50 } });
188+
expect(manifest.limits).toEqual({ cpuMs: 50 });
189+
});
190+
191+
it('omits the limits key entirely when options.cpuMs is unset (void manifest)', () => {
192+
const manifest = buildVoidManifest({ bindings: makeBindings(), options: {} });
193+
expect('limits' in manifest).toBe(false);
194+
});
195+
196+
it('emits manifest.limits when packageOptions.cpuMs is set (framework manifest)', () => {
197+
const manifest = buildFrameworkManifest({
198+
frameworkName: 'astro',
199+
workerMain: 'index.mjs',
200+
bindings: makeBindings(),
201+
packageOptions: { cpuMs: 30000 },
202+
});
203+
expect(manifest.limits).toEqual({ cpuMs: 30000 });
204+
});
205+
206+
it('omits the limits key entirely when packageOptions.cpuMs is unset (framework manifest)', () => {
207+
const manifest = buildFrameworkManifest({
208+
frameworkName: 'astro',
209+
workerMain: 'index.mjs',
210+
bindings: makeBindings(),
211+
packageOptions: {},
212+
});
213+
expect('limits' in manifest).toBe(false);
214+
});
215+
216+
it('static manifests never carry limits', () => {
217+
for (const appType of ['static', 'spa'] as const) {
218+
const manifest = buildStaticManifest({ appType });
219+
expect('limits' in manifest).toBe(false);
220+
}
221+
});
222+
});
223+
170224
describe('resolveSpaFallbackChain', () => {
171225
it('returns SPA platform switch with no rules when user has no fallbacks (SPA)', () => {
172226
const result = resolveSpaFallbackChain('spa', undefined);

‎packages/void/test/unit/plugin-dev-asset-routing.test.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,28 @@ describe('voidPlugin dev asset routing', () => {
250250
expect(assets?.run_worker_first).toEqual(['/api', '/api/*', '/__void', '/__void/*']);
251251
});
252252

253+
it('passes worker.limits.cpu_ms from void.json into the generated worker config', async () => {
254+
const root = createProjectRoot();
255+
writeFileSync(
256+
join(root, 'void.json'),
257+
JSON.stringify({
258+
worker: { compatibility_date: '2026-02-24', limits: { cpu_ms: 120000 } },
259+
}),
260+
);
261+
262+
const config = await resolveCloudflareConfig(root);
263+
264+
expect(config.limits).toEqual({ cpu_ms: 120000 });
265+
});
266+
267+
it('emits no limits key when worker.limits is not configured', async () => {
268+
const root = createProjectRoot();
269+
270+
const config = await resolveCloudflareConfig(root);
271+
272+
expect('limits' in config).toBe(false);
273+
});
274+
253275
it('routes document routes through the worker before SPA fallback HTML', async () => {
254276
const root = createDocumentRouteProjectRoot();
255277
const assets = await resolveVoidPluginAssets(root);

0 commit comments

Comments
 (0)