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
20 changes: 18 additions & 2 deletions packages/void/src/asset-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,31 @@ 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 =
!workerFirst && (inputs.hasApiRoutes === true || inputs.authEnabled === true);
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],
Comment thread
Brooooooklyn marked this conversation as resolved.
}
: {
not_found_handling: 'single-page-application',
run_worker_first: [...VOID_INTERNAL_WORKER_FIRST],
};
return {
assetConfig,
apiWorkerFirst,
Expand Down
2 changes: 1 addition & 1 deletion packages/void/test/integration/spa-middleware-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
57 changes: 55 additions & 2 deletions packages/void/test/unit/deploy-asset-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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({
Expand Down
8 changes: 4 additions & 4 deletions packages/void/test/unit/plugin-dev-asset-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -228,15 +228,15 @@ 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 () => {
const root = createAuthOnlyProjectRoot();
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 () => {
Expand All @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/void/test/unit/plugin-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
Expand Down