Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/guide/edge/headers.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ Define custom response headers in [`void.config.ts`](../../reference/config) usi
- **Header values** use `Name: value` format. The value may contain colons.
- All matching rules are merged. When multiple rules set the same header name, the **last match wins**.
- User-defined `Cache-Control` overrides the built-in default. The default still applies when no rule matches.
- With [authentication](../auth.md), responses to requests that send cookies or credentials stay private, and auth endpoints stay uncached, during local development, with `void deploy --platform cloudflare`, and on [other runtimes](../deployment.md#other-runtimes). On a [Void platform](../deployment.md#deploy-to-a-team-platform), a `Cache-Control` rule replaces the app's value, so do not match signed-in pages or `/api/auth/*` with a public cache rule.
- Rules apply to matching error responses too, including 404 responses. An immutable cache rule can therefore cache a missing file. Use immutable caching only for URLs that change when their content changes.

## Scope

Expand Down
7 changes: 4 additions & 3 deletions packages/void/src/router/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,9 +294,6 @@ export function compileRouteTable(
if (emitEdgeRules && options.redirectRules?.length) {
compileRoutingRulesMiddleware(lines, options.redirectRules, options.dev === true);
}
if (emitEdgeRules && options.headerRules?.length) {
compileHeaderMiddleware(lines, options.headerRules);
}

// After routing rules, which ignore the action key as platform dispatch does,
// and before auth and user middleware, so their c.redirect() drops it too.
Expand All @@ -314,6 +311,10 @@ export function compileRouteTable(
lines.push('');
}

if (emitEdgeRules && options.headerRules?.length) {
compileHeaderMiddleware(lines, options.headerRules);
Comment on lines +314 to +315

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply authored headers when auth fails before next

If auth initialization or auth.api.getSession() throws before next()—for example, on a transient database failure—registering the header middleware after auth means it is never entered. createErrorRenderer.onError returns the error response without applying header rules, so a configured Access-Control-Allow-Origin is absent and cross-origin clients cannot read the error response; previously the outer header middleware still decorated it. Keep non-cache header finalization outside this failure boundary, or explicitly apply it to auth error responses, while retaining auth's control over the final cache policy.

Useful? React with 👍 / 👎.

}

// Register user middleware
for (let i = 0; i < scan.middleware.length; i++) {
lines.push(`app.use(middleware${i});`);
Expand Down
37 changes: 36 additions & 1 deletion packages/void/src/runtime/better-auth-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,36 @@ function bareCacheDirectives(policy: string | null): Set<string> {
* `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.
*/
/**
* True when the response sets a Better Auth cookie, such as the session from a
* sign-in reached through a rewrite or fallback. A cookie the app sets itself
* keeps the route's own cache policy.
*/
function setsAuthCookie(
headers: Headers,
advanced:
| { cookiePrefix?: string; cookies?: Record<string, { name?: string } | undefined> }
| undefined,
): boolean {
const prefix = `${advanced?.cookiePrefix || 'better-auth'}.`;
const custom = new Set<string>();
for (const cookie of Object.values(advanced?.cookies ?? {})) {
if (cookie?.name) {
custom.add(cookie.name);
}
}
return headers.getSetCookie().some((value) => {
const equals = value.indexOf('=');
let name = (equals === -1 ? value : value.slice(0, equals)).trim();
for (const secure of ['__Secure-', '__Host-']) {
if (name.startsWith(secure)) {
name = name.slice(secure.length);
}
}
return name.startsWith(prefix) || custom.has(name);
});
}

export function privateCachePolicy(policy: string | null): string {
const directives = splitCacheDirectives(policy ?? '');
if (directives.length === 0) {
Expand Down Expand Up @@ -629,7 +659,12 @@ export function createVoidAuthRuntime(
appendAuthHeaders(
c,
result.headers,
state !== null || credentialed || result.headers?.has('set-cookie') === true,
state !== null ||
credentialed ||
result.headers?.has('set-cookie') === true ||
setsAuthCookie(c.res.headers, auth.options.advanced) ||
c.req.path === '/api/auth' ||
c.req.path.startsWith('/api/auth/'),
Comment on lines +665 to +667

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve auth cache protection through cookie-free rewrites

In a native/local app with a public cache rule, an anonymous GET /session implemented as c.rewrite('/api/auth/get-session') loses its auth cache protection. The destination correctly emits no-store, but the source request's headerMiddleware overwrites it. These checks then see neither an auth source path nor a response cookie, so the outer auth middleware skips restoring the policy. The final auth response advertises the configured public TTL, allowing downstream caches to retain the anonymous session result. Propagate auth-response identity through recursive rewrites rather than inferring it only from the source path or Set-Cookie.

Useful? React with 👍 / 👎.

isUnchangedStaticAsset(c),
);
}
Expand Down
23 changes: 16 additions & 7 deletions packages/void/src/runtime/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { convertReturnValue } from './response.ts';
import { toUrlString } from './route-url.ts';
import { ASSET_EXTENSIONS } from './routing-assets.ts';
import { markRouteHandler } from './routing-pages.ts';
import { isUnchangedStaticAsset, markStaticAsset } from './static-asset-marker.ts';
export { createErrorRenderer } from './routing-errors.ts';

export type RoutingRule = {
Expand Down Expand Up @@ -64,6 +65,9 @@ export function createRoutingRuntime(
): RoutingRuntime {
const originalUrls = new WeakMap<Request, URL>();
const fallbackHandled = new WeakSet<Request>();
// Outer requests a fallback answered. The fallback already applied the
// destination's header rules, as edge routing and platform dispatch do.
const fallbackServed = new WeakSet<Request>();
const warnedPatterns = new Set<string>();
const requestContext = new AsyncLocalStorage<RequestContextStore>();
const assetExtensions = options.blockedAssetExtensions ?? ASSET_EXTENSIONS;
Expand Down Expand Up @@ -135,6 +139,7 @@ export function createRoutingRuntime(
keepActionKey(c.req.raw, request, search);
if (fallback) {
fallbackHandled.add(request);
fallbackServed.add(c.req.raw);
}
const response = await dispatch(request, c);
const kind = rule.force === true ? 'rewrite' : 'fallback';
Expand Down Expand Up @@ -231,6 +236,9 @@ export function createRoutingRuntime(
},
headerMiddleware: (rules) => async (c, next) => {
await next();
if (fallbackServed.has(c.req.raw)) {
return;
}
const pathname = new URL(c.req.url).pathname;
const matched: Array<[string, string]> = [];
for (const rule of rules) {
Expand All @@ -247,15 +255,16 @@ export function createRoutingRuntime(
if (!matched.length) {
return;
}
const headers = new Headers(c.res.headers);
const asset = isUnchangedStaticAsset(c);
for (const [name, value] of matched) {
headers.set(name, value);
// A Set-Cookie rule adds its cookie and keeps the response's own cookies.
c.header(name, value, { append: name.toLowerCase() === 'set-cookie' });
}
if (asset) {
// A configured Cache-Control is the file's policy from here on, so auth
// still treats the response as a static file and only makes it private.
markStaticAsset(c, c.res);
}
c.res = new Response(c.res.body, {
headers,
status: c.res.status,
statusText: c.res.statusText,
});
},
applyFallback: (c, rules) =>
fallbackHandled.has(c.req.raw) ? Promise.resolve(undefined) : applyRules(c, rules, true),
Expand Down
102 changes: 101 additions & 1 deletion packages/void/test/integration/auth-isr-cache-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
createPagesFallback,
createSsrFallback,
} from '../../src/runtime/routing-pages.ts';
import { createRoutingRuntime } from '../../src/runtime/routing.ts';
import { createRoutingRuntime, type RoutingContext } 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 };
Expand Down Expand Up @@ -100,6 +100,106 @@ describe('real anonymous Better Auth lookup cache policy', () => {
});
});

describe('auth cache policy with authored headers', () => {
function headerFixture() {
const database = memoryAdapter({ user: [], session: [], account: [], verification: [] });
const runtime = createVoidAuthRuntime('sqlite', async () => {});
const app = new Hono();
const routing = createRoutingRuntime(app, { nodeTarget: true });
app.use(routing.contextMiddleware);
app.use(async (c, next) => runtime.runVoidAuthMiddleware(c, next, options, database));
app.use(
routing.headerMiddleware([
{
pattern: '/*',
headers: [
['Cache-Control', 'public, max-age=31536000, immutable'],
['X-Frame-Options', 'DENY'],
],
},
]),
);
app.on(['GET', 'POST'], '/api/auth/*', (c) =>
runtime.handleVoidAuthRequest(c, options, database),
);
return { app, routing };
}

it('retains anonymous auth endpoint cache protection under a public rule', async () => {
const { app } = headerFixture();
const response = await app.request('https://app.test/api/auth/get-session', undefined, env);
expect(response.status).toBe(200);
expect(response.headers.get('cache-control')).toBe('no-store');
expect(response.headers.get('pragma')).toBe('no-cache');
expect(response.headers.get('x-frame-options')).toBe('DENY');
await expect(response.json()).resolves.toBeNull();
});

it.each(['/api/auth/sign-in/email', '/login', '/fallback-login'])(
'keeps an anonymous body-credential sign-in private through %s',
async (path) => {
const { app, routing } = headerFixture();
const email = 'auth-headers@example.test';
const password = 'correct-horse-battery-staple';
app.post('/login', (c) => (c as RoutingContext).rewrite('/api/auth/sign-in/email'));
app.notFound(
async (c) =>
(await routing.applyFallback(c, [
{ source: '/fallback-login', destination: '/api/auth/sign-in/email', status: 200 },
])) ?? c.text('not found', 404),
);
const signUp = await app.request(
'https://app.test/api/auth/sign-up/email',
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: 'Auth headers', email, password }),
},
env,
);
expect(signUp.status).toBe(200);
await signUp.arrayBuffer();
const response = await app.request(
'https://app.test' + path,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, password }),
},
env,
);
expect(response.status).toBe(200);
expect(response.headers.get('cache-control')).toBe('no-store');
expect(response.headers.get('pragma')).toBe('no-cache');
expect(response.headers.get('x-frame-options')).toBe('DENY');
const cookie = response.headers
.getSetCookie()
.map((value) => value.split(';')[0])
.join('; ');
expect(cookie).toContain('better-auth.session_token=');
await expect(response.json()).resolves.toMatchObject({ user: { email } });
const session = await app.request(
'https://app.test/api/auth/get-session',
{
headers: { cookie },
},
env,
);
await expect(session.json()).resolves.toMatchObject({ user: { email } });
},
);

it('keeps authored headers on a user middleware redirect', async () => {
const { app } = headerFixture();
app.use('/private', async (c) => c.redirect('/login'));
const response = await app.request('https://app.test/private', undefined, env);
expect(response.status).toBe(302);
expect(response.headers.get('location')).toBe('/login');
expect(response.headers.get('x-frame-options')).toBe('DENY');
await response.arrayBuffer();
});
});

describe('real authenticated Better Auth session cache policy', () => {
async function signedInApp(
routeHeaders: Record<string, string>,
Expand Down
40 changes: 39 additions & 1 deletion packages/void/test/integration/native-asset-cache-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,10 +183,17 @@ beforeAll(async () => {
import { createRoutingRuntime } from ${JSON.stringify(runtimeSource('routing.ts'))};
import { createErrorRenderer } from ${JSON.stringify(runtimeSource('routing-errors.ts'))};
const apps = {};
for (const mode of ['pages', 'ssr', 'static']) {
for (const mode of ['pages', 'ssr', 'static', 'headers']) {
const app = new Hono();
const routing = createRoutingRuntime(app, {});
app.use(routing.contextMiddleware);
if (mode === 'headers') {
app.use(routing.headerMiddleware([{ pattern: '/static/*', headers: [
['Cache-Control', 'public, max-age=31536000, immutable'],
['X-Void-Header', 'configured'],
] }]));
app.get('/health', (c) => c.text('healthy', 200, { 'cache-control': 'private, no-store' }));
}
app.notFound(mode === 'pages'
? createPagesFallback({ routing, assetsDir: 'static', errors: createErrorRenderer({ projectRoot: '/fixture', homeDir: '/home' }) })
: mode === 'ssr'
Expand Down Expand Up @@ -304,6 +311,37 @@ describe('native asset cache policy in real Workers', () => {
},
);

it.each(['css', 'javascript'])(
'overrides an existing ASSETS cache policy for %s',
async (kind) => {
const file = kind === 'css' ? cssAsset : uncachedAsset;
const response = await runtime!.dispatchFetch(`https://app.test/${file}?mode=headers`);
expect(response.status).toBe(200);
expect(response.headers.get('cache-control')).toBe('public, max-age=31536000, immutable');
expect(response.headers.get('x-void-header')).toBe('configured');
expect(response.headers.get('content-type')).toContain(
kind === 'css' ? 'text/css' : 'javascript',
);
expect(await response.text()).toContain(kind === 'css' ? 'color' : 'uncached');
},
);

it('applies matching headers to real asset misses and preserves unmatched responses', async () => {
const missing = await runtime!.dispatchFetch(
'https://app.test/static/missing-abcdefgh.js?mode=headers',
);
expect(missing.status).toBe(404);
expect(missing.headers.get('cache-control')).toBe('public, max-age=31536000, immutable');
expect(missing.headers.get('x-void-header')).toBe('configured');
await missing.arrayBuffer();

const unmatched = await runtime!.dispatchFetch('https://app.test/health?mode=headers');
expect(unmatched.status).toBe(200);
expect(unmatched.headers.get('cache-control')).toBe('private, no-store');
expect(unmatched.headers.has('x-void-header')).toBe(false);
await expect(unmatched.text()).resolves.toBe('healthy');
});

it('wraps an immutable Cache API response without mutating its headers or consuming its body', async () => {
const response = await runtime!.dispatchFetch('https://app.test/cache-proof');
expect(response.status).toBe(200);
Expand Down
Loading
Loading