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
34 changes: 34 additions & 0 deletions docs/guide/edge/static-assets.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,40 @@ This happens automatically for all static assets. No configuration is needed.

You can override caching headers or add your own for any static asset path using [Custom Headers](./headers).

## Request pipeline

Static assets can run in front of the worker, behind the worker, or without any worker at all. Void chooses the pipeline from the app shape so static pages stay static unless application code must inspect document navigations.

### Deploy shapes

| Shape | Worker deployed | First handler for assets | First handler for document navigations | Miss behavior |
| --------------------------------------------------------------------------------------- | --------------- | ------------------------ | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `inference.appType: "static"` | No | Asset platform | Asset platform | Platform 404 page. |
| Static SPA deploy | No | Asset platform | Asset platform | Platform serves `/index.html` for unmatched navigations. If user `routing.fallbacks` exist, Void ships fallback rules. |
| Void app with only `/api` routes | Yes | Asset platform | Asset platform | Static navigations keep the platform SPA fallback; API asset misses fall through to the worker route table. |
| Void app with `middleware/`, managed auth, non-`/api` routes, WebSockets, live bindings | Yes | Worker first | Worker first | Asset misses stay 404, then the worker serves `/index.html` for HTML requests after routes and middleware run. |
| Pages, SSR, and framework apps | Yes | Worker first | Worker first | Asset misses stay 404. Pages, SSR, or framework rendering owns HTML responses, including intentional HTML 404s. |

### Worker-first Void apps

For worker-owned HTML, Void sets Cloudflare assets to `not_found_handling: "none"` and configures `run_worker_first`. The request order is:

1. Platform routing rules that always run before assets, such as redirects and forced rewrites.
2. Worker route table, middleware, auth, WebSocket upgrades, Pages, or SSR.
3. `env.ASSETS.fetch()` from inside the worker for static files.
4. For non-Pages, non-SSR Void apps only, a worker-side SPA fallback to `/index.html` when the original request accepts HTML.
5. The worker's original 404.

This is the path needed for preview auth and other middleware. Cloudflare's platform SPA fallback can serve `index.html` directly for browser navigations; when that happens, middleware never sees the request. Worker-owned HTML avoids that by moving fallback HTML behind middleware.

### Asset-first Void apps

For Void apps with only `/api` routes, Void does not enable `run_worker_first`. Static assets and SPA navigations stay on the asset platform. Requests that do not resolve to an asset and are not handled by the platform SPA fallback, such as `/api/hello`, fall through to the worker.

### Generated config

Void owns the generated asset routing policy during dev and build. If a root `wrangler.jsonc` contains stale `not_found_handling` or `run_worker_first` values, Void replaces those fields so generated config cannot accidentally change which layer sees a request first.

## API routes and SSR pages

API responses (`/api/*`) and SSR-rendered pages without file extensions always hit the worker. They are **not** edge-cached by the dispatch layer.
58 changes: 58 additions & 0 deletions packages/void/src/asset-routing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { AssetConfig } from './cli/types.ts';
import type { RouteDefinition } from './router/parse-filename.ts';

export type AssetRoutingInputs = {
isSsr: boolean;
hasPages: boolean;
hasDocumentRoutes: boolean;
hasMiddleware: boolean;
hasWebSockets?: boolean;
authEnabled?: boolean;
liveEnabled?: boolean;
};

export type AssetRoutingPolicy = {
assetConfig: AssetConfig;
workerFirst: boolean;
workerSpaFallback: boolean;
};

export function isApiRoutePattern(pattern: string): boolean {
return pattern === '/api' || pattern.startsWith('/api/');
}

export function hasDocumentRoutes(routes: Array<Pick<RouteDefinition, 'pattern'>>): boolean {
return routes.some((route) => !isApiRoutePattern(route.pattern));
}

/**
* Returns true when browser document navigations must reach the worker before
* a static asset fallback can serve HTML.
*/
export function workerOwnsHtmlRouting(inputs: AssetRoutingInputs): boolean {
return (
inputs.isSsr ||
inputs.hasPages ||
inputs.hasDocumentRoutes ||
inputs.hasMiddleware ||
inputs.hasWebSockets === true ||
inputs.authEnabled === true ||
inputs.liveEnabled === true
);
}

