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/guide/edge/static-assets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 7 additions & 1 deletion packages/deploy-cloudflare/src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ export type CloudflareFrameworkDeployConfig = Readonly<{
requiredSecrets?: ReadonlyArray<string>;
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<string>;
}>;

/**
Expand Down Expand Up @@ -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]);
Expand Down
30 changes: 30 additions & 0 deletions packages/deploy-cloudflare/test/unit/framework.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
8 changes: 8 additions & 0 deletions packages/void/src/cli/deploy-cloudflare-static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
86 changes: 82 additions & 4 deletions packages/void/test/unit/cloudflare-deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'] },
Expand All @@ -7883,16 +7887,17 @@ 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({
name: 'skapp',
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',
Expand All @@ -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))
Expand All @@ -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({
Expand Down
Loading