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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
20 changes: 11 additions & 9 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 @@ -307,13 +304,24 @@ export function compileRouteTable(
lines.push('app.use(__createNamedActionMarker(__matchedRoutes));');
}

const headerRules = emitEdgeRules ? options.headerRules : undefined;
if (headerRules?.length) {
lines.push(`const __headerRules = ${JSON.stringify(headerRules)};`);
}
if (auth) {
if (headerRules?.length) {
lines.push('app.use("*", __routingRuntime.headerMiddleware(__headerRules, "auth-error"));');
}
lines.push('app.use(async (c, next) => {');
lines.push(' return runVoidAuthMiddleware(c, next, __voidAuthOptions, __voidAuthDatabase);');
lines.push('});');
lines.push('');
}

if (headerRules?.length) {
lines.push('app.use("*", __routingRuntime.headerMiddleware(__headerRules));');
}

// Register user middleware
for (let i = 0; i < scan.middleware.length; i++) {
lines.push(`app.use(middleware${i});`);
Expand Down Expand Up @@ -1486,12 +1494,6 @@ function compileRoutingRulesMiddleware(
lines.push('app.use("*", __routingRuntime.routingMiddleware(__routingRules));');
}

function compileHeaderMiddleware(lines: Array<string>, rules: Array<HeaderRule>): void {
lines.push('// Header rules (applied to responses)');
lines.push(`const __headerRules = ${JSON.stringify(rules)};`);
lines.push('app.use("*", __routingRuntime.headerMiddleware(__headerRules));');
}

/** Generate the server-side fetch client, which calls `app.fetch()` with the current request's credentials. */
export function compileWorkerClient(authClientPath: string, nodeTarget = false): string {
const envImport = nodeTarget ? '' : 'import { env } from "cloudflare:workers";\n';
Expand Down
9 changes: 9 additions & 0 deletions packages/void/src/runtime/auth-response.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Requests whose response depends on auth, including parent requests after a rewrite.
export const authDependentRequests: WeakSet<Request> = new WeakSet();

// Targeted policies a CDN can follow ahead of Cache-Control.
export const SHARED_CACHE_HEADERS: ReadonlyArray<string> = [
'cdn-cache-control',
'cloudflare-cdn-cache-control',
'surrogate-control',
];
56 changes: 55 additions & 1 deletion packages/void/src/runtime/better-auth-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type VoidAuthProvider,
type VoidSocialAuthProvider,
} from '../auth/providers.ts';
import { SHARED_CACHE_HEADERS, authDependentRequests } from './auth-response.ts';
import { authContext, type AuthState, type VoidAuthConfig } from './auth.ts';
import { isUnchangedStaticAsset } from './static-asset-marker.ts';

Expand Down Expand Up @@ -282,6 +283,7 @@ function appendAuthHeaders(
? 'private, no-store'
: privateCachePolicy(c.res.headers.get('cache-control')),
);
dropSharedCachePolicy(c);
}
return;
}
Expand Down Expand Up @@ -315,6 +317,19 @@ function appendAuthHeaders(
) {
c.header('Cache-Control', 'private, no-store');
}
if (includeCachePolicy || source.has('set-cookie')) {
dropSharedCachePolicy(c);
}
}

// A CDN may follow these ahead of Cache-Control, so a private response drops
// them, including values a header rule set.
function dropSharedCachePolicy(c: Context): void {
for (const name of SHARED_CACHE_HEADERS) {
if (c.res.headers.has(name)) {
c.header(name, undefined);
}
}
}

