diff --git a/packages/void/src/asset-routing.ts b/packages/void/src/asset-routing.ts index 42f15df0e..1078c6dfa 100644 --- a/packages/void/src/asset-routing.ts +++ b/packages/void/src/asset-routing.ts @@ -51,6 +51,16 @@ 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; 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 { const workerFirst = workerOwnsHtmlRouting(inputs); const apiWorkerFirst = @@ -58,8 +68,14 @@ 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' }; + ? { + not_found_handling: 'single-page-application', + run_worker_first: ['/api', '/api/*', ...VOID_INTERNAL_WORKER_FIRST], + } + : { + not_found_handling: 'single-page-application', + run_worker_first: [...VOID_INTERNAL_WORKER_FIRST], + }; return { assetConfig, apiWorkerFirst, 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..bb314ff67 100644 --- a/packages/void/test/unit/deploy-asset-config.test.ts +++ b/packages/void/test/unit/deploy-asset-config.test.ts @@ -15,10 +15,13 @@ 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/*'], + run_worker_first: ['/api', '/api/*', '/__void', '/__void/*'], }; it('uses none when SSR is enabled', () => { @@ -55,6 +58,56 @@ 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('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({ 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, }),