From d4bf994c80618226e015245a12224b0da1fae881 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 22 Oct 2026 19:18:51 +0800 Subject: [PATCH 01/11] fix(auth): keep static asset cache policy for signed-in requests Static files that the Worker serves from ASSETS ran inside the auth middleware, which copied the session lookup's `no-store` and `pragma: no-cache` onto them for any request with a cookie, Authorization or CF Access header. Browsers then refetched hashed JS and CSS on every navigation once a user signed in. The Pages, SSR and asset-only fallbacks now record the Cache-Control of the file they served on the Hono context. When the final response is still that file (success status, same Cache-Control, no added Set-Cookie), the auth middleware keeps the file's own policy and only makes it private for credentialed requests, e.g. `private, max-age=31536000, immutable`. A session cookie refresh on the same request still gets `private, no-store`. Pages, API routes, and files whose headers user middleware changed keep today's behaviour. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guide/auth.md | 2 + .../void/src/runtime/better-auth-shared.ts | 66 ++++++++++++ packages/void/src/runtime/routing-pages.ts | 7 +- .../void/src/runtime/static-asset-marker.ts | 28 +++++ .../integration/auth-isr-cache-policy.test.ts | 101 +++++++++++++++++- .../void/test/unit/auth-middleware.test.ts | 96 ++++++++++++++++- .../void/test/unit/routing-runtime.test.ts | 77 +++++++++++++ 7 files changed, 371 insertions(+), 6 deletions(-) create mode 100644 packages/void/src/runtime/static-asset-marker.ts diff --git a/docs/guide/auth.md b/docs/guide/auth.md index 1b8829fe6..881396a71 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, marked `private`, so the browser reuses cached JavaScript and CSS. A request that refreshes the session cookie still 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/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..d34b71137 --- /dev/null +++ b/packages/void/src/runtime/static-asset-marker.ts @@ -0,0 +1,28 @@ +import type { Context } from 'hono'; + +const STATIC_ASSET_KEY = '__voidStaticAsset'; + +/** + * Records that the fallback answered with a file from the ASSETS binding, and + * the Cache-Control it carried. 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. + */ +export function markStaticAsset(c: Context, response: Response): Response { + c.set(STATIC_ASSET_KEY, response.headers.get('cache-control') ?? ''); + return response; +} + +/** + * True when the final response is still the marked file: a success status, the + * Cache-Control the fallback served, and no cookie added by later middleware. + */ +export function isUnchangedStaticAsset(c: Context): boolean { + const policy: unknown = c.get(STATIC_ASSET_KEY); + return ( + typeof policy === 'string' && + [200, 206, 304].includes(c.res.status) && + (c.res.headers.get('cache-control') ?? '') === 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..558b07e5d 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,85 @@ 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'], + }, + }); + } + return ['/assets/client-abcdefgh.js', '/favicon.ico'].includes(path) + ? new Response('file', { headers: { 'cache-control': revalidate, etag: '"file"' } }) + : new Response('Not Found', { status: 404 }); + }, + }, + }; + 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); + }, + ); + + 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/unit/auth-middleware.test.ts b/packages/void/test/unit/auth-middleware.test.ts index 50ed51642..0a0d426ae 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,95 @@ 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=/')], + ])('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('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/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts index c8405ef25..0df9950ae 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()).toBe(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(); + }); +}); From 69f69eb5bb83134e51e4fe2cd8b7acb81a4bf6f4 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 22 Oct 2026 19:53:50 +0800 Subject: [PATCH 02/11] feat(cloudflare): let hashed build files skip the Worker on eligible native builds Every request for a fingerprinted chunk started the Worker, ran the auth middleware and, for signed-in users, a session read, only to copy the asset response. Native Cloudflare builds now patch the built wrangler.json to `run_worker_first: ['/**', '!//*']` and write a Void-generated `_headers` that lists each manifest-proven chunk with `public, max-age=31536000, immutable`, so the asset layer keeps today's cache policy. This applies only when every condition holds: no global middleware, no header/redirect/rewrite/fallback rules, no public `_headers`/`_redirects`, no route, page, WebSocket, prerender or revalidate pattern that reaches the assets folder, base '/', 1-100 hashed chunks, no user `_headers` in the client output, and not a managed build. Otherwise the output is unchanged, and a stale generated `_headers` is removed. The pinned-secrets version upload sends the generated `_headers` as a `_headers` text/plain module, as the Workers versions API and cf expect. User-authored `_headers` files are still not sent on that path. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guide/edge/static-assets.md | 2 + packages/void/src/asset-worker-bypass.ts | 195 ++++++++++++++++++ packages/void/src/cli/cf-version-api.ts | 31 +++ packages/void/src/client-asset-manifest.ts | 12 +- packages/void/src/index.ts | 2 + packages/void/src/plugin-routing.ts | 153 +++++++++++++- .../native-asset-cache-policy.test.ts | 83 ++++++++ .../integration/spa-middleware-auth.test.ts | 11 + .../test/integration/ssr-playground.test.ts | 26 ++- .../test/unit/asset-worker-bypass.test.ts | 156 ++++++++++++++ .../void/test/unit/cf-version-runner.test.ts | 93 +++++++++ .../test/unit/client-asset-manifest.test.ts | 57 +++++ .../void/test/unit/plugin-routing.test.ts | 170 ++++++++++++++- 13 files changed, 978 insertions(+), 13 deletions(-) create mode 100644 packages/void/src/asset-worker-bypass.ts create mode 100644 packages/void/test/unit/asset-worker-bypass.test.ts create mode 100644 packages/void/test/unit/client-asset-manifest.test.ts diff --git a/docs/guide/edge/static-assets.md b/docs/guide/edge/static-assets.md index aa90a73c2..2fc089229 100644 --- a/docs/guide/edge/static-assets.md +++ b/docs/guide/edge/static-assets.md @@ -37,6 +37,8 @@ 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. +If your app has no global middleware and no header, redirect, rewrite or fallback rules, Cloudflare serves the files in the assets directory (`assets/` by default) without starting your Worker, so they do not count as Worker requests. Adding global middleware, one of those rules, or a route or page that matches paths in the assets directory sends these files through your Worker again. + ## 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..933e972d9 --- /dev/null +++ b/packages/void/src/asset-worker-bypass.ts @@ -0,0 +1,195 @@ +/** + * 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 + * `//*`: 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. + * + * The asset layer does not know which files are fingerprinted, so a generated + * `_headers` file lists every manifest-proven chunk with the immutable policy + * the Worker would have set. Files it does not list keep Cloudflare's default + * revalidation policy, which matches what the Worker returns for them today. + */ + +export const GENERATED_ASSET_HEADERS_MARKER = + '# Generated by Void: immutable caching for fingerprinted build files. Do not edit.'; +/** Cloudflare accepts at most 100 `_headers` rules. */ +export const MAX_ASSET_HEADER_RULES = 100; +/** Cloudflare limits each `run_worker_first` rule to 100 characters. */ +export 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 = { + assetsDir: string; + /** Immutable files under `assetsDir`, sorted. */ + files: Array; +}; + +/** 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 !== '..'); +} + +/** 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; + if ( + !Array.isArray(runWorkerFirst) || + runWorkerFirst.length !== 1 || + runWorkerFirst[0] !== '/**' || + 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) || !withAssetBypassRule(['/**'], 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(); + if ( + files.length === 0 || + files.length > MAX_ASSET_HEADER_RULES || + files.some((file) => !isPlainPath(file)) + ) { + return null; + } + return { assetsDir, files }; +} + +/** `['/**']` plus the negative rule for the assets folder, or null when it cannot be added. */ +export function withAssetBypassRule( + runWorkerFirst: unknown, + assetsDir: string, +): Array | null { + if ( + !Array.isArray(runWorkerFirst) || + runWorkerFirst.length !== 1 || + runWorkerFirst[0] !== '/**' + ) { + return null; + } + const rule = '!/' + assetsDir + '/*'; + return rule.length > MAX_RUN_WORKER_FIRST_RULE_LENGTH ? null : ['/**', rule]; +} + +/** The generated `_headers` text, or null when the files do not fit Cloudflare's rule limit. */ +export function renderGeneratedAssetHeaders(files: Array): string | null { + if (files.length === 0 || files.length > MAX_ASSET_HEADER_RULES) { + return null; + } + 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..8fe91c122 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,14 @@ import { resolveAssetRoutingPolicy, type AssetRoutingPolicy, } from './asset-routing.ts'; +import { + isGeneratedAssetHeaders, + renderGeneratedAssetHeaders, + resolveAssetWorkerBypass, + withAssetBypassRule, +} 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 +95,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 +387,88 @@ 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 and the patched `wrangler.json` text when + * this build can skip the Worker for its assets folder, or null. Reads only, + * so a read failure leaves the output unchanged. + */ + function planAssetWorkerBypass( + clientDir: string, + assetsDir: string, + ): { headers: string; wranglerPath: string; wranglerText: string } | null { + if (!scan || !resolvedConfig || compileOptions.managedDispatch) { + return null; + } + let wranglerPath: string; + let text: string; + let wrangler: { assets?: Record }; + try { + const distDir = resolveDistDir(root); + wranglerPath = join(distDir, resolveWorkerDirName(distDir), 'wrangler.json'); + text = readFileSync(wranglerPath, 'utf8'); + wrangler = JSON.parse(text); + } 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 bypass = resolveAssetWorkerBypass({ + 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 runWorkerFirst = bypass && withAssetBypassRule(assets.run_worker_first, bypass.assetsDir); + const headers = bypass && renderGeneratedAssetHeaders(bypass.files); + if (!runWorkerFirst || !headers) { + return null; + } + // Keep the file's own layout: only `run_worker_first` changes. + let indent = ''; + if (text[1] === '\n') { + for (let i = 2; text[i] === ' ' || text[i] === '\t'; i++) { + indent += text[i]; + } + } + const patched = JSON.stringify( + { ...wrangler, assets: { ...assets, run_worker_first: runWorkerFirst } }, + null, + indent || undefined, + ); + return { + headers, + wranglerPath, + wranglerText: text.endsWith('\n') ? patched + '\n' : patched, + }; + } + const sandboxEntry = options.hasSandbox ? { bindingName: options.sandboxBindingName, @@ -519,16 +614,58 @@ 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; + if (existsSync(headersPath)) { + try { + generatedHeaders = isGeneratedAssetHeaders(readFileSync(headersPath, 'utf8')); + } catch { + return; + } + // A user-authored `_headers` keeps the Worker in front, as before. + if (!generatedHeaders) { + return; + } + } + const plan = planAssetWorkerBypass(clientDir, assetsDir); + if (!plan) { + // A `_headers` left by an earlier build must not outlive the bypass. + if (generatedHeaders) { + rmSync(headersPath); + } + return; + } + // `_headers` first: on its own it changes nothing, as the Worker sets + // the same policy for these files. Write errors fail the build. + writeFileSync(headersPath, plan.headers); + writeFileSync(plan.wranglerPath, plan.wranglerText); + }, + async buildStart() { await rescan(); if (options.target && (scan?.websockets?.length ?? 0) > 0) { 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..af3b2e7be 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 { renderGeneratedAssetHeaders, withAssetBypassRule } 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,85 @@ 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;'); + } + writeFileSync( + join(assets, '_headers'), + renderGeneratedAssetHeaders([ + 'static/client-abcdefgh.js', + 'static/nested/chunk-abcdefgh.js', + ])!, + ); + const runWorkerFirst = withAssetBypassRule(['/**'], 'static')!; + expect(runWorkerFirst).toEqual(['/**', '!/static/*']); + 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('keeps the default policy for unlisted files and does not cache misses', 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.has('x-void-worker')).toBe(false); + await unlisted.arrayBuffer(); + const missing = await bypass!.dispatchFetch('https://app.test/static/gone-abcdefgh.js'); + expect(missing.status).toBe(404); + expect(missing.headers.get('cache-control') ?? '').not.toContain('immutable'); + expect(missing.headers.has('x-void-worker')).toBe(false); + 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..46b515a4a 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,25 @@ 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. + expect(wranglerConfig.assets?.run_worker_first).toEqual(['/**', '!/assets/*']); 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..5c12405e9 --- /dev/null +++ b/packages/void/test/unit/asset-worker-bypass.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { + GENERATED_ASSET_HEADERS_MARKER, + isGeneratedAssetHeaders, + normalizeAssetsDir, + patternCanReachUnder, + renderGeneratedAssetHeaders, + resolveAssetWorkerBypass, + withAssetBypassRule, + 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 = { + assetsDir: 'assets', + files: ['assets/chunk-12345678.js', 'assets/main-abcdefgh.js'], + }; + expect(resolveAssetWorkerBypass(facts())).toEqual(expected); + expect(resolveAssetWorkerBypass(facts({ authEnabled: true }))).toEqual(expected); + }); + + it('lists only immutable files under the assets folder', () => { + expect( + resolveAssetWorkerBypass( + facts({ immutableFiles: ['root-abcdefgh.js', 'assets/a-12345678.js'] }), + )?.files, + ).toEqual(['assets/a-12345678.js']); + }); + + 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: [] }], + ['101 immutable files', { immutableFiles: files(101) }], + ['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 exactly 100 immutable files', () => { + expect(resolveAssetWorkerBypass(facts({ immutableFiles: files(100) }))?.files).toHaveLength( + 100, + ); + }); +}); + +describe('withAssetBypassRule', () => { + it('adds the negative rule only to a lone /** rule', () => { + expect(withAssetBypassRule(['/**'], 'assets')).toEqual(['/**', '!/assets/*']); + expect(withAssetBypassRule(['/**', '/x'], 'assets')).toBeNull(); + expect(withAssetBypassRule(true, 'assets')).toBeNull(); + expect(withAssetBypassRule(['/**'], 'a'.repeat(100))).toBeNull(); + }); +}); + +describe('renderGeneratedAssetHeaders', () => { + it('writes one immutable rule per file after the marker', () => { + expect(renderGeneratedAssetHeaders(['assets/a-12345678.js', 'assets/b-abcdefgh.js'])).toBe( + `${GENERATED_ASSET_HEADERS_MARKER} +/assets/a-12345678.js + Cache-Control: public, max-age=31536000, immutable +/assets/b-abcdefgh.js + Cache-Control: public, max-age=31536000, immutable +`, + ); + }); + + it('returns null outside the rule limit', () => { + expect(renderGeneratedAssetHeaders([])).toBeNull(); + expect(renderGeneratedAssetHeaders(files(101))).toBeNull(); + }); +}); + +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/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..3b765e992 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,170 @@ 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 assets folder from the Worker and lists the chunk as immutable', async () => { + const root = createBuildRoot(); + await runClientBuild(root); + const wrangler = JSON.parse(wranglerText(root)); + expect(wrangler.assets.run_worker_first).toEqual(['/**', '!/assets/*']); + 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 }], + ])('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); + }); + + 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); + }); +}); From da40e28a34178d23a9b79856cc8eea83b33aa5fa Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 22 Oct 2026 20:26:19 +0800 Subject: [PATCH 03/11] fix(auth): treat a static file whose body middleware replaced as a route A middleware that swaps c.res after next() keeps the file's Cache-Control and ETag, so the auth middleware still saw an unchanged file and gave the new body a cacheable private policy. Record the body stream with the marker and require the final response to keep it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../void/src/runtime/static-asset-marker.ts | 27 ++++++++++++------- .../void/test/unit/auth-middleware.test.ts | 26 ++++++++++++++++++ .../void/test/unit/routing-runtime.test.ts | 2 +- 3 files changed, 45 insertions(+), 10 deletions(-) diff --git a/packages/void/src/runtime/static-asset-marker.ts b/packages/void/src/runtime/static-asset-marker.ts index d34b71137..421704182 100644 --- a/packages/void/src/runtime/static-asset-marker.ts +++ b/packages/void/src/runtime/static-asset-marker.ts @@ -2,27 +2,36 @@ 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, and - * the Cache-Control it carried. 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. + * 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 { - c.set(STATIC_ASSET_KEY, response.headers.get('cache-control') ?? ''); + 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 - * Cache-Control the fallback served, and no cookie added by later middleware. + * same body and Cache-Control the fallback served, and no cookie added by + * later middleware. */ export function isUnchangedStaticAsset(c: Context): boolean { - const policy: unknown = c.get(STATIC_ASSET_KEY); + const mark = c.get(STATIC_ASSET_KEY) as StaticAssetMark | undefined; return ( - typeof policy === 'string' && + mark !== undefined && [200, 206, 304].includes(c.res.status) && - (c.res.headers.get('cache-control') ?? '') === policy && + 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/unit/auth-middleware.test.ts b/packages/void/test/unit/auth-middleware.test.ts index 0a0d426ae..296b1f90c 100644 --- a/packages/void/test/unit/auth-middleware.test.ts +++ b/packages/void/test/unit/auth-middleware.test.ts @@ -472,6 +472,18 @@ describe('runVoidAuthMiddleware static asset cache policy', () => { 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) => { @@ -486,6 +498,20 @@ describe('runVoidAuthMiddleware static asset cache policy', () => { 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'); diff --git a/packages/void/test/unit/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts index 0df9950ae..2fca4b381 100644 --- a/packages/void/test/unit/routing-runtime.test.ts +++ b/packages/void/test/unit/routing-runtime.test.ts @@ -938,7 +938,7 @@ describe('static asset marker', () => { 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()).toBe(response.headers.get('cache-control')); + 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); From d4e7dfa0b3e49fcb10e737015af5474e5b93544a Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 22 Oct 2026 20:26:46 +0800 Subject: [PATCH 04/11] fix(cloudflare): drop unused layout handling when patching wrangler.json The Cloudflare Vite plugin writes the built wrangler.json as compact JSON with no trailing newline, so the indent and newline branches never ran. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/plugin-routing.ts | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/packages/void/src/plugin-routing.ts b/packages/void/src/plugin-routing.ts index 8fe91c122..dd02e1f67 100644 --- a/packages/void/src/plugin-routing.ts +++ b/packages/void/src/plugin-routing.ts @@ -450,22 +450,13 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug if (!runWorkerFirst || !headers) { return null; } - // Keep the file's own layout: only `run_worker_first` changes. - let indent = ''; - if (text[1] === '\n') { - for (let i = 2; text[i] === ' ' || text[i] === '\t'; i++) { - indent += text[i]; - } - } - const patched = JSON.stringify( - { ...wrangler, assets: { ...assets, run_worker_first: runWorkerFirst } }, - null, - indent || undefined, - ); return { headers, wranglerPath, - wranglerText: text.endsWith('\n') ? patched + '\n' : patched, + wranglerText: JSON.stringify({ + ...wrangler, + assets: { ...assets, run_worker_first: runWorkerFirst }, + }), }; } From 35abb26791b9222e88a047f9dd599511e80b8814 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 22 Oct 2026 20:28:09 +0800 Subject: [PATCH 05/11] fix(cloudflare): build the asset bypass rule and _headers in one place resolveAssetWorkerBypass now returns the final run_worker_first and the _headers text, so the plugin no longer repeats its checks through two exported helpers whose null results could not happen. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/asset-worker-bypass.ts | 39 ++++---------- packages/void/src/plugin-routing.ts | 19 ++----- .../native-asset-cache-policy.test.ts | 26 +++++---- .../test/unit/asset-worker-bypass.test.ts | 53 +++++-------------- 4 files changed, 47 insertions(+), 90 deletions(-) diff --git a/packages/void/src/asset-worker-bypass.ts b/packages/void/src/asset-worker-bypass.ts index 933e972d9..e23efe377 100644 --- a/packages/void/src/asset-worker-bypass.ts +++ b/packages/void/src/asset-worker-bypass.ts @@ -15,9 +15,9 @@ export const GENERATED_ASSET_HEADERS_MARKER = '# Generated by Void: immutable caching for fingerprinted build files. Do not edit.'; /** Cloudflare accepts at most 100 `_headers` rules. */ -export const MAX_ASSET_HEADER_RULES = 100; +const MAX_ASSET_HEADER_RULES = 100; /** Cloudflare limits each `run_worker_first` rule to 100 characters. */ -export const MAX_RUN_WORKER_FIRST_RULE_LENGTH = 100; +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. @@ -47,9 +47,10 @@ export type AssetWorkerBypassFacts = { }; export type AssetWorkerBypass = { - assetsDir: string; - /** Immutable files under `assetsDir`, sorted. */ - files: Array; + /** The new `assets.run_worker_first`. */ + runWorkerFirst: Array; + /** The generated `_headers` text. */ + headers: string; }; /** Trim surrounding slashes; null when nothing is left. */ @@ -133,7 +134,8 @@ export function resolveAssetWorkerBypass(facts: AssetWorkerBypassFacts): AssetWo return null; } const assetsDir = normalizeAssetsDir(facts.assetsDir); - if (!assetsDir || !isPlainPath(assetsDir) || !withAssetBypassRule(['/**'], assetsDir)) { + const rule = '!/' + assetsDir + '/*'; + if (!assetsDir || !isPlainPath(assetsDir) || rule.length > MAX_RUN_WORKER_FIRST_RULE_LENGTH) { return null; } const patterns = [ @@ -153,30 +155,11 @@ export function resolveAssetWorkerBypass(facts: AssetWorkerBypassFacts): AssetWo ) { return null; } - return { assetsDir, files }; + return { runWorkerFirst: ['/**', rule], headers: renderGeneratedAssetHeaders(files) }; } -/** `['/**']` plus the negative rule for the assets folder, or null when it cannot be added. */ -export function withAssetBypassRule( - runWorkerFirst: unknown, - assetsDir: string, -): Array | null { - if ( - !Array.isArray(runWorkerFirst) || - runWorkerFirst.length !== 1 || - runWorkerFirst[0] !== '/**' - ) { - return null; - } - const rule = '!/' + assetsDir + '/*'; - return rule.length > MAX_RUN_WORKER_FIRST_RULE_LENGTH ? null : ['/**', rule]; -} - -/** The generated `_headers` text, or null when the files do not fit Cloudflare's rule limit. */ -export function renderGeneratedAssetHeaders(files: Array): string | null { - if (files.length === 0 || files.length > MAX_ASSET_HEADER_RULES) { - return null; - } +/** 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); diff --git a/packages/void/src/plugin-routing.ts b/packages/void/src/plugin-routing.ts index dd02e1f67..978b0084b 100644 --- a/packages/void/src/plugin-routing.ts +++ b/packages/void/src/plugin-routing.ts @@ -11,12 +11,7 @@ import { resolveAssetRoutingPolicy, type AssetRoutingPolicy, } from './asset-routing.ts'; -import { - isGeneratedAssetHeaders, - renderGeneratedAssetHeaders, - resolveAssetWorkerBypass, - withAssetBypassRule, -} from './asset-worker-bypass.ts'; +import { 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'; @@ -403,13 +398,11 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug return null; } let wranglerPath: string; - let text: string; let wrangler: { assets?: Record }; try { const distDir = resolveDistDir(root); wranglerPath = join(distDir, resolveWorkerDirName(distDir), 'wrangler.json'); - text = readFileSync(wranglerPath, 'utf8'); - wrangler = JSON.parse(text); + wrangler = JSON.parse(readFileSync(wranglerPath, 'utf8')); } catch { return null; } @@ -445,17 +438,15 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug assetsDir, immutableFiles: clientImmutableFiles, }); - const runWorkerFirst = bypass && withAssetBypassRule(assets.run_worker_first, bypass.assetsDir); - const headers = bypass && renderGeneratedAssetHeaders(bypass.files); - if (!runWorkerFirst || !headers) { + if (!bypass) { return null; } return { - headers, + headers: bypass.headers, wranglerPath, wranglerText: JSON.stringify({ ...wrangler, - assets: { ...assets, run_worker_first: runWorkerFirst }, + assets: { ...assets, run_worker_first: bypass.runWorkerFirst }, }), }; } 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 af3b2e7be..2bb5105a9 100644 --- a/packages/void/test/integration/native-asset-cache-policy.test.ts +++ b/packages/void/test/integration/native-asset-cache-policy.test.ts @@ -6,7 +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 { renderGeneratedAssetHeaders, withAssetBypassRule } from '../../src/asset-worker-bypass.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); @@ -295,14 +295,22 @@ describe('native asset Worker bypass in real Workers', () => { for (const file of ['client-abcdefgh.js', 'client.js', 'nested/chunk-abcdefgh.js']) { writeFileSync(join(assets, 'static', file), 'export const value = 1;'); } - writeFileSync( - join(assets, '_headers'), - renderGeneratedAssetHeaders([ - 'static/client-abcdefgh.js', - 'static/nested/chunk-abcdefgh.js', - ])!, - ); - const runWorkerFirst = withAssetBypassRule(['/**'], 'static')!; + 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/*']); bypass = new Miniflare( convertV4MiniflareOptions({ diff --git a/packages/void/test/unit/asset-worker-bypass.test.ts b/packages/void/test/unit/asset-worker-bypass.test.ts index 5c12405e9..fde98308c 100644 --- a/packages/void/test/unit/asset-worker-bypass.test.ts +++ b/packages/void/test/unit/asset-worker-bypass.test.ts @@ -4,9 +4,7 @@ import { isGeneratedAssetHeaders, normalizeAssetsDir, patternCanReachUnder, - renderGeneratedAssetHeaders, resolveAssetWorkerBypass, - withAssetBypassRule, type AssetWorkerBypassFacts, } from '../../src/asset-worker-bypass.ts'; @@ -65,19 +63,24 @@ describe('normalizeAssetsDir', () => { describe('resolveAssetWorkerBypass', () => { it('lets eligible apps, including auth apps, skip the Worker for the assets folder', () => { const expected = { - assetsDir: 'assets', - files: ['assets/chunk-12345678.js', 'assets/main-abcdefgh.js'], + runWorkerFirst: ['/**', '!/assets/*'], + 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('lists only immutable files under the assets folder', () => { - expect( - resolveAssetWorkerBypass( - facts({ immutableFiles: ['root-abcdefgh.js', 'assets/a-12345678.js'] }), - )?.files, - ).toEqual(['assets/a-12345678.js']); + 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]>([ @@ -112,36 +115,8 @@ describe('resolveAssetWorkerBypass', () => { }); it('accepts exactly 100 immutable files', () => { - expect(resolveAssetWorkerBypass(facts({ immutableFiles: files(100) }))?.files).toHaveLength( - 100, - ); - }); -}); - -describe('withAssetBypassRule', () => { - it('adds the negative rule only to a lone /** rule', () => { - expect(withAssetBypassRule(['/**'], 'assets')).toEqual(['/**', '!/assets/*']); - expect(withAssetBypassRule(['/**', '/x'], 'assets')).toBeNull(); - expect(withAssetBypassRule(true, 'assets')).toBeNull(); - expect(withAssetBypassRule(['/**'], 'a'.repeat(100))).toBeNull(); - }); -}); - -describe('renderGeneratedAssetHeaders', () => { - it('writes one immutable rule per file after the marker', () => { - expect(renderGeneratedAssetHeaders(['assets/a-12345678.js', 'assets/b-abcdefgh.js'])).toBe( - `${GENERATED_ASSET_HEADERS_MARKER} -/assets/a-12345678.js - Cache-Control: public, max-age=31536000, immutable -/assets/b-abcdefgh.js - Cache-Control: public, max-age=31536000, immutable -`, - ); - }); - - it('returns null outside the rule limit', () => { - expect(renderGeneratedAssetHeaders([])).toBeNull(); - expect(renderGeneratedAssetHeaders(files(101))).toBeNull(); + const headers = resolveAssetWorkerBypass(facts({ immutableFiles: files(100) }))?.headers; + expect(headers?.split('\n').filter((line) => line.startsWith('/'))).toHaveLength(100); }); }); From 4eb6a196c5698ec7140285b3ea9a64e0c48be2a2 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 22 Oct 2026 20:30:33 +0800 Subject: [PATCH 06/11] fix(cloudflare): skip the Worker only for the hashed files themselves A negative run_worker_first rule sends every matching request to the asset layer, misses included, so '!/assets/*' took SSR rendering, the SPA shell, the custom 404 page and the /assets redirect away from paths under the assets folder. Emit one negative rule per immutable file instead, which caps the bypass at 99 files. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/asset-worker-bypass.ts | 31 ++++++++++--------- .../native-asset-cache-policy.test.ts | 22 ++++++++----- .../test/integration/ssr-playground.test.ts | 4 ++- .../test/unit/asset-worker-bypass.test.ts | 11 ++++--- .../void/test/unit/plugin-routing.test.ts | 4 +-- 5 files changed, 42 insertions(+), 30 deletions(-) diff --git a/packages/void/src/asset-worker-bypass.ts b/packages/void/src/asset-worker-bypass.ts index e23efe377..3ea98951b 100644 --- a/packages/void/src/asset-worker-bypass.ts +++ b/packages/void/src/asset-worker-bypass.ts @@ -1,21 +1,23 @@ /** * 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 - * `//*`: 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. + * 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 lists every manifest-proven chunk with the immutable policy - * the Worker would have set. Files it does not list keep Cloudflare's default - * revalidation policy, which matches what the Worker returns for them today. + * `_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 `_headers` rules. */ -const MAX_ASSET_HEADER_RULES = 100; +/** 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; @@ -134,8 +136,7 @@ export function resolveAssetWorkerBypass(facts: AssetWorkerBypassFacts): AssetWo return null; } const assetsDir = normalizeAssetsDir(facts.assetsDir); - const rule = '!/' + assetsDir + '/*'; - if (!assetsDir || !isPlainPath(assetsDir) || rule.length > MAX_RUN_WORKER_FIRST_RULE_LENGTH) { + if (!assetsDir || !isPlainPath(assetsDir)) { return null; } const patterns = [ @@ -148,14 +149,16 @@ export function resolveAssetWorkerBypass(facts: AssetWorkerBypassFacts): AssetWo } 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_ASSET_HEADER_RULES || - files.some((file) => !isPlainPath(file)) + 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: ['/**', rule], headers: renderGeneratedAssetHeaders(files) }; + return { runWorkerFirst: ['/**', ...rules], headers: renderGeneratedAssetHeaders(files) }; } /** One immutable rule per file after the marker line. */ 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 2bb5105a9..0d34072df 100644 --- a/packages/void/test/integration/native-asset-cache-policy.test.ts +++ b/packages/void/test/integration/native-asset-cache-policy.test.ts @@ -311,7 +311,11 @@ describe('native asset Worker bypass in real Workers', () => { })!; writeFileSync(join(assets, '_headers'), plan.headers); const runWorkerFirst = plan.runWorkerFirst; - expect(runWorkerFirst).toEqual(['/**', '!/static/*']); + expect(runWorkerFirst).toEqual([ + '/**', + '!/static/client-abcdefgh.js', + '!/static/nested/chunk-abcdefgh.js', + ]); bypass = new Miniflare( convertV4MiniflareOptions({ compatibilityDate: '2026-07-01', @@ -353,16 +357,18 @@ describe('native asset Worker bypass in real Workers', () => { } }); - it('keeps the default policy for unlisted files and does not cache misses', async () => { + 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.has('x-void-worker')).toBe(false); + expect(unlisted.headers.get('x-void-worker')).toBe('1'); await unlisted.arrayBuffer(); - const missing = await bypass!.dispatchFetch('https://app.test/static/gone-abcdefgh.js'); - expect(missing.status).toBe(404); - expect(missing.headers.get('cache-control') ?? '').not.toContain('immutable'); - expect(missing.headers.has('x-void-worker')).toBe(false); - await missing.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 () => { diff --git a/packages/void/test/integration/ssr-playground.test.ts b/packages/void/test/integration/ssr-playground.test.ts index 46b515a4a..681367061 100644 --- a/packages/void/test/integration/ssr-playground.test.ts +++ b/packages/void/test/integration/ssr-playground.test.ts @@ -140,7 +140,9 @@ describe('playground/ssr build + preview', () => { kv_namespaces?: Array<{ binding: string }>; }; // No middleware or routing rules: Cloudflare serves the hashed bundles itself. - expect(wranglerConfig.assets?.run_worker_first).toEqual(['/**', '!/assets/*']); + 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); diff --git a/packages/void/test/unit/asset-worker-bypass.test.ts b/packages/void/test/unit/asset-worker-bypass.test.ts index fde98308c..c9a7269ca 100644 --- a/packages/void/test/unit/asset-worker-bypass.test.ts +++ b/packages/void/test/unit/asset-worker-bypass.test.ts @@ -63,7 +63,7 @@ describe('normalizeAssetsDir', () => { describe('resolveAssetWorkerBypass', () => { it('lets eligible apps, including auth apps, skip the Worker for the assets folder', () => { const expected = { - runWorkerFirst: ['/**', '!/assets/*'], + 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 @@ -104,7 +104,7 @@ describe('resolveAssetWorkerBypass', () => { { authEnabled: true, assetsDir: 'api/auth', immutableFiles: ['api/auth/a-12345678.js'] }, ], ['no immutable files', { immutableFiles: [] }], - ['101 immutable files', { immutableFiles: files(101) }], + ['100 immutable files', { immutableFiles: files(100) }], ['a file name with a placeholder', { immutableFiles: ['assets/a:b-12345678.js'] }], [ 'a rule over 100 characters', @@ -114,9 +114,10 @@ describe('resolveAssetWorkerBypass', () => { expect(resolveAssetWorkerBypass(facts(overrides))).toBeNull(); }); - it('accepts exactly 100 immutable files', () => { - const headers = resolveAssetWorkerBypass(facts({ immutableFiles: files(100) }))?.headers; - expect(headers?.split('\n').filter((line) => line.startsWith('/'))).toHaveLength(100); + 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); }); }); diff --git a/packages/void/test/unit/plugin-routing.test.ts b/packages/void/test/unit/plugin-routing.test.ts index 3b765e992..796a27d33 100644 --- a/packages/void/test/unit/plugin-routing.test.ts +++ b/packages/void/test/unit/plugin-routing.test.ts @@ -536,11 +536,11 @@ describe('routingPlugin native asset Worker bypass', () => { readFileSync(join(root, 'dist', 'ssr', 'wrangler.json'), 'utf8'); const headersPath = (root: string) => join(root, 'dist', 'client', '_headers'); - it('excludes the assets folder from the Worker and lists the chunk as immutable', async () => { + 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(['/**', '!/assets/*']); + expect(wrangler.assets.run_worker_first).toEqual(['/**', `!/${CHUNK}`]); expect({ ...wrangler, assets: { ...wrangler.assets, run_worker_first: ['/**'] } }).toEqual( JSON.parse(WRANGLER), ); From 175608089fafde412e6cd99cd575e62f3c76683c Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 22 Oct 2026 20:32:57 +0800 Subject: [PATCH 07/11] fix(cloudflare): keep _headers and run_worker_first in step on watch rebuilds In vite build --watch a client-only rebuild empties dist/client but keeps the Worker's wrangler.json, which still held the earlier bypass rule. The plan then saw no lone '/**' and wrote nothing, leaving hashed chunks out of the Worker with no immutable _headers. Treat the earlier rule as '/**', and write '/**' back when the rebuild cannot use the bypass. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/asset-worker-bypass.ts | 25 +++++++- packages/void/src/plugin-routing.ts | 62 ++++++++++--------- .../test/unit/asset-worker-bypass.test.ts | 19 ++++++ .../void/test/unit/plugin-routing.test.ts | 22 +++++++ 4 files changed, 97 insertions(+), 31 deletions(-) diff --git a/packages/void/src/asset-worker-bypass.ts b/packages/void/src/asset-worker-bypass.ts index 3ea98951b..356285048 100644 --- a/packages/void/src/asset-worker-bypass.ts +++ b/packages/void/src/asset-worker-bypass.ts @@ -118,13 +118,32 @@ function isPlainPath(path: string): boolean { 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 ( - !Array.isArray(runWorkerFirst) || - runWorkerFirst.length !== 1 || - runWorkerFirst[0] !== '/**' || + !allPaths || facts.notFoundHandling !== 'none' || facts.base !== '/' || facts.hasMiddleware || diff --git a/packages/void/src/plugin-routing.ts b/packages/void/src/plugin-routing.ts index 978b0084b..99d0db701 100644 --- a/packages/void/src/plugin-routing.ts +++ b/packages/void/src/plugin-routing.ts @@ -11,7 +11,11 @@ import { resolveAssetRoutingPolicy, type AssetRoutingPolicy, } from './asset-routing.ts'; -import { isGeneratedAssetHeaders, resolveAssetWorkerBypass } from './asset-worker-bypass.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'; @@ -386,14 +390,16 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug let clientImmutableFiles: Array = []; /** - * The generated `_headers` text and the patched `wrangler.json` text when - * this build can skip the Worker for its assets folder, or null. Reads only, - * so a read failure leaves the output unchanged. + * 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, - ): { headers: string; wranglerPath: string; wranglerText: string } | null { + userHeaders: boolean, + ): { headers: string | null; wranglerPath: string; wranglerText: string | null } | null { if (!scan || !resolvedConfig || compileOptions.managedDispatch) { return null; } @@ -416,7 +422,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug return null; } const revalidate = options.routing?.revalidate; - const bypass = resolveAssetWorkerBypass({ + const facts = { runWorkerFirst: assets.run_worker_first, notFoundHandling: assets.not_found_handling, base: resolvedConfig.base, @@ -437,17 +443,18 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug ], assetsDir, immutableFiles: clientImmutableFiles, - }); - if (!bypass) { - return null; - } + }; + 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, + headers: bypass?.headers ?? null, wranglerPath, - wranglerText: JSON.stringify({ - ...wrangler, - assets: { ...assets, run_worker_first: bypass.runWorkerFirst }, - }), + wranglerText: + runWorkerFirst && + JSON.stringify({ ...wrangler, assets: { ...assets, run_worker_first: runWorkerFirst } }), }; } @@ -623,6 +630,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug 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')); @@ -630,22 +638,20 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug return; } // A user-authored `_headers` keeps the Worker in front, as before. - if (!generatedHeaders) { - return; - } - } - const plan = planAssetWorkerBypass(clientDir, assetsDir); - if (!plan) { - // A `_headers` left by an earlier build must not outlive the bypass. - if (generatedHeaders) { - rmSync(headersPath); - } - return; + 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. - writeFileSync(headersPath, plan.headers); - writeFileSync(plan.wranglerPath, plan.wranglerText); + 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() { diff --git a/packages/void/test/unit/asset-worker-bypass.test.ts b/packages/void/test/unit/asset-worker-bypass.test.ts index c9a7269ca..1e04d4c4a 100644 --- a/packages/void/test/unit/asset-worker-bypass.test.ts +++ b/packages/void/test/unit/asset-worker-bypass.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vite-plus/test'; import { GENERATED_ASSET_HEADERS_MARKER, + isAssetBypassRule, isGeneratedAssetHeaders, normalizeAssetsDir, patternCanReachUnder, @@ -75,6 +76,13 @@ describe('resolveAssetWorkerBypass', () => { 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'] }), @@ -121,6 +129,17 @@ describe('resolveAssetWorkerBypass', () => { }); }); +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); diff --git a/packages/void/test/unit/plugin-routing.test.ts b/packages/void/test/unit/plugin-routing.test.ts index 796a27d33..7073fc574 100644 --- a/packages/void/test/unit/plugin-routing.test.ts +++ b/packages/void/test/unit/plugin-routing.test.ts @@ -612,6 +612,28 @@ describe('routingPlugin native asset Worker bypass', () => { 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); From 2edbe9f9ebc71437d9cbd8e98b6ef1317c04e0e6 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 22 Oct 2026 20:34:26 +0800 Subject: [PATCH 08/11] test(cloudflare): cover the plugin's asset bypass safety checks Add plugin-level cases for a route and a page under the assets folder, redirect and fallback rules, a public _redirects file, and revalidate and prerender paths under the folder. Each fails when its fact is dropped. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../void/test/unit/plugin-routing.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/void/test/unit/plugin-routing.test.ts b/packages/void/test/unit/plugin-routing.test.ts index 7073fc574..6ad9a723a 100644 --- a/packages/void/test/unit/plugin-routing.test.ts +++ b/packages/void/test/unit/plugin-routing.test.ts @@ -587,6 +587,35 @@ describe('routingPlugin native asset Worker bypass', () => { ], ['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); From d42e9e93a607de77713d69993280a60d455f7a21 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 22 Oct 2026 20:34:56 +0800 Subject: [PATCH 09/11] test(auth): cover a signed-in 304 revalidation of a public file Co-Authored-By: Claude Opus 5.5 (1M context) --- .../integration/auth-isr-cache-policy.test.ts | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) 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 558b07e5d..711d5f868 100644 --- a/packages/void/test/integration/auth-isr-cache-policy.test.ts +++ b/packages/void/test/integration/auth-isr-cache-policy.test.ts @@ -220,9 +220,13 @@ describe('real authenticated Better Auth session cache policy', () => { }, }); } - return ['/assets/client-abcdefgh.js', '/favicon.ico'].includes(path) - ? new Response('file', { headers: { 'cache-control': revalidate, etag: '"file"' } }) - : new Response('Not Found', { status: 404 }); + 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 }); }, }, }; @@ -274,6 +278,16 @@ describe('real authenticated Better Auth session cache policy', () => { 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); }, ); From dcebecc4357034dbc7231e6cd861a5a1dfeb739c Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 22 Oct 2026 20:35:33 +0800 Subject: [PATCH 10/11] docs(cloudflare): list every condition for serving bundles without the Worker Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guide/edge/static-assets.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/guide/edge/static-assets.md b/docs/guide/edge/static-assets.md index 2fc089229..bb697b886 100644 --- a/docs/guide/edge/static-assets.md +++ b/docs/guide/edge/static-assets.md @@ -37,7 +37,15 @@ 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. -If your app has no global middleware and no header, redirect, rewrite or fallback rules, Cloudflare serves the files in the assets directory (`assets/` by default) without starting your Worker, so they do not count as Worker requests. Adding global middleware, one of those rules, or a route or page that matches paths in the assets directory sends these files through your Worker again. +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 From 803a53bce13ffdbdd454232a394e49dd712d2034 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 22 Oct 2026 20:35:43 +0800 Subject: [PATCH 11/11] docs(auth): say static files are marked private only when the app serves them Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guide/auth.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/auth.md b/docs/guide/auth.md index 881396a71..e66866cf2 100644 --- a/docs/guide/auth.md +++ b/docs/guide/auth.md @@ -129,7 +129,7 @@ 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, marked `private`, so the browser reuses cached JavaScript and CSS. A request that refreshes the session cookie still gets `no-store`. +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.