// Lowercased Cache-Control directive names that carry no value. Commas inside a
Expand All @@ -333,6 +348,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 @@ -619,6 +664,11 @@ export function createVoidAuthRuntime(
c.set('user', state?.user ?? null);
c.set('session', state?.session ?? null);

const authEndpoint = c.req.path === '/api/auth' || c.req.path.startsWith('/api/auth/');
if (authEndpoint) {
authDependentRequests.add(c.req.raw);
}

await authContext.run({ user: state?.user ?? null, state }, async () => {
await next();
});
Expand All @@ -629,7 +679,11 @@ 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) ||
authEndpoint,
isUnchangedStaticAsset(c),
);
}
Expand Down
89 changes: 71 additions & 18 deletions packages/void/src/runtime/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ import {
markActionKey,
searchWithoutActionKey,
} from '../pages/action-key.ts';
import { SHARED_CACHE_HEADERS, authDependentRequests } from './auth-response.ts';
import { VoidAssetRewriteError } from './handler.ts';
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 All @@ -47,7 +49,7 @@ export type RoutingRuntime = {
contextMiddleware: MiddlewareHandler;
requestMiddleware: MiddlewareHandler;
routingMiddleware: (rules: Array<RoutingRule>) => MiddlewareHandler;
headerMiddleware: (rules: Array<RoutingHeaderRule>) => MiddlewareHandler;
headerMiddleware: (rules: Array<RoutingHeaderRule>, phase?: 'auth-error') => MiddlewareHandler;
applyFallback: (context: Context, rules: Array<RoutingRule>) => Promise<Response | undefined>;
markNoMatch: (request: Request, response: Response) => Response;
};
Expand All @@ -64,6 +66,12 @@ 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>();
// Contexts whose inner header pass ran after next(). A handler that throws
// past it, such as notFound, leaves the context out.
const headerApplied = new WeakSet<Context>();
const warnedPatterns = new Set<string>();
const requestContext = new AsyncLocalStorage<RequestContextStore>();
const assetExtensions = options.blockedAssetExtensions ?? ASSET_EXTENSIONS;
Expand All @@ -82,10 +90,19 @@ export function createRoutingRuntime(
headers,
});
};
const dispatch = (request: Request, c: Context): Promise<Response> =>
Promise.resolve(
options.nodeTarget ? app.fetch(request, c.env) : app.fetch(request, c.env, c.executionCtx),
const dispatch = async (request: Request, c: Context, trace: string): Promise<Response> => {
const response = annotate(
await (options.nodeTarget
? app.fetch(request, c.env)
: app.fetch(request, c.env, c.executionCtx)),
trace,
);
if (authDependentRequests.has(request)) {
// The enclosing response may depend on auth even if middleware clones or replaces it.
authDependentRequests.add(c.req.raw);
}
return response;
};
const rememberOriginal = (request: Request, c: Context): void => {
originalUrls.set(request, originalUrls.get(c.req.raw) ?? new URL(c.req.url));
};
Expand Down Expand Up @@ -135,11 +152,12 @@ 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';
return annotate(
response,
return dispatch(
request,
c,
`${kind}[${rule.source}] -> ${destination} (${rule.origin || 'unknown'})`,
);
}
Expand Down Expand Up @@ -185,9 +203,7 @@ export function createRoutingRuntime(
const request = new Request(url.toString(), c.req.raw);
rememberOriginal(request, c);
keepActionKey(c.req.raw, request, search);
return dispatch(request, c).then((response) =>
annotate(response, `c.rewrite -> ${path} (middleware)`),
);
return dispatch(request, c, `c.rewrite -> ${path} (middleware)`);
};
const redirect = context.redirect.bind(context);
c.redirect = (destination, status) => {
Expand Down Expand Up @@ -229,8 +245,29 @@ export function createRoutingRuntime(
}
}
},
headerMiddleware: (rules) => async (c, next) => {
headerMiddleware: (rules, phase) => async (c, next) => {
const authErrorsOnly = phase === 'auth-error';
await next();
if (authErrorsOnly && (!c.error || headerApplied.has(c))) {
return;
}
if (!authErrorsOnly) {
headerApplied.add(c);
}
const protectCache = authErrorsOnly || authDependentRequests.has(c.req.raw);
Comment thread
Brooooooklyn marked this conversation as resolved.
Resolved
if (protectCache) {
authDependentRequests.add(c.req.raw);
c.header('Cache-Control', 'no-store');
c.header('Pragma', 'no-cache');
for (const name of SHARED_CACHE_HEADERS) {
if (c.res.headers.has(name)) {
c.header(name, undefined);
}
}
}
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 +284,31 @@ export function createRoutingRuntime(
if (!matched.length) {
return;
}
const headers = new Headers(c.res.headers);
// The last match wins for each header name, Set-Cookie included.
const last = new Map<string, [string, string]>();
for (const [name, value] of matched) {
headers.set(name, value);
last.set(name.toLowerCase(), [name, value]);
}
const asset = isUnchangedStaticAsset(c);
for (const [key, [name, value]] of last) {
if (
protectCache &&
(key === 'cache-control' || key === 'pragma' || SHARED_CACHE_HEADERS.includes(key))
) {
continue;
}
// A Set-Cookie rule adds its cookie and keeps the response's own cookies.
// After a rewrite the destination's pass may already have added it last.
if (key === 'set-cookie' && c.res.headers.getSetCookie().at(-1) === value) {
continue;
}
c.header(name, value, { append: key === '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
Loading
Loading