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
2 changes: 1 addition & 1 deletion adev/src/content/guide/ssr.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Hydration is the process that restores the server side rendered application on t

[`HttpClient`](api/common/http/HttpClient) cached outgoing network requests when running on the server. This information is serialized and transferred to the browser as part of the initial HTML sent from the server. In the browser, `HttpClient` checks whether it has data in the cache and if so, reuses it instead of making a new HTTP request during initial application rendering. `HttpClient` stops using the cache once an application becomes [stable](api/core/ApplicationRef#isStable) while running in a browser.

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`](api/platform-browser/withHttpTransferCacheOptions) when providing hydration.
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`. Angular also skips transfer cache when a request or response includes `Cache-Control` directives that forbid caching (`no-store`, `no-cache`, or `private`). You can override the request filtering settings by using [`withHttpTransferCacheOptions`](api/platform-browser/withHttpTransferCacheOptions) when providing hydration.

```typescript
bootstrapApplication(AppComponent, {
Expand Down
27 changes: 26 additions & 1 deletion packages/common/http/src/transfer_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ export function transferCacheInterceptorFn(
(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.
hasUncacheableCacheControl(req.headers) ||
globalOptions.filter?.(req) === false
) {
return next(req);
Expand Down Expand Up @@ -221,7 +223,14 @@ export function transferCacheInterceptorFn(
// Request not found in cache. Make the request and cache it if on the server.
return next(req).pipe(
tap((event: HttpEvent<unknown>) => {
if (event instanceof HttpResponse && typeof ngServerMode !== 'undefined' && ngServerMode) {
// Only cache successful HTTP responses that do not have Cache-Control directives that forbid
// caching.
if (
event instanceof HttpResponse &&
typeof ngServerMode !== 'undefined' &&
ngServerMode &&
!hasUncacheableCacheControl(event.headers)
) {
transferState.set<TransferHttpResponse>(storeKey, {
[BODY]: event.body,
[HEADERS]: getFilteredHeaders(event.headers, headersToInclude),
Expand All @@ -244,6 +253,22 @@ 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 getFilteredHeaders(
headers: HttpHeaders,
includeHeaders: string[] | undefined,
Expand Down
233 changes: 232 additions & 1 deletion packages/common/http/test/transfer_cache_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ 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;
body?: RequestBody;
}
Expand Down Expand Up @@ -156,6 +158,131 @@ 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 responses with Cache-Control: no-cache', () => {
configureInterceptor();

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

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

runOnServer(() => {
expect(runInterceptor(request, firstNext).body).toBe('stale-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-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 @@ -176,7 +303,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 @@ -408,6 +537,108 @@ describe('TransferCache', () => {
});
});

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: no-cache', () => {
makeRequestAndExpectOne('/test-no-cache', 'stale-data', {
responseHeaders: {'Cache-Control': 'no-cache'},
});

makeRequestAndExpectOne('/test-no-cache', '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 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
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@
"TracingAction",
"TracingService",
"TransferState",
"UNCACHEABLE_CACHE_CONTROL_DIRECTIVES",
"USE_VALUE",
"UnsubscriptionError",
"ViewEncapsulation",
Expand Down Expand Up @@ -301,6 +302,7 @@
"hasInSkipHydrationBlockFlag",
"hasSkipHydrationAttrOnRElement",
"hasSkipHydrationAttrOnTNode",
"hasUncacheableCacheControl",
"icuContainerIterate",
"identity",
"importProvidersFrom",
Expand Down Expand Up @@ -462,4 +464,4 @@
"ɵɵdefineInjectable",
"ɵɵdirectiveInject",
"ɵɵinject"
]
]
Loading