Skip to content
Closed
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
8 changes: 6 additions & 2 deletions adev/src/content/guide/ssr.md
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,7 @@ To configure this, update your `angular.json` file as follows:
You can customize how Angular caches HTTP responses during server‑side rendering (SSR) and reuses them during hydration by configuring `HttpTransferCacheOptions`.
This configuration is provided globally using `withHttpTransferCacheOptions` inside `provideClientHydration()`.

By default, `HttpClient` caches all `HEAD` and `GET` requests which don't contain `Authorization`, `Proxy-Authorization`, or `Cookie` headers and are not sent with `withCredentials`. You can override those settings by using `withHttpTransferCacheOptions` to the hydration configuration.
By default, `HttpClient` caches all `HEAD` and `GET` requests which don't contain `Authorization`, `Proxy-Authorization`, or `Cookie` headers and are not sent with `withCredentials` or Fetch API `credentials` modes that can send credentials. Angular also skips transfer cache when a request or response includes `Cache-Control` directives that forbid caching (`no-store`, `no-cache`, or `private`), or when the Fetch API `cache` option is set to `no-store` or `no-cache`. You can override the request filtering settings by using `withHttpTransferCacheOptions` in the hydration configuration.

```ts
import {bootstrapApplication} from '@angular/platform-browser';
Expand Down Expand Up @@ -467,6 +467,8 @@ withHttpTransferCacheOptions({

IMPORTANT: Avoid including sensitive headers like authentication tokens. These can leak user‑specific data between requests.

Including `Cache-Control` in `includeHeaders` only makes that header available on the hydrated response. Angular already evaluates `Cache-Control` headers automatically when deciding whether a request or response is eligible for transfer cache.

---

### `includePostRequests`
Expand All @@ -487,7 +489,7 @@ Use this only when `POST` requests are **idempotent** and safe to reuse between
### `includeRequestsWithAuthHeaders`

Determines whether requests containing `Authorization`, `Proxy‑Authorization`, or `Cookie` headers are eligible for caching.
By default, these are excluded to prevent caching user‑specific responses. Requests sent with `withCredentials` are also excluded by default.
By default, these are excluded to prevent caching user‑specific responses. Requests sent with `withCredentials` or Fetch API `credentials` set to `include` or `same-origin` are also excluded by default.

```ts
withHttpTransferCacheOptions({
Expand Down Expand Up @@ -558,6 +560,8 @@ To disable caching for an individual request, you can specify the [`transferCach
httpClient.get('/api/sensitive-data', {transferCache: false});
```

`HttpTransferCache` does not cache requests or responses that explicitly opt out of caching. Angular skips transfer cache entries when a request includes a `Cache-Control` header with `no-store`, `no-cache`, or `private`, or when the request uses the Fetch API `cache` option set to `no-store` or `no-cache`. Responses with `Cache-Control: no-store`, `Cache-Control: no-cache`, or `Cache-Control: private` are also not stored in the transfer cache.

NOTE: If your application uses different HTTP origins to make API calls on the server and on the client, the `HTTP_TRANSFER_CACHE_ORIGIN_MAP` token allows you to establish a mapping between those origins, so that `HttpTransferCache` feature can recognize those requests as the same ones and reuse the data cached on the server during hydration on the client.

## Configuring a server
Expand Down
38 changes: 34 additions & 4 deletions packages/common/http/src/transfer_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ import {HttpParams} from './params';
* (for example using GraphQL).
* @param includeRequestsWithAuthHeaders Enables caching of requests containing `Authorization`,
* `Proxy-Authorization`, or `Cookie` headers. By default, these requests are excluded from
* caching. Requests sent using `withCredentials` are also excluded by default.
* caching. Requests sent using `withCredentials` or Fetch API `credentials` modes that can send
* credentials are also excluded by default.
*
* @see [Configuring the caching options](guide/ssr#configuring-the-caching-options)
*
Expand Down Expand Up @@ -132,12 +133,16 @@ function shouldCacheRequest(req: HttpRequest<unknown>, options: CacheOptions): b
!isCacheActive ||
requestOptions === false ||
// Do not cache requests sent with credentials.
req.withCredentials ||
hasOutgoingCredentials(req) ||
// POST requests are allowed either globally or at request level
(requestMethod === 'POST' && !globalOptions.includePostRequests && !requestOptions) ||
(requestMethod !== 'POST' && !ALLOWED_METHODS.includes(requestMethod)) ||
// Do not cache requests with authentication or cookie headers unless explicitly enabled.
(!globalOptions.includeRequestsWithAuthHeaders && hasAuthHeaders(req)) ||
// Do not cache requests that explicitly forbid caching via Cache-Control
// or Fetch API cache mode.
hasUncacheableCacheControl(req.headers) ||
isNonCacheableRequest(req.cache) ||
globalOptions.filter?.(req) === false
) {
return false;
Expand Down Expand Up @@ -270,8 +275,9 @@ export function transferCacheInterceptorFn(
// Request not found in cache. Make the request and cache it if on the server.
return event$.pipe(
tap((event: HttpEvent<unknown>) => {
// Only cache successful HTTP responses.
if (event instanceof HttpResponse) {
// Only cache successful HTTP responses that do not have Cache-Control
// directives that forbid shared caching (no-store or private).
if (event instanceof HttpResponse && !hasUncacheableCacheControl(event.headers)) {
transferState.set<TransferHttpResponse>(storeKey, {
[BODY]:
req.responseType === 'arraybuffer' || req.responseType === 'blob'
Expand Down Expand Up @@ -300,6 +306,30 @@ function hasAuthHeaders(req: HttpRequest<unknown>): boolean {
);
}

const UNCACHEABLE_CACHE_CONTROL_DIRECTIVES = new Set(['no-store', 'private', 'no-cache']);

function hasUncacheableCacheControl(headers: HttpHeaders): boolean {
const cacheControl = headers.get('cache-control');

if (!cacheControl) {
return false;
}

return cacheControl.split(',').some((directive) => {
const directiveName = directive.split('=', 1)[0].trim().toLowerCase();

return UNCACHEABLE_CACHE_CONTROL_DIRECTIVES.has(directiveName);
});
}

function isNonCacheableRequest(cache: RequestCache): boolean {
return cache === 'no-cache' || cache === 'no-store';
}

function hasOutgoingCredentials(req: HttpRequest<unknown>): boolean {
return req.withCredentials || req.credentials === 'include' || req.credentials === 'same-origin';
}

function getFilteredHeaders(
headers: HttpHeaders,
includeHeaders: string[] | undefined,
Expand Down
256 changes: 255 additions & 1 deletion packages/common/http/test/transfer_cache_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,11 @@ interface RequestParams {
observe?: 'body' | 'response';
transferCache?: {includeHeaders: string[]} | boolean;
headers?: {[key: string]: string};
/** Separate response headers for flush(); falls back to headers if not set */
responseHeaders?: {[key: string]: string};
withCredentials?: boolean;
credentials?: RequestCredentials;
cache?: RequestCache;
body?: RequestBody;
}

Expand Down Expand Up @@ -157,6 +161,104 @@ describe('TransferCache', () => {
expect(firstNext).toHaveBeenCalledTimes(1);
expect(secondNext).not.toHaveBeenCalled();
});

it('should not cache responses with Cache-Control: no-store', () => {
configureInterceptor();

const request = new HttpRequest('GET', '/test-no-store');

const firstNext = jasmine.createSpy('firstNext').and.returnValue(
of(
new HttpResponse({
body: 'sensitive-data',
headers: new HttpHeaders({'Cache-Control': 'no-store'}),
}),
),
);
const secondNext = jasmine
.createSpy('secondNext')
.and.returnValue(of(new HttpResponse({body: 'fresh-data'})));

runOnServer(() => {
expect(runInterceptor(request, firstNext).body).toBe('sensitive-data');
expect(runInterceptor(request, secondNext).body).toBe('fresh-data');
});

expect(firstNext).toHaveBeenCalledTimes(1);
expect(secondNext).toHaveBeenCalledTimes(1);
});

it('should not cache responses with Cache-Control: private', () => {
configureInterceptor();

const request = new HttpRequest('GET', '/test-private');

const firstNext = jasmine.createSpy('firstNext').and.returnValue(
of(
new HttpResponse({
body: 'user-data',
headers: new HttpHeaders({'Cache-Control': 'private'}),
}),
),
);
const secondNext = jasmine
.createSpy('secondNext')
.and.returnValue(of(new HttpResponse({body: 'public-data'})));

runOnServer(() => {
expect(runInterceptor(request, firstNext).body).toBe('user-data');
expect(runInterceptor(request, secondNext).body).toBe('public-data');
});

expect(firstNext).toHaveBeenCalledTimes(1);
expect(secondNext).toHaveBeenCalledTimes(1);
});

it('should not cache requests with Cache-Control: no-store', () => {
configureInterceptor();

const request = new HttpRequest('GET', '/test-req-no-store', null, {
headers: new HttpHeaders({'Cache-Control': 'no-store'}),
});

const firstNext = jasmine
.createSpy('firstNext')
.and.returnValue(of(new HttpResponse({body: 'data'})));
const secondNext = jasmine
.createSpy('secondNext')
.and.returnValue(of(new HttpResponse({body: 'fresh-data'})));

runOnServer(() => {
expect(runInterceptor(request, firstNext).body).toBe('data');
expect(runInterceptor(request, secondNext).body).toBe('fresh-data');
});

expect(firstNext).toHaveBeenCalledTimes(1);
expect(secondNext).toHaveBeenCalledTimes(1);
});

it('should not cache requests with Cache-Control: no-cache', () => {
configureInterceptor();

const request = new HttpRequest('GET', '/test-req-no-cache', null, {
headers: new HttpHeaders({'Cache-Control': 'no-cache'}),
});

const firstNext = jasmine
.createSpy('firstNext')
.and.returnValue(of(new HttpResponse({body: 'data'})));
const secondNext = jasmine
.createSpy('secondNext')
.and.returnValue(of(new HttpResponse({body: 'fresh-data'})));

runOnServer(() => {
expect(runInterceptor(request, firstNext).body).toBe('data');
expect(runInterceptor(request, secondNext).body).toBe('fresh-data');
});

expect(firstNext).toHaveBeenCalledTimes(1);
expect(secondNext).toHaveBeenCalledTimes(1);
});
});

describe('withHttpTransferCache', () => {
Expand All @@ -177,7 +279,9 @@ describe('TransferCache', () => {
TestBed.inject(HttpClient)
.request(params?.method ?? 'GET', url, params)
.subscribe((r) => (response = r));
TestBed.inject(HttpTestingController).expectOne(url).flush(body, {headers: params?.headers});
TestBed.inject(HttpTestingController)
.expectOne(url)
.flush(body, {headers: params?.responseHeaders ?? params?.headers});
return response;
}

Expand Down Expand Up @@ -440,6 +544,146 @@ describe('TransferCache', () => {
});
});

it('should not cache requests with included credentials', async () => {
makeRequestAndExpectOne('/test-auth', 'foo', {
credentials: 'include',
});

makeRequestAndExpectOne('/test-auth', 'foo', {
credentials: 'include',
});
});

it('should not cache requests with same-origin credentials', async () => {
makeRequestAndExpectOne('/test-auth', 'foo', {
credentials: 'same-origin',
});

makeRequestAndExpectOne('/test-auth', 'foo', {
credentials: 'same-origin',
});
});

it('should cache requests with omitted credentials', async () => {
makeRequestAndExpectOne('/test-auth', 'foo', {
credentials: 'omit',
});

makeRequestAndExpectNone('/test-auth', 'GET', {
credentials: 'omit',
});
});

it('should not cache responses with Cache-Control: no-store', () => {
makeRequestAndExpectOne('/test-no-store', 'private-data', {
responseHeaders: {'Cache-Control': 'no-store'},
});

makeRequestAndExpectOne('/test-no-store', 'fresh-data');
});

it('should not cache responses with Cache-Control: private', () => {
makeRequestAndExpectOne('/test-private', 'user-data', {
responseHeaders: {'Cache-Control': 'private'},
});

makeRequestAndExpectOne('/test-private', 'fresh-data');
});

it('should not cache responses with Cache-Control containing no-store among other directives', () => {
makeRequestAndExpectOne('/test-multi', 'data', {
responseHeaders: {'Cache-Control': 'max-age=0, no-store, must-revalidate'},
});

makeRequestAndExpectOne('/test-multi', 'fresh-data');
});

it('should not cache responses with Cache-Control containing private among other directives', () => {
makeRequestAndExpectOne('/test-multi-private', 'data', {
responseHeaders: {'Cache-Control': 'max-age=60, private'},
});

makeRequestAndExpectOne('/test-multi-private', 'fresh-data');
});

it('should cache responses with Cache-Control: public', () => {
makeRequestAndExpectOne('/test-public', 'public-data', {
responseHeaders: {'Cache-Control': 'public'},
});

makeRequestAndExpectNone('/test-public');
});

it('should cache responses with Cache-Control: max-age without no-store or private', () => {
makeRequestAndExpectOne('/test-max-age', 'cacheable-data', {
responseHeaders: {'Cache-Control': 'max-age=3600'},
});

makeRequestAndExpectNone('/test-max-age');
});

it('should cache responses without Cache-Control header', () => {
makeRequestAndExpectOne('/test-no-cc', 'data');

makeRequestAndExpectNone('/test-no-cc');
});

it('should not cache responses with Cache-Control: no-store (case-insensitive)', () => {
makeRequestAndExpectOne('/test-case-resp', 'data', {
responseHeaders: {'Cache-Control': 'No-Store'},
});

makeRequestAndExpectOne('/test-case-resp', 'fresh-data');
});

it('should not cache requests with Cache-Control: no-store', () => {
makeRequestAndExpectOne('/test-req-no-store', 'data', {
headers: {'Cache-Control': 'no-store'},
});

makeRequestAndExpectOne('/test-req-no-store', 'fresh-data');
});

it('should not cache requests with Cache-Control: no-cache', () => {
makeRequestAndExpectOne('/test-req-no-cache', 'data', {
headers: {'Cache-Control': 'no-cache'},
});

makeRequestAndExpectOne('/test-req-no-cache', 'fresh-data');
});

it('should not cache requests with Cache-Control containing no-store among other directives', () => {
makeRequestAndExpectOne('/test-req-multi', 'data', {
headers: {'Cache-Control': 'max-age=0, no-store'},
});

makeRequestAndExpectOne('/test-req-multi', 'fresh-data');
});

it('should cache requests with Cache-Control: max-age', () => {
makeRequestAndExpectOne('/test-req-max-age', 'data', {
headers: {'Cache-Control': 'max-age=3600'},
});

makeRequestAndExpectNone('/test-req-max-age');
});

it('should not cache requests with Fetch API cache mode: no-store', () => {
makeRequestAndExpectOne('/test-fetch-no-store', 'data', {
cache: 'no-store',
});

makeRequestAndExpectOne('/test-fetch-no-store', 'fresh-data');
});

it('should not cache requests with Fetch API cache mode: no-cache', () => {
makeRequestAndExpectOne('/test-fetch-no-cache', 'data', {
cache: 'no-cache',
});

makeRequestAndExpectOne('/test-fetch-no-cache', 'fresh-data');
});

it('should cache POST with the differing body in string form', () => {
makeRequestAndExpectOne('/test-1', null, {method: 'POST', transferCache: true, body: 'foo'});
makeRequestAndExpectNone('/test-1', 'POST', {transferCache: true, body: 'foo'});
Expand Down Expand Up @@ -602,6 +846,16 @@ describe('TransferCache', () => {
});
});

it(`should not cache requests with included credentials when 'includeRequestsWithAuthHeaders' is 'true'`, async () => {
makeRequestAndExpectOne('/test-auth', 'foo', {
credentials: 'include',
});

makeRequestAndExpectOne('/test-auth', 'foo', {
credentials: 'include',
});
});

it('should cache a POST request', () => {
makeRequestAndExpectOne('/include?foo=1', 'post-body', {method: 'POST'});

Expand Down
Loading
Loading