diff --git a/docs/reference/config.md b/docs/reference/config.md index ab4455777..e6309dddb 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -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 { @@ -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. diff --git a/packages/void/schema.json b/packages/void/schema.json index 60dd139ab..705b97685 100644 --- a/packages/void/schema.json +++ b/packages/void/schema.json @@ -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 diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index e8f085182..bf3029728 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -5828,6 +5828,7 @@ async function runFrameworkDeploy( workerMain: actualWorkerMain, compatibilityDate: wranglerCompat?.compatibilityDate, compatibilityFlags: wranglerCompat?.compatibilityFlags, + cpuMs: voidConfig.worker?.limits?.cpu_ms, }; const manifest = buildFrameworkManifest({ @@ -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 }), diff --git a/packages/void/src/cli/package.ts b/packages/void/src/cli/package.ts index 01570cab9..ac012f1ea 100644 --- a/packages/void/src/cli/package.ts +++ b/packages/void/src/cli/package.ts @@ -422,6 +422,8 @@ export type PackageOptions = { compatibilityDate?: string; /** Cloudflare compatibility_flags from framework's wrangler config. */ compatibilityFlags?: Array; + /** 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. */ @@ -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; @@ -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; diff --git a/packages/void/src/cli/types.ts b/packages/void/src/cli/types.ts index ff12b094f..ec05fb9d5 100644 --- a/packages/void/src/cli/types.ts +++ b/packages/void/src/cli/types.ts @@ -200,6 +200,8 @@ export type DeployManifest = { workerMain?: string; compatibilityDate?: string; compatibilityFlags?: Array; + /** Worker resource limit overrides from void.json `worker.limits`. */ + limits?: { cpuMs?: number }; hashedAssetsPrefix?: string; headerRules?: Array; redirectRules?: Array; diff --git a/packages/void/src/config.ts b/packages/void/src/config.ts index 7ee550510..945e330f3 100644 --- a/packages/void/src/config.ts +++ b/packages/void/src/config.ts @@ -23,6 +23,7 @@ export type WranglerConfig = { compatibility_date?: string; compatibility_flags?: Array; vars?: Record; + limits?: { cpu_ms?: number }; }; /** @@ -119,6 +120,8 @@ export const VALID_NOT_FOUND_HANDLING: Set = new Set([ */ export const VALID_REDIRECT_STATUSES: Set = 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([ 'dev', 'lite', @@ -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; + 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 diff --git a/packages/void/src/index.ts b/packages/void/src/index.ts index 7f7b708e4..be2a4b884 100644 --- a/packages/void/src/index.ts +++ b/packages/void/src/index.ts @@ -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' diff --git a/packages/void/test/unit/config.test.ts b/packages/void/test/unit/config.test.ts index e34532dc9..626ff7dd6 100644 --- a/packages/void/test/unit/config.test.ts +++ b/packages/void/test/unit/config.test.ts @@ -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); @@ -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'), diff --git a/packages/void/test/unit/package.test.ts b/packages/void/test/unit/package.test.ts index 19829e9b6..037df5ee3 100644 --- a/packages/void/test/unit/package.test.ts +++ b/packages/void/test/unit/package.test.ts @@ -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, @@ -168,6 +169,58 @@ describe('buildVoidManifest — self-host non-regression', () => { }); }); +describe('manifest limits (worker.limits.cpu_ms)', () => { + function makeBindings(overrides: Partial = {}): 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); diff --git a/packages/void/test/unit/plugin-dev-asset-routing.test.ts b/packages/void/test/unit/plugin-dev-asset-routing.test.ts index 1c364db02..88d77b905 100644 --- a/packages/void/test/unit/plugin-dev-asset-routing.test.ts +++ b/packages/void/test/unit/plugin-dev-asset-routing.test.ts @@ -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);