export function resolveAssetConfig(inputs: AssetRoutingInputs): AssetConfig {
return resolveAssetRoutingPolicy(inputs).assetConfig;
}

export function resolveAssetRoutingPolicy(inputs: AssetRoutingInputs): AssetRoutingPolicy {
const workerFirst = workerOwnsHtmlRouting(inputs);
const assetConfig: AssetConfig = workerFirst
? { not_found_handling: 'none', run_worker_first: ['/**'] }
: { not_found_handling: 'single-page-application' };
return {
assetConfig,
workerFirst,
workerSpaFallback: workerFirst && !inputs.isSsr && !inputs.hasPages,
};
}
43 changes: 8 additions & 35 deletions packages/void/src/cli/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import { join, relative, resolve } from 'pathe';
const drizzleKitBin = join(fileURLToPath(import.meta.resolve('drizzle-kit')), '..', 'bin.cjs');
const deployDriftCheckDir = join('.void', 'deploy-drift-check');
import { intro, outro, log, note, confirm, isCancel, type SpinnerResult } from '@clack/prompts';
import {
hasDocumentRoutes as routeScanHasDocumentRoutes,
resolveAssetConfig,
} from '../asset-routing.ts';
import { findVoidAuthConfig } from '../auth/config.ts';
import { findEnvFile } from '../codegen/env-types.ts';
import {
Expand Down Expand Up @@ -220,40 +224,7 @@ export async function resolveDeployLiveBindingConfig(
});
}

/**
* Resolve the Cloudflare Workers Static Assets `not_found_handling` config.
*
* Any worker capable of producing HTML (SSR, pages, file-based routes, or
* middleware) must use `'none'` so the asset binding returns 404 for
* unmatched paths and the worker — gated by `run_worker_first` — owns the
* response. With `'single-page-application'`, CF short-circuits any
* `Sec-Fetch-Mode: navigate` request straight to the SPA shell *before*
* `run_worker_first` is consulted, silently bypassing auth middleware and
* breaking OAuth callbacks (which arrive as navigations from the provider's
* 302 redirect).
*
* Pure-frontend SPAs without any backend code keep the SPA-fallback config
* because the worker has no responsibility for HTML routing.
*/
export function resolveAssetConfig(opts: {
isSsr: boolean;
hasPages: boolean;
hasRoutes: boolean;
hasMiddleware: boolean;
hasWebSockets?: boolean;
authEnabled?: boolean;
}): AssetConfig {
const workerHandlesHtml =
opts.isSsr ||
opts.hasPages ||
opts.hasRoutes ||
opts.hasMiddleware ||
opts.hasWebSockets === true ||
opts.authEnabled === true;
return workerHandlesHtml
? { not_found_handling: 'none' }
: { not_found_handling: 'single-page-application' };
}
export { resolveAssetConfig } from '../asset-routing.ts';

