diff --git a/docs/guide/auth.md b/docs/guide/auth.md index 1b8829fe6..e66866cf2 100644 --- a/docs/guide/auth.md +++ b/docs/guide/auth.md @@ -129,6 +129,8 @@ Local development needs no secret setup. To preview a production build locally, Responses to signed-in requests get `Cache-Control: no-store`, so neither the browser cache nor a shared cache keeps a user's data. A route keeps its own policy when it sets `no-store`, or `private` together with `no-cache`. To answer repeat requests with `304`, use `private, no-cache` with Hono's [`etag()`](https://hono.dev/docs/middleware/builtin/etag) middleware. If you set the `ETag` yourself, make it differ per user, so the `304` doesn't reuse another account's page. The browser cache keeps a copy of a `private, no-cache` page, so the Back button can show it again after the user signs out. Keep the default `no-store` for pages that must not stay in the browser cache. +Static files from your build and `public/` keep their own cache policy for signed-in requests, so the browser reuses cached JavaScript and CSS. When your app handles the request, the policy is marked `private`, and a request that refreshes the session cookie gets `no-store`. + `POST`, `PUT`, `PATCH` and `DELETE` requests and WebSocket connections from another origin don't use the signed-in session unless that origin is in `trustedOrigins`. If a separate frontend calls your app, add its origin there (see [Customization](#customization)). If your routes use their own cookies, check the request `Origin` yourself, for example with Hono's [`csrf()`](https://hono.dev/docs/middleware/builtin/csrf) middleware. Deployed apps on Cloudflare limit auth requests per client IP with Better Auth's defaults, for example 3 sign-in attempts per 10 seconds. Each Worker instance keeps its own count. Change or turn off the limits with `rateLimit` in `auth.ts`. To share one count across instances, set `rateLimit: { storage: 'database' }`, then run `void db generate` for a direct Cloudflare deploy. diff --git a/docs/guide/edge/static-assets.md b/docs/guide/edge/static-assets.md index aa90a73c2..bb697b886 100644 --- a/docs/guide/edge/static-assets.md +++ b/docs/guide/edge/static-assets.md @@ -37,6 +37,16 @@ With `void deploy --platform cloudflare`, generated JavaScript bundles with cont Use [Custom Headers](./headers) to choose a cache policy for other files. Apply immutable caching only to URLs that change whenever their content changes. +Cloudflare serves these fingerprinted JavaScript bundles without starting your Worker, so they do not count as Worker requests, when your app: + +- has no global middleware +- has no header, redirect, rewrite or fallback rules, and no `routing.notFound` setting +- uses the default `base` of `/` +- has no route or page that matches paths in the assets directory (`assets/` by default) +- builds at most 99 fingerprinted bundles + +Otherwise these bundles go through your Worker and keep the same caching. + ## Non-hashed assets Other static files, such as `index.html` and `favicon.ico`, are cached until the next deploy. You do not need to purge them manually. diff --git a/packages/void/src/asset-worker-bypass.ts b/packages/void/src/asset-worker-bypass.ts new file mode 100644 index 000000000..356285048 --- /dev/null +++ b/packages/void/src/asset-worker-bypass.ts @@ -0,0 +1,200 @@ +/** + * Build-time decision for letting Cloudflare serve fingerprinted build files + * without starting the Worker. Native Cloudflare builds of apps with no global + * middleware and no edge rules gain nothing from running the Worker for those + * files: the Worker would only copy the asset response. Skipping it makes + * those requests free static requests and keeps auth and its session read off + * them. Each file gets its own negative `run_worker_first` rule, because a + * request matching a negative rule never reaches the Worker, even when the + * file is missing. Misses and other paths in the folder keep the Worker's + * not-found, SPA and page handling. + * + * The asset layer does not know which files are fingerprinted, so a generated + * `_headers` file gives every bypassed chunk the immutable policy the Worker + * would have set. + */ + +export const GENERATED_ASSET_HEADERS_MARKER = + '# Generated by Void: immutable caching for fingerprinted build files. Do not edit.'; +/** Cloudflare accepts at most 100 `run_worker_first` rules, `/**` included. */ +const MAX_BYPASS_FILES = 99; +/** Cloudflare limits each `run_worker_first` rule to 100 characters. */ +const MAX_RUN_WORKER_FIRST_RULE_LENGTH = 100; + +const IMMUTABLE_CACHE_CONTROL = 'public, max-age=31536000, immutable'; +// Void's own Worker paths. They are not in the route scan. +const INTERNAL_PATTERNS = ['/__void/*']; +const AUTH_PATTERNS = ['/api/auth/*']; + +export type AssetWorkerBypassFacts = { + /** `assets.run_worker_first` from the built Worker config. */ + runWorkerFirst: unknown; + /** `assets.not_found_handling` from the built Worker config. */ + notFoundHandling: unknown; + /** Vite's resolved `base`. */ + base: string; + hasMiddleware: boolean; + authEnabled: boolean; + headerRuleCount: number; + redirectRuleCount: number; + fallbackRuleCount: number; + /** Whether `_headers` or `_redirects` exists in Vite's `publicDir`. */ + publicEdgeFiles: boolean; + /** Every route, page, WebSocket, prerender and revalidate pattern. */ + patterns: Array; + /** Vite's `build.assetsDir`. */ + assetsDir: string; + /** Fingerprinted files the manifest marks immutable, relative to the client root. */ + immutableFiles: Array; +}; + +export type AssetWorkerBypass = { + /** The new `assets.run_worker_first`. */ + runWorkerFirst: Array; + /** The generated `_headers` text. */ + headers: string; +}; + +/** Trim surrounding slashes; null when nothing is left. */ +export function normalizeAssetsDir(dir: string): string | null { + let start = 0; + let end = dir.length; + while (start < end && dir[start] === '/') { + start++; + } + while (end > start && dir[end - 1] === '/') { + end--; + } + const trimmed = dir.slice(start, end); + return trimmed === '' || trimmed === '.' ? null : trimmed; +} + +/** + * Whether a route pattern can match a path below `//`. Parameters, + * wildcards and patterns with more segments than the directory count as a + * match, so the answer errs towards keeping the Worker in front. + */ +export function patternCanReachUnder(pattern: string, dir: string): boolean { + const patternSegments = (pattern.startsWith('/') ? pattern.slice(1) : pattern).split('/'); + const dirSegments = (dir.startsWith('/') ? dir.slice(1) : dir).split('/'); + for (let i = 0; i < dirSegments.length; i++) { + const segment = patternSegments[i]; + if (segment === undefined) { + return false; + } + if (segment.includes('*') || segment.includes('{')) { + return true; + } + if (segment.startsWith(':')) { + continue; + } + if (segment.toLowerCase() !== dirSegments[i].toLowerCase()) { + return false; + } + } + return patternSegments.length > dirSegments.length; +} + +/** + * Characters that are plain text in both a `_headers` path and a + * `run_worker_first` rule. `*`, `:` and `!` have meaning there, and anything + * that needs URL encoding would not match the request path as written. + */ +function isPlainPath(path: string): boolean { + for (const char of path) { + const plain = + (char >= 'a' && char <= 'z') || + (char >= 'A' && char <= 'Z') || + (char >= '0' && char <= '9') || + char === '-' || + char === '_' || + char === '.' || + char === '~' || + char === '/'; + if (!plain) { + return false; + } + } + return path.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..'); +} + +/** + * Whether `run_worker_first` is the rule an earlier bypass wrote. A watch + * rebuild of only the client still finds it, because the Worker build that + * would write `['/**']` again did not run. + */ +export function isAssetBypassRule(runWorkerFirst: unknown, assetsDir: string): boolean { + const dir = normalizeAssetsDir(assetsDir); + return ( + dir !== null && + Array.isArray(runWorkerFirst) && + runWorkerFirst.length > 1 && + runWorkerFirst[0] === '/**' && + runWorkerFirst + .slice(1) + .every((rule) => typeof rule === 'string' && rule.startsWith('!/' + dir + '/')) + ); +} + +/** The bypass this build can use, or null when any rule keeps the Worker in front. */ +export function resolveAssetWorkerBypass(facts: AssetWorkerBypassFacts): AssetWorkerBypass | null { + const { runWorkerFirst } = facts; + const allPaths = + (Array.isArray(runWorkerFirst) && runWorkerFirst.length === 1 && runWorkerFirst[0] === '/**') || + isAssetBypassRule(runWorkerFirst, facts.assetsDir); + if ( + !allPaths || + facts.notFoundHandling !== 'none' || + facts.base !== '/' || + facts.hasMiddleware || + facts.headerRuleCount > 0 || + facts.redirectRuleCount > 0 || + facts.fallbackRuleCount > 0 || + facts.publicEdgeFiles + ) { + return null; + } + const assetsDir = normalizeAssetsDir(facts.assetsDir); + if (!assetsDir || !isPlainPath(assetsDir)) { + return null; + } + const patterns = [ + ...facts.patterns, + ...INTERNAL_PATTERNS, + ...(facts.authEnabled ? AUTH_PATTERNS : []), + ]; + if (patterns.some((pattern) => patternCanReachUnder(pattern, assetsDir))) { + return null; + } + const prefix = assetsDir + '/'; + const files = [...new Set(facts.immutableFiles)].filter((file) => file.startsWith(prefix)).sort(); + const rules = files.map((file) => '!/' + file); + if ( + files.length === 0 || + files.length > MAX_BYPASS_FILES || + files.some((file) => !isPlainPath(file)) || + rules.some((rule) => rule.length > MAX_RUN_WORKER_FIRST_RULE_LENGTH) + ) { + return null; + } + return { runWorkerFirst: ['/**', ...rules], headers: renderGeneratedAssetHeaders(files) }; +} + +/** One immutable rule per file after the marker line. */ +function renderGeneratedAssetHeaders(files: Array): string { + const lines = [GENERATED_ASSET_HEADERS_MARKER]; + for (const file of files) { + lines.push('/' + file, ' Cache-Control: ' + IMMUTABLE_CACHE_CONTROL); + } + return lines.join('\n') + '\n'; +} + +/** Whether a `_headers` file is the one Void generated. */ +export function isGeneratedAssetHeaders(text: string): boolean { + const newline = text.indexOf('\n'); + let firstLine = newline === -1 ? text : text.slice(0, newline); + if (firstLine.endsWith('\r')) { + firstLine = firstLine.slice(0, -1); + } + return firstLine === GENERATED_ASSET_HEADERS_MARKER; +} diff --git a/packages/void/src/cli/cf-version-api.ts b/packages/void/src/cli/cf-version-api.ts index a16a3c827..ffba8ef2b 100644 --- a/packages/void/src/cli/cf-version-api.ts +++ b/packages/void/src/cli/cf-version-api.ts @@ -17,9 +17,11 @@ import type { CloudflareCommandResult } from '@void/deploy-cloudflare'; import ignore from 'ignore'; import mime from 'mime'; import { extname, join, relative } from 'pathslash'; +import { isGeneratedAssetHeaders } from '../asset-worker-bypass.ts'; import type { CfBuildOutput } from './cf-build-output.ts'; import { record as object } from './cf-config-migrate.ts'; import { getVoidUserAgent } from './cf-user-agent.ts'; +import { Buffer } from './node-buffer.ts'; type Json = Record; export type CfCommand = ( @@ -923,6 +925,27 @@ async function ensureContainerApplications(options: { } } +/** + * The `_headers` file a native build generated for its fingerprinted files. + * This API takes it as a `_headers` module, as cf does on its JSON version + * uploads. A user-authored `_headers` is not sent on this path. + */ +function readGeneratedAssetHeaders(output: CfBuildOutput): string | undefined { + if (!output.assetsDirectory) { + return undefined; + } + let text: string; + try { + text = readFileSync(join(getWorkerAssetsDir(output.directory), '_headers'), 'utf8'); + } catch (cause) { + if (cause instanceof Error && 'code' in cause && cause.code === 'ENOENT') { + return undefined; + } + throw cause; + } + return isGeneratedAssetHeaders(text) ? text : undefined; +} + async function uploadAssets(options: { output: CfBuildOutput; accountUrl: string; @@ -1162,6 +1185,14 @@ export async function uploadPinnedCloudflareVersion(options: { ).toString('base64'), }); } + const generatedHeaders = readGeneratedAssetHeaders(options.output); + if (generatedHeaders !== undefined) { + modules.push({ + name: '_headers', + content_type: 'text/plain', + content_base64: Buffer.from(generatedHeaders).toString('base64'), + }); + } await preflightWorkflowExports({ exports, accountUrl, diff --git a/packages/void/src/client-asset-manifest.ts b/packages/void/src/client-asset-manifest.ts index 9a10add2a..183890a80 100644 --- a/packages/void/src/client-asset-manifest.ts +++ b/packages/void/src/client-asset-manifest.ts @@ -3,15 +3,18 @@ import type { Rolldown } from 'vite'; import type { ClientManifest } from './runtime/routing-assets.ts'; import type { RoutingHeaderRule } from './runtime/routing.ts'; -/** Attach cache provenance to the existing manifest without changing its entry inventory. */ +/** + * Attach cache provenance to the existing manifest without changing its entry inventory. + * Returns the stamped immutable files, sorted, relative to the client root. + */ export function stampClientAssetCachePolicy( bundle: Rolldown.OutputBundle, manifestPath: string, headerRules: Array = [], -): void { +): Array { const asset = bundle[manifestPath]; if (asset?.type !== 'asset') { - return; + return []; } const fingerprinted = new Set(); const authoredCachePatterns = headerRules @@ -36,12 +39,15 @@ export function stampClientAssetCachePolicy( } } const manifest = JSON.parse(String(asset.source)) as ClientManifest; + const stamped = new Set(); for (const entry of Object.values(manifest)) { if (entry.file && fingerprinted.has(entry.file)) { entry.immutableFiles = [entry.file]; + stamped.add(entry.file); } else { delete entry.immutableFiles; } } asset.source = JSON.stringify(manifest, null, 2); + return [...stamped].sort(); } diff --git a/packages/void/src/index.ts b/packages/void/src/index.ts index ff44bb337..fdb7af2b8 100644 --- a/packages/void/src/index.ts +++ b/packages/void/src/index.ts @@ -999,6 +999,8 @@ export function voidPlugin(options?: { durableObjects, websocketTombstones, onDevAssetRoutingPolicyChange: updateDevAssetRoutingPolicy, + // Native builds of eligible apps let Cloudflare serve hashed build files directly. + assetWorkerBypass: true, }), drizzlePlugin(root, true, dialect, { d1BindingName: bindingNames?.d1, diff --git a/packages/void/src/plugin-routing.ts b/packages/void/src/plugin-routing.ts index a6f2e6c5f..99d0db701 100644 --- a/packages/void/src/plugin-routing.ts +++ b/packages/void/src/plugin-routing.ts @@ -1,7 +1,7 @@ -import { existsSync, mkdirSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { fileURLToPath } from 'node:url'; -import { dirname, join, relative } from 'pathslash'; +import { dirname, join, relative, resolve } from 'pathslash'; import { normalizePath, type Plugin } from 'vite'; import type { ApplicationAnalysis } from './application-analysis.ts'; import { completeApplicationAnalysis } from './application-routing.ts'; @@ -11,7 +11,13 @@ import { resolveAssetRoutingPolicy, type AssetRoutingPolicy, } from './asset-routing.ts'; +import { + isAssetBypassRule, + isGeneratedAssetHeaders, + resolveAssetWorkerBypass, +} from './asset-worker-bypass.ts'; import { loadVoidAuthConfig } from './auth/config.ts'; +import { resolveDistDir, resolveWorkerDirName } from './cli/build-output-dir.ts'; import { lintRoutingPhases, mergeRoutingRules } from './cli/headers.ts'; import { stampClientAssetCachePolicy } from './client-asset-manifest.ts'; import { findEnvFileForPaths } from './codegen/env-types.ts'; @@ -88,6 +94,12 @@ type RoutingPluginOptions = { /** Data-preserving exports for removed WebSocket Durable Object routes. */ websocketTombstones?: Array; onDevAssetRoutingPolicyChange?: (policy: AssetRoutingPolicy) => boolean; + /** + * Let Cloudflare serve fingerprinted build files without the Worker when the + * app allows it (see `asset-worker-bypass.ts`). Only the native Cloudflare + * app build sets this. + */ + assetWorkerBypass?: boolean; }; /** @@ -374,6 +386,78 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug }); } + // Immutable files stamped into the current client build's manifest. + let clientImmutableFiles: Array = []; + + /** + * The generated `_headers` text when this build can skip the Worker for its + * hashed files, and the new `wrangler.json` text when it changes; null when + * there is no built Worker config. Reads only, so a read failure leaves the + * output unchanged. + */ + function planAssetWorkerBypass( + clientDir: string, + assetsDir: string, + userHeaders: boolean, + ): { headers: string | null; wranglerPath: string; wranglerText: string | null } | null { + if (!scan || !resolvedConfig || compileOptions.managedDispatch) { + return null; + } + let wranglerPath: string; + let wrangler: { assets?: Record }; + try { + const distDir = resolveDistDir(root); + wranglerPath = join(distDir, resolveWorkerDirName(distDir), 'wrangler.json'); + wrangler = JSON.parse(readFileSync(wranglerPath, 'utf8')); + } catch { + return null; + } + const assets = wrangler?.assets; + if ( + !assets || + typeof assets !== 'object' || + typeof assets.directory !== 'string' || + resolve(dirname(wranglerPath), assets.directory) !== clientDir + ) { + return null; + } + const revalidate = options.routing?.revalidate; + const facts = { + runWorkerFirst: assets.run_worker_first, + notFoundHandling: assets.not_found_handling, + base: resolvedConfig.base, + hasMiddleware: scan.middleware.length > 0, + authEnabled: compileOptions.auth !== null, + headerRuleCount: compileOptions.headerRules?.length ?? 0, + redirectRuleCount: compileOptions.redirectRules?.length ?? 0, + fallbackRuleCount: compileOptions.fallbackRules?.length ?? 0, + publicEdgeFiles: + publicDir !== null && + (existsSync(join(publicDir, '_headers')) || existsSync(join(publicDir, '_redirects'))), + patterns: [ + ...scan.routes.map((route) => route.pattern), + ...scan.pages.pages.map((page) => page.pattern), + ...(scan.websockets ?? []).map((route) => route.pattern), + ...(options.prerenderPaths ?? []).filter((path) => typeof path === 'string'), + ...(revalidate && typeof revalidate === 'object' ? Object.keys(revalidate) : []), + ], + assetsDir, + immutableFiles: clientImmutableFiles, + }; + const bypass = userHeaders ? null : resolveAssetWorkerBypass(facts); + // A client-only watch rebuild finds the rule an earlier bypass wrote. + const runWorkerFirst = + bypass?.runWorkerFirst ?? + (isAssetBypassRule(assets.run_worker_first, assetsDir) ? ['/**'] : null); + return { + headers: bypass?.headers ?? null, + wranglerPath, + wranglerText: + runWorkerFirst && + JSON.stringify({ ...wrangler, assets: { ...assets, run_worker_first: runWorkerFirst } }), + }; + } + const sandboxEntry = options.hasSandbox ? { bindingName: options.sandboxBindingName, @@ -519,16 +603,57 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug order: 'post', handler(_output, bundle) { const manifest = this.environment.config.build.manifest; - if (this.environment.name === 'client' && manifest) { - stampClientAssetCachePolicy( - bundle, - typeof manifest === 'string' ? manifest : '.vite/manifest.json', - compileOptions.headerRules, - ); + if (this.environment.name === 'client') { + clientImmutableFiles = manifest + ? stampClientAssetCachePolicy( + bundle, + typeof manifest === 'string' ? manifest : '.vite/manifest.json', + compileOptions.headerRules, + ) + : []; } }, }, + // Runs after the Cloudflare plugin wrote the Worker's `wrangler.json` and + // before the client `closeBundle` prerender reads it. + writeBundle() { + if ( + !options.assetWorkerBypass || + options.target || + resolvedConfig?.command !== 'build' || + this.environment.name !== 'client' + ) { + return; + } + const { outDir, assetsDir } = this.environment.config.build; + const clientDir = resolve(resolvedConfig.root, outDir); + const headersPath = join(clientDir, '_headers'); + let generatedHeaders = false; + let userHeaders = false; + if (existsSync(headersPath)) { + try { + generatedHeaders = isGeneratedAssetHeaders(readFileSync(headersPath, 'utf8')); + } catch { + return; + } + // A user-authored `_headers` keeps the Worker in front, as before. + userHeaders = !generatedHeaders; + } + const plan = planAssetWorkerBypass(clientDir, assetsDir, userHeaders); + // `_headers` first: on its own it changes nothing, as the Worker sets + // the same policy for these files. Write errors fail the build. + if (plan?.headers) { + writeFileSync(headersPath, plan.headers); + } else if (generatedHeaders) { + // A `_headers` left by an earlier build must not outlive the bypass. + rmSync(headersPath); + } + if (plan?.wranglerText) { + writeFileSync(plan.wranglerPath, plan.wranglerText); + } + }, + async buildStart() { await rescan(); if (options.target && (scan?.websockets?.length ?? 0) > 0) { diff --git a/packages/void/src/runtime/better-auth-shared.ts b/packages/void/src/runtime/better-auth-shared.ts index 652ac862f..e28111fe9 100644 --- a/packages/void/src/runtime/better-auth-shared.ts +++ b/packages/void/src/runtime/better-auth-shared.ts @@ -11,6 +11,7 @@ import { type VoidSocialAuthProvider, } from '../auth/providers.ts'; import { authContext, type AuthState, type VoidAuthConfig } from './auth.ts'; +import { isUnchangedStaticAsset } from './static-asset-marker.ts'; export type VoidAuthDialect = 'sqlite' | 'postgresql' | 'mysql'; @@ -259,11 +260,32 @@ function appendAuthHeaders( c: Context, source: Headers | undefined, includeCachePolicy: boolean, + staticAsset: boolean, ): void { if (!source) { return; } + // A static file's policy belongs to the build output. A credentialed request + // only makes it private, so a shared cache never keeps a copy served through + // a Worker that user middleware may gate, while the browser still reuses it. + if (staticAsset) { + source.forEach((value, key) => { + if (!['cache-control', 'pragma'].includes(key.toLowerCase())) { + c.header(key, value, { append: key.toLowerCase() === 'set-cookie' }); + } + }); + if (includeCachePolicy) { + c.header( + 'Cache-Control', + source.has('set-cookie') + ? 'private, no-store' + : privateCachePolicy(c.res.headers.get('cache-control')), + ); + } + return; + } + // `no-store`, or `private` with `no-cache`, keeps the response out of shared // caches and makes the browser ask before reusing it, which allows ETag + 304 // (the ETag must differ per user). `private` alone lets the browser show the @@ -306,6 +328,49 @@ function bareCacheDirectives(policy: string | null): Set { ); } +/** + * The same policy for one browser only: `public`, `proxy-revalidate`, + * `s-maxage` and a field-scoped `private` give way to a leading `private`. + * A policy that is already private or no-store stays as it is. + */ +export function privateCachePolicy(policy: string | null): string { + const directives = splitCacheDirectives(policy ?? ''); + if (directives.length === 0) { + return 'private, no-store'; + } + const lowered = directives.map((directive) => directive.toLowerCase()); + if (lowered.includes('private') || lowered.includes('no-store')) { + return policy!; + } + const kept = directives.filter((_directive, index) => { + const directive = lowered[index]!; + const equals = directive.indexOf('='); + const name = (equals === -1 ? directive : directive.slice(0, equals)).trim(); + return !['public', 'proxy-revalidate', 's-maxage', 'private'].includes(name); + }); + return ['private', ...kept].join(', '); +} + +// Cache-Control directives split on commas outside quoted values, trimmed. +function splitCacheDirectives(policy: string): Array { + const directives: Array = []; + let start = 0; + let quoted = false; + for (let index = 0; index < policy.length; index++) { + const char = policy[index]; + if (quoted && char === '\\') { + index++; + } else if (char === '"') { + quoted = !quoted; + } else if (char === ',' && !quoted) { + directives.push(policy.slice(start, index)); + start = index + 1; + } + } + directives.push(policy.slice(start)); + return directives.map((directive) => directive.trim()).filter(Boolean); +} + function resolveUserConfig( userConfig: VoidAuthConfig | undefined, defaults: BetterAuthOptions, @@ -565,6 +630,7 @@ export function createVoidAuthRuntime( c, result.headers, state !== null || credentialed || result.headers?.has('set-cookie') === true, + isUnchangedStaticAsset(c), ); } diff --git a/packages/void/src/runtime/routing-pages.ts b/packages/void/src/runtime/routing-pages.ts index 124981c50..8c16fe160 100644 --- a/packages/void/src/runtime/routing-pages.ts +++ b/packages/void/src/runtime/routing-pages.ts @@ -5,6 +5,7 @@ import type { ClientAssetTags } from './routing-assets.ts'; import type { ErrorRenderer } from './routing-errors.ts'; import { createAssetCachePolicy } from './routing-worker.ts'; import type { RoutingRule, RoutingRuntime } from './routing.ts'; +import { markStaticAsset } from './static-asset-marker.ts'; type PageGetArguments = Parameters; type PageModule = { @@ -133,7 +134,7 @@ export function createPagesFallback(options: { ? await assets.fetch(c.req.raw) : options.routing.markNoMatch(c.req.raw, new Response('Not Found', { status: 404 })); if (assetResponse.status !== 404) { - return cachePolicy(c.req.raw, c.env, assetResponse); + return markStaticAsset(c, await cachePolicy(c.req.raw, c.env, assetResponse)); } const fallback = options.fallbackRules ? await options.routing.applyFallback(c, options.fallbackRules) @@ -177,7 +178,7 @@ export function createSsrFallback(options: { const response: Response = await binding.fetch(c.req.raw); return response.status === 404 ? options.routing.markNoMatch(c.req.raw, new Response('Not Found', { status: 404 })) - : cachePolicy(c.req.raw, c.env, response); + : markStaticAsset(c, await cachePolicy(c.req.raw, c.env, response)); }; return async (c) => { if (options.nodeTarget && options.fallbackRules) { @@ -224,7 +225,7 @@ export function createAssetNotFound(options: { if (assets && typeof assets.fetch === 'function') { const response: Response = await assets.fetch(c.req.raw); if (response.status !== 404) { - return cachePolicy(c.req.raw, c.env, response); + return markStaticAsset(c, await cachePolicy(c.req.raw, c.env, response)); } } } diff --git a/packages/void/src/runtime/static-asset-marker.ts b/packages/void/src/runtime/static-asset-marker.ts new file mode 100644 index 000000000..421704182 --- /dev/null +++ b/packages/void/src/runtime/static-asset-marker.ts @@ -0,0 +1,37 @@ +import type { Context } from 'hono'; + +const STATIC_ASSET_KEY = '__voidStaticAsset'; + +type StaticAssetMark = { policy: string; body: ReadableStream | null }; + +/** + * Records that the fallback answered with a file from the ASSETS binding: the + * Cache-Control it carried and its body stream. The value lives on the + * context, not on the response object: `c.header()` after the handler rebuilds + * `c.res`, so a response identity check would miss the file. The rebuild keeps + * the body stream, while a middleware that swaps the body does not. + */ +export function markStaticAsset(c: Context, response: Response): Response { + const mark: StaticAssetMark = { + policy: response.headers.get('cache-control') ?? '', + body: response.body, + }; + c.set(STATIC_ASSET_KEY, mark); + return response; +} + +/** + * True when the final response is still the marked file: a success status, the + * same body and Cache-Control the fallback served, and no cookie added by + * later middleware. + */ +export function isUnchangedStaticAsset(c: Context): boolean { + const mark = c.get(STATIC_ASSET_KEY) as StaticAssetMark | undefined; + return ( + mark !== undefined && + [200, 206, 304].includes(c.res.status) && + c.res.body === mark.body && + (c.res.headers.get('cache-control') ?? '') === mark.policy && + !c.res.headers.has('set-cookie') + ); +} diff --git a/packages/void/test/integration/auth-isr-cache-policy.test.ts b/packages/void/test/integration/auth-isr-cache-policy.test.ts index db4de051b..711d5f868 100644 --- a/packages/void/test/integration/auth-isr-cache-policy.test.ts +++ b/packages/void/test/integration/auth-isr-cache-policy.test.ts @@ -1,9 +1,16 @@ import { handleIsr, type IsrCacheEnv } from '@void/isr/runtime'; import { memoryAdapter } from 'better-auth/adapters/memory'; -import { Hono } from 'hono'; +import { Hono, type NotFoundHandler } from 'hono'; import { etag } from 'hono/etag'; import { describe, expect, it } from 'vite-plus/test'; import { createVoidAuthRuntime } from '../../src/runtime/better-auth-shared.ts'; +import { createErrorRenderer } from '../../src/runtime/routing-errors.ts'; +import { + createAssetNotFound, + createPagesFallback, + createSsrFallback, +} from '../../src/runtime/routing-pages.ts'; +import { createRoutingRuntime } from '../../src/runtime/routing.ts'; const env = { BETTER_AUTH_SECRET: 'local-test-secret-with-at-least-32-characters' }; const options = { dialect: 'sqlite' as const, providers: null }; @@ -94,10 +101,19 @@ describe('real anonymous Better Auth lookup cache policy', () => { }); describe('real authenticated Better Auth session cache policy', () => { - async function signedInApp(routeHeaders: Record, { withEtag = false } = {}) { + async function signedInApp( + routeHeaders: Record, + { + withEtag = false, + fallback, + }: { withEtag?: boolean; fallback?: (app: Hono) => NotFoundHandler } = {}, + ) { const database = memoryAdapter({ user: [], session: [], account: [], verification: [] }); const runtime = createVoidAuthRuntime('sqlite', async () => {}); const app = new Hono(); + if (fallback) { + app.notFound(fallback(app)); + } app.use(async (c, next) => runtime.runVoidAuthMiddleware(c, next, options, database)); app.on(['GET', 'POST'], '/api/auth/*', (c) => runtime.handleVoidAuthRequest(c, options, database), @@ -188,4 +204,99 @@ describe('real authenticated Better Auth session cache policy', () => { expect(repeat.headers.has('pragma')).toBe(false); expect(repeat.headers.get('etag')).toBe(tag); }); + + describe('static assets', () => { + const revalidate = 'public, max-age=0, must-revalidate'; + const assetEnv = { + ...env, + ASSETS: { + async fetch(request: Request) { + const path = new URL(request.url).pathname; + if (path === '/.vite/manifest.json') { + return Response.json({ + 'src/client.ts': { + file: 'assets/client-abcdefgh.js', + immutableFiles: ['assets/client-abcdefgh.js'], + }, + }); + } + if (!['/assets/client-abcdefgh.js', '/favicon.ico'].includes(path)) { + return new Response('Not Found', { status: 404 }); + } + const headers = { 'cache-control': revalidate, etag: '"file"' }; + return request.headers.get('if-none-match') === '"file"' + ? new Response(null, { status: 304, headers }) + : new Response('file', { headers }); + }, + }, + }; + const errors = createErrorRenderer({ projectRoot: '/project', homeDir: '/home' }); + const fallbacks: Array<[string, (app: Hono) => NotFoundHandler]> = [ + ['Pages', (app) => createPagesFallback({ routing: createRoutingRuntime(app, {}), errors })], + [ + 'SSR', + (app) => + createSsrFallback({ + routing: createRoutingRuntime(app, {}), + load: async () => ({ render: async () => new Response('rendered') }), + assets: async () => ({ css: '', body: '', preloads: '' }), + }), + ], + ['asset-only', (app) => createAssetNotFound({ routing: createRoutingRuntime(app, {}) })], + ]; + + async function fetchAsset( + app: Hono, + path: string, + headers: Record = {}, + ): Promise { + return app.request('https://app.test' + path, { headers }, assetEnv); + } + + it.each(fallbacks)( + 'keeps the file policy private from the %s fallback', + async (_label, fallback) => { + const { app, cookie } = await signedInApp({}, { fallback }); + const signedIn = await fetchAsset(app, '/assets/client-abcdefgh.js', { cookie }); + expect(signedIn.status).toBe(200); + expect(signedIn.headers.get('cache-control')).toBe('private, max-age=31536000, immutable'); + expect(signedIn.headers.has('pragma')).toBe(false); + expect(signedIn.headers.get('etag')).toBe('"file"'); + await expect(signedIn.text()).resolves.toBe('file'); + + const unrelatedCookie = await fetchAsset(app, '/assets/client-abcdefgh.js', { + cookie: '_ga=1', + }); + expect(unrelatedCookie.headers.get('cache-control')).toBe( + 'private, max-age=31536000, immutable', + ); + expect(unrelatedCookie.headers.has('pragma')).toBe(false); + + const anonymous = await fetchAsset(app, '/assets/client-abcdefgh.js'); + expect(anonymous.headers.get('cache-control')).toBe('public, max-age=31536000, immutable'); + + const publicFile = await fetchAsset(app, '/favicon.ico', { cookie }); + expect(publicFile.headers.get('cache-control')).toBe('private, max-age=0, must-revalidate'); + expect(publicFile.headers.has('pragma')).toBe(false); + + const revalidated = await fetchAsset(app, '/favicon.ico', { + cookie, + 'if-none-match': '"file"', + }); + expect(revalidated.status).toBe(304); + expect(revalidated.headers.get('cache-control')).toBe( + 'private, max-age=0, must-revalidate', + ); + expect(revalidated.headers.has('pragma')).toBe(false); + }, + ); + + it('still applies no-store to a signed-in page miss', async () => { + const { app, cookie } = await signedInApp({}, { fallback: fallbacks[0]![1] }); + const response = await fetchAsset(app, '/missing', { cookie }); + expect(response.status).toBe(404); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(response.headers.get('pragma')).toBe('no-cache'); + }); + }); }); diff --git a/packages/void/test/integration/native-asset-cache-policy.test.ts b/packages/void/test/integration/native-asset-cache-policy.test.ts index c99a2e602..0d34072df 100644 --- a/packages/void/test/integration/native-asset-cache-policy.test.ts +++ b/packages/void/test/integration/native-asset-cache-policy.test.ts @@ -6,6 +6,7 @@ import { Miniflare, convertV4MiniflareOptions } from 'miniflare'; import { build, type Manifest } from 'vite'; import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; import { analyzeApplicationSync } from '../../src/application-analysis.ts'; +import { resolveAssetWorkerBypass } from '../../src/asset-worker-bypass.ts'; import { routingPlugin } from '../../src/plugin-routing.ts'; const runtimeSource = (file: string) => join(import.meta.dirname, '../../src/runtime', file); @@ -282,3 +283,99 @@ describe('native asset cache policy in real Workers', () => { }); }); }); + +describe('native asset Worker bypass in real Workers', () => { + let bypassDirectory: string; + let bypass: Miniflare | undefined; + + beforeAll(async () => { + bypassDirectory = mkdtempSync(join(tmpdir(), 'void-native-asset-bypass-')); + const assets = join(bypassDirectory, 'client'); + mkdirSync(join(assets, 'static', 'nested'), { recursive: true }); + for (const file of ['client-abcdefgh.js', 'client.js', 'nested/chunk-abcdefgh.js']) { + writeFileSync(join(assets, 'static', file), 'export const value = 1;'); + } + const plan = resolveAssetWorkerBypass({ + runWorkerFirst: ['/**'], + notFoundHandling: 'none', + base: '/', + hasMiddleware: false, + authEnabled: false, + headerRuleCount: 0, + redirectRuleCount: 0, + fallbackRuleCount: 0, + publicEdgeFiles: false, + patterns: ['/'], + assetsDir: 'static', + immutableFiles: ['static/client-abcdefgh.js', 'static/nested/chunk-abcdefgh.js'], + })!; + writeFileSync(join(assets, '_headers'), plan.headers); + const runWorkerFirst = plan.runWorkerFirst; + expect(runWorkerFirst).toEqual([ + '/**', + '!/static/client-abcdefgh.js', + '!/static/nested/chunk-abcdefgh.js', + ]); + bypass = new Miniflare( + convertV4MiniflareOptions({ + compatibilityDate: '2026-07-01', + modules: true, + script: `export default { async fetch(request, env) { + const asset = await env.ASSETS.fetch(request); + const response = new Response(asset.body, asset); + response.headers.set('x-void-worker', '1'); + return response; + } };`, + assets: { + directory: assets, + binding: 'ASSETS', + run_worker_first: runWorkerFirst, + routerConfig: { has_user_worker: true }, + assetConfig: { html_handling: 'none', not_found_handling: 'none' }, + }, + }), + ); + }); + + afterAll(async () => { + await bypass?.dispose(); + if (bypassDirectory) { + rmSync(bypassDirectory, { recursive: true, force: true }); + } + }); + + it('serves listed chunks immutable without starting the Worker, even with cookies', async () => { + for (const path of ['/static/client-abcdefgh.js', '/static/nested/chunk-abcdefgh.js']) { + const response = await bypass!.dispatchFetch(`https://app.test${path}`, { + headers: { cookie: '__Secure-better-auth.session_token=abc; _ga=1' }, + }); + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('public, max-age=31536000, immutable'); + expect(response.headers.get('etag')).toBeTruthy(); + expect(response.headers.has('x-void-worker')).toBe(false); + await response.arrayBuffer(); + } + }); + + it('sends unlisted files and misses in the assets folder to the Worker', async () => { + const unlisted = await bypass!.dispatchFetch('https://app.test/static/client.js'); + expect(unlisted.headers.get('cache-control')).toBe('public, max-age=0, must-revalidate'); + expect(unlisted.headers.get('x-void-worker')).toBe('1'); + await unlisted.arrayBuffer(); + for (const path of ['/static/gone-abcdefgh.js', '/static/42', '/static/']) { + const missing = await bypass!.dispatchFetch(`https://app.test${path}`); + expect(missing.status, path).toBe(404); + expect(missing.headers.get('cache-control') ?? '').not.toContain('immutable'); + expect(missing.headers.get('x-void-worker'), path).toBe('1'); + await missing.arrayBuffer(); + } + }); + + it('still sends other paths to the Worker', async () => { + for (const path of ['/', '/api/x', '/static']) { + const response = await bypass!.dispatchFetch(`https://app.test${path}`); + expect(response.headers.get('x-void-worker')).toBe('1'); + await response.arrayBuffer(); + } + }); +}); diff --git a/packages/void/test/integration/spa-middleware-auth.test.ts b/packages/void/test/integration/spa-middleware-auth.test.ts index 928eecfce..338f63b89 100644 --- a/packages/void/test/integration/spa-middleware-auth.test.ts +++ b/packages/void/test/integration/spa-middleware-auth.test.ts @@ -9,6 +9,7 @@ import { startServer, type IsolatedPlayground, } from '../../../../test/integration-helpers.ts'; +import { isGeneratedAssetHeaders } from '../../src/asset-worker-bypass.ts'; import { renderVoidConfig } from '../../src/config-write.ts'; import { readConfig } from '../../src/config.ts'; @@ -152,6 +153,14 @@ function readBuiltAssetConfig(): { return emittedWrangler.assets ?? {}; } +/** Apps that keep the Worker in front of their assets get no generated `_headers`. */ +function expectNoGeneratedAssetHeaders(): void { + const headers = join(dist, 'client/_headers'); + if (existsSync(headers)) { + expect(isGeneratedAssetHeaders(readFileSync(headers, 'utf-8'))).toBe(false); + } +} + beforeAll(() => { playground = createIsolatedPlayground(ROOT, SOURCE_PLAYGROUND); authMiddleware = join(playground.root, 'middleware/00.basic-auth-regression.ts'); @@ -200,6 +209,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/*', '/__void', '/__void/*']); + expectNoGeneratedAssetHeaders(); }); it('applies native production routing rules before existing static assets', async () => { @@ -230,6 +240,7 @@ describe('SPA asset routing policy', () => { }); expect(readBuiltAssetConfig().run_worker_first).toEqual(['/**']); + expectNoGeneratedAssetHeaders(); const base = await startSpaPreviewServer(); const headerResponse = await fetch(`${base}/void-native-header-regression.txt`); expect(headerResponse.status).toBe(200); diff --git a/packages/void/test/integration/ssr-playground.test.ts b/packages/void/test/integration/ssr-playground.test.ts index ef4b46deb..681367061 100644 --- a/packages/void/test/integration/ssr-playground.test.ts +++ b/packages/void/test/integration/ssr-playground.test.ts @@ -4,11 +4,19 @@ import { join } from 'node:path'; import spawn from 'cross-spawn'; import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; import { killChild, startServer } from '../../../../test/integration-helpers.ts'; +import { GENERATED_ASSET_HEADERS_MARKER } from '../../src/asset-worker-bypass.ts'; const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); let DEV_BASE: string; let PREVIEW_BASE: string; +function clientEntryFile(): string { + const manifest = JSON.parse( + readFileSync(join(ROOT, 'playground/ssr/dist/client/.vite/manifest.json'), 'utf-8'), + ) as Record; + return Object.values(manifest).find((entry) => entry.isEntry)!.file; +} + describe('playground/ssr dev', () => { let child: ReturnType; @@ -131,9 +139,27 @@ describe('playground/ssr build + preview', () => { }; kv_namespaces?: Array<{ binding: string }>; }; - expect(wranglerConfig.assets?.run_worker_first).toEqual(['/**']); + // No middleware or routing rules: Cloudflare serves the hashed bundles itself. + const runWorkerFirst = wranglerConfig.assets?.run_worker_first as Array; + expect(runWorkerFirst[0]).toBe('/**'); + expect(runWorkerFirst).toContain(`!/${clientEntryFile()}`); expect(wranglerConfig.assets?.not_found_handling).toBe('none'); expect(wranglerConfig.kv_namespaces?.some((b) => b.binding === 'KV')).toBe(true); + + const headers = readFileSync(join(ROOT, 'playground/ssr/dist/client/_headers'), 'utf-8'); + expect(headers.split('\n')[0]).toBe(GENERATED_ASSET_HEADERS_MARKER); + expect(headers).toContain( + `/${clientEntryFile()}\n Cache-Control: public, max-age=31536000, immutable\n`, + ); + }); + + it('preview serves the hashed client entry immutable with and without cookies', async () => { + for (const headers of [{}, { cookie: 'a=b' }] as Array>) { + const response = await fetch(`${PREVIEW_BASE}/${clientEntryFile()}`, { headers }); + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('public, max-age=31536000, immutable'); + await response.arrayBuffer(); + } }); it('preview serves SSR pages', async () => { diff --git a/packages/void/test/unit/asset-worker-bypass.test.ts b/packages/void/test/unit/asset-worker-bypass.test.ts new file mode 100644 index 000000000..1e04d4c4a --- /dev/null +++ b/packages/void/test/unit/asset-worker-bypass.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { + GENERATED_ASSET_HEADERS_MARKER, + isAssetBypassRule, + isGeneratedAssetHeaders, + normalizeAssetsDir, + patternCanReachUnder, + resolveAssetWorkerBypass, + type AssetWorkerBypassFacts, +} from '../../src/asset-worker-bypass.ts'; + +function facts(overrides: Partial = {}): AssetWorkerBypassFacts { + return { + runWorkerFirst: ['/**'], + notFoundHandling: 'none', + base: '/', + hasMiddleware: false, + authEnabled: false, + headerRuleCount: 0, + redirectRuleCount: 0, + fallbackRuleCount: 0, + publicEdgeFiles: false, + patterns: ['/', '/api/users/:id', '/blog/:slug'], + assetsDir: 'assets', + immutableFiles: ['assets/main-abcdefgh.js', 'assets/chunk-12345678.js'], + ...overrides, + }; +} + +const files = (count: number) => + Array.from({ length: count }, (_, i) => `assets/chunk-${String(i).padStart(8, '0')}.js`); + +describe('patternCanReachUnder', () => { + it.each(['/assets/:id', '/:a/:b', '/:slug{.+}', '*', '/*', '/Assets/x', '/assets/*'])( + '%s can reach /assets/*', + (pattern) => { + expect(patternCanReachUnder(pattern, 'assets')).toBe(true); + }, + ); + + it.each(['/:slug', '/assets', '/api/*', '/blog/:id', '/'])( + '%s cannot reach /assets/*', + (pattern) => { + expect(patternCanReachUnder(pattern, 'assets')).toBe(false); + }, + ); + + it('walks nested asset directories segment by segment', () => { + expect(patternCanReachUnder('/static/:x/:y', 'static/build')).toBe(true); + expect(patternCanReachUnder('/static/:x', 'static/build')).toBe(false); + }); +}); + +describe('normalizeAssetsDir', () => { + it('trims slashes and rejects an empty directory', () => { + expect(normalizeAssetsDir('/assets/')).toBe('assets'); + expect(normalizeAssetsDir('static/build')).toBe('static/build'); + expect(normalizeAssetsDir('')).toBeNull(); + expect(normalizeAssetsDir('/')).toBeNull(); + expect(normalizeAssetsDir('.')).toBeNull(); + }); +}); + +describe('resolveAssetWorkerBypass', () => { + it('lets eligible apps, including auth apps, skip the Worker for the assets folder', () => { + const expected = { + runWorkerFirst: ['/**', '!/assets/chunk-12345678.js', '!/assets/main-abcdefgh.js'], + headers: `${GENERATED_ASSET_HEADERS_MARKER} +/assets/chunk-12345678.js + Cache-Control: public, max-age=31536000, immutable +/assets/main-abcdefgh.js + Cache-Control: public, max-age=31536000, immutable +`, + }; + expect(resolveAssetWorkerBypass(facts())).toEqual(expected); + expect(resolveAssetWorkerBypass(facts({ authEnabled: true }))).toEqual(expected); + }); + + it('replaces the rule an earlier bypass wrote', () => { + expect( + resolveAssetWorkerBypass(facts({ runWorkerFirst: ['/**', '!/assets/old-12345678.js'] })) + ?.runWorkerFirst, + ).toEqual(['/**', '!/assets/chunk-12345678.js', '!/assets/main-abcdefgh.js']); + }); + + it('lists only immutable files under the assets folder', () => { + const headers = resolveAssetWorkerBypass( + facts({ immutableFiles: ['root-abcdefgh.js', 'assets/a-12345678.js'] }), + )?.headers; + expect(headers).toContain('/assets/a-12345678.js\n'); + expect(headers).not.toContain('root-abcdefgh'); + }); + + it.each<[string, Partial]>([ + ['run_worker_first true', { runWorkerFirst: true }], + ['an API-only run_worker_first', { runWorkerFirst: ['/api', '/api/*'] }], + ['an extra run_worker_first rule', { runWorkerFirst: ['/**', '/x'] }], + ['a SPA not_found_handling', { notFoundHandling: 'single-page-application' }], + ['a non-root base', { base: '/app/' }], + ['middleware', { hasMiddleware: true }], + ['a header rule', { headerRuleCount: 1 }], + ['a redirect rule', { redirectRuleCount: 1 }], + ['a fallback rule', { fallbackRuleCount: 1 }], + ['public edge files', { publicEdgeFiles: true }], + ['an empty assetsDir', { assetsDir: '' }], + ['an assetsDir with a placeholder', { assetsDir: 'a:b' }], + ['a revalidate * key', { patterns: ['*'] }], + ['a clashing page pattern', { patterns: ['/:section/:slug'] }], + ['Void internal paths', { assetsDir: '__void', immutableFiles: ['__void/a-12345678.js'] }], + [ + 'auth paths', + { authEnabled: true, assetsDir: 'api/auth', immutableFiles: ['api/auth/a-12345678.js'] }, + ], + ['no immutable files', { immutableFiles: [] }], + ['100 immutable files', { immutableFiles: files(100) }], + ['a file name with a placeholder', { immutableFiles: ['assets/a:b-12345678.js'] }], + [ + 'a rule over 100 characters', + { assetsDir: 'a'.repeat(97), immutableFiles: [`${'a'.repeat(97)}/x-12345678.js`] }, + ], + ])('keeps the Worker in front for %s', (_name, overrides) => { + expect(resolveAssetWorkerBypass(facts(overrides))).toBeNull(); + }); + + it('accepts 99 immutable files, one run_worker_first rule each', () => { + const bypass = resolveAssetWorkerBypass(facts({ immutableFiles: files(99) })); + expect(bypass?.runWorkerFirst).toHaveLength(100); + expect(bypass?.headers.split('\n').filter((line) => line.startsWith('/'))).toHaveLength(99); + }); +}); + +describe('isAssetBypassRule', () => { + it('recognises only /** followed by negative rules in the assets folder', () => { + expect(isAssetBypassRule(['/**', '!/assets/a-12345678.js'], '/assets/')).toBe(true); + expect(isAssetBypassRule(['/**'], 'assets')).toBe(false); + expect(isAssetBypassRule(['/**', '/x'], 'assets')).toBe(false); + expect(isAssetBypassRule(['/**', '!/other/a-12345678.js'], 'assets')).toBe(false); + expect(isAssetBypassRule(['/api/*', '!/assets/a-12345678.js'], 'assets')).toBe(false); + expect(isAssetBypassRule(true, 'assets')).toBe(false); + }); +}); + +describe('isGeneratedAssetHeaders', () => { + it('recognises only the marker line', () => { + expect(isGeneratedAssetHeaders(`${GENERATED_ASSET_HEADERS_MARKER}\n/a\n`)).toBe(true); + expect(isGeneratedAssetHeaders(`${GENERATED_ASSET_HEADERS_MARKER}\r\n/a\r\n`)).toBe(true); + expect(isGeneratedAssetHeaders(GENERATED_ASSET_HEADERS_MARKER)).toBe(true); + expect(isGeneratedAssetHeaders('/assets/*\n Cache-Control: no-store\n')).toBe(false); + expect(isGeneratedAssetHeaders(`# note\n${GENERATED_ASSET_HEADERS_MARKER}\n`)).toBe(false); + }); +}); diff --git a/packages/void/test/unit/auth-middleware.test.ts b/packages/void/test/unit/auth-middleware.test.ts index 50ed51642..296b1f90c 100644 --- a/packages/void/test/unit/auth-middleware.test.ts +++ b/packages/void/test/unit/auth-middleware.test.ts @@ -1,4 +1,4 @@ -import { Hono } from 'hono'; +import { Hono, type Context } from 'hono'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; const mocks = vi.hoisted(() => ({ @@ -13,8 +13,10 @@ vi.mock('better-auth', () => ({ import { requireAuth, type AuthState, type AuthUser } from '../../src/runtime/auth.ts'; import { createVoidAuthRuntime, + privateCachePolicy, type VoidAuthRuntimeOptions, } from '../../src/runtime/better-auth-shared.ts'; +import { markStaticAsset } from '../../src/runtime/static-asset-marker.ts'; const authRuntimeOptions: VoidAuthRuntimeOptions = { dialect: 'sqlite', @@ -416,3 +418,121 @@ describe('cross-origin cookie sessions', () => { expect(warn).not.toHaveBeenCalled(); }); }); + +describe('runVoidAuthMiddleware static asset cache policy', () => { + const immutable = 'public, max-age=31536000, immutable'; + const env = { BETTER_AUTH_SECRET: 'test-secret' }; + const sessionLookup = () => new Headers({ 'cache-control': 'no-store', pragma: 'no-cache' }); + + function assetApp(headers: Headers, status = 200): Hono { + const app = createApp({ response: null, headers }); + app.get('/assets/client-abcdefgh.js', (c) => + markStaticAsset( + c, + new Response('export {}', { status, headers: { 'cache-control': immutable } }), + ), + ); + return app; + } + + function withCookie(app: Hono): Response | Promise { + return app.request( + 'http://localhost/assets/client-abcdefgh.js', + { headers: { cookie: 'session=current' } }, + env, + ); + } + + it('makes the file policy private for a credentialed request', async () => { + const response = await withCookie(assetApp(sessionLookup())); + expect(response.headers.get('cache-control')).toBe('private, max-age=31536000, immutable'); + expect(response.headers.has('pragma')).toBe(false); + await expect(response.text()).resolves.toBe('export {}'); + }); + + it('keeps a session cookie refresh out of every cache', async () => { + const headers = sessionLookup(); + headers.append('set-cookie', 'session=rotated; Path=/; HttpOnly'); + const response = await withCookie(assetApp(headers)); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + expect(response.headers.has('pragma')).toBe(false); + expect(response.headers.getSetCookie()).toEqual(['session=rotated; Path=/; HttpOnly']); + }); + + it('leaves the file policy public without a credential', async () => { + const response = await assetApp(sessionLookup()).request( + 'http://localhost/assets/client-abcdefgh.js', + undefined, + env, + ); + expect(response.headers.get('cache-control')).toBe(immutable); + expect(response.headers.has('pragma')).toBe(false); + }); + + it.each([ + ['changes its Cache-Control', (c: Context) => c.header('cache-control', 'public, max-age=60')], + ['adds a cookie', (c: Context) => c.header('set-cookie', 'route=value; Path=/')], + [ + 'replaces the body', + (c: Context) => { + c.res = c.html('

Please sign in

'); + }, + ], + [ + 'rewrites the body with the same headers', + (c: Context) => { + c.res = new Response('export const user = "a"', c.res); + }, + ], + ])('treats a file as a route when later middleware %s', async (_label, change) => { + const app = createApp({ response: null, headers: sessionLookup() }); + app.use(async (c, next) => { + await next(); + change(c); + }); + app.get('/assets/client-abcdefgh.js', (c) => + markStaticAsset(c, new Response('export {}', { headers: { 'cache-control': immutable } })), + ); + const response = await withCookie(app); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(response.headers.get('pragma')).toBe('no-cache'); + }); + + it('keeps the file policy when later middleware only adds a header', async () => { + const app = createApp({ response: null, headers: sessionLookup() }); + app.use(async (c, next) => { + await next(); + c.header('x-frame-options', 'DENY'); + }); + app.get('/assets/client-abcdefgh.js', (c) => + markStaticAsset(c, new Response('export {}', { headers: { 'cache-control': immutable } })), + ); + const response = await withCookie(app); + expect(response.headers.get('cache-control')).toBe('private, max-age=31536000, immutable'); + expect(response.headers.get('x-frame-options')).toBe('DENY'); + }); + + it('treats a marked response with a failure status as a route', async () => { + const response = await withCookie(assetApp(sessionLookup(), 404)); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(response.headers.get('pragma')).toBe('no-cache'); + }); +}); + +describe('privateCachePolicy', () => { + it.each([ + ['public, max-age=31536000, immutable', 'private, max-age=31536000, immutable'], + ['public, max-age=0, must-revalidate', 'private, max-age=0, must-revalidate'], + ['s-maxage=60, max-age=10', 'private, max-age=10'], + ['PUBLIC, proxy-revalidate, max-age=10', 'private, max-age=10'], + ['private="set-cookie", max-age=60', 'private, max-age=60'], + ['no-store', 'no-store'], + ['Private, max-age=60', 'Private, max-age=60'], + ['', 'private, no-store'], + [null, 'private, no-store'], + ['no-cache="a,private", public, max-age=60', 'private, no-cache="a,private", max-age=60'], + ['no-cache="a\\",no-store", public', 'private, no-cache="a\\",no-store"'], + ])('%j -> %j', (policy, expected) => { + expect(privateCachePolicy(policy)).toBe(expected); + }); +}); diff --git a/packages/void/test/unit/cf-version-runner.test.ts b/packages/void/test/unit/cf-version-runner.test.ts index d993d0bd7..f737eb8a3 100644 --- a/packages/void/test/unit/cf-version-runner.test.ts +++ b/packages/void/test/unit/cf-version-runner.test.ts @@ -8,6 +8,7 @@ import { join } from 'node:path'; import type { Duplex } from 'node:stream'; import { fileURLToPath } from 'node:url'; import { + getWorkerAssetsDir, getWorkerConfigPath, readBuildOutput, writeContainerConfig, @@ -27,6 +28,7 @@ import { } from '../../../deploy-cloudflare/src/version.ts'; import { operationFixture } from '../../../deploy-cloudflare/test/operation-fixture.ts'; import { voidSecretDraftVersion } from '../../../deploy-cloudflare/test/secret-draft-fixture.ts'; +import { GENERATED_ASSET_HEADERS_MARKER } from '../../src/asset-worker-bypass.ts'; import { prepareCfBuildOutput } from '../../src/cli/cf-build-output.ts'; import { exportsFromOutput, uploadPinnedCloudflareVersion } from '../../src/cli/cf-version-api.ts'; import { @@ -1197,6 +1199,97 @@ it( REAL_CF_TIMEOUT_MS, ); +it.each([ + [ + 'Void-generated', + `${GENERATED_ASSET_HEADERS_MARKER}\n/assets/a-12345678.js\n Cache-Control: public, max-age=31536000, immutable\n`, + ], + ['user-authored', '/assets/*\n X-Frame-Options: DENY\n'], + ['missing', undefined], +] as const)( + 'uploads only a Void-generated _headers file with pinned secrets (%s)', + async (_kind, headers) => { + const prepared = await output({ + secrets: { required: ['AUTH_SECRET'] }, + assets: { + directory: '../client', + binding: 'ASSETS', + not_found_handling: 'none', + run_worker_first: ['/**', '!/assets/*'], + }, + }); + try { + if (headers !== undefined) { + writeFileSync(join(getWorkerAssetsDir(prepared.directory), '_headers'), headers); + } + await pinActiveWorkerSecrets({ + output: prepared, + activeVersionId: '11111111-1111-4111-8111-111111111111', + bindings: [{ name: 'AUTH_SECRET', type: 'secret_text' }], + requiredNames: ['AUTH_SECRET'], + }); + const posts: Array<{ url: string; body: string }> = []; + const server = createServer(async (request, response) => { + const chunks: Array = []; + for await (const chunk of request) { + chunks.push(Buffer.from(chunk)); + } + const url = request.url ?? ''; + if (request.method === 'POST') { + posts.push({ url, body: Buffer.concat(chunks).toString('utf8') }); + } + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end( + JSON.stringify({ + success: true, + errors: [], + result: url.endsWith('/assets-upload-session') + ? { jwt: 'asset-token', buckets: [] } + : request.method === 'GET' + ? { + id: '11111111-1111-4111-8111-111111111111', + bindings: [{ name: 'AUTH_SECRET', type: 'secret_text' }], + } + : { id: '22222222-2222-4222-8222-222222222222', urls: [] }, + }), + ); + }); + await uploadPinnedCloudflareVersion({ + output: prepared, + accountId: 'account-id', + apiBaseUrl: await listen(server), + workerName: 'void-test', + token: 'synthetic-token', + atomic: false, + verifyUploader: () => true, + }); + const session = posts.find(({ url }) => url.endsWith('/assets-upload-session')); + expect(Object.keys(JSON.parse(session!.body).manifest)).toEqual(['/index.html']); + const body = JSON.parse(posts.find(({ url }) => url.endsWith('/versions'))!.body); + expect(body.assets).toEqual({ + jwt: 'asset-token', + config: { not_found_handling: 'none', run_worker_first: ['/**', '!/assets/*'] }, + }); + const modules = (body.modules as Array<{ name: string }>).filter( + ({ name }) => name === '_headers', + ); + expect(modules).toEqual( + _kind === 'Void-generated' + ? [ + { + name: '_headers', + content_type: 'text/plain', + content_base64: Buffer.from(headers!).toString('base64'), + }, + ] + : [], + ); + } finally { + prepared.dispose(); + } + }, +); + it('uploads Worker limits, placement and cache with pinned secrets', async () => { const prepared = await output({ secrets: { required: ['AUTH_SECRET'] }, diff --git a/packages/void/test/unit/client-asset-manifest.test.ts b/packages/void/test/unit/client-asset-manifest.test.ts new file mode 100644 index 000000000..9174c0c3d --- /dev/null +++ b/packages/void/test/unit/client-asset-manifest.test.ts @@ -0,0 +1,57 @@ +import type { Rolldown } from 'vite'; +import { describe, expect, it } from 'vite-plus/test'; +import { stampClientAssetCachePolicy } from '../../src/client-asset-manifest.ts'; + +const MANIFEST = '.vite/manifest.json'; + +function chunk(fileName: string, preliminaryFileName: string): Rolldown.OutputChunk { + return { type: 'chunk', fileName, preliminaryFileName } as Rolldown.OutputChunk; +} + +function fakeBundle(): Rolldown.OutputBundle { + const manifest = { + 'main.js': { + file: 'assets/main-abcdefgh.js', + isEntry: true, + css: ['assets/main-12345678.css'], + }, + '_shared.js': { file: 'assets/shared-12345678.js' }, + 'stable.js': { file: 'assets/stable.js', immutableFiles: ['assets/stable.js'] }, + 'covered.js': { file: 'assets/covered-abcdefgh.js' }, + 'main.css': { file: 'assets/main-12345678.css' }, + }; + return { + 'assets/main-abcdefgh.js': chunk('assets/main-abcdefgh.js', 'assets/main-!~{001}~.js'), + 'assets/shared-12345678.js': chunk('assets/shared-12345678.js', 'assets/shared-!~{002}~.js'), + 'assets/stable.js': chunk('assets/stable.js', 'assets/stable.js'), + 'assets/covered-abcdefgh.js': chunk('assets/covered-abcdefgh.js', 'assets/covered-!~{003}~.js'), + 'assets/main-12345678.css': { + type: 'asset', + fileName: 'assets/main-12345678.css', + source: 'body{}', + }, + [MANIFEST]: { type: 'asset', fileName: MANIFEST, source: JSON.stringify(manifest) }, + } as unknown as Rolldown.OutputBundle; +} + +describe('stampClientAssetCachePolicy', () => { + it('returns the sorted hashed chunks it marks immutable', () => { + const bundle = fakeBundle(); + const files = stampClientAssetCachePolicy(bundle, MANIFEST, [ + { pattern: '/assets/covered-*', headers: [['Cache-Control', 'public, max-age=60']] }, + ]); + expect(files).toEqual(['assets/main-abcdefgh.js', 'assets/shared-12345678.js']); + const manifest = JSON.parse(String((bundle[MANIFEST] as Rolldown.OutputAsset).source)); + const stamped = Object.values(manifest) + .flatMap((entry) => (entry as { immutableFiles?: Array }).immutableFiles ?? []) + .sort(); + expect(stamped).toEqual(files); + expect(manifest['stable.js']).not.toHaveProperty('immutableFiles'); + }); + + it('returns no files without a manifest', () => { + const bundle = fakeBundle(); + delete bundle[MANIFEST]; + expect(stampClientAssetCachePolicy(bundle, MANIFEST)).toEqual([]); + }); +}); diff --git a/packages/void/test/unit/plugin-routing.test.ts b/packages/void/test/unit/plugin-routing.test.ts index e5a920b80..6ad9a723a 100644 --- a/packages/void/test/unit/plugin-routing.test.ts +++ b/packages/void/test/unit/plugin-routing.test.ts @@ -1,9 +1,10 @@ -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { ConfigEnv, ResolvedConfig, UserConfig } from 'vite'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { analyzeApplicationSync } from '../../src/application-analysis.ts'; +import { GENERATED_ASSET_HEADERS_MARKER } from '../../src/asset-worker-bypass.ts'; import { readConfig } from '../../src/config.ts'; import { routingPlugin } from '../../src/plugin-routing.ts'; import { getHookHandler } from '../helpers/vite-hooks.ts'; @@ -451,3 +452,221 @@ describe('routingPlugin', () => { }, ); }); + +describe('routingPlugin native asset Worker bypass', () => { + const CHUNK = 'assets/main-abcdefgh.js'; + const WRANGLER = JSON.stringify({ + name: 'app', + main: 'index.js', + assets: { + binding: 'ASSETS', + not_found_handling: 'none', + run_worker_first: ['/**'], + directory: '../client', + }, + }); + + afterEach(() => { + vi.unstubAllEnvs(); + while (tempDirs.length > 0) { + rmSync(tempDirs.pop()!, { recursive: true, force: true }); + } + }); + + function createBuildRoot(options: { wrangler?: boolean } = {}): string { + const root = createTempRoot(); + writeFileSync(join(root, 'routes', 'index.ts'), 'export const GET = () => "ok";\n'); + mkdirSync(join(root, 'dist', 'ssr'), { recursive: true }); + mkdirSync(join(root, 'dist', 'client', 'assets'), { recursive: true }); + if (options.wrangler !== false) { + writeFileSync(join(root, 'dist', 'ssr', 'wrangler.json'), WRANGLER); + } + return root; + } + + function clientBundle(): Record { + return { + [CHUNK]: { type: 'chunk', fileName: CHUNK, preliminaryFileName: 'assets/main-!~{001}~.js' }, + '.vite/manifest.json': { + type: 'asset', + fileName: '.vite/manifest.json', + source: JSON.stringify({ 'main.js': { file: CHUNK, isEntry: true } }), + }, + }; + } + + async function runClientBuild( + root: string, + options: Partial[1], 'getApplicationAnalysis'>> = {}, + base = '/', + ): Promise { + const plugin = createRoutingPlugin(root, { + isAuthEnabled: () => false, + assetWorkerBypass: true, + ...options, + }); + runConfig(plugin, { command: 'build', mode: 'production' }); + (getHookHandler(plugin.configResolved) as (config: ResolvedConfig) => void)({ + build: { assetsDir: 'assets' }, + base, + root, + publicDir: join(root, 'public'), + command: 'build', + mode: 'production', + plugins: [], + } as unknown as ResolvedConfig); + await runBuildStart(plugin); + const environment = { + name: 'client', + config: { build: { manifest: true, outDir: 'dist/client', assetsDir: 'assets' } }, + }; + (getHookHandler(plugin.generateBundle) as (...args: Array) => void).call( + { environment }, + {}, + clientBundle(), + ); + (getHookHandler(plugin.writeBundle) as (...args: Array) => void).call( + { environment }, + {}, + {}, + ); + } + + const wranglerText = (root: string) => + readFileSync(join(root, 'dist', 'ssr', 'wrangler.json'), 'utf8'); + const headersPath = (root: string) => join(root, 'dist', 'client', '_headers'); + + it('excludes the hashed chunk from the Worker and lists it as immutable', async () => { + const root = createBuildRoot(); + await runClientBuild(root); + const wrangler = JSON.parse(wranglerText(root)); + expect(wrangler.assets.run_worker_first).toEqual(['/**', `!/${CHUNK}`]); + expect({ ...wrangler, assets: { ...wrangler.assets, run_worker_first: ['/**'] } }).toEqual( + JSON.parse(WRANGLER), + ); + const headers = readFileSync(headersPath(root), 'utf8'); + expect(headers.split('\n')[0]).toBe(GENERATED_ASSET_HEADERS_MARKER); + expect(headers).toContain(`/${CHUNK}\n Cache-Control: public, max-age=31536000, immutable\n`); + }); + + it('rewrites its own _headers on the next build', async () => { + const root = createBuildRoot(); + writeFileSync( + headersPath(root), + `${GENERATED_ASSET_HEADERS_MARKER}\n/assets/old-12345678.js\n`, + ); + await runClientBuild(root); + const headers = readFileSync(headersPath(root), 'utf8'); + expect(headers).toContain(`/${CHUNK}`); + expect(headers).not.toContain('old-12345678'); + }); + + it.each<[string, (root: string) => void, Parameters[1]?, string?]>([ + ['managed builds', () => vi.stubEnv('VOID_DEPLOY_PROJECT_ID', 'project-id')], + [ + 'global middleware', + (root) => { + mkdirSync(join(root, 'middleware')); + writeFileSync( + join(root, 'middleware', '01.log.ts'), + 'export default async (_c, next) => next();\n', + ); + }, + ], + [ + 'a routing header rule', + () => {}, + { routing: { headers: { '/*': ['X-Frame-Options: DENY'] } } }, + ], + [ + 'a public _headers file', + (root) => { + mkdirSync(join(root, 'public')); + writeFileSync(join(root, 'public', '_headers'), ''); + }, + ], + ['a non-root base', () => {}, {}, '/app/'], + ['the option unset', () => {}, { assetWorkerBypass: undefined }], + [ + 'a route under the assets folder', + (root) => { + mkdirSync(join(root, 'routes', 'assets')); + writeFileSync(join(root, 'routes', 'assets', '[id].ts'), 'export const GET = () => "";\n'); + }, + ], + [ + 'a page under the assets folder', + (root) => { + mkdirSync(join(root, 'pages', 'assets'), { recursive: true }); + writeFileSync(join(root, 'pages', 'assets', '[id].tsx'), 'export default () => null;\n'); + }, + ], + ['a redirect rule', () => {}, { routing: { redirects: { '/old': '/new' } } }], + ['a fallback rule', () => {}, { routing: { fallbacks: { '/*': '/index.html' } } }], + [ + 'a public _redirects file', + (root) => { + mkdirSync(join(root, 'public')); + writeFileSync(join(root, 'public', '_redirects'), ''); + }, + ], + [ + 'a revalidate key under the assets folder', + () => {}, + { routing: { revalidate: { '/assets/*': 60 } } }, + ], + ['a prerender path under the assets folder', () => {}, { prerenderPaths: ['/assets/page'] }], + ])('leaves the output unchanged for %s', async (_name, prepare, options, base) => { + const root = createBuildRoot(); + prepare(root); + await runClientBuild(root, options, base); + expect(wranglerText(root)).toBe(WRANGLER); + expect(existsSync(headersPath(root))).toBe(false); + }); + + it('keeps a user-authored _headers file and the Worker in front', async () => { + const root = createBuildRoot(); + const userHeaders = '/assets/*\n X-Frame-Options: DENY\n'; + writeFileSync(headersPath(root), userHeaders); + await runClientBuild(root); + expect(wranglerText(root)).toBe(WRANGLER); + expect(readFileSync(headersPath(root), 'utf8')).toBe(userHeaders); + }); + + it('removes its stale _headers when the app is no longer eligible', async () => { + const root = createBuildRoot(); + writeFileSync(headersPath(root), `${GENERATED_ASSET_HEADERS_MARKER}\n/${CHUNK}\n`); + await runClientBuild(root, { routing: { headers: { '/*': ['X-Frame-Options: DENY'] } } }); + expect(wranglerText(root)).toBe(WRANGLER); + expect(existsSync(headersPath(root))).toBe(false); + }); + + function writeEarlierBypass(root: string): void { + const wrangler = JSON.parse(WRANGLER); + wrangler.assets.run_worker_first = ['/**', '!/assets/old-12345678.js']; + writeFileSync(join(root, 'dist', 'ssr', 'wrangler.json'), JSON.stringify(wrangler)); + } + + it('refreshes an earlier bypass on a client-only watch rebuild', async () => { + const root = createBuildRoot(); + writeEarlierBypass(root); + await runClientBuild(root); + expect(JSON.parse(wranglerText(root)).assets.run_worker_first).toEqual(['/**', `!/${CHUNK}`]); + expect(readFileSync(headersPath(root), 'utf8')).toContain(`/${CHUNK}\n`); + }); + + it('sends every path to the Worker again when a client-only rebuild is not eligible', async () => { + const root = createBuildRoot(); + writeEarlierBypass(root); + await runClientBuild(root, { routing: { headers: { '/*': ['X-Frame-Options: DENY'] } } }); + expect(wranglerText(root)).toBe(WRANGLER); + expect(existsSync(headersPath(root))).toBe(false); + }); + + it('writes nothing without a built wrangler.json', async () => { + const root = createBuildRoot({ wrangler: false }); + await runClientBuild(root); + expect(existsSync(join(root, 'dist', 'ssr', 'wrangler.json'))).toBe(false); + expect(existsSync(headersPath(root))).toBe(false); + }); +}); diff --git a/packages/void/test/unit/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts index c8405ef25..2fca4b381 100644 --- a/packages/void/test/unit/routing-runtime.test.ts +++ b/packages/void/test/unit/routing-runtime.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from 'vite-plus/test'; import { createClientAssetTags, createManifestLoader } from '../../src/runtime/routing-assets.ts'; import { createErrorRenderer } from '../../src/runtime/routing-errors.ts'; import { + createAssetNotFound, createPageGetHandler, createPageMutationHandler, createPagesFallback, @@ -875,3 +876,79 @@ describe('typed client asset tags', () => { }); }); }); + +describe('static asset marker', () => { + const policy = 'public, max-age=0, must-revalidate'; + const env = { + ASSETS: { + fetch: async (request: Request) => + ['/favicon.ico', '/assets/client-abcdefgh.js'].includes(new URL(request.url).pathname) + ? new Response('file', { headers: { 'cache-control': policy } }) + : new Response('missing', { status: 404 }), + }, + }; + + function markerApp( + fallback: ( + routing: ReturnType, + ) => (c: Context) => Promise, + nodeTarget = false, + ) { + const app = new Hono(); + const routing = createRoutingRuntime(app, { nodeTarget }); + const seen: Array = []; + app.use(routing.contextMiddleware); + app.use(async (c, next) => { + await next(); + seen.push((c as Context).get('__voidStaticAsset')); + }); + app.get('/new', (c) => c.text('fallback rule')); + app.notFound(fallback(routing)); + return { app, seen }; + } + const fallbackRules = [{ source: '/old', destination: '/new', status: 200 }]; + const errors = createErrorRenderer({ projectRoot: '/project', homeDir: '/home' }); + + it.each([ + [ + 'Pages', + (routing: ReturnType) => + createPagesFallback({ routing, errors, fallbackRules }), + ['/missing', '/missing.css', '/old', '/missing/'], + ], + [ + 'SSR', + (routing: ReturnType) => + createSsrFallback({ + routing, + fallbackRules, + load: async () => ({ render: async () => new Response('rendered') }), + assets: async () => ({ css: '', body: '', preloads: '' }), + }), + ['/document', '/missing.css', '/old.css'], + ], + [ + 'asset-only', + (routing: ReturnType) => + createAssetNotFound({ routing, fallbackRules }), + ['/missing', '/old'], + ], + ])('marks only ASSETS hits from the %s fallback', async (_label, fallback, unmarked) => { + const { app, seen } = markerApp(fallback); + for (const path of ['/favicon.ico', '/assets/client-abcdefgh.js']) { + const response = await app.fetch(new Request('https://app.test' + path), env); + expect(response.status).toBe(200); + expect(seen.pop()).toMatchObject({ policy: response.headers.get('cache-control') }); + } + for (const path of unmarked) { + await app.fetch(new Request('https://app.test' + path), env); + expect(seen.pop(), path).toBeUndefined(); + } + }); + + it('does not mark responses without an ASSETS binding', async () => { + const { app, seen } = markerApp((routing) => createPagesFallback({ routing, errors })); + await app.fetch(new Request('https://app.test/favicon.ico')); + expect(seen.pop()).toBeUndefined(); + }); +});