From 2cafdc854609ba98648ea8f74a4addd61a9fc414 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sat, 4 Jul 2026 09:46:35 +0800 Subject: [PATCH 1/2] fix(assets): route /__void internal namespace to the worker for SPA + API apps SPA apps with API routes (or auth) resolve to `not_found_handling: 'single-page-application'` with `run_worker_first: ['/api', '/api/*']`. That list omits Void's reserved `/__void/*` namespace, so Cloudflare Static Assets serves the SPA shell for `GET /__void/migrate/capabilities` before the worker runs. The migration handler is never reached, the platform's capability probe parses `` as JSON, and the deploy fails with "migration protocol v2 capability negotiation failed". Add `/__void` and `/__void/*` to `run_worker_first` in the apiWorkerFirst branch so the reserved namespace always reaches the worker. The worker-first (`/**`) branch already covers it; the pure-SPA branch has no server routes (hence no migrations) and is left unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/asset-routing.ts | 13 ++++++- .../integration/spa-middleware-auth.test.ts | 2 +- .../test/unit/deploy-asset-config.test.ts | 34 ++++++++++++++++++- .../unit/plugin-dev-asset-routing.test.ts | 8 ++--- .../void/test/unit/plugin-routing.test.ts | 2 +- 5 files changed, 51 insertions(+), 8 deletions(-) diff --git a/packages/void/src/asset-routing.ts b/packages/void/src/asset-routing.ts index 42f15df0e..7c6072f43 100644 --- a/packages/void/src/asset-routing.ts +++ b/packages/void/src/asset-routing.ts @@ -51,6 +51,14 @@ export function resolveAssetConfig(inputs: AssetRoutingInputs): AssetConfig { return resolveAssetRoutingPolicy(inputs).assetConfig; } +// Void's reserved internal namespace (`/__void/ready`, `/__void/migrate/*`, +// etc.). These must reach the worker before CF Static Assets can serve the SPA +// shell — otherwise `not_found_handling: 'single-page-application'` returns +// index.html for GET /__void/migrate/capabilities and migration capability +// negotiation fails at deploy time. The worker-first (`/**`) branch already +// covers it; the SPA-fallback branch must list it explicitly. +const VOID_INTERNAL_WORKER_FIRST = ['/__void', '/__void/*']; + export function resolveAssetRoutingPolicy(inputs: AssetRoutingInputs): AssetRoutingPolicy { const workerFirst = workerOwnsHtmlRouting(inputs); const apiWorkerFirst = @@ -58,7 +66,10 @@ export function resolveAssetRoutingPolicy(inputs: AssetRoutingInputs): AssetRout const assetConfig: AssetConfig = workerFirst ? { not_found_handling: 'none', run_worker_first: ['/**'] } : apiWorkerFirst - ? { not_found_handling: 'single-page-application', run_worker_first: ['/api', '/api/*'] } + ? { + not_found_handling: 'single-page-application', + run_worker_first: ['/api', '/api/*', ...VOID_INTERNAL_WORKER_FIRST], + } : { not_found_handling: 'single-page-application' }; return { assetConfig, diff --git a/packages/void/test/integration/spa-middleware-auth.test.ts b/packages/void/test/integration/spa-middleware-auth.test.ts index 3366e1868..05f393d5a 100644 --- a/packages/void/test/integration/spa-middleware-auth.test.ts +++ b/packages/void/test/integration/spa-middleware-auth.test.ts @@ -160,7 +160,7 @@ describe('SPA asset routing policy', () => { const assetConfig = readBuiltAssetConfig(); expect(assetConfig.not_found_handling).toBe('single-page-application'); - expect(assetConfig.run_worker_first).toEqual(['/api', '/api/*']); + expect(assetConfig.run_worker_first).toEqual(['/api', '/api/*', '/__void', '/__void/*']); }); it('routes document routes through the worker before SPA fallback HTML', async () => { diff --git a/packages/void/test/unit/deploy-asset-config.test.ts b/packages/void/test/unit/deploy-asset-config.test.ts index 6b62d8b7e..c8b0dde5c 100644 --- a/packages/void/test/unit/deploy-asset-config.test.ts +++ b/packages/void/test/unit/deploy-asset-config.test.ts @@ -18,7 +18,7 @@ describe('resolveAssetConfig', () => { const SPA = { not_found_handling: 'single-page-application' as const }; const SPA_API = { not_found_handling: 'single-page-application' as const, - run_worker_first: ['/api', '/api/*'], + run_worker_first: ['/api', '/api/*', '/__void', '/__void/*'], }; it('uses none when SSR is enabled', () => { @@ -55,6 +55,38 @@ describe('resolveAssetConfig', () => { ).toEqual(SPA_API); }); + it('routes the /__void internal namespace to the worker for SPA + API apps', () => { + // Regression: a SPA app with API routes + D1 migrations lands in the + // apiWorkerFirst branch. Without /__void in run_worker_first, CF Static + // Assets serves index.html for GET /__void/migrate/capabilities before the + // worker runs, so the migration handler is never reached and the deploy + // fails with "migration protocol v2 capability negotiation failed". + const config = resolveAssetConfig({ + isSsr: false, + hasPages: false, + hasApiRoutes: true, + hasDocumentRoutes: false, + hasMiddleware: false, + }); + expect(config.not_found_handling).toBe('single-page-application'); + expect(config.run_worker_first).toContain('/__void'); + expect(config.run_worker_first).toContain('/__void/*'); + }); + + it('routes the /__void internal namespace worker-first for auth-enabled SPA apps', () => { + // authEnabled also triggers the apiWorkerFirst branch (auth apps have + // migrations for the auth tables), so /__void must reach the worker too. + const config = resolveAssetConfig({ + isSsr: false, + hasPages: false, + hasApiRoutes: false, + hasDocumentRoutes: false, + hasMiddleware: false, + authEnabled: true, + }); + expect(config.run_worker_first).toContain('/__void/*'); + }); + it('uses none when the project has document routes', () => { expect( resolveAssetConfig({ 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 9ad4de1e1..a9a28e6cc 100644 --- a/packages/void/test/unit/plugin-dev-asset-routing.test.ts +++ b/packages/void/test/unit/plugin-dev-asset-routing.test.ts @@ -196,7 +196,7 @@ describe('voidPlugin dev asset routing', () => { }); expect(assets?.not_found_handling).toBe('single-page-application'); - expect(assets?.run_worker_first).toEqual(['/api', '/api/*']); + expect(assets?.run_worker_first).toEqual(['/api', '/api/*', '/__void', '/__void/*']); }); it('does not return existing D1 or R2 bindings when adding inferred defaults', async () => { @@ -228,7 +228,7 @@ describe('voidPlugin dev asset routing', () => { const assets = await resolveVoidPluginAssets(root); expect(assets?.not_found_handling).toBe('single-page-application'); - expect(assets?.run_worker_first).toEqual(['/api', '/api/*']); + expect(assets?.run_worker_first).toEqual(['/api', '/api/*', '/__void', '/__void/*']); }); it('routes managed auth endpoints to the worker without making all documents worker-first', async () => { @@ -236,7 +236,7 @@ describe('voidPlugin dev asset routing', () => { const assets = await resolveVoidPluginAssets(root); expect(assets?.not_found_handling).toBe('single-page-application'); - expect(assets?.run_worker_first).toEqual(['/api', '/api/*']); + expect(assets?.run_worker_first).toEqual(['/api', '/api/*', '/__void', '/__void/*']); }); it('keeps API-only projects static-first when middleware directory has no active files', async () => { @@ -247,7 +247,7 @@ describe('voidPlugin dev asset routing', () => { const assets = await resolveVoidPluginAssets(root); expect(assets?.not_found_handling).toBe('single-page-application'); - expect(assets?.run_worker_first).toEqual(['/api', '/api/*']); + expect(assets?.run_worker_first).toEqual(['/api', '/api/*', '/__void', '/__void/*']); }); it('routes document routes through the worker before SPA fallback HTML', async () => { diff --git a/packages/void/test/unit/plugin-routing.test.ts b/packages/void/test/unit/plugin-routing.test.ts index 47023ecf2..6b5c37f8d 100644 --- a/packages/void/test/unit/plugin-routing.test.ts +++ b/packages/void/test/unit/plugin-routing.test.ts @@ -215,7 +215,7 @@ describe('routingPlugin', () => { apiWorkerFirst: true, assetConfig: expect.objectContaining({ not_found_handling: 'single-page-application', - run_worker_first: ['/api', '/api/*'], + run_worker_first: ['/api', '/api/*', '/__void', '/__void/*'], }), workerFirst: false, }), From ababddb86e7609ce931b04ad0f6dce16d55ba7e0 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sat, 4 Jul 2026 11:15:51 +0800 Subject: [PATCH 2/2] fix(assets): also route /__void worker-first for migration-only SPA apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review (Codex #250 P2): the previous change only covered the apiWorkerFirst branch, but a SPA can advertise migrations without API/auth routes. resolveAssetConfig receives only route/auth/ssr/ws/live flags — not schedules/queues — while buildVoidManifest emits manifest.migrations from validatedMigrations independently. So a cron/queue-only SPA with D1 migrations falls to the pure-SPA branch and still had GET /__void/migrate/capabilities served as index.html, failing capability negotiation. Add /__void and /__void/* to run_worker_first in the pure-SPA branch too, so the reserved namespace always reaches the worker whenever the SPA fallback is active. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/asset-routing.ts | 9 ++++++-- .../test/unit/deploy-asset-config.test.ts | 23 ++++++++++++++++++- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/void/src/asset-routing.ts b/packages/void/src/asset-routing.ts index 7c6072f43..1078c6dfa 100644 --- a/packages/void/src/asset-routing.ts +++ b/packages/void/src/asset-routing.ts @@ -56,7 +56,9 @@ export function resolveAssetConfig(inputs: AssetRoutingInputs): AssetConfig { // shell — otherwise `not_found_handling: 'single-page-application'` returns // index.html for GET /__void/migrate/capabilities and migration capability // negotiation fails at deploy time. The worker-first (`/**`) branch already -// covers it; the SPA-fallback branch must list it explicitly. +// covers it; both SPA-fallback branches must list it explicitly, since a SPA +// can advertise migrations without API/auth routes (e.g. a cron/queue-only app +// with D1 migrations still emits manifest.migrations). const VOID_INTERNAL_WORKER_FIRST = ['/__void', '/__void/*']; export function resolveAssetRoutingPolicy(inputs: AssetRoutingInputs): AssetRoutingPolicy { @@ -70,7 +72,10 @@ export function resolveAssetRoutingPolicy(inputs: AssetRoutingInputs): AssetRout not_found_handling: 'single-page-application', run_worker_first: ['/api', '/api/*', ...VOID_INTERNAL_WORKER_FIRST], } - : { not_found_handling: 'single-page-application' }; + : { + not_found_handling: 'single-page-application', + run_worker_first: [...VOID_INTERNAL_WORKER_FIRST], + }; return { assetConfig, apiWorkerFirst, diff --git a/packages/void/test/unit/deploy-asset-config.test.ts b/packages/void/test/unit/deploy-asset-config.test.ts index c8b0dde5c..bb314ff67 100644 --- a/packages/void/test/unit/deploy-asset-config.test.ts +++ b/packages/void/test/unit/deploy-asset-config.test.ts @@ -15,7 +15,10 @@ import { // worker does not need to inspect document navigations. describe('resolveAssetConfig', () => { const NONE = { not_found_handling: 'none' as const, run_worker_first: ['/**'] }; - const SPA = { not_found_handling: 'single-page-application' as const }; + const SPA = { + not_found_handling: 'single-page-application' as const, + run_worker_first: ['/__void', '/__void/*'], + }; const SPA_API = { not_found_handling: 'single-page-application' as const, run_worker_first: ['/api', '/api/*', '/__void', '/__void/*'], @@ -87,6 +90,24 @@ describe('resolveAssetConfig', () => { expect(config.run_worker_first).toContain('/__void/*'); }); + it('routes the /__void internal namespace worker-first for a migration-only SPA (cron/queue app, no API/auth/pages)', () => { + // A SPA whose only server code is a cron/queue handler still advertises + // manifest.migrations (validatedMigrations is passed to buildVoidManifest + // independently of routes), but has none of the asset-routing flags, so it + // falls to the pure-SPA branch. Without /__void there, CF serves index.html + // for GET /__void/migrate/capabilities and negotiation fails. (Codex #250 P2) + const config = resolveAssetConfig({ + isSsr: false, + hasPages: false, + hasApiRoutes: false, + hasDocumentRoutes: false, + hasMiddleware: false, + }); + expect(config.not_found_handling).toBe('single-page-application'); + expect(config.run_worker_first).toContain('/__void'); + expect(config.run_worker_first).toContain('/__void/*'); + }); + it('uses none when the project has document routes', () => { expect( resolveAssetConfig({