diff --git a/docs/guide/edge/static-assets.md b/docs/guide/edge/static-assets.md index 33a0b4a46..5d4e105ea 100644 --- a/docs/guide/edge/static-assets.md +++ b/docs/guide/edge/static-assets.md @@ -91,6 +91,8 @@ Choose `single-page-application`, `404-page`, or `none`. These settings preserve SvelteKit, Nuxt, Analog, and Astro handle their own error pages and ignore `routing.notFound`. For direct Cloudflare deployment with TanStack Start, React Router, or vinext, configure the asset binding, directory, and routing policy through the framework's Cloudflare config. +With `void deploy --platform cloudflare`, SvelteKit, Nuxt, Analog, and Astro apps serve built files and prerendered pages without starting the Worker, and requests without a matching file reach the framework. To start the Worker first for other paths, set [`run_worker_first`](https://developers.cloudflare.com/workers/static-assets/binding/#run_worker_first) under `cloudflare.assets` in `void.config.ts`. With `true`, every request starts the Worker and counts toward its request usage. + ## API routes and SSR pages API responses and SSR pages are not cached as static assets. Use [Revalidation](./revalidation) to cache public rendered pages. diff --git a/packages/deploy-cloudflare/src/framework.ts b/packages/deploy-cloudflare/src/framework.ts index c12b462c9..e0914dcc1 100644 --- a/packages/deploy-cloudflare/src/framework.ts +++ b/packages/deploy-cloudflare/src/framework.ts @@ -17,6 +17,12 @@ export type CloudflareFrameworkDeployConfig = Readonly<{ requiredSecrets?: ReadonlyArray; versionMetadataBinding?: string; ai?: Readonly<{ binding: string; gateway?: string }>; + /** + * `assets.run_worker_first`. Cloudflare serves a request it does not match + * from the asset directory when a file exists there, without starting the + * Worker. Defaults to `true`, which starts the Worker for every request. + */ + runWorkerFirst?: boolean | ReadonlyArray; }>; /** @@ -54,7 +60,7 @@ export function writeCloudflareFrameworkDeployConfig( upsert(['no_bundle'], false); upsert(['assets', 'directory'], options.assetsDirectory); upsert(['assets', 'binding'], 'ASSETS'); - upsert(['assets', 'run_worker_first'], true); + upsert(['assets', 'run_worker_first'], options.runWorkerFirst ?? true); upsert(['assets', 'not_found_handling'], 'none'); if (options.crons !== undefined) { upsert(['triggers', 'crons'], [...options.crons]); diff --git a/packages/deploy-cloudflare/test/unit/framework.test.ts b/packages/deploy-cloudflare/test/unit/framework.test.ts index c5b017f47..7c048bba4 100644 --- a/packages/deploy-cloudflare/test/unit/framework.test.ts +++ b/packages/deploy-cloudflare/test/unit/framework.test.ts @@ -95,6 +95,36 @@ describe('Cloudflare framework deployment', () => { ); }); + it.each([[['/__void', '/__void/*']], [false]])( + 'writes the given run_worker_first over the value an earlier deploy wrote (%j)', + (runWorkerFirst) => { + const root = mkdtempSync(join(tmpdir(), 'void-framework-config-')); + const configPath = join(root, 'wrangler.jsonc'); + writeFileSync( + configPath, + JSON.stringify({ assets: { run_worker_first: true, not_found_handling: 'none' } }), + ); + const input = { + configPath, + deployPointerPath: join(root, '.wrangler/deploy/config.json'), + name: 'framework-app', + accountId: 'account-id', + main: 'dist/server/entry.mjs', + assetsDirectory: 'dist/client', + compatibilityDate: '2026-09-11', + runWorkerFirst, + }; + expect(writeCloudflareFrameworkDeployConfig(input)).toBe(true); + expect(writeCloudflareFrameworkDeployConfig(input)).toBe(false); + expect(parseRootWranglerConfig(configPath).assets).toEqual({ + run_worker_first: runWorkerFirst, + not_found_handling: 'none', + directory: 'dist/client', + binding: 'ASSETS', + }); + }, + ); + it('removes stale cron schedules when the current source has none', () => { const root = mkdtempSync(join(tmpdir(), 'void-framework-config-')); const configPath = join(root, 'wrangler.jsonc'); diff --git a/packages/void/src/cli/deploy-cloudflare-static.ts b/packages/void/src/cli/deploy-cloudflare-static.ts index fc6c76d64..ca2ed3674 100644 --- a/packages/void/src/cli/deploy-cloudflare-static.ts +++ b/packages/void/src/cli/deploy-cloudflare-static.ts @@ -149,6 +149,14 @@ export async function prepareCloudflareFrameworkArtifact(options: { requiredSecrets: requiredSecretNames, versionMetadataBinding, ...(ai ? { ai: { ...ai, gateway: voidConfig.ai?.gateway } } : {}), + // Like the framework's own Cloudflare config, let Cloudflare serve built files and + // prerendered pages without starting the Worker. Paths without a file still reach it through + // `not_found_handling: 'none'`; the rule also keeps Void's internal paths on it. Read the + // authored value: the generated config can still hold the `true` an older deploy wrote. + runWorkerFirst: voidConfig.cloudflare?.assets?.run_worker_first ?? [ + ...VOID_INTERNAL_WORKER_FIRST, + ], + // Cloudflare serves any uploaded file without the Worker, so list every module Void adds here. ...(preset.workerDir === preset.assetsDir ? { assetsIgnore: [ diff --git a/packages/void/test/unit/cloudflare-deploy.test.ts b/packages/void/test/unit/cloudflare-deploy.test.ts index 80d01e26e..3f3017be8 100644 --- a/packages/void/test/unit/cloudflare-deploy.test.ts +++ b/packages/void/test/unit/cloudflare-deploy.test.ts @@ -95,6 +95,10 @@ import { writeWranglerPublishedTrafficSnapshot, writeProvisionedWranglerConfig, } from '../../src/cli/wrangler.ts'; +import { + materializeCloudflareConfig, + persistCloudflareConfig, +} from '../../src/cloudflare-config.ts'; import { readConfig } from '../../src/config.ts'; // `runDeploy` calls `runCloudflareDeploy` without deps. A test that starts from `runDeploy` sets @@ -7874,7 +7878,7 @@ describe('runCloudflareDeploy — framework artifact guard', () => { assets: { directory: '.svelte-kit/cloudflare', binding: 'ASSETS', - run_worker_first: true, + run_worker_first: ['/__void', '/__void/*'], }, triggers: { crons: ['0 * * * *'] }, secrets: { required: ['API_TOKEN'] }, @@ -7883,8 +7887,9 @@ describe('runCloudflareDeploy — framework artifact guard', () => { expect(readFileSync(join(root, 'wrangler.jsonc'), 'utf-8')).toContain('// keep this comment'); }); - // SvelteKit's Worker directory is also its asset directory. In an ES module package, which is - // what `sv create` makes, Vite names the cron and queue bundle `__void_handlers.js`. + // SvelteKit's Worker directory is also its asset directory, and Cloudflare serves an uploaded + // file there without starting the Worker. In an ES module package, which is what `sv create` + // makes, Vite names the cron and queue bundle `__void_handlers.js`. it('keeps the modules it adds to an ES module SvelteKit Worker out of its assets', async () => { const root = makeProject({ 'package.json': JSON.stringify({ @@ -7892,7 +7897,7 @@ describe('runCloudflareDeploy — framework artifact guard', () => { type: 'module', dependencies: { '@sveltejs/kit': '2.0.0' }, }), - 'void.json': JSON.stringify({}), + 'void.json': JSON.stringify({ routing: { revalidate: { '/news/*': 60 } } }), 'wrangler.jsonc': JSON.stringify({ name: 'skapp', account_id: 'acc123' }), '.svelte-kit/cloudflare/_worker.js': 'export default { fetch() { return new Response("ok") } };\n', @@ -7916,6 +7921,7 @@ describe('runCloudflareDeploy — framework artifact guard', () => { const directory = join(root, '.svelte-kit/cloudflare'); expect(existsSync(join(directory, '__void_handlers.js'))).toBe(true); + expect(existsSync(join(directory, '__void_isr.mjs'))).toBe(true); const ignored = ignore().add(readFileSync(join(directory, '.assetsignore'), 'utf-8')); const assets = readdirSync(directory, { recursive: true, encoding: 'utf8' }) .map((file) => toSlash(file)) @@ -7924,6 +7930,78 @@ describe('runCloudflareDeploy — framework artifact guard', () => { expect(assets).toEqual(['_app/immutable/app.js']); }); + // Astro's own Cloudflare config starts the Worker only for paths without a file. An older Void + // deploy wrote `run_worker_first: true` into the generated config, which void.lock.json can keep. + it('replaces the run_worker_first an older framework deploy recorded', async () => { + const preset = FRAMEWORK_PRESETS.astro!; + const root = makeProject({ + 'void.config.ts': `export default { cloudflare: { name: 'astro-app', account_id: 'acc123' } };\n`, + [`${preset.workerDir}/${preset.workerMain}`]: + 'export default { fetch() { return new Response("page") } };\n', + [`${preset.assetsDir}/_astro/app.js`]: 'console.log("client");\n', + }); + const configPath = materializeCloudflareConfig(root); + writeFileSync( + configPath, + JSON.stringify({ + ...parseRootWranglerConfig(configPath), + assets: { run_worker_first: true }, + }), + ); + persistCloudflareConfig(root); + expect(parseRootWranglerConfig(materializeCloudflareConfig(root)).assets).toEqual({ + run_worker_first: true, + }); + + await prepareCloudflareFrameworkArtifact({ + root, + configPath, + accountId: 'acc123', + workerName: 'astro-app', + voidConfig: readConfig(root), + framework: { name: 'astro', class: 'c' }, + preset, + }); + + expect(parseRootWranglerConfig(configPath).assets).toMatchObject({ + run_worker_first: ['/__void', '/__void/*'], + not_found_handling: 'none', + }); + }); + + it.each([[['/api/*']], [true], [false]])( + 'writes the run_worker_first that void.config.ts sets for a framework Worker (%j)', + async (runWorkerFirst) => { + const preset = FRAMEWORK_PRESETS.astro!; + const cloudflare = { + name: 'astro-app', + account_id: 'acc123', + assets: { run_worker_first: runWorkerFirst }, + }; + const root = makeProject({ + 'void.config.ts': `export default { cloudflare: ${JSON.stringify(cloudflare)} };\n`, + [`${preset.workerDir}/${preset.workerMain}`]: + 'export default { fetch() { return new Response("page") } };\n', + [`${preset.assetsDir}/_astro/app.js`]: 'console.log("client");\n', + }); + const configPath = materializeCloudflareConfig(root); + + await prepareCloudflareFrameworkArtifact({ + root, + configPath, + accountId: 'acc123', + workerName: 'astro-app', + voidConfig: readConfig(root), + framework: { name: 'astro', class: 'c' }, + preset, + }); + + expect(parseRootWranglerConfig(configPath).assets).toMatchObject({ + run_worker_first: runWorkerFirst, + }); + }, + ); + it('prepares Class A wrapper handlers without replacing its generated config pointer', async () => { const root = makeProject({ 'package.json': JSON.stringify({