diff --git a/.changeset/fix-session-regenerate-dirty.md b/.changeset/fix-session-regenerate-dirty.md new file mode 100644 index 000000000000..efed9ddca719 --- /dev/null +++ b/.changeset/fix-session-regenerate-dirty.md @@ -0,0 +1,7 @@ +--- +'astro': patch +--- + +Fixes a bug that caused `session.regenerate()` to silently lose session data + +Previously, regenerated session data was not saved under the new session ID unless `set()` was also called. diff --git a/.changeset/warm-comics-pump.md b/.changeset/warm-comics-pump.md new file mode 100644 index 000000000000..cbae54f49826 --- /dev/null +++ b/.changeset/warm-comics-pump.md @@ -0,0 +1,139 @@ +--- +'astro': minor +--- + +Adds two new experimental flags for a Route Caching API and further configuration-level Route Rules for controlling SSR response caching. + +Route caching gives you a platform-agnostic way to cache server-rendered responses, based on web standard cache headers. You set caching directives in your routes using `Astro.cache` (in `.astro` pages) or `context.cache` (in API routes and middleware), and Astro translates them into the appropriate headers or runtime behavior depending on your adapter. You can also define cache rules for routes declaratively in your config using `experimental.routeRules`, without modifying route code. + +This feature requires on-demand rendering. Prerendered pages are already static and do not use route caching. + +#### Getting started + +Enable the feature by configuring `experimental.cache` with a cache provider in your Astro config: + +```js +// astro.config.mjs +import { defineConfig } from 'astro/config'; +import node from '@astrojs/node'; +import { memoryCache } from 'astro/config'; + +export default defineConfig({ + adapter: node({ mode: 'standalone' }), + experimental: { + cache: { + provider: memoryCache(), + }, + }, +}); +``` + +#### Using `Astro.cache` and `context.cache` + +In `.astro` pages, use `Astro.cache.set()` to control caching: + +```astro +--- +// src/pages/index.astro +Astro.cache.set({ + maxAge: 120, // Cache for 2 minutes + swr: 60, // Serve stale for 1 minute while revalidating + tags: ['home'], // Tag for targeted invalidation +}); +--- +Cached page +``` + +In API routes and middleware, use `context.cache`: + +```ts +// src/pages/api/data.ts +export function GET(context) { + context.cache.set({ + maxAge: 300, + tags: ['api', 'data'], + }); + return Response.json({ ok: true }); +} +``` + +#### Cache options + +`cache.set()` accepts the following options: + +- **`maxAge`** (number): Time in seconds the response is considered fresh. +- **`swr`** (number): Stale-while-revalidate window in seconds. During this window, stale content is served while a fresh response is generated in the background. +- **`tags`** (string[]): Cache tags for targeted invalidation. Tags accumulate across multiple `set()` calls within a request. +- **`lastModified`** (Date): When multiple `set()` calls provide `lastModified`, the most recent date wins. +- **`etag`** (string): Entity tag for conditional requests. + +Call `cache.set(false)` to explicitly opt out of caching for a request. + +Multiple calls to `cache.set()` within a single request are merged: scalar values use last-write-wins, `lastModified` uses most-recent-wins, and tags accumulate. + +#### Invalidation + +Purge cached entries by tag or path using `cache.invalidate()`: + +```ts +// Invalidate all entries tagged 'data' +await context.cache.invalidate({ tags: ['data'] }); + +// Invalidate a specific path +await context.cache.invalidate({ path: '/api/data' }); +``` + +#### Config-level route rules + +Use `experimental.routeRules` to set default cache options for routes without modifying route code. Supports Nitro-style shortcuts for ergonomic configuration: + +```js +import { memoryCache } from 'astro/config'; + +export default defineConfig({ + experimental: { + cache: { + provider: memoryCache(), + }, + routeRules: { + // Shortcut form (Nitro-style) + '/api/*': { swr: 600 }, + + // Full form with nested cache + '/products/*': { cache: { maxAge: 3600, tags: ['products'] } }, + }, + }, +}); +``` + +Route patterns support static paths, dynamic parameters (`[slug]`), and rest parameters (`[...path]`). Per-route `cache.set()` calls merge with (and can override) the config-level defaults. + +You can also read the current cache state via `cache.options`: + +```ts +const { maxAge, swr, tags } = context.cache.options; +``` + +#### Cache providers + +Cache behavior is determined by the configured **cache provider**. There are two types: + +- **CDN providers** set response headers (e.g. `CDN-Cache-Control`, `Cache-Tag`) and let the CDN handle caching. Astro strips these headers before sending the response to the client. +- **Runtime providers** implement `onRequest()` to intercept and cache responses in-process, adding an `X-Astro-Cache` header (HIT/MISS/STALE) for observability. + +#### Built-in memory cache provider + +Astro includes a built-in, in-memory LRU runtime cache provider. Import `memoryCache` from `astro/config` to configure it. + +Features: +- In-memory LRU cache with configurable max entries (default: 1000) +- Stale-while-revalidate support +- Tag-based and path-based invalidation +- `X-Astro-Cache` response header: `HIT`, `MISS`, or `STALE` +- Query parameter sorting for better hit rates (`?b=2&a=1` and `?a=1&b=2` hit the same entry) +- Common tracking parameters (`utm_*`, `fbclid`, `gclid`, etc.) excluded from cache keys by default +- `Vary` header support — responses that set `Vary` automatically get separate cache entries per variant +- Configurable query parameter filtering via `query.exclude` (glob patterns) and `query.include` (allowlist) + +For more information on enabling and using this feature in your project, see the [Experimental Route Caching docs](https://docs.astro.build/en/reference/experimental-flags/route-caching/). +For a complete overview and to give feedback on this experimental API, see the [Route Caching RFC](https://github.com/withastro/roadmap/pull/1245). diff --git a/packages/astro/package.json b/packages/astro/package.json index 6774383b5c0d..29f211728d85 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -70,6 +70,7 @@ "./assets/endpoint/*": "./dist/assets/endpoint/*.js", "./assets/services/sharp": "./dist/assets/services/sharp.js", "./assets/services/noop": "./dist/assets/services/noop.js", + "./cache/memory": "./dist/core/cache/memory-provider.js", "./assets/fonts/runtime.js": "./dist/assets/fonts/runtime.js", "./loaders": "./dist/content/loaders/index.js", "./content/config": "./dist/content/config.js", diff --git a/packages/astro/src/actions/runtime/types.ts b/packages/astro/src/actions/runtime/types.ts index 0c71f0fe9654..5078e3ad24fe 100644 --- a/packages/astro/src/actions/runtime/types.ts +++ b/packages/astro/src/actions/runtime/types.ts @@ -103,6 +103,7 @@ export type ActionAPIContext = Pick< | 'preferredLocaleList' | 'originPathname' | 'session' + | 'cache' | 'csp' >; diff --git a/packages/astro/src/config/entrypoint.ts b/packages/astro/src/config/entrypoint.ts index 17846e69b076..60283f128670 100644 --- a/packages/astro/src/config/entrypoint.ts +++ b/packages/astro/src/config/entrypoint.ts @@ -1,7 +1,9 @@ // IMPORTANT: this file is the entrypoint for "astro/config". Keep it as light as possible! import type { SharpImageServiceConfig } from '../assets/services/sharp.js'; +import type { MemoryCacheProviderOptions } from '../core/cache/memory-provider.js'; +import type { CacheProviderConfig } from '../core/cache/types.js'; import type { ImageServiceConfig } from '../types/public/index.js'; export { fontProviders } from '../assets/fonts/providers/index.js'; @@ -33,3 +35,17 @@ export function passthroughImageService(): ImageServiceConfig { config: {}, }; } + +/** + * Return the configuration needed to use the built-in in-memory LRU cache provider. + * This is a runtime-agnostic provider suitable for single-instance deployments. + */ +export function memoryCache( + config: MemoryCacheProviderOptions = {}, +): CacheProviderConfig { + return { + name: 'memory', + entrypoint: 'astro/cache/memory', + config, + }; +} diff --git a/packages/astro/src/core/app/base.ts b/packages/astro/src/core/app/base.ts index 6ec73e37363b..2782b0c52f2e 100644 --- a/packages/astro/src/core/app/base.ts +++ b/packages/astro/src/core/app/base.ts @@ -31,6 +31,7 @@ import { type CreateRenderContext, RenderContext } from '../render-context.js'; import { redirectTemplate } from '../routing/3xx.js'; import { ensure404Route } from '../routing/astro-designed-error-pages.js'; import { matchRoute } from '../routing/match.js'; +import { type CacheLike, applyCacheHeaders } from '../cache/runtime/cache.js'; import { Router } from '../routing/router.js'; import { type AstroSession, PERSIST_SYMBOL } from '../session/runtime.js'; import type { AppPipeline } from './pipeline.js'; @@ -468,6 +469,7 @@ export abstract class BaseApp

