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
57 changes: 57 additions & 0 deletions src/lib/cache/headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { CacheControlDirectives } from '~/lib/cache/types';

/** Standard Cache-Control header name */
export const CACHE_CONTROL_HEADER = 'Cache-Control';
/** Cloudflare CDN-specific cache control header */
export const CF_CACHE_CONTROL_HEADER = 'CDN-Cache-Control';

/**
* Serializes a {@link CacheControlDirectives} object into a `Cache-Control` header value string.
*
* Directives are only included when explicitly set — `undefined` fields are omitted.
* Returns `undefined` when no directives are provided or all fields are `undefined`,
* so callers can skip setting the header entirely.
*
* @param options - Cache-Control directives to serialize.
* @returns A comma-separated directive string (e.g. `"public, max-age=3600"`),
* or `undefined` if there is nothing to serialize.
*
* @example
* buildCacheControlHeader({ scope: 'public', maxAge: 3600, staleWhileRevalidate: 60 });
* // => "public, max-age=3600, stale-while-revalidate=60"
*
* buildCacheControlHeader(undefined);
* // => undefined
*/
export function buildCacheControlHeader(
options?: CacheControlDirectives,
): string | undefined {
if (!options) return undefined;
const directives: string[] = [];

if (options.scope) {
directives.push(options.scope);
}

if (options.maxAge !== undefined) {
directives.push(`max-age=${options.maxAge}`);
}

if (options.sMaxAge !== undefined) {
directives.push(`s-maxage=${options.sMaxAge}`);
}

if (options.staleWhileRevalidate !== undefined) {
directives.push(`stale-while-revalidate=${options.staleWhileRevalidate}`);
}

if (options.immutable) {
directives.push('immutable');
}

if (directives.length === 0) {
return undefined;
}

return directives.join(', ');
}
70 changes: 70 additions & 0 deletions src/lib/cache/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Middleware utilities for setting cache headers on requests and functions.
*
* Provides two middleware creators:
* - cacheRequestMiddleware: for request-level caching
* - cacheFunctionMiddleware: for function-level caching (GET only)
*/
import { createMiddleware } from '@tanstack/react-start';
import { setResponseHeader } from '@tanstack/react-start/server';

import {
buildCacheControlHeader,
CACHE_CONTROL_HEADER,
CF_CACHE_CONTROL_HEADER,
} from '~/lib/cache/headers';
import type { CacheOptions } from '~/lib/cache/types';

/**
* Creates a request middleware that sets cache headers on the response.
*
* @param options - Cache options to control the Cache-Control and CF-Cache-Control headers.
* @returns Middleware that sets cache headers for each request.
*/
export function cacheRequestMiddleware(options: CacheOptions) {
return createMiddleware({ type: 'request' }).server(async ({ next }) => {
const result = await next();

// Set standard Cache-Control header
const cacheControl = buildCacheControlHeader(options);
if (cacheControl) {
result.response.headers.set(CACHE_CONTROL_HEADER, cacheControl);
}

// Set Cloudflare-specific cache header if options.cloudflare is provided
const cfCacheControl = buildCacheControlHeader(options.cloudflare);
if (cfCacheControl) {
result.response.headers.set(CF_CACHE_CONTROL_HEADER, cfCacheControl);
}

return result;
});
}

/**
* Creates a function middleware that sets cache headers for GET requests only.
*
* @param options - Cache options to control the Cache-Control and CF-Cache-Control headers.
* @returns Middleware that sets cache headers for GET function calls.
*/
export function cacheFunctionMiddleware(options: CacheOptions) {
return createMiddleware({ type: 'function' }).server(
async ({ next, method }) => {
if (method !== 'GET') return next();
const result = await next();

// Set standard Cache-Control header
const cacheControl = buildCacheControlHeader(options);
if (cacheControl) {
setResponseHeader(CACHE_CONTROL_HEADER, cacheControl);
}

// Set Cloudflare-specific cache header if options.cloudflare is provided
const cfCacheControl = buildCacheControlHeader(options.cloudflare);
if (cfCacheControl) {
setResponseHeader(CF_CACHE_CONTROL_HEADER, cfCacheControl);
}
return result;
},
);
}
201 changes: 201 additions & 0 deletions src/lib/cache/presets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import type { CacheOptions } from '~/lib/cache/types';

