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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fix-session-regenerate-dirty.md
Original file line number Diff line number Diff line change
@@ -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.
139 changes: 139 additions & 0 deletions .changeset/warm-comics-pump.md
Original file line number Diff line number Diff line change
@@ -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
});
---
<html><body>Cached page</body></html>
```

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).
1 change: 1 addition & 0 deletions packages/astro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/actions/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export type ActionAPIContext = Pick<
| 'preferredLocaleList'
| 'originPathname'
| 'session'
| 'cache'
| 'csp'
>;

Expand Down
16 changes: 16 additions & 0 deletions packages/astro/src/config/entrypoint.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<MemoryCacheProviderOptions> {
return {
name: 'memory',
entrypoint: 'astro/cache/memory',
config,
};
}
33 changes: 32 additions & 1 deletion packages/astro/src/core/app/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -468,6 +469,7 @@ export abstract class BaseApp<P extends Pipeline = AppPipeline> {

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);
Expand All @@ -481,7 +483,36 @@ export abstract class BaseApp<P extends Pipeline = AppPipeline> {
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);

Expand Down
3 changes: 3 additions & 0 deletions packages/astro/src/core/app/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -112,10 +113,12 @@ export type SSRManifest = {
middleware?: () => Promise<AstroMiddlewareInstance> | AstroMiddlewareInstance;
actions?: () => Promise<SSRActions> | SSRActions;
sessionDriver?: () => Promise<{ default: SessionDriverFactory | null }>;
cacheProvider?: () => Promise<{ default: CacheProviderFactory | null }>;
checkOrigin: boolean;
allowedDomains?: Partial<RemotePattern>[];
actionBodySizeLimit: number;
sessionConfig?: SSRManifestSession;
cacheConfig?: SSRManifestCache;
cacheDir: URL;
srcDir: URL;
outDir: URL;
Expand Down
25 changes: 25 additions & 0 deletions packages/astro/src/core/base-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;

Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -176,6 +182,25 @@ export abstract class Pipeline {
return null;
}

async getCacheProvider(): Promise<CacheProvider | null> {
// 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<ServerIslandMappings> {
if (this.serverIslands) {
return this.serverIslands();
Expand Down
5 changes: 5 additions & 0 deletions packages/astro/src/core/build/plugins/plugin-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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,
Expand Down
40 changes: 40 additions & 0 deletions packages/astro/src/core/cache/config.ts
Original file line number Diff line number Diff line change
@@ -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);
Loading
Loading