{ let response; let session: AstroSession | undefined; + let cache: CacheLike | undefined; try { // Load route module. We also catch its error here if it fails on initialization const componentInstance = await this.pipeline.getComponentByRoute(routeData); @@ -481,7 +483,36 @@ export abstract class BaseApp

{ clientAddress, }); session = renderContext.session; - response = await renderContext.render(componentInstance); + cache = renderContext.cache; + + if (this.pipeline.cacheProvider) { + // If the cache provider has an onRequest handler (runtime caching), + // wrap the render call so the provider can serve from cache + const cacheProvider = await this.pipeline.getCacheProvider(); + if (cacheProvider?.onRequest) { + response = await cacheProvider.onRequest( + { + request, + url: new URL(request.url), + }, + async () => { + const res = await renderContext.render(componentInstance); + // Apply cache headers before the provider reads them + applyCacheHeaders(cache!, res); + return res; + }, + ); + // Strip CDN headers after the runtime provider has read them + response.headers.delete('CDN-Cache-Control'); + response.headers.delete('Cache-Tag'); + } else { + response = await renderContext.render(componentInstance); + // Apply cache headers for CDN-based providers (no onRequest) + applyCacheHeaders(cache!, response); + } + } else { + response = await renderContext.render(componentInstance); + } const isRewrite = response.headers.has(REWRITE_DIRECTIVE_HEADER_KEY); diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index 12569a4b9c67..45e63e0d7217 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -17,6 +17,7 @@ import type { SinglePageBuiltModule } from '../build/types.js'; import type { CspDirective } from '../csp/config.js'; import type { LoggerLevel } from '../logger/core.js'; import type { RoutingStrategies } from './common.js'; +import type { CacheProviderFactory, SSRManifestCache } from '../cache/types.js'; import type { BaseSessionConfig, SessionDriverFactory } from '../session/types.js'; import type { DevToolbarPlacement } from '../../types/public/toolbar.js'; import type { MiddlewareMode } from '../../types/public/integrations.js'; @@ -112,10 +113,12 @@ export type SSRManifest = { middleware?: () => Promise | AstroMiddlewareInstance; actions?: () => Promise | SSRActions; sessionDriver?: () => Promise<{ default: SessionDriverFactory | null }>; + cacheProvider?: () => Promise<{ default: CacheProviderFactory | null }>; checkOrigin: boolean; allowedDomains?: Partial[]; actionBodySizeLimit: number; sessionConfig?: SSRManifestSession; + cacheConfig?: SSRManifestCache; cacheDir: URL; srcDir: URL; outDir: URL; diff --git a/packages/astro/src/core/base-pipeline.ts b/packages/astro/src/core/base-pipeline.ts index 78deccf522b0..a93b37c3ec3d 100644 --- a/packages/astro/src/core/base-pipeline.ts +++ b/packages/astro/src/core/base-pipeline.ts @@ -23,6 +23,8 @@ import { sequence } from './middleware/sequence.js'; import { RedirectSinglePageBuiltModule } from './redirects/index.js'; import { RouteCache } from './render/route-cache.js'; import { createDefaultRoutes } from './routing/default.js'; +import type { CacheProvider, CacheProviderFactory } from './cache/types.js'; +import type { CompiledCacheRoute } from './cache/runtime/route-matching.js'; import type { SessionDriverFactory } from './session/types.js'; import { NodePool } from '../runtime/server/render/queue/pool.js'; import { HTMLStringCache } from '../runtime/server/html-string-cache.js'; @@ -38,6 +40,8 @@ export abstract class Pipeline { resolvedMiddleware: MiddlewareHandler | undefined = undefined; resolvedActions: SSRActions | undefined = undefined; resolvedSessionDriver: SessionDriverFactory | null | undefined = undefined; + resolvedCacheProvider: CacheProvider | null | undefined = undefined; + compiledCacheRoutes: CompiledCacheRoute[] | undefined = undefined; nodePool: NodePool | undefined; htmlStringCache: HTMLStringCache | undefined; @@ -74,6 +78,8 @@ export abstract class Pipeline { readonly actions = manifest.actions, readonly sessionDriver = manifest.sessionDriver, + readonly cacheProvider = manifest.cacheProvider, + readonly cacheConfig = manifest.cacheConfig, readonly serverIslands = manifest.serverIslandMappings, ) { this.internalMiddleware = []; @@ -176,6 +182,25 @@ export abstract class Pipeline { return null; } + async getCacheProvider(): Promise { + // Return cached value if already resolved (including null) + if (this.resolvedCacheProvider !== undefined) { + return this.resolvedCacheProvider; + } + + // Try to load the provider from the manifest + if (this.cacheProvider) { + const mod = await this.cacheProvider(); + const factory: CacheProviderFactory | null = mod?.default || null; + this.resolvedCacheProvider = factory ? factory(this.cacheConfig?.options) : null; + return this.resolvedCacheProvider; + } + + // No provider configured + this.resolvedCacheProvider = null; + return null; + } + async getServerIslands(): Promise { if (this.serverIslands) { return this.serverIslands(); diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts index 414402aca3bf..63488435ace7 100644 --- a/packages/astro/src/core/build/plugins/plugin-manifest.ts +++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts @@ -35,6 +35,7 @@ import type { BuildInternals } from '../internal.js'; import { cssOrder, mergeInlineCss } from '../runtime.js'; import type { StaticBuildOptions } from '../types.js'; import { makePageDataKey } from './util.js'; +import { cacheConfigToManifest } from '../../cache/utils.js'; import { sessionConfigToManifest } from '../../session/utils.js'; /** @@ -347,6 +348,10 @@ async function buildManifest( allowedDomains: settings.config.security?.allowedDomains, key: encodedKey, sessionConfig: sessionConfigToManifest(settings.config.session), + cacheConfig: cacheConfigToManifest( + settings.config.experimental?.cache, + settings.config.experimental?.routeRules, + ), csp, image: { objectFit: settings.config.image.objectFit, diff --git a/packages/astro/src/core/cache/config.ts b/packages/astro/src/core/cache/config.ts new file mode 100644 index 000000000000..9323b243e051 --- /dev/null +++ b/packages/astro/src/core/cache/config.ts @@ -0,0 +1,40 @@ +import * as z from 'zod/v4'; + +const CacheProviderConfigSchema = z.object({ + config: z.record(z.string(), z.any()).optional(), + entrypoint: z.union([z.string(), z.instanceof(URL)]), + name: z.string().optional(), +}); + +/** + * Cache options that can be applied to a route. + */ +const CacheOptionsSchema = z.object({ + maxAge: z.number().int().min(0).optional(), + swr: z.number().int().min(0).optional(), + tags: z.array(z.string()).optional(), +}); + +/** + * Cache provider configuration (experimental.cache). + * Provider only - routes are configured via experimental.routeRules. + */ +export const CacheSchema = z.object({ + provider: CacheProviderConfigSchema.optional(), +}); + +const RouteRuleSchema = CacheOptionsSchema; + +/** + * Route rules configuration (experimental.routeRules). + * Maps glob patterns to route rules. + * + * Example: + * ```ts + * routeRules: { + * '/api/*': { swr: 600 }, + * '/products/*': { maxAge: 3600, tags: ['products'] }, + * } + * ``` + */ +export const RouteRulesSchema = z.record(z.string(), RouteRuleSchema); diff --git a/packages/astro/src/core/cache/memory-provider.ts b/packages/astro/src/core/cache/memory-provider.ts new file mode 100644 index 000000000000..627dee94c176 --- /dev/null +++ b/packages/astro/src/core/cache/memory-provider.ts @@ -0,0 +1,538 @@ +import picomatch from 'picomatch'; +import { AstroError } from '../errors/errors.js'; +import { CacheQueryConfigConflict } from '../errors/errors-data.js'; +import type { CacheProvider, CacheProviderFactory, InvalidateOptions } from './types.js'; + +interface CachedEntry { + body: ArrayBuffer; + status: number; + headers: [string, string][]; + /** Absolute timestamp (ms) when the entry was stored */ + storedAt: number; + /** max-age in seconds from CDN-Cache-Control */ + maxAge: number; + /** stale-while-revalidate window in seconds */ + swr: number; + /** Tags for invalidation */ + tags: string[]; + /** Headers from the Vary response header (lowercased), used for cache key discrimination */ + vary?: string[]; + /** Snapshot of request header values for the Vary'd headers, used to match subsequent requests */ + varyValues?: Record; +} + +export interface MemoryCacheQueryOptions { + /** + * Sort query parameters alphabetically so that parameter order does not + * affect the cache key. Enabled by default. + * @default true + */ + sort?: boolean; + /** + * Only include these query parameter names in the cache key. + * All other parameters are ignored, including the default tracking + * parameter exclusions. Cannot be used together with `exclude`. + * + * @example + * ```js + * memoryCache({ query: { include: ['page', 'sort', 'q'] } }) + * ``` + */ + include?: string[]; + /** + * Exclude query parameters whose names match these patterns from the cache + * key. Supports glob wildcards (e.g. `"utm_*"`). Cannot be used together + * with `include`. + * + * By default, common tracking and analytics parameters (`utm_*`, `fbclid`, + * `gclid`, etc.) are excluded. Set to `[]` to include all query parameters + * in the cache key. + * + * @default ['utm_*', 'fbclid', 'gclid', 'gbraid', 'wbraid', 'dclid', 'msclkid', 'twclid', 'li_fat_id', 'mc_cid', 'mc_eid', '_ga', '_gl', '_hsenc', '_hsmi', '_ke', 'oly_anon_id', 'oly_enc_id', 'rb_clickid', 's_cid', 'vero_id', 'wickedid', 'yclid', '__s', 'ref'] + * + * @example + * ```js + * // Only exclude specific params (replaces defaults) + * memoryCache({ query: { exclude: ['session_id', 'token'] } }) + * ``` + * + * @example + * ```js + * // Include all query parameters (disable default exclusions) + * memoryCache({ query: { exclude: [] } }) + * ``` + */ + exclude?: string[]; +} + +export interface MemoryCacheProviderOptions { + /** Maximum number of entries to keep in cache. Defaults to 1000. */ + max?: number; + /** + * Query parameter handling for cache keys. + * By default, parameters are sorted alphabetically so that order does not + * affect the cache key. + */ + query?: MemoryCacheQueryOptions; +} + +/** + * Parse CDN-Cache-Control directives from a header value. + * Returns maxAge and swr in seconds. + */ +function parseCdnCacheControl(header: string | null): { maxAge: number; swr: number } { + let maxAge = 0; + let swr = 0; + if (!header) return { maxAge, swr }; + + for (const part of header.split(',')) { + const trimmed = part.trim().toLowerCase(); + if (trimmed.startsWith('max-age=')) { + maxAge = Number.parseInt(trimmed.slice(8), 10) || 0; + } else if (trimmed.startsWith('stale-while-revalidate=')) { + swr = Number.parseInt(trimmed.slice(23), 10) || 0; + } + } + return { maxAge, swr }; +} + +/** + * Parse Cache-Tag header into an array of tags. + */ +function parseCacheTags(header: string | null): string[] { + if (!header) return []; + return header + .split(',') + .map((t) => t.trim()) + .filter(Boolean); +} + +/** + * Common tracking/analytics query parameters that are excluded from cache + * keys by default. These do not affect page content but create unnecessary + * cache fragmentation. + * + * Set `query.exclude` to `[]` to include all query parameters. + */ +const DEFAULT_EXCLUDED_PARAMS = [ + 'utm_*', + 'fbclid', + 'gclid', + 'gbraid', + 'wbraid', + 'dclid', + 'msclkid', + 'twclid', + 'li_fat_id', + 'mc_cid', + 'mc_eid', + '_ga', + '_gl', + '_hsenc', + '_hsmi', + '_ke', + 'oly_anon_id', + 'oly_enc_id', + 'rb_clickid', + 's_cid', + 'vero_id', + 'wickedid', + 'yclid', + '__s', + 'ref', +]; + +interface NormalizedQueryConfig { + sort: boolean; + include: string[] | null; + excludeMatcher: picomatch.Matcher | null; +} + +function normalizeQueryConfig(query: MemoryCacheQueryOptions | undefined): NormalizedQueryConfig { + if (query?.include && query?.exclude) { + throw new AstroError(CacheQueryConfigConflict); + } + + const sort = query?.sort !== false; + const include = query?.include ?? null; + + // When `include` is set, exclude is irrelevant — only the allowlisted params matter. + const excludePatterns = include ? [] : (query?.exclude ?? DEFAULT_EXCLUDED_PARAMS); + const excludeMatcher = + excludePatterns.length > 0 ? picomatch(excludePatterns, { nocase: true }) : null; + return { sort, include, excludeMatcher }; +} + +/** + * Build the query string portion of a cache key, applying sorting and filtering. + */ +function buildQueryString(url: URL, config: NormalizedQueryConfig): string { + const params = new URLSearchParams(url.searchParams); + + // Filter: include mode (allowlist) + if (config.include) { + const allowed = new Set(config.include); + for (const key of [...params.keys()]) { + if (!allowed.has(key)) { + params.delete(key); + } + } + } + + // Filter: exclude mode (blocklist with globs) + if (config.excludeMatcher) { + for (const key of [...params.keys()]) { + if (config.excludeMatcher(key)) { + params.delete(key); + } + } + } + + // Sort + if (config.sort) { + params.sort(); + } + + const qs = params.toString(); + return qs ? `?${qs}` : ''; +} + +function getCacheKey(url: URL, queryConfig: NormalizedQueryConfig): string { + return `${url.origin}${url.pathname}${buildQueryString(url, queryConfig)}`; +} + +function getPathFromCacheKey(key: string, queryConfig: NormalizedQueryConfig): string | null { + // Strip Vary suffix (everything after the first NUL separator) + const urlPart = key.split('\0')[0]; + if (!URL.canParse(urlPart)) return null; + const url = new URL(urlPart); + return `${url.pathname}${buildQueryString(url, queryConfig)}`; +} + +/** + * Headers that should not be used for Vary-based cache key discrimination. + * `cookie` is excluded because it has extremely high cardinality (every user + * has different cookies), making it effectively uncacheable. Use config-level + * cookie-based vary instead when that is supported. + * `set-cookie` is a response header and should never appear in Vary. + */ +const IGNORED_VARY_HEADERS = new Set(['cookie', 'set-cookie']); + +/** + * Parse the Vary header into an array of lowercased header names. + * Returns undefined if no Vary header or Vary: * + */ +function parseVaryHeader(response: Response): string[] | undefined { + const vary = response.headers.get('Vary'); + if (!vary || vary.trim() === '*') return undefined; + const headers = vary + .split(',') + .map((h) => h.trim().toLowerCase()) + .filter((h) => h && !IGNORED_VARY_HEADERS.has(h)); + return headers.length > 0 ? headers : undefined; +} + +/** + * Extract the values of Vary'd headers from a request. + */ +function getVaryValues(request: Request, varyHeaders: string[]): Record { + const values: Record = {}; + for (const header of varyHeaders) { + values[header] = request.headers.get(header) ?? ''; + } + return values; +} + +/** + * Check whether a request matches the Vary'd header values stored in a cache entry. + */ +function matchesVary(request: Request, entry: CachedEntry): boolean { + if (!entry.vary || !entry.varyValues) return true; + for (const header of entry.vary) { + const requestValue = request.headers.get(header) ?? ''; + if (requestValue !== entry.varyValues[header]) return false; + } + return true; +} + +function hasSetCookieHeader(response: Response): boolean { + return response.headers.has('set-cookie'); +} + +function warnSkippedSetCookie(url: URL): void { + console.warn( + `[astro:cache] Skipping cache for ${url.pathname}${url.search} because response includes Set-Cookie.`, + ); +} + +/** + * Simple LRU cache backed by a Map (insertion-order iteration). + * When the cache exceeds `max` entries, the oldest entry is evicted. + */ +class LRUMap { + #map = new Map(); + #max: number; + + constructor(max: number) { + this.#max = max; + } + + get(key: K): V | undefined { + const value = this.#map.get(key); + if (value !== undefined) { + // Move to end (most recently used) + this.#map.delete(key); + this.#map.set(key, value); + } + return value; + } + + set(key: K, value: V): void { + if (this.#map.has(key)) { + this.#map.delete(key); + } else if (this.#map.size >= this.#max) { + // Evict oldest (first inserted) + const oldest = this.#map.keys().next().value!; + this.#map.delete(oldest); + } + this.#map.set(key, value); + } + + delete(key: K): boolean { + return this.#map.delete(key); + } + + values(): IterableIterator { + return this.#map.values(); + } + + keys(): IterableIterator { + return this.#map.keys(); + } + + get size(): number { + return this.#map.size; + } +} + +/** + * Serialize a Response into a CachedEntry. Consumes the response body. + * + * Callers are responsible for cloning the response first if they still need to + * return it to the client (see the cache-miss path). In the SWR revalidation + * path, the stale response has already been sent so no clone is needed. + */ +async function serializeResponse( + response: Response, + request: Request, + maxAge: number, + swr: number, + tags: string[], +): Promise { + const body = await response.arrayBuffer(); + const headers: [string, string][] = []; + response.headers.forEach((value, key) => { + if (key.toLowerCase() === 'set-cookie') return; + headers.push([key, value]); + }); + const vary = parseVaryHeader(response); + return { + body, + status: response.status, + headers, + storedAt: Date.now(), + maxAge, + swr, + tags, + vary, + varyValues: vary ? getVaryValues(request, vary) : undefined, + }; +} + +/** + * Create a new Response from a CachedEntry. + */ +function createResponseFromCacheEntry(entry: CachedEntry): Response { + const headers = new Headers(entry.headers); + return new Response(entry.body.slice(0), { + status: entry.status, + headers, + }); +} + +function isExpired(entry: CachedEntry): boolean { + const age = (Date.now() - entry.storedAt) / 1000; + return age > entry.maxAge; +} + +function isStale(entry: CachedEntry): boolean { + const age = (Date.now() - entry.storedAt) / 1000; + return age > entry.maxAge && age <= entry.maxAge + entry.swr; +} + +/** + * Build a Vary-aware cache key suffix from a request and a known set of Vary headers. + * Returns an empty string if there are no Vary headers. + * + * Uses NUL (`\0`) as the separator because it cannot appear in URLs or HTTP + * header values, so there's no risk of collisions with the primary key or + * between Vary'd values. This keeps the key as a flat string in the LRU map + * rather than needing a nested lookup structure. + */ +function buildVarySuffix(request: Request, varyHeaders: string[]): string { + if (varyHeaders.length === 0) return ''; + const parts: string[] = []; + for (const header of varyHeaders) { + parts.push(`${header}=${request.headers.get(header) ?? ''}`); + } + return `\0${parts.join('\0')}`; +} + +const memoryProvider = ((config): CacheProvider => { + const max = config?.max ?? 1000; + const queryConfig = normalizeQueryConfig(config?.query); + const cache = new LRUMap(max); + + /** + * Maps a primary cache key (URL without Vary) to the set of Vary header names + * learned from responses. This lets us build the correct Vary-aware key on + * subsequent requests before we even look up the entry. + */ + const varyMap = new Map(); + + return { + name: 'memory', + + async onRequest(context, next) { + const requestUrl = new URL(context.request.url); + + // Only cache GET requests. + if (context.request.method !== 'GET') { + return next(); + } + + const primaryKey = getCacheKey(requestUrl, queryConfig); + + // Build the full key including Vary'd header values if we know them + const knownVary = varyMap.get(primaryKey); + const varySuffix = knownVary ? buildVarySuffix(context.request, knownVary) : ''; + const key = primaryKey + varySuffix; + + const cached = cache.get(key); + + if (cached) { + // Double-check Vary match (defensive — the key should already be correct) + if (matchesVary(context.request, cached)) { + if (!isExpired(cached)) { + // Fresh cache hit + const response = createResponseFromCacheEntry(cached); + response.headers.set('X-Astro-Cache', 'HIT'); + return response; + } + + if (isStale(cached)) { + // SWR: serve stale, trigger background revalidation. + // The promise is intentionally not awaited — it runs in the + // background on the long-lived server process and updates the + // cache entry for subsequent requests. + next() + .then(async (freshResponse) => { + const cdnCC = freshResponse.headers.get('CDN-Cache-Control'); + const { maxAge: newMaxAge, swr: newSwr } = parseCdnCacheControl(cdnCC); + if (newMaxAge > 0) { + if (hasSetCookieHeader(freshResponse)) { + warnSkippedSetCookie(requestUrl); + return; + } + const newTags = parseCacheTags(freshResponse.headers.get('Cache-Tag')); + const newEntry = await serializeResponse( + freshResponse, + context.request, + newMaxAge, + newSwr, + newTags, + ); + // Update Vary map if the response changed its Vary headers + if (newEntry.vary) { + varyMap.set(primaryKey, newEntry.vary); + } + cache.set(key, newEntry); + } + }) + .catch((error) => { + console.warn( + `[astro:cache] Background revalidation failed for ${requestUrl.pathname}${requestUrl.search}: ${String( + error, + )}`, + ); + }); + + const response = createResponseFromCacheEntry(cached); + response.headers.set('X-Astro-Cache', 'STALE'); + return response; + } + } + + // Past SWR window or Vary mismatch — expired, treat as miss + } + + // Cache miss — render fresh + const response = await next(); + + // Parse cache directives from the response headers set by _applyHeaders() + const cdnCC = response.headers.get('CDN-Cache-Control'); + const { maxAge, swr } = parseCdnCacheControl(cdnCC); + + if (maxAge > 0) { + if (hasSetCookieHeader(response)) { + warnSkippedSetCookie(requestUrl); + return response; + } + const tags = parseCacheTags(response.headers.get('Cache-Tag')); + // Clone the response so we can read the body for caching and still return it + const [forCache, forClient] = [response.clone(), response]; + const entry = await serializeResponse(forCache, context.request, maxAge, swr, tags); + + // Learn Vary headers from the response and build the storage key + let storeKey = primaryKey; + if (entry.vary) { + varyMap.set(primaryKey, entry.vary); + storeKey = primaryKey + buildVarySuffix(context.request, entry.vary); + } + + cache.set(storeKey, entry); + forClient.headers.set('X-Astro-Cache', 'MISS'); + return forClient; + } + + // No cache directives — pass through + return response; + }, + + async invalidate(invalidateOptions: InvalidateOptions) { + if (invalidateOptions.path) { + // Path invalidation is exact-match only (no glob/wildcard patterns) + for (const key of [...cache.keys()]) { + if (getPathFromCacheKey(key, queryConfig) === invalidateOptions.path) { + cache.delete(key); + } + } + } + if (invalidateOptions.tags) { + const tagsToInvalidate = Array.isArray(invalidateOptions.tags) + ? invalidateOptions.tags + : [invalidateOptions.tags]; + const tagsSet = new Set(tagsToInvalidate); + // Iterate and delete entries whose tags overlap + for (const key of [...cache.keys()]) { + const entry = cache.get(key); + if (entry && entry.tags.some((t) => tagsSet.has(t))) { + cache.delete(key); + } + } + } + }, + }; +}) satisfies CacheProviderFactory; + +export default memoryProvider; diff --git a/packages/astro/src/core/cache/runtime/cache.ts b/packages/astro/src/core/cache/runtime/cache.ts new file mode 100644 index 000000000000..0288ecf78d2c --- /dev/null +++ b/packages/astro/src/core/cache/runtime/cache.ts @@ -0,0 +1,144 @@ +import type { + CacheHint, + CacheOptions, + CacheProvider, + InvalidateOptions, + LiveDataEntry, +} from '../types.js'; +import { AstroError } from '../../errors/errors.js'; +import { CacheNotEnabled } from '../../errors/errors-data.js'; +import { defaultSetHeaders, isLiveDataEntry } from './utils.js'; + +const APPLY_HEADERS = Symbol.for('astro:cache:apply'); +const IS_ACTIVE = Symbol.for('astro:cache:active'); + +export interface CacheLike { + /** + * Set cache options for the current request. Call multiple times to merge options. + * Pass `false` to explicitly opt out of caching. + */ + set(input: CacheOptions | CacheHint | LiveDataEntry | false): void; + /** All accumulated cache tags for this request. */ + readonly tags: string[]; + /** A read-only snapshot of the current cache options, including accumulated tags. */ + readonly options: Readonly; + /** + * Purge cached entries by tag or path. Requires a cache provider to be configured. + */ + invalidate(input: InvalidateOptions | LiveDataEntry): Promise; +} + +export class AstroCache implements CacheLike { + #options: CacheOptions = {}; + #tags = new Set(); + #disabled = false; + #provider: CacheProvider | null; + + constructor(provider: CacheProvider | null) { + this.#provider = provider; + } + + set(input: CacheOptions | CacheHint | LiveDataEntry | false): void { + if (input === false) { + this.#disabled = true; + this.#tags.clear(); + this.#options = {}; + return; + } + this.#disabled = false; + + // Extract CacheHint from LiveDataEntry + let options: CacheOptions | CacheHint; + if (isLiveDataEntry(input)) { + if (!input.cacheHint) return; + options = input.cacheHint; + } else { + options = input; + } + + // Merge scalars: last-write-wins + if ('maxAge' in options && options.maxAge !== undefined) this.#options.maxAge = options.maxAge; + if ('swr' in options && (options as CacheOptions).swr !== undefined) + this.#options.swr = (options as CacheOptions).swr; + if ('etag' in options && (options as CacheOptions).etag !== undefined) + this.#options.etag = (options as CacheOptions).etag; + + // lastModified: most recent wins + if (options.lastModified !== undefined) { + if (!this.#options.lastModified || options.lastModified > this.#options.lastModified) { + this.#options.lastModified = options.lastModified; + } + } + + // Tags: accumulate + if (options.tags) { + for (const tag of options.tags) this.#tags.add(tag); + } + } + + get tags(): string[] { + return [...this.#tags]; + } + + /** + * Get the current cache options (read-only snapshot). + * Includes all accumulated options: maxAge, swr, tags, etag, lastModified. + */ + get options(): Readonly { + return { + ...this.#options, + tags: this.tags, + }; + } + + async invalidate(input: InvalidateOptions | LiveDataEntry): Promise { + if (!this.#provider) { + throw new AstroError(CacheNotEnabled); + } + let options: InvalidateOptions; + if (isLiveDataEntry(input)) { + options = { tags: input.cacheHint?.tags ?? [] }; + } else { + options = input; + } + return this.#provider.invalidate(options); + } + + /** @internal */ + [APPLY_HEADERS](response: Response): void { + if (this.#disabled) return; + const finalOptions: CacheOptions = { ...this.#options, tags: this.tags }; + if (finalOptions.maxAge === undefined && !finalOptions.tags?.length) return; + + const headers = this.#provider?.setHeaders?.(finalOptions) ?? defaultSetHeaders(finalOptions); + for (const [key, value] of headers) { + response.headers.set(key, value); + } + } + + /** @internal */ + get [IS_ACTIVE](): boolean { + return !this.#disabled && (this.#options.maxAge !== undefined || this.#tags.size > 0); + } +} + +// ─── Framework-internal helpers (not exported from the `astro` package) ───── + +/** + * Apply cache headers to a response. + */ +export function applyCacheHeaders(cache: CacheLike, response: Response): void { + if (APPLY_HEADERS in cache) { + (cache as AstroCache)[APPLY_HEADERS](response); + } +} + +/** + * Check whether the cache has any active state worth acting on. + */ +export function isCacheActive(cache: CacheLike): boolean { + if (IS_ACTIVE in cache) { + return (cache as AstroCache)[IS_ACTIVE]; + } + return false; +} diff --git a/packages/astro/src/core/cache/runtime/noop.ts b/packages/astro/src/core/cache/runtime/noop.ts new file mode 100644 index 000000000000..fd06d3626032 --- /dev/null +++ b/packages/astro/src/core/cache/runtime/noop.ts @@ -0,0 +1,48 @@ +import { AstroError } from '../../errors/errors.js'; +import { CacheNotEnabled } from '../../errors/errors-data.js'; +import type { CacheLike } from './cache.js'; +import type { CacheOptions } from '../types.js'; + +/** + * A no-op cache implementation used in dev mode when cache is configured. + * The API is available so user code doesn't need conditional checks, + * but nothing is actually cached. + */ +const EMPTY_OPTIONS = Object.freeze({ tags: [] }) as Readonly; + +export class NoopAstroCache implements CacheLike { + set(): void {} + + get tags(): string[] { + return []; + } + + get options(): Readonly { + return EMPTY_OPTIONS; + } + + async invalidate(): Promise {} +} + +/** + * A cache implementation that throws on any method call. + * Used when cache is not configured — provides a clear, actionable error + * instead of silently doing nothing or returning undefined. + */ +export class DisabledAstroCache implements CacheLike { + set(): void { + throw new AstroError(CacheNotEnabled); + } + + get tags(): string[] { + throw new AstroError(CacheNotEnabled); + } + + get options(): Readonly { + throw new AstroError(CacheNotEnabled); + } + + async invalidate(): Promise { + throw new AstroError(CacheNotEnabled); + } +} diff --git a/packages/astro/src/core/cache/runtime/route-matching.ts b/packages/astro/src/core/cache/runtime/route-matching.ts new file mode 100644 index 000000000000..20034d15d25b --- /dev/null +++ b/packages/astro/src/core/cache/runtime/route-matching.ts @@ -0,0 +1,57 @@ +import { removeLeadingForwardSlash } from '@astrojs/internal-helpers/path'; +import type { AstroConfig } from '../../../types/public/config.js'; +import type { RouteData, RoutePart } from '../../../types/public/internal.js'; +import { getParts } from '../../routing/parts.js'; +import { getPattern } from '../../routing/pattern.js'; +import { routeComparator } from '../../routing/priority.js'; +import type { CacheOptions } from '../types.js'; + +export interface CompiledCacheRoute { + pattern: RegExp; + options: CacheOptions; + segments: RoutePart[][]; + route: string; +} + +/** + * Compile config-level cache route patterns into RegExps. + * The result is memoized on the pipeline — this function is only called once, + * on the first request that needs route matching. + * Returns compiled patterns sorted by Astro's standard route priority (most specific first). + */ +export function compileCacheRoutes( + routes: Record, + base: AstroConfig['base'], + trailingSlash: AstroConfig['trailingSlash'], +): CompiledCacheRoute[] { + const compiled = Object.entries(routes).map(([path, options]) => { + const segments = removeLeadingForwardSlash(path) + .split('/') + .filter(Boolean) + .map((s: string) => getParts(s, path)); + const pattern = getPattern(segments, base, trailingSlash); + return { pattern, options, segments, route: path }; + }); + // Sort using Astro's standard route priority comparator + // routeComparator expects objects with `segments`, `route`, and `type` + compiled.sort((a, b) => + routeComparator( + { segments: a.segments, route: a.route, type: 'page' } as RouteData, + { segments: b.segments, route: b.route, type: 'page' } as RouteData, + ), + ); + return compiled; +} + +/** + * Called per-request to find the first matching cache rule for a given pathname. + */ +export function matchCacheRoute( + pathname: string, + compiledRoutes: CompiledCacheRoute[], +): CacheOptions | null { + for (const route of compiledRoutes) { + if (route.pattern.test(pathname)) return route.options; + } + return null; +} diff --git a/packages/astro/src/core/cache/runtime/utils.ts b/packages/astro/src/core/cache/runtime/utils.ts new file mode 100644 index 000000000000..e0113b5b2be5 --- /dev/null +++ b/packages/astro/src/core/cache/runtime/utils.ts @@ -0,0 +1,52 @@ +import type { CacheHint, CacheOptions, LiveDataEntry } from '../types.js'; + +/** + * Generate default cache response headers from CacheOptions. + * Used when the provider doesn't supply its own `setHeaders()`. + */ +export function defaultSetHeaders(options: CacheOptions): Headers { + const headers = new Headers(); + + // CDN-Cache-Control + const directives: string[] = []; + if (options.maxAge !== undefined) { + directives.push(`max-age=${options.maxAge}`); + } + if (options.swr !== undefined) { + directives.push(`stale-while-revalidate=${options.swr}`); + } + if (directives.length > 0) { + headers.set('CDN-Cache-Control', directives.join(', ')); + } + + // Cache-Tag + if (options.tags && options.tags.length > 0) { + headers.set('Cache-Tag', options.tags.join(', ')); + } + + // Last-Modified + if (options.lastModified) { + headers.set('Last-Modified', options.lastModified.toUTCString()); + } + + // ETag + if (options.etag) { + headers.set('ETag', options.etag); + } + + return headers; +} + +export function isCacheHint(value: unknown): value is CacheHint { + return value != null && typeof value === 'object' && 'tags' in value; +} + +export function isLiveDataEntry(value: unknown): value is LiveDataEntry { + return ( + value != null && + typeof value === 'object' && + 'id' in value && + 'data' in value && + 'cacheHint' in value + ); +} diff --git a/packages/astro/src/core/cache/types.ts b/packages/astro/src/core/cache/types.ts new file mode 100644 index 000000000000..e6701fc850a1 --- /dev/null +++ b/packages/astro/src/core/cache/types.ts @@ -0,0 +1,74 @@ +import type { MiddlewareNext } from '../../types/public/common.js'; + +export interface CacheOptions { + maxAge?: number; + swr?: number; + tags?: string[]; + lastModified?: Date; + etag?: string; +} + +export interface CacheHint { + tags?: string[]; + lastModified?: Date; +} + +export interface LiveDataEntry { + id: string; + data: unknown; + cacheHint?: CacheHint; +} + +export interface InvalidateOptions { + path?: string; + tags?: string | string[]; +} + +export interface CacheProvider { + name: string; + setHeaders?(options: CacheOptions): Headers; + onRequest?(context: { request: Request; url: URL }, next: MiddlewareNext): Promise; + invalidate(options: InvalidateOptions): Promise; +} + +export type CacheProviderFactory = Record> = ( + config: TConfig | undefined, +) => CacheProvider; + +export interface CacheProviderConfig = Record> { + /** Optional display name for logs and errors */ + name?: string; + /** URL or package import */ + entrypoint: string | URL; + /** Serializable options used by the provider implementation */ + config?: TConfig; +} + +export interface NormalizedCacheProviderConfig { + name: string | undefined; + entrypoint: string; + config: Record | undefined; +} + +export interface SSRManifestCache { + provider: string; + options?: Record; + routes?: Record; +} + +export interface RouteRule { + /** + * Cache max-age in seconds. + */ + maxAge?: number; + /** + * Stale-while-revalidate window in seconds. + */ + swr?: number; + /** + * Cache tags for invalidation. + */ + tags?: string[]; +} + +export type RouteRules = Record; diff --git a/packages/astro/src/core/cache/utils.ts b/packages/astro/src/core/cache/utils.ts new file mode 100644 index 000000000000..6165171b9d02 --- /dev/null +++ b/packages/astro/src/core/cache/utils.ts @@ -0,0 +1,80 @@ +import type { AstroConfig } from '../../types/public/index.js'; +import type { + CacheOptions, + CacheProviderConfig, + NormalizedCacheProviderConfig, + SSRManifestCache, +} from './types.js'; + +export function normalizeCacheProviderConfig( + provider: CacheProviderConfig, +): NormalizedCacheProviderConfig { + return { + name: provider.name, + entrypoint: provider.entrypoint instanceof URL ? provider.entrypoint.href : provider.entrypoint, + config: provider.config, + }; +} + +/** + * Normalize a route rule to extract cache options. + */ +export function normalizeRouteRuleCacheOptions( + rule: + | { + maxAge?: number; + swr?: number; + tags?: string[]; + } + | undefined, +): CacheOptions | undefined { + if (!rule) return undefined; + + if (rule.maxAge === undefined && rule.swr === undefined && rule.tags === undefined) { + return undefined; + } + + return { + maxAge: rule.maxAge, + swr: rule.swr, + tags: rule.tags, + }; +} + +/** + * Extract cache routes from experimental.routeRules config. + */ +export function extractCacheRoutesFromRouteRules( + routeRules: AstroConfig['experimental']['routeRules'], +): Record | undefined { + if (!routeRules) return undefined; + + const cacheRoutes: Record = {}; + + for (const [pattern, rule] of Object.entries(routeRules)) { + const cacheOptions = normalizeRouteRuleCacheOptions(rule); + if (cacheOptions) { + cacheRoutes[pattern] = cacheOptions; + } + } + + return Object.keys(cacheRoutes).length > 0 ? cacheRoutes : undefined; +} + +export function cacheConfigToManifest( + cacheConfig: AstroConfig['experimental']['cache'], + routeRulesConfig: AstroConfig['experimental']['routeRules'], +): SSRManifestCache | undefined { + if (!cacheConfig?.provider) { + return undefined; + } + + const provider = normalizeCacheProviderConfig(cacheConfig.provider); + const routes = extractCacheRoutesFromRouteRules(routeRulesConfig); + + return { + provider: provider.entrypoint, + options: provider.config, + routes, + }; +} diff --git a/packages/astro/src/core/cache/vite-plugin.ts b/packages/astro/src/core/cache/vite-plugin.ts new file mode 100644 index 000000000000..d09949c1abca --- /dev/null +++ b/packages/astro/src/core/cache/vite-plugin.ts @@ -0,0 +1,64 @@ +import { fileURLToPath } from 'node:url'; +import type { Plugin as VitePlugin } from 'vite'; +import type { AstroSettings } from '../../types/astro.js'; +import { AstroError } from '../errors/errors.js'; +import { CacheProviderNotFound } from '../errors/errors-data.js'; +import { normalizeCacheProviderConfig } from './utils.js'; + +export const VIRTUAL_CACHE_PROVIDER_ID = 'virtual:astro:cache-provider'; +const RESOLVED_VIRTUAL_CACHE_PROVIDER_ID = '\0' + VIRTUAL_CACHE_PROVIDER_ID; + +export function vitePluginCacheProvider({ + settings, +}: { + settings: AstroSettings; +}): VitePlugin | undefined { + const providerConfig = settings.config.experimental?.cache?.provider; + if (!providerConfig) { + return; + } + + return { + name: VIRTUAL_CACHE_PROVIDER_ID, + enforce: 'pre', + + resolveId: { + filter: { + id: new RegExp(`^${VIRTUAL_CACHE_PROVIDER_ID}$`), + }, + handler() { + return RESOLVED_VIRTUAL_CACHE_PROVIDER_ID; + }, + }, + + load: { + filter: { + id: new RegExp(`^${RESOLVED_VIRTUAL_CACHE_PROVIDER_ID}$`), + }, + async handler() { + const provider = normalizeCacheProviderConfig(providerConfig); + // Use the project root as the importer so that adapter-provided + // providers (e.g. astro/cache/memory) resolve from the project's + // node_modules, not from astro core's location. + const importerPath = fileURLToPath(new URL('package.json', settings.config.root)); + let resolved; + try { + resolved = await this.resolve(provider.entrypoint, importerPath); + } catch { + // Resolution can throw for invalid package specifiers + } + if (!resolved) { + const displayName = provider.name ?? provider.entrypoint; + throw new AstroError({ + ...CacheProviderNotFound, + message: CacheProviderNotFound.message(displayName), + }); + } + + return { + code: `import { default as _default } from '${resolved.id}';\nexport * from '${resolved.id}';\nexport default _default;`, + }; + }, + }, + }; +} diff --git a/packages/astro/src/core/config/schemas/base.ts b/packages/astro/src/core/config/schemas/base.ts index c5c1d9a1ddae..b19b361dbbe4 100644 --- a/packages/astro/src/core/config/schemas/base.ts +++ b/packages/astro/src/core/config/schemas/base.ts @@ -13,6 +13,7 @@ import { FontFamilySchema } from '../../../assets/fonts/config.js'; import { EnvSchema } from '../../../env/schema.js'; import type { AstroUserConfig, ViteUserConfig } from '../../../types/public/config.js'; import { allowedDirectivesSchema, cspAlgorithmSchema, cspHashSchema } from '../../csp/config.js'; +import { CacheSchema, RouteRulesSchema } from '../../cache/config.js'; import { SessionSchema } from '../../session/config.js'; // The below types are required boilerplate to workaround a Zod issue since v3.21.2. Since that version, @@ -502,6 +503,8 @@ export const AstroConfigSchema = z.object({ .union([z.boolean(), z.custom((value) => value && typeof value === 'object')]) .optional() .default(ASTRO_CONFIG_DEFAULTS.experimental.svgo), + cache: CacheSchema.optional(), + routeRules: RouteRulesSchema.optional(), rustCompiler: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.rustCompiler), queuedRendering: z .object({ diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts index 921a94f9de02..323a384e89d0 100644 --- a/packages/astro/src/core/create-vite.ts +++ b/packages/astro/src/core/create-vite.ts @@ -45,6 +45,7 @@ import { createViteLogger } from './logger/vite.js'; import { vitePluginMiddleware } from './middleware/vite-plugin.js'; import { joinPaths } from './path.js'; import { vitePluginServerIslands } from './server-islands/vite-plugin-server-islands.js'; +import { vitePluginCacheProvider } from './cache/vite-plugin.js'; import { vitePluginSessionDriver } from './session/vite-plugin.js'; import { isObject } from './util-runtime.js'; import { vitePluginEnvironment } from '../vite-plugin-environment/index.js'; @@ -163,6 +164,7 @@ export async function createVite( vitePluginActions({ fs, settings }), vitePluginServerIslands({ settings, logger }), vitePluginSessionDriver({ settings }), + vitePluginCacheProvider({ settings }), astroContainer(), astroHmrReloadPlugin(), vitePluginChromedevtools({ settings }), diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts index c6ab0b78c4ec..0dfeb19a31a4 100644 --- a/packages/astro/src/core/errors/errors-data.ts +++ b/packages/astro/src/core/errors/errors-data.ts @@ -608,6 +608,17 @@ export const UnsupportedImageConversion = { 'Converting between vector (such as SVGs) and raster (such as PNGs and JPEGs) images is not currently supported.', } satisfies ErrorData; +/** + * @docs + * @message An error occurred while optimizing the SVG file with SVGO. + */ +export const CannotOptimizeSvg = { + name: 'CannotOptimizeSvg', + title: 'Cannot optimize SVG', + message: (path: string) => `An error occurred while optimizing SVG file "${path}" with SVGO.`, + hint: 'Review the included SVGO error message provided for guidance.', +} satisfies ErrorData; + /** * @docs * @see @@ -2090,13 +2101,52 @@ export const SessionStorageSaveError = { /** * @docs - * @message An error occurred while optimizing the SVG file with SVGO. + * @kind heading + * @name Cache Errors */ -export const CannotOptimizeSvg = { - name: 'CannotOptimizeSvg', - title: 'Cannot optimize SVG', - message: (path: string) => `An error occurred while optimizing SVG file "${path}" with SVGO.`, - hint: 'Review the included SVGO error message provided for guidance.', +// Cache Errors +/** + * @docs + * @message Could not resolve the cache provider `PROVIDER`. Make sure the package is installed. + * @description + * Thrown when the configured cache provider cannot be resolved. This usually means the package is not installed or the import path is wrong. + */ +export const CacheProviderNotFound = { + name: 'CacheProviderNotFound', + title: 'Cache provider not found.', + message: (provider: string) => + `Could not resolve the cache provider \`${provider}\`. Make sure the package is installed.`, + hint: "If your adapter provides a default cache provider, you may not need to set one explicitly. Check your adapter's documentation.", +} satisfies ErrorData; + +/** + * @docs + * @message `Astro.cache` is not available because the cache feature is not enabled. + * @description + * Thrown when `Astro.cache` or `context.cache` is used but the cache feature has not been enabled in the Astro config. + */ +export const CacheNotEnabled = { + name: 'CacheNotEnabled', + title: 'Cache is not enabled.', + message: + '`Astro.cache` is not available because the cache feature is not enabled. To use caching, configure a cache provider in your Astro config under `experimental.cache`.', + hint: 'Use an adapter that provides a default cache provider, or set one explicitly: `experimental: { cache: { provider: "..." } }`. See https://docs.astro.build/en/reference/experimental-flags/route-caching/.', +} satisfies ErrorData; + +/** + * @docs + * @message `query.include` and `query.exclude` cannot be used together. + * @description + * The memory cache provider's `query.include` and `query.exclude` options are mutually exclusive. + * Use `include` to allowlist specific query parameters that affect the cache key, or `exclude` to + * blocklist parameters. When `include` is set, all other parameters are automatically ignored. + */ +export const CacheQueryConfigConflict = { + name: 'CacheQueryConfigConflict', + title: 'Conflicting cache query configuration.', + message: + '`query.include` and `query.exclude` cannot be used together. Use `include` to allowlist specific parameters, or `exclude` to blocklist them.', + hint: 'When using `include`, all parameters not in the list are automatically excluded, making `exclude` redundant.', } satisfies ErrorData; /* diff --git a/packages/astro/src/core/logger/core.ts b/packages/astro/src/core/logger/core.ts index b6971ddbe74c..dc164acdaab7 100644 --- a/packages/astro/src/core/logger/core.ts +++ b/packages/astro/src/core/logger/core.ts @@ -35,6 +35,7 @@ type LoggerLabel = | 'update' | 'adapter' | 'islands' + | 'cache' | 'csp' // SKIP_FORMAT: A special label that tells the logger not to apply any formatting. // Useful for messages that are already formatted, like the server start message. diff --git a/packages/astro/src/core/middleware/index.ts b/packages/astro/src/core/middleware/index.ts index 31d2a5ddaaaf..859db0bf8ace 100644 --- a/packages/astro/src/core/middleware/index.ts +++ b/packages/astro/src/core/middleware/index.ts @@ -6,6 +6,7 @@ import { } from '../../i18n/utils.js'; import type { Params, RewritePayload } from '../../types/public/common.js'; import type { APIContext } from '../../types/public/context.js'; +import { DisabledAstroCache } from '../cache/runtime/noop.js'; import { ASTRO_GENERATOR } from '../constants.js'; import { AstroCookies } from '../cookies/index.js'; import { AstroError, AstroErrorData } from '../errors/index.js'; @@ -115,6 +116,7 @@ function createContext({ throw new AstroError(AstroErrorData.LocalsReassigned); }, session: undefined, + cache: new DisabledAstroCache(), csp: undefined, }; return Object.assign(context, { diff --git a/packages/astro/src/core/render-context.ts b/packages/astro/src/core/render-context.ts index aaa4071b86df..8e40ea12a761 100644 --- a/packages/astro/src/core/render-context.ts +++ b/packages/astro/src/core/render-context.ts @@ -36,6 +36,9 @@ import { renderRedirect } from './redirects/render.js'; import { getParams, getProps, type Pipeline, Slots } from './render/index.js'; import { isRoute404or500, isRouteExternalRedirect, isRouteServerIsland } from './routing/match.js'; import { copyRequest, getOriginPathname, setOriginPathname } from './routing/rewrite.js'; +import { AstroCache, type CacheLike } from './cache/runtime/cache.js'; +import { NoopAstroCache, DisabledAstroCache } from './cache/runtime/noop.js'; +import { compileCacheRoutes, matchCacheRoute } from './cache/runtime/route-matching.js'; import { AstroSession } from './session/runtime.js'; import { collapseDuplicateLeadingSlashes } from '@astrojs/internal-helpers/path'; import { validateAndDecodePathname } from './util/pathname.js'; @@ -82,6 +85,7 @@ export class RenderContext { public partial: undefined | boolean = undefined, public shouldInjectCspMetaTags = pipeline.manifest.shouldInjectCspMetaTags, public session: AstroSession | undefined = undefined, + public cache: CacheLike, public skipMiddleware = false, ) {} @@ -153,6 +157,33 @@ export class RenderContext { }) : undefined; + // Create cache instance + let cache: CacheLike; + if (!pipeline.cacheConfig) { + // Cache not configured — throws on use + cache = new DisabledAstroCache(); + } else if (pipeline.runtimeMode === 'development') { + cache = new NoopAstroCache(); + } else { + const cacheProvider = await pipeline.getCacheProvider(); + cache = new AstroCache(cacheProvider); + + // Apply config-level cache route matching as initial state + if (pipeline.cacheConfig?.routes) { + if (!pipeline.compiledCacheRoutes) { + pipeline.compiledCacheRoutes = compileCacheRoutes( + pipeline.cacheConfig.routes, + pipeline.manifest.base, + pipeline.manifest.trailingSlash, + ); + } + const matched = matchCacheRoute(pathname, pipeline.compiledCacheRoutes); + if (matched) { + cache.set(matched); + } + } + } + return new RenderContext( pipeline, locals, @@ -171,6 +202,7 @@ export class RenderContext { partial, shouldInjectCspMetaTags ?? pipeline.manifest.shouldInjectCspMetaTags, session, + cache, skipMiddleware, ); } @@ -484,6 +516,9 @@ export class RenderContext { } return renderContext.session; }, + get cache() { + return renderContext.cache; + }, get csp(): APIContext['csp'] { if (!pipeline.manifest.csp) { if (pipeline.runtimeMode === 'production') { @@ -721,6 +756,9 @@ export class RenderContext { } return renderContext.session; }, + get cache() { + return renderContext.cache; + }, get clientAddress() { return renderContext.getClientAddress(); }, diff --git a/packages/astro/src/core/session/runtime.ts b/packages/astro/src/core/session/runtime.ts index 4c8c62179323..d814ca95e528 100644 --- a/packages/astro/src/core/session/runtime.ts +++ b/packages/astro/src/core/session/runtime.ts @@ -243,6 +243,7 @@ export class AstroSession { // Create new session this.#sessionID = crypto.randomUUID(); this.#data = data; + this.#dirty = true; await this.#setCookie(); // Clean up old session asynchronously diff --git a/packages/astro/src/manifest/serialized.ts b/packages/astro/src/manifest/serialized.ts index 73f2b93b5068..b60f621b05c6 100644 --- a/packages/astro/src/manifest/serialized.ts +++ b/packages/astro/src/manifest/serialized.ts @@ -17,11 +17,13 @@ import { import { createKey, encodeKey, getEnvironmentKey, hasEnvironmentKey } from '../core/encryption.js'; import { MIDDLEWARE_MODULE_ID } from '../core/middleware/vite-plugin.js'; import { SERVER_ISLAND_MANIFEST } from '../core/server-islands/vite-plugin-server-islands.js'; +import { VIRTUAL_CACHE_PROVIDER_ID } from '../core/cache/vite-plugin.js'; import { VIRTUAL_SESSION_DRIVER_ID } from '../core/session/vite-plugin.js'; import type { AstroSettings } from '../types/astro.js'; import { VIRTUAL_PAGES_MODULE_ID } from '../vite-plugin-pages/index.js'; import { ASTRO_RENDERERS_MODULE_ID } from '../vite-plugin-renderers/index.js'; import { ASTRO_ROUTES_MODULE_ID } from '../vite-plugin-routes/index.js'; +import { cacheConfigToManifest } from '../core/cache/utils.js'; import { sessionConfigToManifest } from '../core/session/utils.js'; import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../core/constants.js'; import { resolveMiddlewareMode } from '../integrations/adapter-utils.js'; @@ -81,6 +83,10 @@ export function serializedManifestPlugin({ const serialized = await createSerializedManifest(settings); manifestData = JSON.stringify(serialized); } + const hasCacheConfig = !!settings.config.experimental?.cache?.provider; + const cacheProviderLine = hasCacheConfig + ? `cacheProvider: () => import('${VIRTUAL_CACHE_PROVIDER_ID}'),` + : ''; const code = ` import { deserializeManifest as _deserializeManifest } from 'astro/app'; import { renderers } from '${ASTRO_RENDERERS_MODULE_ID}'; @@ -100,6 +106,7 @@ export function serializedManifestPlugin({ actions: () => import('${ACTIONS_ENTRYPOINT_VIRTUAL_MODULE_ID}'), middleware: () => import('${MIDDLEWARE_MODULE_ID}'), sessionDriver: () => import('${VIRTUAL_SESSION_DRIVER_ID}'), + ${cacheProviderLine} serverIslandMappings: () => import('${SERVER_ISLAND_MANIFEST}'), routes: manifestRoutes, pageMap, @@ -174,6 +181,10 @@ async function createSerializedManifest(settings: AstroSettings): Promise} + * @default `undefined` + * @description + * + * Route patterns mapped to cache rules. + * Uses the same `[param]` and `[...rest]` syntax as file-based routing. + * + * ```js + * // astro.config.mjs + * import { memoryCache } from 'astro/config'; + * + * { + * experimental: { + * cache: { provider: memoryCache() }, + * routeRules: { + * '/api/*': { swr: 600 }, + * '/products/*': { maxAge: 3600, tags: ['products'] }, + * }, + * }, + * } + * ``` + */ + routeRules?: RouteRules; + /* * @name experimental.rustCompiler * @type {boolean} * @default `false` diff --git a/packages/astro/src/types/public/context.ts b/packages/astro/src/types/public/context.ts index 7b5cbf7073be..ae3ece78a6ae 100644 --- a/packages/astro/src/types/public/context.ts +++ b/packages/astro/src/types/public/context.ts @@ -1,6 +1,7 @@ import type { ActionClient, ActionReturnType } from '../../actions/runtime/types.js'; import type { AstroCookies } from '../../core/cookies/cookies.js'; import type { CspDirective, CspHash } from '../../core/csp/config.js'; +import type { CacheLike } from '../../core/cache/runtime/cache.js'; import type { AstroSession } from '../../core/session/runtime.js'; import type { AstroComponentFactory } from '../../runtime/server/index.js'; import type { RewritePayload } from './common.js'; @@ -203,6 +204,16 @@ export interface APIContext< */ session: AstroSession | undefined; + /** + * An object for controlling route-level caching of SSR responses. + * + * Use `cache.set()` to configure caching options, `cache.tags` to read accumulated tags, + * and `cache.invalidate()` to purge cached entries. + * + * In dev mode, the cache object is available but performs no caching. + */ + cache: CacheLike; + /** * A standard [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) object containing information about the current request. * diff --git a/packages/astro/src/types/public/index.ts b/packages/astro/src/types/public/index.ts index 59bd635408be..03f2134b16ee 100644 --- a/packages/astro/src/types/public/index.ts +++ b/packages/astro/src/types/public/index.ts @@ -38,6 +38,13 @@ export type { AstroSession } from '../../core/session/runtime.js'; export type { ToolbarServerHelpers } from '../../runtime/client/dev-toolbar/helpers.js'; export type { AstroEnvironmentNames } from '../../core/constants.js'; export type { SessionDriver, SessionDriverConfig } from '../../core/session/types.js'; +export type { + CacheProvider, + CacheProviderConfig, + CacheProviderFactory, + CacheOptions, + InvalidateOptions, +} from '../../core/cache/types.js'; export type * from './common.js'; export type * from './config.js'; export type * from './content.js'; diff --git a/packages/astro/test/cache-memory-query.test.js b/packages/astro/test/cache-memory-query.test.js new file mode 100644 index 000000000000..8695be955763 --- /dev/null +++ b/packages/astro/test/cache-memory-query.test.js @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import testAdapter from './test-adapter.js'; +import { loadFixture } from './test-utils.js'; + +describe('Memory cache provider — default query exclusions', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + /** @type {import('astro/app').App} */ + let app; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/cache-memory-query/', + output: 'server', + adapter: testAdapter(), + }); + await fixture.build({}); + app = await fixture.loadTestAdapterApp(); + }); + + async function renderRequest(path, options) { + const request = new Request('http://example.com' + path, options); + return app.render(request); + } + + it('excludes utm_* params from cache key by default', async () => { + const first = await renderRequest('/cached?page=1&utm_source=google'); + assert.equal(first.headers.get('X-Astro-Cache'), 'MISS'); + + // Same page, different UTM — should be a HIT + const second = await renderRequest('/cached?page=1&utm_source=twitter&utm_medium=social'); + assert.equal(second.headers.get('X-Astro-Cache'), 'HIT'); + + // Same page, no UTM — should also be a HIT + const third = await renderRequest('/cached?page=1'); + assert.equal(third.headers.get('X-Astro-Cache'), 'HIT'); + }); + + it('excludes fbclid from cache key by default', async () => { + const first = await renderRequest('/cached?page=2'); + assert.equal(first.headers.get('X-Astro-Cache'), 'MISS'); + + const second = await renderRequest('/cached?page=2&fbclid=abc123'); + assert.equal(second.headers.get('X-Astro-Cache'), 'HIT'); + }); + + it('excludes gclid from cache key by default', async () => { + const first = await renderRequest('/cached?page=3'); + assert.equal(first.headers.get('X-Astro-Cache'), 'MISS'); + + const second = await renderRequest('/cached?page=3&gclid=xyz789'); + assert.equal(second.headers.get('X-Astro-Cache'), 'HIT'); + }); + + it('still differentiates on non-excluded params', async () => { + const first = await renderRequest('/cached?page=10'); + assert.equal(first.headers.get('X-Astro-Cache'), 'MISS'); + + const second = await renderRequest('/cached?page=20'); + assert.equal(second.headers.get('X-Astro-Cache'), 'MISS'); + }); +}); + +describe('Memory cache provider — query include', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + /** @type {import('astro/app').App} */ + let app; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/cache-memory-query-include/', + output: 'server', + adapter: testAdapter(), + }); + await fixture.build({}); + app = await fixture.loadTestAdapterApp(); + }); + + async function renderRequest(path, options) { + const request = new Request('http://example.com' + path, options); + return app.render(request); + } + + it('only includes allowlisted params in cache key', async () => { + const first = await renderRequest('/cached?page=1&sort=name'); + assert.equal(first.headers.get('X-Astro-Cache'), 'MISS'); + + // Same allowed params + extra unknown param — should be a HIT + const second = await renderRequest('/cached?page=1&sort=name&tracking=abc'); + assert.equal(second.headers.get('X-Astro-Cache'), 'HIT'); + + // Same allowed params, different order — also a HIT + const third = await renderRequest('/cached?sort=name&page=1'); + assert.equal(third.headers.get('X-Astro-Cache'), 'HIT'); + }); + + it('different allowed param values produce different entries', async () => { + const first = await renderRequest('/cached?page=5&sort=name'); + assert.equal(first.headers.get('X-Astro-Cache'), 'MISS'); + + const second = await renderRequest('/cached?page=5&sort=price'); + assert.equal(second.headers.get('X-Astro-Cache'), 'MISS'); + }); + + it('request with no query params matches request with only non-allowed params', async () => { + const first = await renderRequest('/cached'); + assert.equal(first.headers.get('X-Astro-Cache'), 'MISS'); + + // Only non-allowed params — effectively same key as no params + const second = await renderRequest('/cached?utm_source=google&tracking=abc'); + assert.equal(second.headers.get('X-Astro-Cache'), 'HIT'); + }); +}); diff --git a/packages/astro/test/cache-memory.test.js b/packages/astro/test/cache-memory.test.js new file mode 100644 index 000000000000..febf7944734d --- /dev/null +++ b/packages/astro/test/cache-memory.test.js @@ -0,0 +1,200 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import testAdapter from './test-adapter.js'; +import { loadFixture } from './test-utils.js'; + +describe('Memory cache provider', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + /** @type {import('astro/app').App} */ + let app; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/cache-memory/', + output: 'server', + adapter: testAdapter(), + }); + await fixture.build({}); + app = await fixture.loadTestAdapterApp(); + }); + + async function renderRequest(path, options) { + const request = new Request('http://example.com' + path, options); + return app.render(request); + } + + it('does not cache HEAD requests', async () => { + const head = await renderRequest('/head-cached', { method: 'HEAD' }); + assert.equal(head.status, 200); + assert.equal(head.headers.get('X-Astro-Cache'), null); + + const firstGet = await renderRequest('/head-cached'); + assert.equal(firstGet.headers.get('X-Astro-Cache'), 'MISS'); + + const secondGet = await renderRequest('/head-cached'); + assert.equal(secondGet.headers.get('X-Astro-Cache'), 'HIT'); + }); + + it('uses host-aware cache keys', async () => { + const aFirst = await app.render(new Request('http://a.example/cached')); + assert.equal(aFirst.headers.get('X-Astro-Cache'), 'MISS'); + + const bFirst = await app.render(new Request('http://b.example/cached')); + assert.equal(bFirst.headers.get('X-Astro-Cache'), 'MISS'); + + const aSecond = await app.render(new Request('http://a.example/cached')); + assert.equal(aSecond.headers.get('X-Astro-Cache'), 'HIT'); + + const bSecond = await app.render(new Request('http://b.example/cached')); + assert.equal(bSecond.headers.get('X-Astro-Cache'), 'HIT'); + }); + + it('cached response is served on second request (cache hit)', async () => { + const first = await renderRequest('/cached'); + assert.equal(first.status, 200); + const firstBody = await first.json(); + + const second = await renderRequest('/cached'); + assert.equal(second.status, 200); + const secondBody = await second.json(); + assert.equal(second.headers.get('X-Astro-Cache'), 'HIT'); + assert.equal(firstBody.timestamp, secondBody.timestamp, 'Cached body should match'); + }); + + it('uncached route passes through without cache headers', async () => { + const response = await renderRequest('/no-cache'); + assert.equal(response.status, 200); + assert.equal(response.headers.get('X-Astro-Cache'), null); + }); + + it('CDN-Cache-Control and Cache-Tag headers are stripped from response', async () => { + const response = await renderRequest('/cached'); + assert.equal(response.status, 200); + assert.equal(response.headers.get('CDN-Cache-Control'), null); + assert.equal(response.headers.get('Cache-Tag'), null); + }); + + it('cache.invalidate({ tags }) removes matching entries', async () => { + // Prime and verify cache + await renderRequest('/cached'); + const hit = await renderRequest('/cached'); + assert.equal(hit.headers.get('X-Astro-Cache'), 'HIT'); + + // Invalidate by tag + const inv = await renderRequest('/invalidate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + assert.equal(inv.status, 200); + + // Should be a miss now + const after = await renderRequest('/cached'); + assert.equal(after.headers.get('X-Astro-Cache'), 'MISS'); + }); + + it('cache.invalidate({ path }) removes matching entry', async () => { + // Prime and verify cache + await renderRequest('/cached'); + const hit = await renderRequest('/cached'); + assert.equal(hit.headers.get('X-Astro-Cache'), 'HIT'); + + // Invalidate by path + const inv = await renderRequest('/invalidate-path', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + assert.equal(inv.status, 200); + + // Should be a miss now + const after = await renderRequest('/cached'); + assert.equal(after.headers.get('X-Astro-Cache'), 'MISS'); + }); + + it('cache.set(false) opts out — no caching', async () => { + const response = await renderRequest('/opt-out'); + assert.equal(response.status, 200); + assert.equal(response.headers.get('X-Astro-Cache'), null); + }); + + it('response body is correctly served from cache', async () => { + const first = await renderRequest('/cached'); + const firstBody = await first.json(); + assert.ok(firstBody.timestamp); + + const second = await renderRequest('/cached'); + const secondBody = await second.json(); + assert.deepEqual(firstBody, secondBody); + }); + + it('does not cache responses that include Set-Cookie', async () => { + const first = await renderRequest('/with-cookie'); + assert.equal(first.status, 200); + assert.equal(first.headers.get('X-Astro-Cache'), null); + assert.ok(first.headers.get('Set-Cookie')); + const firstBody = await first.json(); + + const second = await renderRequest('/with-cookie'); + assert.equal(second.status, 200); + assert.equal(second.headers.get('X-Astro-Cache'), null); + assert.ok(second.headers.get('Set-Cookie')); + const secondBody = await second.json(); + + assert.notEqual(firstBody.nonce, secondBody.nonce); + }); + + it('normalizes query parameter order (sorting)', async () => { + // Prime cache with one param order + const first = await renderRequest('/cached?b=2&a=1'); + assert.equal(first.headers.get('X-Astro-Cache'), 'MISS'); + + // Same params, different order — should be a HIT + const second = await renderRequest('/cached?a=1&b=2'); + assert.equal(second.headers.get('X-Astro-Cache'), 'HIT'); + }); + + it('varies cache by Vary response header', async () => { + // Request with Accept-Language: en + const enFirst = await app.render( + new Request('http://example.com/vary-lang', { + headers: { 'Accept-Language': 'en' }, + }), + ); + assert.equal(enFirst.headers.get('X-Astro-Cache'), 'MISS'); + const enBody = await enFirst.json(); + assert.equal(enBody.lang, 'en'); + + // Same URL, same language — should be a HIT + const enSecond = await app.render( + new Request('http://example.com/vary-lang', { + headers: { 'Accept-Language': 'en' }, + }), + ); + assert.equal(enSecond.headers.get('X-Astro-Cache'), 'HIT'); + + // Same URL, different language — should be a MISS (different Vary key) + const frFirst = await app.render( + new Request('http://example.com/vary-lang', { + headers: { 'Accept-Language': 'fr' }, + }), + ); + assert.equal(frFirst.headers.get('X-Astro-Cache'), 'MISS'); + const frBody = await frFirst.json(); + assert.equal(frBody.lang, 'fr'); + + // Verify both variants are now cached independently + const enThird = await app.render( + new Request('http://example.com/vary-lang', { + headers: { 'Accept-Language': 'en' }, + }), + ); + assert.equal(enThird.headers.get('X-Astro-Cache'), 'HIT'); + + const frSecond = await app.render( + new Request('http://example.com/vary-lang', { + headers: { 'Accept-Language': 'fr' }, + }), + ); + assert.equal(frSecond.headers.get('X-Astro-Cache'), 'HIT'); + }); +}); diff --git a/packages/astro/test/cache-route.test.js b/packages/astro/test/cache-route.test.js new file mode 100644 index 000000000000..991ac9813e0e --- /dev/null +++ b/packages/astro/test/cache-route.test.js @@ -0,0 +1,157 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import testAdapter from './test-adapter.js'; +import { loadFixture } from './test-utils.js'; + +describe('context.cache', () => { + it('build fails for invalid cache option values', async () => { + await assert.rejects( + () => + loadFixture({ + root: './fixtures/cache-route/', + output: 'server', + adapter: testAdapter(), + experimental: { + cache: { + provider: { + entrypoint: fileURLToPath( + new URL('./fixtures/cache-route/mock-cache-provider.mjs', import.meta.url), + ), + }, + }, + routeRules: { + '/api': { maxAge: -1 }, + }, + }, + }), + (err) => { + assert.ok(err.message.includes('maxAge')); + return true; + }, + ); + }); + + it('build fails with a clear error for an invalid cache provider', async () => { + const fixture = await loadFixture({ + root: './fixtures/cache-route/', + output: 'server', + adapter: testAdapter(), + experimental: { + cache: { + provider: { entrypoint: 'nonexistent-cache-provider-package' }, + }, + }, + }); + await assert.rejects( + () => fixture.build({}), + (err) => { + assert.ok( + err.message.includes('nonexistent-cache-provider-package'), + `Expected provider name in error, got: ${err.message}`, + ); + return true; + }, + ); + }); + + describe('Production (CDN-style provider)', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + /** @type {import('astro/app').App} */ + let app; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/cache-route/', + output: 'server', + adapter: testAdapter(), + experimental: { + cache: { + provider: { + entrypoint: fileURLToPath( + new URL('./fixtures/cache-route/mock-cache-provider.mjs', import.meta.url), + ), + }, + }, + routeRules: { + '/config-route': { maxAge: 600, tags: ['config'] }, + }, + }, + }); + await fixture.build({}); + app = await fixture.loadTestAdapterApp(); + }); + + async function fetchResponse(path) { + const request = new Request('http://example.com' + path); + const response = await app.render(request); + return response; + } + + it('sets CDN-Cache-Control and Cache-Tag headers from context.cache.set()', async () => { + const response = await fetchResponse('/api'); + assert.equal(response.status, 200); + const cacheControl = response.headers.get('CDN-Cache-Control'); + assert.ok(cacheControl, 'CDN-Cache-Control header should be present'); + assert.ok(cacheControl.includes('max-age=300'), `Expected max-age=300, got: ${cacheControl}`); + assert.ok( + cacheControl.includes('stale-while-revalidate=60'), + `Expected stale-while-revalidate=60, got: ${cacheControl}`, + ); + const cacheTag = response.headers.get('Cache-Tag'); + assert.ok(cacheTag, 'Cache-Tag header should be present'); + assert.ok(cacheTag.includes('api'), `Expected 'api' in Cache-Tag, got: ${cacheTag}`); + assert.ok(cacheTag.includes('data'), `Expected 'data' in Cache-Tag, got: ${cacheTag}`); + }); + + it('produces no cache headers when cache.set(false)', async () => { + const response = await fetchResponse('/no-cache'); + assert.equal(response.status, 200); + assert.equal(response.headers.get('CDN-Cache-Control'), null); + assert.equal(response.headers.get('Cache-Tag'), null); + }); + + it('produces Cache-Tag but no CDN-Cache-Control for tags-only', async () => { + const response = await fetchResponse('/tags-only'); + assert.equal(response.status, 200); + assert.equal( + response.headers.get('CDN-Cache-Control'), + null, + 'CDN-Cache-Control should not be set for tags-only', + ); + const cacheTag = response.headers.get('Cache-Tag'); + assert.ok(cacheTag, 'Cache-Tag header should be present'); + assert.ok(cacheTag.includes('product'), `Expected 'product' tag, got: ${cacheTag}`); + assert.ok(cacheTag.includes('sku-123'), `Expected 'sku-123' tag, got: ${cacheTag}`); + }); + + it('applies config-level route cache options automatically', async () => { + const response = await fetchResponse('/config-route'); + assert.equal(response.status, 200); + const cacheControl = response.headers.get('CDN-Cache-Control'); + assert.ok(cacheControl, 'CDN-Cache-Control header should be present from config'); + assert.ok(cacheControl.includes('max-age=600'), `Expected max-age=600, got: ${cacheControl}`); + const cacheTag = response.headers.get('Cache-Tag'); + assert.ok(cacheTag, 'Cache-Tag header should be present from config'); + assert.ok(cacheTag.includes('config'), `Expected 'config' tag, got: ${cacheTag}`); + }); + + it('sets cache headers on .astro pages via Astro.cache', async () => { + const response = await fetchResponse('/'); + assert.equal(response.status, 200); + const cacheControl = response.headers.get('CDN-Cache-Control'); + assert.ok(cacheControl, 'CDN-Cache-Control should be set on .astro page'); + assert.ok(cacheControl.includes('max-age=120'), `Expected max-age=120, got: ${cacheControl}`); + const cacheTag = response.headers.get('Cache-Tag'); + assert.ok(cacheTag, 'Cache-Tag should be set on .astro page'); + assert.ok(cacheTag.includes('home'), `Expected 'home' tag, got: ${cacheTag}`); + }); + + it('response body is correct JSON from API route', async () => { + const response = await fetchResponse('/api'); + const body = await response.json(); + assert.deepEqual(body, { ok: true }); + }); + }); +}); diff --git a/packages/astro/test/fixtures/cache-memory-query-include/astro.config.mjs b/packages/astro/test/fixtures/cache-memory-query-include/astro.config.mjs new file mode 100644 index 000000000000..c1a5f504f622 --- /dev/null +++ b/packages/astro/test/fixtures/cache-memory-query-include/astro.config.mjs @@ -0,0 +1,14 @@ +// @ts-check +import { defineConfig, memoryCache } from 'astro/config'; + +export default defineConfig({ + experimental: { + cache: { + provider: memoryCache({ + query: { + include: ['page', 'sort'], + }, + }), + }, + }, +}); diff --git a/packages/astro/test/fixtures/cache-memory-query-include/package.json b/packages/astro/test/fixtures/cache-memory-query-include/package.json new file mode 100644 index 000000000000..1b39afe02d21 --- /dev/null +++ b/packages/astro/test/fixtures/cache-memory-query-include/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/cache-memory-query-include", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/cache-memory-query-include/src/pages/cached.ts b/packages/astro/test/fixtures/cache-memory-query-include/src/pages/cached.ts new file mode 100644 index 000000000000..86ecea5dd380 --- /dev/null +++ b/packages/astro/test/fixtures/cache-memory-query-include/src/pages/cached.ts @@ -0,0 +1,6 @@ +export const prerender = false; + +export const GET = async (context) => { + context.cache.set({ maxAge: 300, tags: ['data'] }); + return Response.json({ timestamp: Date.now(), url: context.url.href }); +}; diff --git a/packages/astro/test/fixtures/cache-memory-query/astro.config.mjs b/packages/astro/test/fixtures/cache-memory-query/astro.config.mjs new file mode 100644 index 000000000000..cdd0297ad592 --- /dev/null +++ b/packages/astro/test/fixtures/cache-memory-query/astro.config.mjs @@ -0,0 +1,12 @@ +// @ts-check +import { defineConfig, memoryCache } from 'astro/config'; + +// Uses default query config — tracking params (utm_*, fbclid, etc.) are +// excluded automatically, and params are sorted. +export default defineConfig({ + experimental: { + cache: { + provider: memoryCache(), + }, + }, +}); diff --git a/packages/astro/test/fixtures/cache-memory-query/package.json b/packages/astro/test/fixtures/cache-memory-query/package.json new file mode 100644 index 000000000000..0d1cf5db42f0 --- /dev/null +++ b/packages/astro/test/fixtures/cache-memory-query/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/cache-memory-query", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/cache-memory-query/src/pages/cached.ts b/packages/astro/test/fixtures/cache-memory-query/src/pages/cached.ts new file mode 100644 index 000000000000..86ecea5dd380 --- /dev/null +++ b/packages/astro/test/fixtures/cache-memory-query/src/pages/cached.ts @@ -0,0 +1,6 @@ +export const prerender = false; + +export const GET = async (context) => { + context.cache.set({ maxAge: 300, tags: ['data'] }); + return Response.json({ timestamp: Date.now(), url: context.url.href }); +}; diff --git a/packages/astro/test/fixtures/cache-memory/astro.config.mjs b/packages/astro/test/fixtures/cache-memory/astro.config.mjs new file mode 100644 index 000000000000..c4a8f521be6c --- /dev/null +++ b/packages/astro/test/fixtures/cache-memory/astro.config.mjs @@ -0,0 +1,10 @@ +// @ts-check +import { defineConfig, memoryCache } from 'astro/config'; + +export default defineConfig({ + experimental: { + cache: { + provider: memoryCache(), + }, + }, +}); diff --git a/packages/astro/test/fixtures/cache-memory/package.json b/packages/astro/test/fixtures/cache-memory/package.json new file mode 100644 index 000000000000..4976b8840ba2 --- /dev/null +++ b/packages/astro/test/fixtures/cache-memory/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/cache-memory", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/cache-memory/src/pages/cached.ts b/packages/astro/test/fixtures/cache-memory/src/pages/cached.ts new file mode 100644 index 000000000000..3e0715de97e4 --- /dev/null +++ b/packages/astro/test/fixtures/cache-memory/src/pages/cached.ts @@ -0,0 +1,6 @@ +export const prerender = false; + +export const GET = async (context) => { + context.cache.set({ maxAge: 300, swr: 60, tags: ['data'] }); + return Response.json({ timestamp: Date.now() }); +}; diff --git a/packages/astro/test/fixtures/cache-memory/src/pages/head-cached.ts b/packages/astro/test/fixtures/cache-memory/src/pages/head-cached.ts new file mode 100644 index 000000000000..3f7aeb67c6dd --- /dev/null +++ b/packages/astro/test/fixtures/cache-memory/src/pages/head-cached.ts @@ -0,0 +1,6 @@ +export const prerender = false; + +export const GET = async (context) => { + context.cache.set({ maxAge: 300, tags: ['head-cached'] }); + return Response.json({ timestamp: Date.now() }); +}; diff --git a/packages/astro/test/fixtures/cache-memory/src/pages/invalidate-path.ts b/packages/astro/test/fixtures/cache-memory/src/pages/invalidate-path.ts new file mode 100644 index 000000000000..6b7ac70c89a0 --- /dev/null +++ b/packages/astro/test/fixtures/cache-memory/src/pages/invalidate-path.ts @@ -0,0 +1,6 @@ +export const prerender = false; + +export const POST = async (context) => { + await context.cache.invalidate({ path: '/cached' }); + return Response.json({ invalidated: true }); +}; diff --git a/packages/astro/test/fixtures/cache-memory/src/pages/invalidate.ts b/packages/astro/test/fixtures/cache-memory/src/pages/invalidate.ts new file mode 100644 index 000000000000..8e7fa89519e9 --- /dev/null +++ b/packages/astro/test/fixtures/cache-memory/src/pages/invalidate.ts @@ -0,0 +1,6 @@ +export const prerender = false; + +export const POST = async (context) => { + await context.cache.invalidate({ tags: ['data'] }); + return Response.json({ invalidated: true }); +}; diff --git a/packages/astro/test/fixtures/cache-memory/src/pages/no-cache.ts b/packages/astro/test/fixtures/cache-memory/src/pages/no-cache.ts new file mode 100644 index 000000000000..c704355fa61e --- /dev/null +++ b/packages/astro/test/fixtures/cache-memory/src/pages/no-cache.ts @@ -0,0 +1,6 @@ +export const prerender = false; + +export const GET = async (context) => { + // No cache.set() — should pass through without caching + return Response.json({ timestamp: Date.now() }); +}; diff --git a/packages/astro/test/fixtures/cache-memory/src/pages/opt-out.ts b/packages/astro/test/fixtures/cache-memory/src/pages/opt-out.ts new file mode 100644 index 000000000000..2b30ed53efee --- /dev/null +++ b/packages/astro/test/fixtures/cache-memory/src/pages/opt-out.ts @@ -0,0 +1,6 @@ +export const prerender = false; + +export const GET = async (context) => { + context.cache.set(false); + return Response.json({ timestamp: Date.now() }); +}; diff --git a/packages/astro/test/fixtures/cache-memory/src/pages/vary-lang.ts b/packages/astro/test/fixtures/cache-memory/src/pages/vary-lang.ts new file mode 100644 index 000000000000..43255fe42774 --- /dev/null +++ b/packages/astro/test/fixtures/cache-memory/src/pages/vary-lang.ts @@ -0,0 +1,12 @@ +export const prerender = false; + +export const GET = async (context) => { + const lang = context.request.headers.get('Accept-Language') || 'en'; + context.cache.set({ maxAge: 300, tags: ['vary-test'] }); + return new Response(JSON.stringify({ lang, timestamp: Date.now() }), { + headers: { + 'Content-Type': 'application/json', + Vary: 'Accept-Language', + }, + }); +}; diff --git a/packages/astro/test/fixtures/cache-memory/src/pages/with-cookie.ts b/packages/astro/test/fixtures/cache-memory/src/pages/with-cookie.ts new file mode 100644 index 000000000000..c240f26ff439 --- /dev/null +++ b/packages/astro/test/fixtures/cache-memory/src/pages/with-cookie.ts @@ -0,0 +1,8 @@ +export const prerender = false; + +export const GET = async (context) => { + context.cache.set({ maxAge: 300, tags: ['cookie'] }); + const response = Response.json({ timestamp: Date.now(), nonce: Math.random() }); + response.headers.set('Set-Cookie', 'session=test; Path=/; HttpOnly'); + return response; +}; diff --git a/packages/astro/test/fixtures/cache-route/astro.config.mjs b/packages/astro/test/fixtures/cache-route/astro.config.mjs new file mode 100644 index 000000000000..23000852f939 --- /dev/null +++ b/packages/astro/test/fixtures/cache-route/astro.config.mjs @@ -0,0 +1,9 @@ +// @ts-check +import { defineConfig } from 'astro/config'; +import node from '@astrojs/node'; + +export default defineConfig({ + adapter: node({ + mode: 'standalone' + }) +}); diff --git a/packages/astro/test/fixtures/cache-route/mock-cache-provider.mjs b/packages/astro/test/fixtures/cache-route/mock-cache-provider.mjs new file mode 100644 index 000000000000..26d55bacef1e --- /dev/null +++ b/packages/astro/test/fixtures/cache-route/mock-cache-provider.mjs @@ -0,0 +1,12 @@ +/** + * A CDN-style cache provider for testing. + * Does NOT implement onRequest — just relies on CDN-Cache-Control / Cache-Tag headers. + */ +export default function createCacheProvider(_config) { + return { + name: 'mock-cdn-cache', + async invalidate(_options) { + // no-op for testing + }, + }; +} diff --git a/packages/astro/test/fixtures/cache-route/package.json b/packages/astro/test/fixtures/cache-route/package.json new file mode 100644 index 000000000000..0a893b9a79ed --- /dev/null +++ b/packages/astro/test/fixtures/cache-route/package.json @@ -0,0 +1,9 @@ +{ + "name": "@test/cache-route", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/node": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/cache-route/src/pages/api.ts b/packages/astro/test/fixtures/cache-route/src/pages/api.ts new file mode 100644 index 000000000000..9a59ea7d8640 --- /dev/null +++ b/packages/astro/test/fixtures/cache-route/src/pages/api.ts @@ -0,0 +1,7 @@ +export const prerender = false; + +export const GET = async (context) => { + // Set cache options via the API + context.cache.set({ maxAge: 300, swr: 60, tags: ['api', 'data'] }); + return Response.json({ ok: true }); +}; diff --git a/packages/astro/test/fixtures/cache-route/src/pages/config-route.ts b/packages/astro/test/fixtures/cache-route/src/pages/config-route.ts new file mode 100644 index 000000000000..96ca16969b20 --- /dev/null +++ b/packages/astro/test/fixtures/cache-route/src/pages/config-route.ts @@ -0,0 +1,7 @@ +export const prerender = false; + +export const GET = async (context) => { + // This route is configured via config-level cache routes. + // Don't call cache.set() — the config match should apply automatically. + return Response.json({ fromConfig: true }); +}; diff --git a/packages/astro/test/fixtures/cache-route/src/pages/index.astro b/packages/astro/test/fixtures/cache-route/src/pages/index.astro new file mode 100644 index 000000000000..a410e1c1cc64 --- /dev/null +++ b/packages/astro/test/fixtures/cache-route/src/pages/index.astro @@ -0,0 +1,9 @@ +--- +export const prerender = false; +Astro.cache.set({ maxAge: 120, tags: ['home'] }); +--- + + +

Cache Test Home

+ + diff --git a/packages/astro/test/fixtures/cache-route/src/pages/no-cache.ts b/packages/astro/test/fixtures/cache-route/src/pages/no-cache.ts new file mode 100644 index 000000000000..1293893178c1 --- /dev/null +++ b/packages/astro/test/fixtures/cache-route/src/pages/no-cache.ts @@ -0,0 +1,7 @@ +export const prerender = false; + +export const GET = async (context) => { + // Explicitly disable caching + context.cache.set(false); + return Response.json({ cached: false }); +}; diff --git a/packages/astro/test/fixtures/cache-route/src/pages/slow.astro b/packages/astro/test/fixtures/cache-route/src/pages/slow.astro new file mode 100644 index 000000000000..b2e2ffe77936 --- /dev/null +++ b/packages/astro/test/fixtures/cache-route/src/pages/slow.astro @@ -0,0 +1,13 @@ +--- +export const prerender = false; +Astro.cache.set({ maxAge: 30 }); + +await new Promise((resolve) => setTimeout(resolve, 3000)); + +--- + + +

Cache Test Home

+{new Date().toLocaleString()} + + diff --git a/packages/astro/test/fixtures/cache-route/src/pages/tags-only.ts b/packages/astro/test/fixtures/cache-route/src/pages/tags-only.ts new file mode 100644 index 000000000000..52ccc2ad0c6c --- /dev/null +++ b/packages/astro/test/fixtures/cache-route/src/pages/tags-only.ts @@ -0,0 +1,7 @@ +export const prerender = false; + +export const GET = async (context) => { + // Set only tags, no maxAge + context.cache.set({ tags: ['product', 'sku-123'] }); + return Response.json({ tagged: true }); +}; diff --git a/packages/astro/test/types/schemas.ts b/packages/astro/test/types/schemas.ts index d666f64db0f1..5059f763b167 100644 --- a/packages/astro/test/types/schemas.ts +++ b/packages/astro/test/types/schemas.ts @@ -3,6 +3,8 @@ import { expectTypeOf } from 'expect-type'; import type * as z from 'zod/v4'; import { type FontProviderSchema, FontFamilySchema } from '../../src/assets/fonts/config.js'; import type { FontProvider, FontFamily } from '../../src/assets/fonts/types.js'; +import type { CacheSchema, RouteRulesSchema } from '../../src/core/cache/config.js'; +import type { CacheProviderConfig, RouteRules } from '../../dist/core/cache/types.js'; import type { SessionDriverConfigSchema } from '../../dist/core/session/config.js'; import type { SessionDriverConfig } from '../../dist/core/session/types.js'; @@ -17,6 +19,18 @@ describe('fonts', () => { }); }); +describe('cache', () => { + it('CacheSchema type matches cache config', () => { + expectTypeOf>().toEqualTypeOf<{ + provider?: CacheProviderConfig; + }>(); + }); + + it('RouteRules type matches RouteRulesSchema', () => { + expectTypeOf>().toEqualTypeOf(); + }); +}); + describe('session', () => { it('SessionDriverConfig type matches SessionDriverConfigSchema', () => { expectTypeOf>().toEqualTypeOf(); diff --git a/packages/astro/test/units/cache/memory-provider.test.js b/packages/astro/test/units/cache/memory-provider.test.js new file mode 100644 index 000000000000..7ea2fb3b9917 --- /dev/null +++ b/packages/astro/test/units/cache/memory-provider.test.js @@ -0,0 +1,532 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import memoryProvider from '../../../dist/core/cache/memory-provider.js'; + +/** + * Helper: create a CacheProvider instance with optional config. + */ +function createProvider(config) { + return memoryProvider(config); +} + +/** + * Helper: create a minimal Request. + */ +function makeRequest(url, headers = {}) { + return new Request(url, { headers }); +} + +/** + * Helper: create a next() function that returns a Response with cache headers. + * @param {object} opts + * @param {string} [opts.body='ok'] + * @param {number} [opts.status=200] + * @param {number} [opts.maxAge] + * @param {number} [opts.swr] + * @param {string[]} [opts.tags] + * @param {Record} [opts.headers] + */ +function makeNext({ body = 'ok', status = 200, maxAge, swr, tags, headers = {} } = {}) { + return async () => { + const h = new Headers(headers); + const parts = []; + if (maxAge !== undefined) parts.push(`max-age=${maxAge}`); + if (swr !== undefined) parts.push(`stale-while-revalidate=${swr}`); + if (parts.length > 0) h.set('CDN-Cache-Control', parts.join(', ')); + if (tags?.length) h.set('Cache-Tag', tags.join(', ')); + return new Response(body, { status, headers: h }); + }; +} + +// ─── onRequest: basic caching ──────────────────────────────────────────────── + +describe('memory-provider onRequest', () => { + it('passes through when no cache headers on response', async () => { + const provider = createProvider(); + const req = makeRequest('http://localhost/page'); + const res = await provider.onRequest({ request: req, url: new URL(req.url) }, makeNext()); + assert.equal(await res.text(), 'ok'); + assert.equal(res.headers.has('X-Astro-Cache'), false); + }); + + it('returns MISS on first cacheable request', async () => { + const provider = createProvider(); + const req = makeRequest('http://localhost/page'); + const res = await provider.onRequest( + { request: req, url: new URL(req.url) }, + makeNext({ maxAge: 60 }), + ); + assert.equal(res.headers.get('X-Astro-Cache'), 'MISS'); + assert.equal(await res.text(), 'ok'); + }); + + it('returns HIT on second request to same URL', async () => { + const provider = createProvider(); + const url = 'http://localhost/page'; + + // First request — MISS + const req1 = makeRequest(url); + await provider.onRequest( + { request: req1, url: new URL(req1.url) }, + makeNext({ maxAge: 60, body: 'first' }), + ); + + // Second request — HIT + const req2 = makeRequest(url); + const res2 = await provider.onRequest( + { request: req2, url: new URL(req2.url) }, + makeNext({ maxAge: 60, body: 'second' }), + ); + assert.equal(res2.headers.get('X-Astro-Cache'), 'HIT'); + assert.equal(await res2.text(), 'first'); + }); + + it('skips caching for non-GET requests', async () => { + const provider = createProvider(); + const req = new Request('http://localhost/page', { method: 'POST' }); + let called = false; + const res = await provider.onRequest({ request: req, url: new URL(req.url) }, async () => { + called = true; + return new Response('posted'); + }); + assert.equal(called, true); + assert.equal(await res.text(), 'posted'); + assert.equal(res.headers.has('X-Astro-Cache'), false); + }); + + it('does not cache responses with Set-Cookie header', async () => { + const provider = createProvider(); + const url = 'http://localhost/page'; + + // First request — has Set-Cookie, should not cache + const req1 = makeRequest(url); + await provider.onRequest( + { request: req1, url: new URL(req1.url) }, + makeNext({ maxAge: 60, headers: { 'Set-Cookie': 'session=abc' } }), + ); + + // Second request — should be a miss (not cached) + const req2 = makeRequest(url); + let nextCalled = false; + await provider.onRequest({ request: req2, url: new URL(req2.url) }, async () => { + nextCalled = true; + const h = new Headers({ 'CDN-Cache-Control': 'max-age=60' }); + return new Response('fresh', { headers: h }); + }); + assert.equal(nextCalled, true); + }); +}); + +// ─── onRequest: host-aware keys ────────────────────────────────────────────── + +describe('memory-provider host-aware cache keys', () => { + it('different hosts produce different cache entries', async () => { + const provider = createProvider(); + + const req1 = makeRequest('http://host-a.com/page'); + await provider.onRequest( + { request: req1, url: new URL(req1.url) }, + makeNext({ maxAge: 60, body: 'host-a' }), + ); + + const req2 = makeRequest('http://host-b.com/page'); + const res2 = await provider.onRequest( + { request: req2, url: new URL(req2.url) }, + makeNext({ maxAge: 60, body: 'host-b' }), + ); + // Should be a MISS because different host + assert.equal(res2.headers.get('X-Astro-Cache'), 'MISS'); + assert.equal(await res2.text(), 'host-b'); + }); +}); + +// ─── onRequest: query parameter handling ───────────────────────────────────── + +describe('memory-provider query parameters', () => { + it('sorts query parameters by default (order-independent keys)', async () => { + const provider = createProvider(); + + const req1 = makeRequest('http://localhost/page?b=2&a=1'); + await provider.onRequest( + { request: req1, url: new URL(req1.url) }, + makeNext({ maxAge: 60, body: 'first' }), + ); + + // Same params, different order — should HIT + const req2 = makeRequest('http://localhost/page?a=1&b=2'); + const res2 = await provider.onRequest( + { request: req2, url: new URL(req2.url) }, + makeNext({ maxAge: 60, body: 'second' }), + ); + assert.equal(res2.headers.get('X-Astro-Cache'), 'HIT'); + }); + + it('excludes utm_* params by default', async () => { + const provider = createProvider(); + + const req1 = makeRequest('http://localhost/page'); + await provider.onRequest( + { request: req1, url: new URL(req1.url) }, + makeNext({ maxAge: 60, body: 'first' }), + ); + + const req2 = makeRequest('http://localhost/page?utm_source=twitter&utm_medium=social'); + const res2 = await provider.onRequest( + { request: req2, url: new URL(req2.url) }, + makeNext({ maxAge: 60, body: 'second' }), + ); + assert.equal(res2.headers.get('X-Astro-Cache'), 'HIT'); + }); + + it('differentiates on non-excluded params', async () => { + const provider = createProvider(); + + const req1 = makeRequest('http://localhost/page?id=1'); + await provider.onRequest( + { request: req1, url: new URL(req1.url) }, + makeNext({ maxAge: 60, body: 'id-1' }), + ); + + const req2 = makeRequest('http://localhost/page?id=2'); + const res2 = await provider.onRequest( + { request: req2, url: new URL(req2.url) }, + makeNext({ maxAge: 60, body: 'id-2' }), + ); + assert.equal(res2.headers.get('X-Astro-Cache'), 'MISS'); + }); + + it('respects query.include allowlist', async () => { + const provider = createProvider({ query: { include: ['page'] } }); + + const req1 = makeRequest('http://localhost/list?page=1&sort=name'); + await provider.onRequest( + { request: req1, url: new URL(req1.url) }, + makeNext({ maxAge: 60, body: 'page-1' }), + ); + + // Different sort but same page — should HIT (sort not in include list) + const req2 = makeRequest('http://localhost/list?page=1&sort=date'); + const res2 = await provider.onRequest( + { request: req2, url: new URL(req2.url) }, + makeNext({ maxAge: 60, body: 'page-1-date' }), + ); + assert.equal(res2.headers.get('X-Astro-Cache'), 'HIT'); + }); + + it('respects query.exclude custom patterns', async () => { + const provider = createProvider({ query: { exclude: ['session_*'] } }); + + const req1 = makeRequest('http://localhost/page?id=1'); + await provider.onRequest( + { request: req1, url: new URL(req1.url) }, + makeNext({ maxAge: 60, body: 'first' }), + ); + + const req2 = makeRequest('http://localhost/page?id=1&session_id=abc'); + const res2 = await provider.onRequest( + { request: req2, url: new URL(req2.url) }, + makeNext({ maxAge: 60, body: 'second' }), + ); + assert.equal(res2.headers.get('X-Astro-Cache'), 'HIT'); + }); + + it('throws when both include and exclude are set', () => { + assert.throws(() => createProvider({ query: { include: ['a'], exclude: ['b'] } }), { + name: 'CacheQueryConfigConflict', + }); + }); +}); + +// ─── onRequest: Vary header support ────────────────────────────────────────── + +describe('memory-provider Vary header', () => { + it('caches different entries for different Vary header values', async () => { + const provider = createProvider(); + const url = 'http://localhost/page'; + + // First request: Accept-Language: en + const req1 = makeRequest(url, { 'Accept-Language': 'en' }); + await provider.onRequest( + { request: req1, url: new URL(req1.url) }, + makeNext({ maxAge: 60, body: 'english', headers: { Vary: 'Accept-Language' } }), + ); + + // Second request: Accept-Language: fr — should MISS + const req2 = makeRequest(url, { 'Accept-Language': 'fr' }); + const res2 = await provider.onRequest( + { request: req2, url: new URL(req2.url) }, + makeNext({ maxAge: 60, body: 'french', headers: { Vary: 'Accept-Language' } }), + ); + assert.equal(res2.headers.get('X-Astro-Cache'), 'MISS'); + assert.equal(await res2.text(), 'french'); + + // Third request: Accept-Language: en — should HIT from first + const req3 = makeRequest(url, { 'Accept-Language': 'en' }); + const res3 = await provider.onRequest( + { request: req3, url: new URL(req3.url) }, + makeNext({ maxAge: 60, body: 'should-not-see' }), + ); + assert.equal(res3.headers.get('X-Astro-Cache'), 'HIT'); + assert.equal(await res3.text(), 'english'); + }); + + it('ignores Cookie in Vary header', async () => { + const provider = createProvider(); + const url = 'http://localhost/page'; + + const req1 = makeRequest(url, { Cookie: 'user=a' }); + await provider.onRequest( + { request: req1, url: new URL(req1.url) }, + makeNext({ maxAge: 60, body: 'first', headers: { Vary: 'Cookie' } }), + ); + + // Different cookie — should still HIT (Cookie is ignored in Vary) + const req2 = makeRequest(url, { Cookie: 'user=b' }); + const res2 = await provider.onRequest( + { request: req2, url: new URL(req2.url) }, + makeNext({ maxAge: 60, body: 'second' }), + ); + assert.equal(res2.headers.get('X-Astro-Cache'), 'HIT'); + }); +}); + +// ─── onRequest: LRU eviction ───────────────────────────────────────────────── + +describe('memory-provider LRU eviction', () => { + it('evicts oldest entry when max is exceeded', async () => { + const provider = createProvider({ max: 2 }); + + // Fill cache with 2 entries + for (const path of ['/a', '/b']) { + const req = makeRequest(`http://localhost${path}`); + await provider.onRequest( + { request: req, url: new URL(req.url) }, + makeNext({ maxAge: 60, body: path }), + ); + } + + // Add a third — should evict /a (oldest) + const req3 = makeRequest('http://localhost/c'); + await provider.onRequest( + { request: req3, url: new URL(req3.url) }, + makeNext({ maxAge: 60, body: '/c' }), + ); + + // /b should still be cached (HIT) + const reqB = makeRequest('http://localhost/b'); + const resB = await provider.onRequest( + { request: reqB, url: new URL(reqB.url) }, + makeNext({ maxAge: 60, body: '/b-new' }), + ); + assert.equal(resB.headers.get('X-Astro-Cache'), 'HIT'); + + // /c should still be cached (HIT) + const reqC = makeRequest('http://localhost/c'); + const resC = await provider.onRequest( + { request: reqC, url: new URL(reqC.url) }, + makeNext({ maxAge: 60, body: '/c-new' }), + ); + assert.equal(resC.headers.get('X-Astro-Cache'), 'HIT'); + + // /a should have been evicted (MISS) — check without caching the result + // by using a next() that returns no cache headers + const reqA = makeRequest('http://localhost/a'); + const resA = await provider.onRequest( + { request: reqA, url: new URL(reqA.url) }, + makeNext({ body: '/a-evicted' }), + ); + assert.equal(resA.headers.has('X-Astro-Cache'), false); + }); +}); + +// ─── invalidate ────────────────────────────────────────────────────────────── + +describe('memory-provider invalidate', () => { + it('invalidates by tag', async () => { + const provider = createProvider(); + const url = 'http://localhost/page'; + + // Cache an entry with tags + const req1 = makeRequest(url); + await provider.onRequest( + { request: req1, url: new URL(req1.url) }, + makeNext({ maxAge: 60, tags: ['product'] }), + ); + + // Verify cached + const req2 = makeRequest(url); + const res2 = await provider.onRequest( + { request: req2, url: new URL(req2.url) }, + makeNext({ maxAge: 60 }), + ); + assert.equal(res2.headers.get('X-Astro-Cache'), 'HIT'); + + // Invalidate + await provider.invalidate({ tags: ['product'] }); + + // Should be MISS now + const req3 = makeRequest(url); + const res3 = await provider.onRequest( + { request: req3, url: new URL(req3.url) }, + makeNext({ maxAge: 60, body: 'fresh' }), + ); + assert.equal(res3.headers.get('X-Astro-Cache'), 'MISS'); + }); + + it('invalidates by path', async () => { + const provider = createProvider(); + + // Cache two entries + for (const path of ['/a', '/b']) { + const req = makeRequest(`http://localhost${path}`); + await provider.onRequest( + { request: req, url: new URL(req.url) }, + makeNext({ maxAge: 60, body: path }), + ); + } + + // Invalidate only /a + await provider.invalidate({ path: '/a' }); + + // /a should miss + const reqA = makeRequest('http://localhost/a'); + const resA = await provider.onRequest( + { request: reqA, url: new URL(reqA.url) }, + makeNext({ maxAge: 60, body: 'a-new' }), + ); + assert.equal(resA.headers.get('X-Astro-Cache'), 'MISS'); + + // /b should still hit + const reqB = makeRequest('http://localhost/b'); + const resB = await provider.onRequest( + { request: reqB, url: new URL(reqB.url) }, + makeNext({ maxAge: 60, body: 'b-new' }), + ); + assert.equal(resB.headers.get('X-Astro-Cache'), 'HIT'); + }); + + it('invalidate with non-matching tag does not remove entries', async () => { + const provider = createProvider(); + const url = 'http://localhost/page'; + + const req1 = makeRequest(url); + await provider.onRequest( + { request: req1, url: new URL(req1.url) }, + makeNext({ maxAge: 60, tags: ['product'] }), + ); + + await provider.invalidate({ tags: ['blog'] }); + + const req2 = makeRequest(url); + const res2 = await provider.onRequest( + { request: req2, url: new URL(req2.url) }, + makeNext({ maxAge: 60 }), + ); + assert.equal(res2.headers.get('X-Astro-Cache'), 'HIT'); + }); +}); + +// ─── onRequest: SWR (stale-while-revalidate) ──────────────────────────────── + +describe('memory-provider SWR', () => { + it('serves STALE and triggers background revalidation', async () => { + const provider = createProvider(); + const url = 'http://localhost/page'; + + // Seed cache with maxAge=0 + swr=60 by manipulating storedAt. + // We can't easily manipulate time, so use a very short maxAge. + // Instead, seed with maxAge=1, swr=60, then wait briefly. + const req1 = makeRequest(url); + await provider.onRequest( + { request: req1, url: new URL(req1.url) }, + makeNext({ maxAge: 1, swr: 60, body: 'stale-body' }), + ); + + // Wait for entry to become stale (just over 1 second) + await new Promise((r) => setTimeout(r, 1100)); + + // Second request should get STALE + const req2 = makeRequest(url); + const res2 = await provider.onRequest( + { request: req2, url: new URL(req2.url) }, + makeNext({ maxAge: 60, swr: 60, body: 'fresh-body' }), + ); + assert.equal(res2.headers.get('X-Astro-Cache'), 'STALE'); + assert.equal(await res2.text(), 'stale-body'); + + // Give background revalidation time to complete + await new Promise((r) => setTimeout(r, 100)); + + // Third request should now get HIT with the fresh content + const req3 = makeRequest(url); + const res3 = await provider.onRequest( + { request: req3, url: new URL(req3.url) }, + makeNext({ maxAge: 60, body: 'should-not-see' }), + ); + assert.equal(res3.headers.get('X-Astro-Cache'), 'HIT'); + assert.equal(await res3.text(), 'fresh-body'); + }); +}); + +// ─── response body correctness ─────────────────────────────────────────────── + +describe('memory-provider response body', () => { + it('serves correct body from cache', async () => { + const provider = createProvider(); + const url = 'http://localhost/page'; + const body = JSON.stringify({ data: [1, 2, 3], nested: { key: 'value' } }); + + const req1 = makeRequest(url); + await provider.onRequest( + { request: req1, url: new URL(req1.url) }, + makeNext({ maxAge: 60, body }), + ); + + const req2 = makeRequest(url); + const res2 = await provider.onRequest( + { request: req2, url: new URL(req2.url) }, + makeNext({ maxAge: 60, body: 'wrong' }), + ); + assert.equal(await res2.text(), body); + }); + + it('preserves response status code from cache', async () => { + const provider = createProvider(); + const url = 'http://localhost/page'; + + const req1 = makeRequest(url); + await provider.onRequest( + { request: req1, url: new URL(req1.url) }, + makeNext({ maxAge: 60, status: 201, body: 'created' }), + ); + + const req2 = makeRequest(url); + const res2 = await provider.onRequest( + { request: req2, url: new URL(req2.url) }, + makeNext({ maxAge: 60 }), + ); + assert.equal(res2.status, 201); + }); + + it('preserves response headers from cache (except Set-Cookie)', async () => { + const provider = createProvider(); + const url = 'http://localhost/page'; + + const req1 = makeRequest(url); + await provider.onRequest( + { request: req1, url: new URL(req1.url) }, + makeNext({ + maxAge: 60, + headers: { 'Content-Type': 'application/json', 'X-Custom': 'hello' }, + }), + ); + + const req2 = makeRequest(url); + const res2 = await provider.onRequest( + { request: req2, url: new URL(req2.url) }, + makeNext({ maxAge: 60 }), + ); + assert.equal(res2.headers.get('Content-Type'), 'application/json'); + assert.equal(res2.headers.get('X-Custom'), 'hello'); + }); +}); diff --git a/packages/astro/test/units/cache/noop.test.js b/packages/astro/test/units/cache/noop.test.js new file mode 100644 index 000000000000..73a455771e17 --- /dev/null +++ b/packages/astro/test/units/cache/noop.test.js @@ -0,0 +1,98 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { NoopAstroCache, DisabledAstroCache } from '../../../dist/core/cache/runtime/noop.js'; +import { applyCacheHeaders, isCacheActive } from '../../../dist/core/cache/runtime/cache.js'; + +describe('NoopAstroCache', () => { + it('set() is callable and does nothing', () => { + const cache = new NoopAstroCache(); + cache.set({ maxAge: 300, tags: ['a'] }); + cache.set(false); + // No error thrown + }); + + it('tags returns empty array', () => { + const cache = new NoopAstroCache(); + assert.deepEqual(cache.tags, []); + }); + + it('invalidate() is callable and resolves', async () => { + const cache = new NoopAstroCache(); + await cache.invalidate({ tags: 'x' }); + // No error thrown + }); + + it('options returns frozen empty object', () => { + const cache = new NoopAstroCache(); + const options = cache.options; + assert.deepEqual(options.tags, []); + assert.equal(Object.isFrozen(options), true); + }); + + it('applyCacheHeaders() no-ops for noop cache', () => { + const cache = new NoopAstroCache(); + const response = new Response('test'); + applyCacheHeaders(cache, response); + assert.equal(response.headers.get('CDN-Cache-Control'), null); + assert.equal(response.headers.get('Cache-Tag'), null); + }); + + it('isCacheActive() returns false for noop cache', () => { + const cache = new NoopAstroCache(); + assert.equal(isCacheActive(cache), false); + }); +}); + +describe('DisabledAstroCache', () => { + it('set() throws AstroError with CacheNotEnabled', () => { + const cache = new DisabledAstroCache(); + assert.throws( + () => cache.set({ maxAge: 300 }), + (err) => err.name === 'CacheNotEnabled', + ); + }); + + it('set(false) throws AstroError with CacheNotEnabled', () => { + const cache = new DisabledAstroCache(); + assert.throws( + () => cache.set(false), + (err) => err.name === 'CacheNotEnabled', + ); + }); + + it('tags getter throws AstroError with CacheNotEnabled', () => { + const cache = new DisabledAstroCache(); + assert.throws( + () => cache.tags, + (err) => err.name === 'CacheNotEnabled', + ); + }); + + it('options getter throws AstroError with CacheNotEnabled', () => { + const cache = new DisabledAstroCache(); + assert.throws( + () => cache.options, + (err) => err.name === 'CacheNotEnabled', + ); + }); + + it('invalidate() throws AstroError with CacheNotEnabled', async () => { + const cache = new DisabledAstroCache(); + await assert.rejects( + () => cache.invalidate({ tags: 'x' }), + (err) => err.name === 'CacheNotEnabled', + ); + }); + + it('applyCacheHeaders() no-ops for disabled cache', () => { + const cache = new DisabledAstroCache(); + const response = new Response('test'); + applyCacheHeaders(cache, response); + assert.equal(response.headers.get('CDN-Cache-Control'), null); + }); + + it('isCacheActive() returns false for disabled cache', () => { + const cache = new DisabledAstroCache(); + assert.equal(isCacheActive(cache), false); + }); +}); diff --git a/packages/astro/test/units/cache/route-matching.test.js b/packages/astro/test/units/cache/route-matching.test.js new file mode 100644 index 000000000000..2eac33d71a01 --- /dev/null +++ b/packages/astro/test/units/cache/route-matching.test.js @@ -0,0 +1,141 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + compileCacheRoutes, + matchCacheRoute, +} from '../../../dist/core/cache/runtime/route-matching.js'; + +/** + * Helper: compile routes with default base '/' and trailingSlash 'ignore'. + */ +function compile(routes) { + return compileCacheRoutes(routes, '/', 'ignore'); +} + +describe('compileCacheRoutes', () => { + it('compiles an exact static path', () => { + const compiled = compile({ '/about': { maxAge: 60 } }); + assert.equal(compiled.length, 1); + assert.ok(compiled[0].pattern instanceof RegExp); + assert.equal(compiled[0].route, '/about'); + assert.deepEqual(compiled[0].options, { maxAge: 60 }); + }); + + it('compiles a dynamic parameter route', () => { + const compiled = compile({ '/blog/[slug]': { maxAge: 120 } }); + assert.equal(compiled.length, 1); + assert.ok(compiled[0].pattern instanceof RegExp); + assert.equal(compiled[0].route, '/blog/[slug]'); + }); + + it('compiles a rest parameter route', () => { + const compiled = compile({ '/docs/[...path]': { maxAge: 300 } }); + assert.equal(compiled.length, 1); + assert.ok(compiled[0].pattern instanceof RegExp); + assert.equal(compiled[0].route, '/docs/[...path]'); + }); + + it('returns empty array for empty routes', () => { + const compiled = compile({}); + assert.deepEqual(compiled, []); + }); + + it('sorts most specific (static) before dynamic before rest', () => { + const compiled = compile({ + '/docs/[...path]': { maxAge: 300 }, + '/docs/[slug]': { maxAge: 200 }, + '/docs/intro': { maxAge: 100 }, + }); + assert.equal(compiled.length, 3); + // Static route should be first (most specific) + assert.equal(compiled[0].route, '/docs/intro'); + // Dynamic parameter should be second + assert.equal(compiled[1].route, '/docs/[slug]'); + // Rest parameter should be last (least specific) + assert.equal(compiled[2].route, '/docs/[...path]'); + }); + + it('sorts multiple static routes alphabetically by segments', () => { + const compiled = compile({ + '/z-page': { maxAge: 10 }, + '/a-page': { maxAge: 20 }, + }); + assert.equal(compiled.length, 2); + // Both are static; sorted by Astro's comparator + // Astro sorts static routes by segments, so /a-page comes first + assert.equal(compiled[0].route, '/a-page'); + assert.equal(compiled[1].route, '/z-page'); + }); +}); + +describe('matchCacheRoute', () => { + it('matches an exact static path', () => { + const compiled = compile({ '/about': { maxAge: 60 } }); + const result = matchCacheRoute('/about', compiled); + assert.deepEqual(result, { maxAge: 60 }); + }); + + it('matches with trailing slash', () => { + const compiled = compile({ '/about': { maxAge: 60 } }); + const result = matchCacheRoute('/about/', compiled); + assert.deepEqual(result, { maxAge: 60 }); + }); + + it('returns null when no route matches', () => { + const compiled = compile({ '/about': { maxAge: 60 } }); + const result = matchCacheRoute('/contact', compiled); + assert.equal(result, null); + }); + + it('matches a dynamic parameter [slug]', () => { + const compiled = compile({ '/blog/[slug]': { maxAge: 120 } }); + assert.deepEqual(matchCacheRoute('/blog/hello-world', compiled), { maxAge: 120 }); + assert.deepEqual(matchCacheRoute('/blog/another-post', compiled), { maxAge: 120 }); + }); + + it('dynamic [slug] does not match multiple segments', () => { + const compiled = compile({ '/blog/[slug]': { maxAge: 120 } }); + // [slug] should only match a single segment + assert.equal(matchCacheRoute('/blog/a/b', compiled), null); + }); + + it('matches a rest parameter [...path]', () => { + const compiled = compile({ '/docs/[...path]': { maxAge: 300 } }); + assert.deepEqual(matchCacheRoute('/docs/intro', compiled), { maxAge: 300 }); + assert.deepEqual(matchCacheRoute('/docs/a/b/c', compiled), { maxAge: 300 }); + assert.deepEqual(matchCacheRoute('/docs/', compiled), { maxAge: 300 }); + }); + + it('most specific route wins when multiple match', () => { + const compiled = compile({ + '/docs/[...path]': { maxAge: 300 }, + '/docs/[slug]': { maxAge: 200 }, + '/docs/intro': { maxAge: 100 }, + }); + // Static match wins over dynamic and rest + assert.deepEqual(matchCacheRoute('/docs/intro', compiled), { maxAge: 100 }); + // Dynamic match wins over rest for single-segment paths + assert.deepEqual(matchCacheRoute('/docs/other', compiled), { maxAge: 200 }); + // Rest match for multi-segment paths + assert.deepEqual(matchCacheRoute('/docs/a/b', compiled), { maxAge: 300 }); + }); + + it('returns null for empty compiled routes', () => { + const result = matchCacheRoute('/anything', []); + assert.equal(result, null); + }); + + it('handles root path /', () => { + const compiled = compile({ '/': { maxAge: 60 } }); + assert.deepEqual(matchCacheRoute('/', compiled), { maxAge: 60 }); + }); + + it('handles complex nested routes', () => { + const compiled = compile({ + '/api/v1/[resource]/[id]': { maxAge: 30 }, + }); + assert.deepEqual(matchCacheRoute('/api/v1/users/123', compiled), { maxAge: 30 }); + assert.equal(matchCacheRoute('/api/v1/users', compiled), null); + assert.equal(matchCacheRoute('/api/v1/users/123/extra', compiled), null); + }); +}); diff --git a/packages/astro/test/units/cache/runtime.test.js b/packages/astro/test/units/cache/runtime.test.js new file mode 100644 index 000000000000..55606cefb679 --- /dev/null +++ b/packages/astro/test/units/cache/runtime.test.js @@ -0,0 +1,321 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + AstroCache, + applyCacheHeaders, + isCacheActive, +} from '../../../dist/core/cache/runtime/cache.js'; + +// Mock provider +function createMockProvider(overrides = {}) { + return { + name: 'test-provider', + invalidate: async () => {}, + ...overrides, + }; +} + +describe('AstroCache - set() with CacheOptions', () => { + it('sets maxAge, swr, tags, lastModified, etag', () => { + const cache = new AstroCache(null); + const lastModified = new Date('2025-01-01'); + cache.set({ maxAge: 300, swr: 60, tags: ['a', 'b'], lastModified, etag: '"abc"' }); + + assert.equal(isCacheActive(cache), true); + assert.deepEqual(cache.tags, ['a', 'b']); + }); + + it('sets maxAge only', () => { + const cache = new AstroCache(null); + cache.set({ maxAge: 600 }); + assert.equal(isCacheActive(cache), true); + }); + + it('sets tags only', () => { + const cache = new AstroCache(null); + cache.set({ tags: ['product'] }); + assert.equal(isCacheActive(cache), true); + assert.deepEqual(cache.tags, ['product']); + }); +}); + +describe('AstroCache - set() with CacheHint', () => { + it('extracts tags and lastModified from CacheHint', () => { + const cache = new AstroCache(null); + cache.set({ tags: ['post'], lastModified: new Date('2025-06-01') }); + assert.deepEqual(cache.tags, ['post']); + assert.equal(isCacheActive(cache), true); + }); +}); + +describe('AstroCache - set() with LiveDataEntry', () => { + it('extracts cacheHint from LiveDataEntry', () => { + const cache = new AstroCache(null); + cache.set({ + id: 'entry-1', + data: { title: 'Test' }, + cacheHint: { tags: ['entry'], lastModified: new Date('2025-03-15') }, + }); + assert.deepEqual(cache.tags, ['entry']); + assert.equal(isCacheActive(cache), true); + }); + + it('no-ops when LiveDataEntry has no cacheHint', () => { + const cache = new AstroCache(null); + cache.set({ id: 'entry-2', data: { title: 'Test' }, cacheHint: undefined }); + assert.equal(isCacheActive(cache), false); + assert.deepEqual(cache.tags, []); + }); +}); + +describe('AstroCache - set(false)', () => { + it('disables and clears everything', () => { + const cache = new AstroCache(null); + cache.set({ maxAge: 300, tags: ['a'] }); + cache.set(false); + assert.equal(isCacheActive(cache), false); + assert.deepEqual(cache.tags, []); + }); +}); + +describe('AstroCache - multiple set() calls', () => { + it('scalar last-write-wins for maxAge and swr', () => { + const cache = new AstroCache(null); + cache.set({ maxAge: 100, swr: 10 }); + cache.set({ maxAge: 200, swr: 20 }); + + const response = new Response('test'); + applyCacheHeaders(cache, response); + assert.equal( + response.headers.get('CDN-Cache-Control'), + 'max-age=200, stale-while-revalidate=20', + ); + }); + + it('lastModified most-recent-wins (not last-write)', () => { + const cache = new AstroCache(null); + const newer = new Date('2025-06-01'); + const older = new Date('2025-01-01'); + + cache.set({ maxAge: 60, lastModified: newer }); + cache.set({ maxAge: 60, lastModified: older }); // older date written last — should NOT win + + const response = new Response('test'); + applyCacheHeaders(cache, response); + assert.equal(response.headers.get('Last-Modified'), newer.toUTCString()); + }); + + it('tags accumulate and deduplicate', () => { + const cache = new AstroCache(null); + cache.set({ tags: ['a', 'b'] }); + cache.set({ tags: ['b', 'c'] }); + assert.deepEqual(cache.tags, ['a', 'b', 'c']); + }); + + it('set(false) after other calls clears everything', () => { + const cache = new AstroCache(null); + cache.set({ maxAge: 300, tags: ['x'] }); + cache.set(false); + assert.equal(isCacheActive(cache), false); + assert.deepEqual(cache.tags, []); + + const response = new Response('test'); + applyCacheHeaders(cache, response); + assert.equal(response.headers.get('CDN-Cache-Control'), null); + }); + + it('set() after set(false) re-enables', () => { + const cache = new AstroCache(null); + cache.set({ maxAge: 300 }); + cache.set(false); + cache.set({ maxAge: 600 }); + assert.equal(isCacheActive(cache), true); + + const response = new Response('test'); + applyCacheHeaders(cache, response); + assert.equal(response.headers.get('CDN-Cache-Control'), 'max-age=600'); + }); +}); + +describe('AstroCache - tags getter', () => { + it('returns a copy — mutations do not affect internal state', () => { + const cache = new AstroCache(null); + cache.set({ tags: ['a', 'b'] }); + + const tags = cache.tags; + tags.push('c'); + + assert.deepEqual(cache.tags, ['a', 'b']); + }); +}); + +describe('AstroCache - options getter', () => { + it('returns all cache options including accumulated tags', () => { + const cache = new AstroCache(null); + const lastModified = new Date('2025-01-01'); + cache.set({ maxAge: 300, swr: 60, tags: ['a'], lastModified, etag: '"abc"' }); + cache.set({ tags: ['b'] }); // accumulate another tag + + const options = cache.options; + assert.equal(options.maxAge, 300); + assert.equal(options.swr, 60); + assert.deepEqual(options.tags, ['a', 'b']); + assert.equal(options.lastModified, lastModified); + assert.equal(options.etag, '"abc"'); + }); + + it('returns a new object each call (mutations do not affect internal state)', () => { + const cache = new AstroCache(null); + cache.set({ maxAge: 300 }); + + const options = cache.options; + options.maxAge = 999; + assert.equal(cache.options.maxAge, 300); + }); + + it('returns empty tags array when no tags set', () => { + const cache = new AstroCache(null); + cache.set({ maxAge: 300 }); + + const options = cache.options; + assert.deepEqual(options.tags, []); + }); +}); + +describe('AstroCache - invalidate()', () => { + it('calls provider.invalidate() with correct options', async () => { + let captured; + const provider = createMockProvider({ + invalidate: async (opts) => { + captured = opts; + }, + }); + const cache = new AstroCache(provider); + await cache.invalidate({ tags: ['product'], path: '/products' }); + assert.deepEqual(captured, { tags: ['product'], path: '/products' }); + }); + + it('extracts tags from LiveDataEntry for invalidate', async () => { + let captured; + const provider = createMockProvider({ + invalidate: async (opts) => { + captured = opts; + }, + }); + const cache = new AstroCache(provider); + await cache.invalidate({ + id: 'entry-1', + data: {}, + cacheHint: { tags: ['blog'] }, + }); + assert.deepEqual(captured, { tags: ['blog'] }); + }); + + it('throws without provider', async () => { + const cache = new AstroCache(null); + await assert.rejects(() => cache.invalidate({ tags: 'x' }), { + name: 'CacheNotEnabled', + }); + }); +}); + +describe('applyCacheHeaders()', () => { + it('generates correct CDN-Cache-Control', () => { + const cache = new AstroCache(null); + cache.set({ maxAge: 300 }); + + const response = new Response('test'); + applyCacheHeaders(cache, response); + assert.equal(response.headers.get('CDN-Cache-Control'), 'max-age=300'); + }); + + it('generates CDN-Cache-Control with swr', () => { + const cache = new AstroCache(null); + cache.set({ maxAge: 300, swr: 60 }); + + const response = new Response('test'); + applyCacheHeaders(cache, response); + assert.equal( + response.headers.get('CDN-Cache-Control'), + 'max-age=300, stale-while-revalidate=60', + ); + }); + + it('generates correct Cache-Tag', () => { + const cache = new AstroCache(null); + cache.set({ maxAge: 60, tags: ['product', 'featured'] }); + + const response = new Response('test'); + applyCacheHeaders(cache, response); + assert.equal(response.headers.get('Cache-Tag'), 'product, featured'); + }); + + it('generates correct Last-Modified and ETag', () => { + const cache = new AstroCache(null); + const date = new Date('2025-06-01T12:00:00Z'); + cache.set({ maxAge: 60, lastModified: date, etag: '"v1"' }); + + const response = new Response('test'); + applyCacheHeaders(cache, response); + assert.equal(response.headers.get('Last-Modified'), date.toUTCString()); + assert.equal(response.headers.get('ETag'), '"v1"'); + }); + + it('uses provider.setHeaders() when available', () => { + const customHeaders = new Headers({ 'X-Custom-Cache': 'hit' }); + const provider = createMockProvider({ + setHeaders: () => customHeaders, + }); + const cache = new AstroCache(provider); + cache.set({ maxAge: 60 }); + + const response = new Response('test'); + applyCacheHeaders(cache, response); + assert.equal(response.headers.get('X-Custom-Cache'), 'hit'); + }); + + it('skips when disabled', () => { + const cache = new AstroCache(null); + cache.set({ maxAge: 300, tags: ['a'] }); + cache.set(false); + + const response = new Response('test'); + applyCacheHeaders(cache, response); + assert.equal(response.headers.get('CDN-Cache-Control'), null); + assert.equal(response.headers.get('Cache-Tag'), null); + }); + + it('skips when nothing was set', () => { + const cache = new AstroCache(null); + + const response = new Response('test'); + applyCacheHeaders(cache, response); + assert.equal(response.headers.get('CDN-Cache-Control'), null); + }); +}); + +describe('isCacheActive()', () => { + it('false initially', () => { + const cache = new AstroCache(null); + assert.equal(isCacheActive(cache), false); + }); + + it('true after setting maxAge', () => { + const cache = new AstroCache(null); + cache.set({ maxAge: 60 }); + assert.equal(isCacheActive(cache), true); + }); + + it('true after setting tags', () => { + const cache = new AstroCache(null); + cache.set({ tags: ['a'] }); + assert.equal(isCacheActive(cache), true); + }); + + it('false after set(false)', () => { + const cache = new AstroCache(null); + cache.set({ maxAge: 60 }); + cache.set(false); + assert.equal(isCacheActive(cache), false); + }); +}); diff --git a/packages/astro/test/units/cache/utils.test.js b/packages/astro/test/units/cache/utils.test.js new file mode 100644 index 000000000000..30957bb7fd76 --- /dev/null +++ b/packages/astro/test/units/cache/utils.test.js @@ -0,0 +1,206 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + cacheConfigToManifest, + extractCacheRoutesFromRouteRules, + normalizeCacheProviderConfig, + normalizeRouteRuleCacheOptions, +} from '../../../dist/core/cache/utils.js'; +import { + defaultSetHeaders, + isCacheHint, + isLiveDataEntry, +} from '../../../dist/core/cache/runtime/utils.js'; + +describe('defaultSetHeaders()', () => { + it('correct CDN-Cache-Control for maxAge only', () => { + const headers = defaultSetHeaders({ maxAge: 300 }); + assert.equal(headers.get('CDN-Cache-Control'), 'max-age=300'); + }); + + it('correct CDN-Cache-Control for maxAge + swr', () => { + const headers = defaultSetHeaders({ maxAge: 300, swr: 60 }); + assert.equal(headers.get('CDN-Cache-Control'), 'max-age=300, stale-while-revalidate=60'); + }); + + it('Cache-Tag header from tags array', () => { + const headers = defaultSetHeaders({ maxAge: 60, tags: ['product', 'featured'] }); + assert.equal(headers.get('Cache-Tag'), 'product, featured'); + }); + + it('Last-Modified header formatting', () => { + const date = new Date('2025-06-01T12:00:00Z'); + const headers = defaultSetHeaders({ lastModified: date }); + assert.equal(headers.get('Last-Modified'), date.toUTCString()); + }); + + it('ETag header', () => { + const headers = defaultSetHeaders({ etag: '"abc123"' }); + assert.equal(headers.get('ETag'), '"abc123"'); + }); + + it('empty options produces no headers', () => { + const headers = defaultSetHeaders({}); + assert.equal([...headers.entries()].length, 0); + }); + + it('tags-only produces Cache-Tag but no CDN-Cache-Control', () => { + const headers = defaultSetHeaders({ tags: ['a'] }); + assert.equal(headers.get('Cache-Tag'), 'a'); + assert.equal(headers.get('CDN-Cache-Control'), null); + }); +}); + +describe('isCacheHint()', () => { + it('true for { tags: [...] }', () => { + assert.equal(isCacheHint({ tags: ['a'] }), true); + }); + + it('true for { tags: [], lastModified: date }', () => { + assert.equal(isCacheHint({ tags: [], lastModified: new Date() }), true); + }); + + it('false for null', () => { + assert.equal(isCacheHint(null), false); + }); + + it('false for undefined', () => { + assert.equal(isCacheHint(undefined), false); + }); + + it('false for string', () => { + assert.equal(isCacheHint('tags'), false); + }); + + it('false for object without tags', () => { + assert.equal(isCacheHint({ maxAge: 300 }), false); + }); +}); + +describe('isLiveDataEntry()', () => { + it('true for { id, data, cacheHint }', () => { + assert.equal(isLiveDataEntry({ id: '1', data: {}, cacheHint: { tags: [] } }), true); + }); + + it('true for entry with undefined cacheHint', () => { + assert.equal(isLiveDataEntry({ id: '1', data: {}, cacheHint: undefined }), true); + }); + + it('false for CacheHint', () => { + assert.equal(isLiveDataEntry({ tags: ['a'] }), false); + }); + + it('false for CacheOptions', () => { + assert.equal(isLiveDataEntry({ maxAge: 300 }), false); + }); + + it('false for null', () => { + assert.equal(isLiveDataEntry(null), false); + }); + + it('false for string', () => { + assert.equal(isLiveDataEntry('entry'), false); + }); +}); + +describe('normalizeCacheProviderConfig()', () => { + it('handles string entrypoint', () => { + const result = normalizeCacheProviderConfig({ + entrypoint: 'astro/cache/memory', + config: { max: 1000 }, + }); + assert.equal(result.entrypoint, 'astro/cache/memory'); + assert.deepEqual(result.config, { max: 1000 }); + }); + + it('handles URL entrypoint', () => { + const url = new URL('file:///tmp/cache-provider.js'); + const result = normalizeCacheProviderConfig({ entrypoint: url }); + assert.equal(result.entrypoint, 'file:///tmp/cache-provider.js'); + assert.equal(result.config, undefined); + }); +}); + +describe('normalizeRouteRuleCacheOptions()', () => { + it('extracts flat shortcuts', () => { + const result = normalizeRouteRuleCacheOptions({ maxAge: 300, swr: 60 }); + assert.deepEqual(result, { maxAge: 300, swr: 60, tags: undefined }); + }); + + it('returns undefined for rule with no cache options', () => { + const result = normalizeRouteRuleCacheOptions({}); + assert.equal(result, undefined); + }); + + it('returns undefined for undefined rule', () => { + const result = normalizeRouteRuleCacheOptions(undefined); + assert.equal(result, undefined); + }); +}); + +describe('extractCacheRoutesFromRouteRules()', () => { + it('extracts cache routes from flat shortcuts', () => { + const result = extractCacheRoutesFromRouteRules({ + '/api/*': { swr: 600 }, + '/blog/*': { maxAge: 300 }, + }); + assert.deepEqual(result, { + '/api/*': { maxAge: undefined, swr: 600, tags: undefined }, + '/blog/*': { maxAge: 300, swr: undefined, tags: undefined }, + }); + }); + + it('filters out rules with no cache options', () => { + const result = extractCacheRoutesFromRouteRules({ + '/about': {}, + '/api/*': { swr: 600 }, + }); + assert.deepEqual(result, { + '/api/*': { maxAge: undefined, swr: 600, tags: undefined }, + }); + }); + + it('returns undefined for empty routeRules', () => { + const result = extractCacheRoutesFromRouteRules({}); + assert.equal(result, undefined); + }); + + it('returns undefined for undefined routeRules', () => { + const result = extractCacheRoutesFromRouteRules(undefined); + assert.equal(result, undefined); + }); +}); + +describe('cacheConfigToManifest()', () => { + it('serializes correctly with routeRules', () => { + const result = cacheConfigToManifest( + { provider: { entrypoint: 'astro/cache/memory', config: { max: 500 } } }, + { '/blog/[...path]': { maxAge: 300 } }, + ); + assert.deepEqual(result, { + provider: 'astro/cache/memory', + options: { max: 500 }, + routes: { '/blog/[...path]': { maxAge: 300, swr: undefined, tags: undefined } }, + }); + }); + + it('returns undefined when no cache config', () => { + assert.equal(cacheConfigToManifest(undefined, { '/api/*': { swr: 600 } }), undefined); + }); + + it('returns undefined when no provider', () => { + assert.equal(cacheConfigToManifest({}, { '/blog': { maxAge: 60 } }), undefined); + }); + + it('handles routeRules with no cache options', () => { + const result = cacheConfigToManifest( + { provider: { entrypoint: 'astro/cache/memory' } }, + { '/about': {} }, + ); + assert.deepEqual(result, { + provider: 'astro/cache/memory', + options: undefined, + routes: undefined, + }); + }); +}); diff --git a/packages/astro/test/units/sessions/astro-session.test.js b/packages/astro/test/units/sessions/astro-session.test.js index d5c9e4c651dc..22457ef49d9c 100644 --- a/packages/astro/test/units/sessions/astro-session.test.js +++ b/packages/astro/test/units/sessions/astro-session.test.js @@ -131,6 +131,43 @@ describe('AstroSession - Session Regeneration', () => { assert.notEqual(initialId, newId); }); + + it('should persist data after regeneration without a subsequent set()', async () => { + const store = new Map(); + const mockStorage = { + get: async (key) => { + const raw = store.get(key); + return raw ? JSON.parse(raw) : null; + }, + setItem: async (key, value) => { + store.set(key, value); + }, + removeItem: async (key) => { + store.delete(key); + }, + }; + + const session = createSession(defaultConfig, defaultMockCookies, mockStorage); + + session.set('user', 'alice'); + await session[PERSIST_SYMBOL](); + + await session.regenerate(); + await session[PERSIST_SYMBOL](); + + // Create a new session that reads from the same storage + const newSession = createSession( + defaultConfig, + { + ...defaultMockCookies, + get: () => ({ value: session.sessionID }), + }, + mockStorage, + ); + + const value = await newSession.get('user'); + assert.equal(value, 'alice'); + }); }); describe('AstroSession - Data Persistence', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 465510f9da9f..af3f83437ae2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2567,6 +2567,33 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/test/fixtures/cache-memory: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + + packages/astro/test/fixtures/cache-memory-query: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + + packages/astro/test/fixtures/cache-memory-query-include: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + + packages/astro/test/fixtures/cache-route: + dependencies: + '@astrojs/node': + specifier: workspace:* + version: link:../../../../integrations/node + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/client-address: dependencies: astro: