diff --git a/docs/guide/edge/static-assets.md b/docs/guide/edge/static-assets.md index 29196f3fe..09b8788cb 100644 --- a/docs/guide/edge/static-assets.md +++ b/docs/guide/edge/static-assets.md @@ -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. diff --git a/packages/void/src/asset-routing.ts b/packages/void/src/asset-routing.ts new file mode 100644 index 000000000..e88a1e4b1 --- /dev/null +++ b/packages/void/src/asset-routing.ts @@ -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>): 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, + }; +} diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index cb5239d7a..5f506ae6c 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -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 { @@ -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 ( @@ -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 @@ -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. diff --git a/packages/void/src/cli/types.ts b/packages/void/src/cli/types.ts index 059e07bea..56d6761c9 100644 --- a/packages/void/src/cli/types.ts +++ b/packages/void/src/cli/types.ts @@ -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; }; export type WebSocketRouteMeta = { diff --git a/packages/void/src/index.ts b/packages/void/src/index.ts index a4771a181..b14384360 100644 --- a/packages/void/src/index.ts +++ b/packages/void/src/index.ts @@ -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'; @@ -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'; @@ -860,6 +866,15 @@ export function voidPlugin(options?: { function mergeBindings(resolved: WorkerConfig): Partial { const result: Record = { ...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).not_found_handling; + delete (resolved.assets as Record).run_worker_first; + } + // D1 — auth requires the app DB only for sqlite apps const d1: Array> = []; if ( @@ -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'); @@ -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(), @@ -1527,6 +1559,7 @@ export function voidPlugin(options?: { sandboxBindingName: sandboxConfig?.binding, sandboxClassName: sandboxConfig?.className, live: liveConfig, + onDevAssetRoutingPolicyChange: updateDevAssetRoutingPolicy, }), drizzlePlugin(root, true, dialect, { forceVirtualDb: authEnabled, diff --git a/packages/void/src/plugin-routing.ts b/packages/void/src/plugin-routing.ts index cf551a537..df15bb3e1 100644 --- a/packages/void/src/plugin-routing.ts +++ b/packages/void/src/plugin-routing.ts @@ -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'; @@ -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 { @@ -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, @@ -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(); diff --git a/packages/void/src/router/compile.ts b/packages/void/src/router/compile.ts index 500aaa0de..59c9e97eb 100644 --- a/packages/void/src/router/compile.ts +++ b/packages/void/src/router/compile.ts @@ -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")) { diff --git a/packages/void/src/router/scan.ts b/packages/void/src/router/scan.ts index 9177cbd6f..f062964bc 100644 --- a/packages/void/src/router/scan.ts +++ b/packages/void/src/router/scan.ts @@ -1,5 +1,5 @@ import { join } from 'pathe'; -import { glob } from 'tinyglobby'; +import { glob, globSync } from 'tinyglobby'; import { scanJobs, type JobDefinition } from '../crons/scan.ts'; import { scanPages } from '../pages/scan.ts'; import type { PageScanResult } from '../pages/types.ts'; @@ -29,6 +29,21 @@ export type MiddlewareDefinition = { }; const HANDLER_EXTENSIONS = ['ts', 'mts', 'js', 'mjs']; +const ROUTE_PATTERN = `**/*.{${HANDLER_EXTENSIONS.join(',')}}`; + +function parseMiddlewareDefinitions(files: Array): Array { + return files + .filter((f) => !f.startsWith('_')) + .map((f) => { + const basename = f.split('/').pop()!; + const match = basename.match(/^(\d+)\./); + return { + order: match ? parseInt(match[1], 10) : Infinity, + filePath: f, + }; + }) + .sort((a, b) => a.order - b.order); +} /** * Scan routes/ and middleware/ directories and return a structured manifest. @@ -38,12 +53,12 @@ export async function scanRoutes( root: string, options?: { output?: 'server' | 'static' }, ): Promise { - const pattern = `**/*.{${HANDLER_EXTENSIONS.join(',')}}`; - const [routeFiles, websockets, middlewareFiles, jobs, queues, pages] = await Promise.all([ - glob(pattern, { cwd: `${root}/routes`, absolute: false }), + glob(ROUTE_PATTERN, { cwd: `${root}/routes`, absolute: false }), scanWebSocketRoutes(root), - glob(pattern, { cwd: `${root}/middleware`, absolute: false }).catch(() => [] as Array), + glob(ROUTE_PATTERN, { cwd: `${root}/middleware`, absolute: false }).catch( + () => [] as Array, + ), scanJobs(root), scanQueues(root), scanPages(root, { output: options?.output }), @@ -69,17 +84,38 @@ export async function scanRoutes( }); // Parse middleware, sorted by numeric prefix - const middleware = middlewareFiles - .filter((f) => !f.startsWith('_')) - .map((f) => { - const basename = f.split('/').pop()!; - const match = basename.match(/^(\d+)\./); - return { - order: match ? parseInt(match[1], 10) : Infinity, - filePath: f, - }; - }) - .sort((a, b) => a.order - b.order); + const middleware = parseMiddlewareDefinitions(middlewareFiles); return { routes, websockets, middleware, jobs, queues, pages }; } + +export function scanMiddlewareSync(root: string): Array { + let middlewareFiles: Array; + try { + middlewareFiles = globSync(ROUTE_PATTERN, { cwd: `${root}/middleware`, absolute: false }); + } catch { + middlewareFiles = []; + } + + return parseMiddlewareDefinitions(middlewareFiles); +} + +export function scanRoutePatternsSync(root: string): Array { + let routeFiles: Array; + try { + routeFiles = globSync(ROUTE_PATTERN, { cwd: `${root}/routes`, absolute: false }); + } catch { + routeFiles = []; + } + + return routeFiles + .filter((f) => !isWebSocketPatternFile(f)) + .filter((f) => !f.split('/').some((s) => s.startsWith('_'))) + .map((f) => parseRouteFilename(f)) + .sort((a, b) => { + if (a.catchAll !== b.catchAll) { + return a.catchAll ? 1 : -1; + } + return a.pattern.localeCompare(b.pattern); + }); +} diff --git a/packages/void/test/integration/spa-middleware-auth.test.ts b/packages/void/test/integration/spa-middleware-auth.test.ts new file mode 100644 index 000000000..9b144bf60 --- /dev/null +++ b/packages/void/test/integration/spa-middleware-auth.test.ts @@ -0,0 +1,225 @@ +import { execSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import spawn from 'cross-spawn'; +import { join } from 'pathe'; +import { afterEach, describe, expect, it } from 'vite-plus/test'; +import { getPort, killChild, waitForServer } from '../../../../test/integration-helpers.ts'; + +const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); +const PLAYGROUND = join(ROOT, 'playground/spa'); +const PLAYGROUND_FILTER = './playground/spa'; +const AUTH_MIDDLEWARE = join(PLAYGROUND, 'middleware/00.basic-auth-regression.ts'); +const DOCUMENT_ROUTE = join(PLAYGROUND, 'routes/dashboard.ts'); +const MIDDLEWARE_DIR = join(PLAYGROUND, 'middleware'); +const MIDDLEWARE_BACKUP_DIR = join(PLAYGROUND, `.middleware.e2e-backup-${process.pid}`); +const PUBLIC_DIR = join(PLAYGROUND, 'public'); +const PROTECTED_ASSET = join(PUBLIC_DIR, 'void-basic-auth-regression.txt'); +const WRANGLER_CONFIG = join(PLAYGROUND, 'wrangler.jsonc'); +const DIST = join(PLAYGROUND, 'dist'); +const AUTH_HEADER = 'Basic Z3Vlc3Q6d2VkZGluZw=='; + +const middlewareSource = `import { defineMiddleware } from 'void'; + +export default defineMiddleware(async (c, next) => { + if (c.req.raw.headers.get('Authorization') !== '${AUTH_HEADER}') { + return new Response('Authentication required', { + status: 401, + headers: { + 'Cache-Control': 'no-store', + 'WWW-Authenticate': 'Basic realm="Preview", charset="UTF-8"', + }, + }); + } + + await next(); +}); +`; + +const documentRouteSource = `export const GET = () => + new Response('document route response', { + headers: { 'Content-Type': 'text/plain' }, + }); +`; + +const staleWranglerSource = `{ + "assets": { + "binding": "ASSETS", + "not_found_handling": "none", + "run_worker_first": ["/", "/*.html"] + } +} +`; + +let child: ReturnType | undefined; +let stderr = ''; +let previousWrangler: string | undefined; +let middlewareBackedUp = false; +let publicDirCreated = false; + +function saveWranglerConfig(): void { + previousWrangler = existsSync(WRANGLER_CONFIG) + ? readFileSync(WRANGLER_CONFIG, 'utf-8') + : undefined; +} + +function restoreWranglerConfig(): void { + if (previousWrangler === undefined) { + rmSync(WRANGLER_CONFIG, { force: true }); + } else { + writeFileSync(WRANGLER_CONFIG, previousWrangler); + } + previousWrangler = undefined; +} + +function disableMiddlewareDir(): void { + rmSync(MIDDLEWARE_BACKUP_DIR, { recursive: true, force: true }); + if (existsSync(MIDDLEWARE_DIR)) { + renameSync(MIDDLEWARE_DIR, MIDDLEWARE_BACKUP_DIR); + middlewareBackedUp = true; + } +} + +function restoreMiddlewareDir(): void { + if (!middlewareBackedUp) { + return; + } + rmSync(MIDDLEWARE_DIR, { recursive: true, force: true }); + if (existsSync(MIDDLEWARE_BACKUP_DIR)) { + renameSync(MIDDLEWARE_BACKUP_DIR, MIDDLEWARE_DIR); + } + middlewareBackedUp = false; +} + +function writeProtectedAsset(): void { + if (!existsSync(PUBLIC_DIR)) { + mkdirSync(PUBLIC_DIR); + publicDirCreated = true; + } + writeFileSync(PROTECTED_ASSET, 'protected static asset'); +} + +async function startSpaDevServer(): Promise { + const port = await getPort(); + const base = `http://127.0.0.1:${port}`; + stderr = ''; + child = spawn( + 'vp', + ['run', '--filter', PLAYGROUND_FILTER, 'dev', '--host', '127.0.0.1', '--port', String(port)], + { + cwd: ROOT, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + VOID_INSPECTOR_PORT: 'false', + }, + }, + ); + + child.stderr!.on('data', (chunk: Buffer) => { + stderr += String(chunk); + }); + + await waitForServer(base, 30_000, { + isAlive: () => child?.exitCode === null && child.signalCode === null, + }); + return base; +} + +function readBuiltAssetConfig(): { + not_found_handling?: string; + run_worker_first?: boolean | Array; +} { + const wranglerConfig = JSON.parse(readFileSync(join(DIST, 'ssr/wrangler.json'), 'utf-8')) as { + assets?: { + not_found_handling?: string; + run_worker_first?: boolean | Array; + }; + }; + return wranglerConfig.assets ?? {}; +} + +afterEach(async () => { + if (child) { + await killChild(child); + child = undefined; + } + rmSync(DOCUMENT_ROUTE, { force: true }); + restoreMiddlewareDir(); + rmSync(AUTH_MIDDLEWARE, { force: true }); + rmSync(PROTECTED_ASSET, { force: true }); + if (publicDirCreated) { + rmSync(PUBLIC_DIR, { recursive: true, force: true }); + publicDirCreated = false; + } + restoreWranglerConfig(); + if (stderr.includes('error when starting dev server')) { + throw new Error(stderr); + } +}); + +describe('SPA asset routing policy', () => { + it('keeps API-only projects on static SPA fallback even with stale wrangler asset rules', () => { + saveWranglerConfig(); + disableMiddlewareDir(); + writeFileSync(WRANGLER_CONFIG, staleWranglerSource); + rmSync(DIST, { recursive: true, force: true }); + + execSync(`vp run --filter ${PLAYGROUND_FILTER} build`, { cwd: ROOT, stdio: 'pipe' }); + + const assetConfig = readBuiltAssetConfig(); + expect(assetConfig.not_found_handling).toBe('single-page-application'); + expect(assetConfig.run_worker_first).toBeUndefined(); + }); + + it('routes document routes through the worker before SPA fallback HTML', async () => { + saveWranglerConfig(); + disableMiddlewareDir(); + writeFileSync(WRANGLER_CONFIG, staleWranglerSource); + writeFileSync(DOCUMENT_ROUTE, documentRouteSource); + + const base = await startSpaDevServer(); + const response = await fetch(`${base}/dashboard`, { + headers: { Accept: 'text/html' }, + }); + + expect(response.status).toBe(200); + expect(await response.text()).toBe('document route response'); + }); + + it('runs middleware before serving SPA fallback HTML on nested navigations', async () => { + saveWranglerConfig(); + writeFileSync(AUTH_MIDDLEWARE, middlewareSource); + writeFileSync(WRANGLER_CONFIG, staleWranglerSource); + writeProtectedAsset(); + + const base = await startSpaDevServer(); + const unauthenticatedAsset = await fetch(`${base}/void-basic-auth-regression.txt`); + expect(unauthenticatedAsset.status).toBe(401); + expect(await unauthenticatedAsset.text()).toBe('Authentication required'); + + const authenticatedAsset = await fetch(`${base}/void-basic-auth-regression.txt`, { + headers: { Authorization: AUTH_HEADER }, + }); + expect(authenticatedAsset.status).toBe(200); + expect(await authenticatedAsset.text()).toBe('protected static asset'); + + const unauthenticated = await fetch(`${base}/nested/spa/path`, { + headers: { Accept: 'text/html' }, + }); + expect(unauthenticated.status).toBe(401); + expect(unauthenticated.headers.get('www-authenticate')).toBe( + 'Basic realm="Preview", charset="UTF-8"', + ); + expect(await unauthenticated.text()).toBe('Authentication required'); + + const authenticated = await fetch(`${base}/nested/spa/path`, { + headers: { + Accept: 'text/html', + Authorization: AUTH_HEADER, + }, + }); + expect(authenticated.status).toBe(200); + expect(authenticated.headers.get('content-type')).toContain('text/html'); + expect(await authenticated.text()).toContain('Vite Cloud Playground'); + }); +}); diff --git a/packages/void/test/unit/deploy-asset-config.test.ts b/packages/void/test/unit/deploy-asset-config.test.ts index d7a902fc4..1c783a475 100644 --- a/packages/void/test/unit/deploy-asset-config.test.ts +++ b/packages/void/test/unit/deploy-asset-config.test.ts @@ -1,37 +1,73 @@ import { describe, expect, it } from 'vite-plus/test'; -import { resolveAssetConfig } from '../../src/cli/deploy.ts'; +import { + hasDocumentRoutes, + resolveAssetConfig, + resolveAssetRoutingPolicy, + workerOwnsHtmlRouting, +} from '../../src/asset-routing.ts'; // `not_found_handling: 'none'` → asset binding 404s on unmatched paths, // worker (run_worker_first) owns HTML responses. // // `not_found_handling: 'single-page-application'` → CF shortcuts browser -// navigations to the SPA shell before the worker runs. Acceptable only for -// pure SPAs with no backend code. +// navigations to the SPA shell before the worker runs. Acceptable when the +// worker does not need to inspect document navigations. describe('resolveAssetConfig', () => { - const NONE = { not_found_handling: 'none' as const }; + const NONE = { not_found_handling: 'none' as const, run_worker_first: ['/**'] }; const SPA = { not_found_handling: 'single-page-application' as const }; it('uses none when SSR is enabled', () => { expect( - resolveAssetConfig({ isSsr: true, hasPages: false, hasRoutes: false, hasMiddleware: false }), + resolveAssetConfig({ + isSsr: true, + hasPages: false, + hasDocumentRoutes: false, + hasMiddleware: false, + }), ).toEqual(NONE); }); it('uses none when pages mode is enabled', () => { expect( - resolveAssetConfig({ isSsr: false, hasPages: true, hasRoutes: false, hasMiddleware: false }), + resolveAssetConfig({ + isSsr: false, + hasPages: true, + hasDocumentRoutes: false, + hasMiddleware: false, + }), ).toEqual(NONE); }); - it('uses none when the project has routes/', () => { + it('keeps SPA handling when the project only has API routes', () => { + expect( + resolveAssetConfig({ + isSsr: false, + hasPages: false, + hasDocumentRoutes: false, + hasMiddleware: false, + }), + ).toEqual(SPA); + }); + + it('uses none when the project has document routes', () => { expect( - resolveAssetConfig({ isSsr: false, hasPages: false, hasRoutes: true, hasMiddleware: false }), + resolveAssetConfig({ + isSsr: false, + hasPages: false, + hasDocumentRoutes: true, + hasMiddleware: false, + }), ).toEqual(NONE); }); it('uses none when the project has middleware/', () => { expect( - resolveAssetConfig({ isSsr: false, hasPages: false, hasRoutes: false, hasMiddleware: true }), + resolveAssetConfig({ + isSsr: false, + hasPages: false, + hasDocumentRoutes: false, + hasMiddleware: true, + }), ).toEqual(NONE); }); @@ -40,7 +76,7 @@ describe('resolveAssetConfig', () => { resolveAssetConfig({ isSsr: false, hasPages: false, - hasRoutes: false, + hasDocumentRoutes: false, hasMiddleware: false, hasWebSockets: true, }), @@ -52,7 +88,7 @@ describe('resolveAssetConfig', () => { resolveAssetConfig({ isSsr: false, hasPages: false, - hasRoutes: false, + hasDocumentRoutes: false, hasMiddleware: false, authEnabled: true, }), @@ -65,13 +101,100 @@ describe('resolveAssetConfig', () => { // with Sec-Fetch-Mode: navigate, get the SPA shell, and never reach // Better Auth — leaving the user "signed in" without a session cookie. expect( - resolveAssetConfig({ isSsr: false, hasPages: false, hasRoutes: true, hasMiddleware: true }), + resolveAssetConfig({ + isSsr: false, + hasPages: false, + hasDocumentRoutes: false, + hasMiddleware: true, + }), ).toEqual(NONE); }); - it('uses single-page-application only when no backend code is present', () => { + it('uses none when live bindings are enabled', () => { expect( - resolveAssetConfig({ isSsr: false, hasPages: false, hasRoutes: false, hasMiddleware: false }), + resolveAssetConfig({ + isSsr: false, + hasPages: false, + hasDocumentRoutes: false, + hasMiddleware: false, + liveEnabled: true, + }), + ).toEqual(NONE); + }); + + it('uses single-page-application when no HTML-owning backend code is present', () => { + expect( + resolveAssetConfig({ + isSsr: false, + hasPages: false, + hasDocumentRoutes: false, + hasMiddleware: false, + }), ).toEqual(SPA); }); + + it('classifies /api routes separately from document routes', () => { + expect( + hasDocumentRoutes([ + { pattern: '/api' }, + { pattern: '/api/hello' }, + { pattern: '/api/users/:id' }, + ]), + ).toBe(false); + + expect(hasDocumentRoutes([{ pattern: '/hello' }])).toBe(true); + expect(hasDocumentRoutes([{ pattern: '/apiish' }])).toBe(true); + }); + + it('uses the same worker-owned HTML decision as the asset config', () => { + const inputs = { + isSsr: false, + hasPages: false, + hasDocumentRoutes: true, + hasMiddleware: false, + }; + expect(workerOwnsHtmlRouting(inputs)).toBe(true); + expect(resolveAssetConfig(inputs)).toEqual(NONE); + }); + + it('resolves the complete request trigger policy', () => { + expect( + resolveAssetRoutingPolicy({ + isSsr: false, + hasPages: false, + hasDocumentRoutes: false, + hasMiddleware: false, + }), + ).toEqual({ + assetConfig: SPA, + workerFirst: false, + workerSpaFallback: false, + }); + + expect( + resolveAssetRoutingPolicy({ + isSsr: false, + hasPages: false, + hasDocumentRoutes: false, + hasMiddleware: true, + }), + ).toEqual({ + assetConfig: NONE, + workerFirst: true, + workerSpaFallback: true, + }); + + expect( + resolveAssetRoutingPolicy({ + isSsr: true, + hasPages: false, + hasDocumentRoutes: false, + hasMiddleware: false, + }), + ).toEqual({ + assetConfig: NONE, + workerFirst: true, + workerSpaFallback: false, + }); + }); }); 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 8ff62385e..66374b81a 100644 --- a/packages/void/test/unit/plugin-dev-asset-routing.test.ts +++ b/packages/void/test/unit/plugin-dev-asset-routing.test.ts @@ -27,14 +27,9 @@ vi.mock('@cloudflare/vite-plugin', () => ({ const tempDirs: Array = []; -function createPagesProjectRoot(): string { +function createProjectRoot(): string { const root = mkdtempSync(join(tmpdir(), 'void-dev-assets-')); tempDirs.push(root); - mkdirSync(join(root, 'pages'), { recursive: true }); - writeFileSync( - join(root, 'pages', 'index.tsx'), - 'export default function Page() { return null; }', - ); writeFileSync(join(root, 'package.json'), JSON.stringify({ type: 'module' })); writeFileSync( join(root, 'void.json'), @@ -43,6 +38,30 @@ function createPagesProjectRoot(): string { return root; } +function createPagesProjectRoot(): string { + const root = createProjectRoot(); + mkdirSync(join(root, 'pages'), { recursive: true }); + writeFileSync( + join(root, 'pages', 'index.tsx'), + 'export default function Page() { return null; }', + ); + return root; +} + +function createApiOnlyProjectRoot(): string { + const root = createProjectRoot(); + mkdirSync(join(root, 'routes/api'), { recursive: true }); + writeFileSync(join(root, 'routes/api/hello.ts'), 'export const GET = () => Response.json({});'); + return root; +} + +function createDocumentRouteProjectRoot(): string { + const root = createProjectRoot(); + mkdirSync(join(root, 'routes'), { recursive: true }); + writeFileSync(join(root, 'routes/hello.ts'), "export const GET = () => new Response('hello');"); + return root; +} + function getHookHandler(hook: T | { handler: T } | undefined): T { if (typeof hook === 'function') { return hook; @@ -62,8 +81,10 @@ afterEach(() => { }); describe('voidPlugin dev asset routing', () => { - it('keeps page requests on the worker while letting Vite internals bypass it', async () => { - const root = createPagesProjectRoot(); + async function resolveVoidPluginAssets( + root: string, + resolved: Record = {}, + ): Promise<{ not_found_handling?: string; run_worker_first?: Array }> { vi.spyOn(process, 'cwd').mockReturnValue(root); const { voidPlugin } = await import('../../src/index.ts'); @@ -83,9 +104,57 @@ describe('voidPlugin dev asset routing', () => { throw new Error('Expected mocked Cloudflare plugin.'); } - const merged = cloudflarePlugin.api.pluginConfig.config({}); - const assets = merged.assets as { run_worker_first?: Array } | undefined; + const merged = cloudflarePlugin.api.pluginConfig.config(resolved); + return merged.assets as { not_found_handling?: string; run_worker_first?: Array }; + } + + it('keeps page requests on the worker while letting Vite internals bypass it', async () => { + const root = createPagesProjectRoot(); + const assets = await resolveVoidPluginAssets(root); + + expect(assets?.run_worker_first).toEqual([ + '!/@vite/*', + '!/@id/*', + '!/@fs/*', + '!/@react-refresh', + '!/@svelte/*', + '!/node_modules/*', + '!/__uno.css', + '/**', + ]); + expect(assets?.not_found_handling).toBe('none'); + }); + + it('keeps API-only projects on the static SPA fallback and clears stale worker-first rules', async () => { + const root = createApiOnlyProjectRoot(); + const assets = await resolveVoidPluginAssets(root, { + assets: { + binding: 'ASSETS', + not_found_handling: 'none', + run_worker_first: ['/', '/*.html'], + }, + }); + + expect(assets?.not_found_handling).toBe('single-page-application'); + expect(assets?.run_worker_first).toBeUndefined(); + }); + + it('keeps API-only projects static-first when middleware directory has no active files', async () => { + const root = createApiOnlyProjectRoot(); + mkdirSync(join(root, 'middleware'), { recursive: true }); + writeFileSync(join(root, 'middleware', '_ignored.ts'), 'export default () => undefined;'); + + const assets = await resolveVoidPluginAssets(root); + + expect(assets?.not_found_handling).toBe('single-page-application'); + expect(assets?.run_worker_first).toBeUndefined(); + }); + + it('routes document routes through the worker before SPA fallback HTML', async () => { + const root = createDocumentRouteProjectRoot(); + const assets = await resolveVoidPluginAssets(root); + expect(assets?.not_found_handling).toBe('none'); expect(assets?.run_worker_first).toEqual([ '!/@vite/*', '!/@id/*', diff --git a/packages/void/test/unit/plugin-routing.test.ts b/packages/void/test/unit/plugin-routing.test.ts index 22302a107..58d5cb978 100644 --- a/packages/void/test/unit/plugin-routing.test.ts +++ b/packages/void/test/unit/plugin-routing.test.ts @@ -1,7 +1,7 @@ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'pathe'; -import { afterEach, describe, expect, it } from 'vite-plus/test'; +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { routingPlugin } from '../../src/plugin-routing.ts'; const tempDirs: Array = []; @@ -82,4 +82,52 @@ describe('routingPlugin', () => { expect(entry).toContain('"/index.html"'); expect(entry).toContain('accept.includes("text/html")'); }); + + it('restarts dev when a structural route change changes the asset routing policy', async () => { + const root = createTempRoot(); + writeFileSync(join(root, 'routes', 'api.ts'), 'export const GET = () => new Response();\n'); + + const onPolicyChange = vi.fn(() => true); + const plugin = routingPlugin(root, { + isAuthEnabled: () => false, + onDevAssetRoutingPolicyChange: onPolicyChange, + }); + const configResolved = getHookHandler(plugin.configResolved) as ( + config: import('vite').ResolvedConfig, + ) => void; + const buildStart = getHookHandler(plugin.buildStart) as () => Promise; + const configureServer = getHookHandler(plugin.configureServer) as (server: unknown) => void; + + configResolved({ + build: { assetsDir: 'assets' }, + command: 'serve', + mode: 'development', + plugins: [], + } as unknown as import('vite').ResolvedConfig); + await buildStart.call({} as never); + + const handlers: Record Promise> = {}; + const restart = vi.fn(async () => {}); + configureServer({ + watcher: { + on: vi.fn((event: string, handler: (path: string) => Promise) => { + handlers[event] = handler; + }), + }, + environments: {}, + restart, + }); + + const documentRoute = join(root, 'routes', 'dashboard.ts'); + writeFileSync(documentRoute, 'export const GET = () => new Response("dashboard");\n'); + await handlers.add(documentRoute); + + expect(onPolicyChange).toHaveBeenCalledWith( + expect.objectContaining({ + assetConfig: expect.objectContaining({ not_found_handling: 'none' }), + workerFirst: true, + }), + ); + expect(restart).toHaveBeenCalledTimes(1); + }); });