export class CachePreset {
/**
* Prevents the response from being stored in any cache — browser or CDN.
*
* **When to use:** Responses that contain sensitive or user-specific data that
* must never be persisted anywhere (e.g. authentication tokens, payment info,
* one-time secrets, or any endpoint whose caching would constitute a security
* risk).
*
* **Why:** `no-store` is the strongest prohibition. Unlike `no-cache` (which
* still allows conditional storage), `no-store` instructs every intermediary
* to discard the response immediately after delivery. No `cloudflare` override
* is set because CDN-level storage of these responses is always wrong.
*
* @example
* CachePreset.noStore()
* // Cache-Control: no-store
*/
static noStore(): CacheOptions {
return { scope: 'no-store' };
}

/**
* Allows caching but requires every cache to revalidate with the origin before
* serving a stored response.
*
* **When to use:** Real-time or near-real-time data where the browser may still
* benefit from conditional requests (304 Not Modified), but serving a stale
* copy silently is not acceptable (e.g. dashboard summaries, live pricing,
* inventory levels).
*
* **Why:** `no-cache` does not mean "do not cache" — it means "always check
* freshness". Browsers can still store the response and avoid re-downloading
* the body when the origin returns 304, saving bandwidth while guaranteeing
* up-to-date content. No `cloudflare` override is set; CDN revalidation on
* every request negates the benefit of a CDN.
*
* @example
* CachePreset.noCache()
* // Cache-Control: no-cache
*/
static noCache(): CacheOptions {
return { scope: 'no-cache' };
}

/**
* Allows the browser to cache the response but prohibits shared/CDN caches
* from storing it.
*
* **When to use:** Authenticated pages or API responses that are personal to
* the logged-in user (e.g. profile pages, account settings, personalized feeds)
* where browser caching speeds up navigation but CDN caching would serve one
* user's data to another.
*
* **Why:** `private` restricts storage to the user's own browser cache.
* `max-age=300` (5 min) lets the browser reuse the response across quick
* navigation. `stale-while-revalidate=60` allows an immediate render of the
* stale copy while a fresh fetch runs in the background, keeping the UX snappy
* without sacrificing freshness. No `cloudflare` key is set deliberately —
* Cloudflare must not store private responses.
*
* @example
* CachePreset.private()
* // Cache-Control: private, max-age=300, stale-while-revalidate=60
*/
static private(): CacheOptions {
return {
scope: 'private',
maxAge: 300,
staleWhileRevalidate: 60,
};
}

/**
* Short browser freshness with a longer CDN TTL for frequently updated public
* content.
*
* **When to use:** Public content that changes often but not on every request
* (e.g. activity feeds, comment counts, leaderboards, live event listings).
* You want CDN to absorb traffic spikes while keeping displayed data reasonably
* fresh.
*
* **Why:** Browsers get a 60 s window (`max-age=60`) before considering the
* response stale, with a 30 s revalidation grace (`stale-while-revalidate=30`)
* for seamless background refreshes. Cloudflare's `s-maxage=300` lets the CDN
* serve the same edge-cached copy for 5 min, absorbing origin load during
* spikes, while a 60 s SWR grace on the CDN side means edge nodes revalidate
* silently without interrupting users.
*
* @example
* CachePreset.shortLived()
* // Cache-Control: public, max-age=60, stale-while-revalidate=30
* // CDN-Cache-Control: s-maxage=300, stale-while-revalidate=60
*/
static shortLived(): CacheOptions {
return {
scope: 'public',
maxAge: 60,
staleWhileRevalidate: 30,
cloudflare: {
sMaxAge: 300,
staleWhileRevalidate: 60,
},
};
}

/**
* Standard public caching policy for typical marketing and informational pages.
*
* **When to use:** Public pages whose content changes infrequently throughout
* the day (e.g. landing pages, feature pages, pricing, about pages). A good
* default for any server-rendered page that does not contain personalized data.
*
* **Why:** Browsers treat responses as fresh for 5 min (`max-age=300`), with a
* 60 s SWR window for background revalidation so users never see a loading
* state between navigation. Cloudflare caches for 1 hour (`s-maxage=3600`)
* with a 5 min SWR grace, dramatically reducing origin hits from repeated
* visitors and crawlers while still picking up deploys within minutes.
*
* @example
* CachePreset.standard()
* // Cache-Control: public, max-age=300, stale-while-revalidate=60
* // CDN-Cache-Control: s-maxage=3600, stale-while-revalidate=300
*/
static standard(): CacheOptions {
return {
scope: 'public',
maxAge: 300,
staleWhileRevalidate: 60,
cloudflare: {
sMaxAge: 3_600,
staleWhileRevalidate: 300,
},
};
}

/**
* Long browser and CDN TTLs for rarely changing public content.
*
* **When to use:** Content that is updated on the order of days or weeks (e.g.
* blog posts, documentation pages, changelogs, help articles). Suitable when
* you can tolerate up to an hour of stale content in browsers and up to a week
* in CDN after a publish.
*
* **Why:** A 24 h browser TTL (`max-age=86400`) eliminates redundant requests
* for returning visitors in the same day, with a 1 h SWR grace to handle the
* transition silently. Cloudflare stores for 7 days (`s-maxage=604800`),
* keeping origin load near zero for stable content, while a 1 day SWR on the
* CDN ensures deploys propagate gradually without a hard cache break.
*
* @example
* CachePreset.longLived()
* // Cache-Control: public, max-age=86400, stale-while-revalidate=3600
* // CDN-Cache-Control: s-maxage=604800, stale-while-revalidate=86400
*/
static longLived(): CacheOptions {
return {
scope: 'public',
maxAge: 86_400,
staleWhileRevalidate: 3_600,
cloudflare: {
sMaxAge: 604_800,
staleWhileRevalidate: 86_400,
},
};
}

/**
* Permanent caching for content-hashed static assets.
*
* **When to use:** Any asset whose URL includes a content hash or build
* fingerprint, guaranteeing the URL changes whenever the content changes
* (e.g. bundled JS/CSS files like `main.abc123.js`, versioned fonts, hashed
* images). Never use this for URLs that can serve different content over time.
*
* **Why:** `max-age=31536000` (1 year) is the de-facto browser maximum for
* permanent caching. The `immutable` directive tells the browser the file will
* never change during its freshness lifetime, suppressing conditional
* revalidation requests on back/forward navigation. Cloudflare mirrors the same
* policy at the edge. Because the URL itself changes on every build, cache
* busting is handled automatically — no manual purge is needed.
*
* @example
* CachePreset.immutable()
* // Cache-Control: public, max-age=31536000, immutable
* // CDN-Cache-Control: s-maxage=31536000, immutable
*/
static immutable(): CacheOptions {
return {
scope: 'public',
maxAge: 31_536_000,
immutable: true,
cloudflare: {
sMaxAge: 31_536_000,
immutable: true,
},
};
}
}
37 changes: 37 additions & 0 deletions src/lib/cache/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Visibility and freshness scope for the `Cache-Control` header.
*
* - `public` — Response may be stored by any cache (browser, CDN, proxy).
* - `private` — Response is intended for a single user and must not be stored by shared caches.
* - `no-cache` — Cache must revalidate with the origin before serving a stored response.
* - `no-store` — Response must not be stored in any cache at all.
*/
export type CacheScope = 'public' | 'private' | 'no-cache' | 'no-store';

/**
* Structured representation of `Cache-Control` HTTP header directives.
*/
export interface CacheControlDirectives {
/** Visibility and freshness scope (e.g. `public`, `private`, `no-cache`, `no-store`). */
scope?: CacheScope;
/** Maximum time in seconds a response is considered fresh by the browser (`max-age`). */
maxAge?: number;
/** Maximum time in seconds a response is considered fresh by shared/CDN caches (`s-maxage`). */
sMaxAge?: number;
/** Seconds a stale response may be served while a revalidation happens in the background (`stale-while-revalidate`). */
staleWhileRevalidate?: number;
/** When `true`, adds the `immutable` directive — signals the response will never change during its freshness lifetime. */
immutable?: boolean;
}

/**
* Cache configuration
*
* Extends {@link CacheControlDirectives} with an optional `cloudflare` override
* that is written to the `CDN-Cache-Control` header, allowing independent
* cache policies for Cloudflare and the browser.
*/
export interface CacheOptions extends CacheControlDirectives {
/** Directives written to the `CDN-Cache-Control` header for Cloudflare-specific caching rules. */
cloudflare?: CacheControlDirectives;
}