function getExitCode(error: unknown): number {
if (
Expand Down Expand Up @@ -1537,6 +1508,7 @@ async function runFullDeploy(
}));
const hasPages = existsSync(join(root, 'pages'));
const hasRoutes = routeScan.routes.length > 0;
const hasDocumentRoutes = routeScanHasDocumentRoutes(routeScan.routes);
const hasMiddleware = routeScan.middleware.length > 0;
const hasWebSockets = webSockets.length > 0;
const isSsr = isFrameworkMode
Expand Down Expand Up @@ -1668,10 +1640,11 @@ async function runFullDeploy(
const assetConfig: AssetConfig = resolveAssetConfig({
isSsr,
hasPages,
hasRoutes,
hasDocumentRoutes,
hasMiddleware,
hasWebSockets,
authEnabled,
liveEnabled: liveConfig !== undefined,
});

// Preflight: hash assets and check which ones changed.
Expand Down
1 change: 1 addition & 0 deletions packages/void/src/cli/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export type DevRedirectRule = RedirectRule & {
export type AssetConfig = {
not_found_handling?: 'none' | 'single-page-application' | '404-page';
html_handling?: 'auto-trailing-slash' | 'force-trailing-slash' | 'drop-trailing-slash' | 'none';
run_worker_first?: boolean | Array<string>;
};

export type WebSocketRouteMeta = {
Expand Down
55 changes: 44 additions & 11 deletions packages/void/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import { cloudflare, type PluginConfig, type WorkerConfig } from '@cloudflare/vi
import { join } from 'pathe';
import pc from 'picocolors';
import { loadEnv, type Plugin } from 'vite';
import {
hasDocumentRoutes as routeScanHasDocumentRoutes,
resolveAssetRoutingPolicy,
type AssetRoutingPolicy,
} from './asset-routing.ts';
import { findVoidAuthConfig } from './auth/config.ts';
import { getToken, isStagingMode } from './cli/auth.ts';
import { readProjectConfig } from './cli/project.ts';
Expand Down Expand Up @@ -59,6 +64,7 @@ import { prerenderPlugin } from './plugin-prerender.ts';
import { restartPlugin } from './plugin-restart.ts';
import { routingPlugin } from './plugin-routing.ts';
import { scanQueuesSync } from './queues/scan.ts';
import { scanMiddlewareSync, scanRoutePatternsSync } from './router/scan.ts';
import { scanWebSocketRoutesSync } from './router/websocket.ts';
import { SANDBOX_MIGRATION_TAG, isSandboxEnabled, resolveSandboxConfig } from './sandbox/config.ts';
import { filterLoadedEnv, resolveRemoteMode, stripInternalEnvKeys } from './shared/env.ts';
Expand Down Expand Up @@ -860,6 +866,15 @@ export function voidPlugin(options?: {
function mergeBindings(resolved: WorkerConfig): Partial<WorkerConfig> {
const result: Record<string, unknown> = { ...workerConfig };

// Void owns the generated asset routing policy. Replace stale root
// wrangler settings instead of letting the Cloudflare plugin concatenate
// them with generated rules. This preserves static-first behavior for
// API-only apps while avoiding asset-served HTML bypassing middleware.
if (workerConfig.assets && resolved.assets) {
delete (resolved.assets as Record<string, unknown>).not_found_handling;
delete (resolved.assets as Record<string, unknown>).run_worker_first;
}

// D1 — auth requires the app DB only for sqlite apps
const d1: Array<Record<string, unknown>> = [];
if (
Expand Down Expand Up @@ -1472,6 +1487,18 @@ export function voidPlugin(options?: {
const jobs = scanJobsSync(root);
const ssrEntry = validateSsrEntry(root);
const hasPages = existsSync(join(root, 'pages'));
const hasMiddleware = scanMiddlewareSync(root).length > 0;
const routePatterns = scanRoutePatternsSync(root);
const hasDocumentRoutes = routeScanHasDocumentRoutes(routePatterns);
let assetRouting = resolveAssetRoutingPolicy({
isSsr: Boolean(ssrEntry),
hasPages,
hasDocumentRoutes,
hasMiddleware,
hasWebSockets: websocketRoutes.length > 0,
authEnabled,
liveEnabled: liveConfig !== undefined,
});

workerConfig.main = join(root, '.void', 'entry.ts');

Expand All @@ -1481,22 +1508,27 @@ export function voidPlugin(options?: {
};
}

if (ssrEntry || hasPages) {
function applyAssetRoutingPolicy(policy: AssetRoutingPolicy): void {
workerConfig.assets = {
binding: 'ASSETS',
// Pages/SSR workers own HTML routing. Asset misses must stay 404s so
// module/script/style requests never receive the app shell as text/html.
not_found_handling: 'none',
// Route all requests through the worker so the entry can set proper
// Cache-Control headers on static asset responses (CF's default is
// max-age=0 and _headers files don't work for WfP dispatch uploads).
// The worker entry falls through to env.ASSETS.fetch() for paths that
// don't match any app route. Dev-mode exclusions are handled in
// envPlugin's config hook (deletes run_worker_first entirely).
run_worker_first: ['/**'],
...policy.assetConfig,
};
}

function updateDevAssetRoutingPolicy(next: AssetRoutingPolicy): boolean {
const previous = assetRouting;
const requiresRestart =
previous.workerFirst !== next.workerFirst ||
previous.assetConfig.not_found_handling !== next.assetConfig.not_found_handling;
assetRouting = next;
if (requiresRestart) {
applyAssetRoutingPolicy(next);
}
return requiresRestart;
}

applyAssetRoutingPolicy(assetRouting);

return [
createCloudflareBuiltinsExternalPlugin(),
createDefaultWorkerEnvironmentPlugin(),
Expand Down Expand Up @@ -1527,6 +1559,7 @@ export function voidPlugin(options?: {
sandboxBindingName: sandboxConfig?.binding,
sandboxClassName: sandboxConfig?.className,
live: liveConfig,
onDevAssetRoutingPolicyChange: updateDevAssetRoutingPolicy,
}),
drizzlePlugin(root, true, dialect, {
forceVirtualDb: authEnabled,
Expand Down
49 changes: 40 additions & 9 deletions packages/void/src/plugin-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import { homedir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { dirname, join, relative } from 'pathe';
import { loadEnv, type Plugin } from 'vite';
import {
hasDocumentRoutes,
resolveAssetRoutingPolicy,
type AssetRoutingPolicy,
} from './asset-routing.ts';
import { findVoidAuthConfig, loadVoidAuthConfig } from './auth/config.ts';
import { generateVoidAuthDatabaseModule, generateVoidAuthDrizzleSchema } from './auth/drizzle.ts';
import { lintDestinationSplats, lintDuplicateSources, mergeRoutingRules } from './cli/headers.ts';
Expand Down Expand Up @@ -71,6 +76,7 @@ type RoutingPluginOptions = {
sandboxBindingName?: string;
sandboxClassName?: string;
live?: { bindingName: string; className: string };
onDevAssetRoutingPolicyChange?: (policy: AssetRoutingPolicy) => boolean;
};

export function routingPlugin(root: string, options: RoutingPluginOptions): Plugin {
Expand Down Expand Up @@ -299,15 +305,33 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug
}

function shouldServeSpaFallback(): boolean {
return (
!!scan &&
scan.pages.pages.length === 0 &&
!ssr &&
(scan.routes.length > 0 ||
(scan.websockets?.length ?? 0) > 0 ||
scan.middleware.length > 0 ||
compileOptions.auth !== null)
);
if (!scan) {
return false;
}
return resolveCurrentAssetRoutingPolicy().workerSpaFallback;
}

function resolveCurrentAssetRoutingPolicy(): AssetRoutingPolicy {
if (!scan) {
return resolveAssetRoutingPolicy({
isSsr: Boolean(ssr),
hasPages: false,
hasDocumentRoutes: false,
hasMiddleware: false,
hasWebSockets: false,
authEnabled: compileOptions.auth !== null,
liveEnabled: options.live !== undefined,
});
}
return resolveAssetRoutingPolicy({
isSsr: Boolean(ssr),
hasPages: scan.pages.pages.length > 0,
hasDocumentRoutes: hasDocumentRoutes(scan.routes),
hasMiddleware: scan.middleware.length > 0,
hasWebSockets: (scan.websockets?.length ?? 0) > 0,
authEnabled: compileOptions.auth !== null,
liveEnabled: options.live !== undefined,
});
}

// Generate the entry file and tsconfig eagerly (at plugin creation time,
Expand Down Expand Up @@ -547,6 +571,13 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug
// not the client — so we must iterate all environments.
await rescan();
resolveAuthCompileOptions();
const shouldRestartForAssetRouting =
event !== 'change' &&
options.onDevAssetRoutingPolicyChange?.(resolveCurrentAssetRoutingPolicy()) === true;
if (shouldRestartForAssetRouting) {
await devServer.restart();
return;
}
await writeAuthDatabaseFiles();
writeRouteTypes();
writeQueueTypes();
Expand Down
8 changes: 4 additions & 4 deletions packages/void/src/router/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2318,10 +2318,10 @@ function __filterInternalBindings(env) {
// cache boundary, which the platform's deploy-scoped cache keys (dispatch
// layer) can't reach once CF's managed edge has cached the response.
const spaFallbackCode = serveSpaFallback
? ` // SPA fallback: when routes/middleware make assetConfig.not_found_handling
// = 'none', the asset binding returns 404 instead of falling back to
// index.html itself. Do that fallback here, after user routes/middleware
// have run, so auth gates and OAuth callbacks see the request first.
? ` // SPA fallback: when HTML-owning features make
// assetConfig.not_found_handling = 'none', the asset binding returns 404
// instead of falling back to index.html itself. Do that fallback here, after
// user routes/middleware have run, so auth gates and OAuth callbacks see the request first.
// Pages and SSR apps leave this disabled so intentional HTML 404s stay 404.
const accept = request.headers.get("accept") ?? "";
if (accept.includes("text/html")) {
Expand Down
Loading