From e3630c23c59445fc8bace5117283f20d6379dded Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Mon, 6 Jul 2026 23:03:32 +0200 Subject: [PATCH] feat(http): add options to allow caching of credentialed and non-cacheable HTTP requests Adds `includeRequestsWithCredentials` and `includeNonCacheableRequests` options to `HttpTransferCacheOptions`. --- adev/src/content/guide/hydration.md | 4 +- adev/src/content/guide/ssr.md | 34 +++++-- goldens/public-api/common/http/index.api.md | 8 +- packages/common/http/src/transfer_cache.ts | 84 +++++++++++------ .../common/http/test/transfer_cache_spec.ts | 91 +++++++++++++++++++ .../hydration/bundle.golden_symbols.json | 1 - 6 files changed, 179 insertions(+), 43 deletions(-) diff --git a/adev/src/content/guide/hydration.md b/adev/src/content/guide/hydration.md index 3bc7b5a36da..08bbd6776ca 100644 --- a/adev/src/content/guide/hydration.md +++ b/adev/src/content/guide/hydration.md @@ -90,9 +90,7 @@ The Event Replay is divided into three main phases: Event replay supports _native browser events_, for example `click`, `mouseover`, and `focusin`. If you'd like to learn more about JSAction, the library that powers event replay, you can read more [on the readme](https://github.com/angular/angular/tree/main/packages/core/primitives/event-dispatch#readme). ---- - -This feature ensures a consistent user experience, preventing user actions performed before Hydration from being ignored. +This feature ensures a consistent user experience, preventing user actions performed before hydration from being ignored. NOTE: If you have [incremental hydration](guide/incremental-hydration) enabled, event replay is automatically enabled under the hood. diff --git a/adev/src/content/guide/ssr.md b/adev/src/content/guide/ssr.md index c5ef64050bc..aed90dd13e1 100644 --- a/adev/src/content/guide/ssr.md +++ b/adev/src/content/guide/ssr.md @@ -478,8 +478,6 @@ bootstrapApplication(App, { }); ``` ---- - ### `includeHeaders` Specifies which headers from the server response should be included in cached entries. @@ -495,8 +493,6 @@ IMPORTANT: Avoid including sensitive headers like authentication tokens. These c 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` By default, only `GET` and `HEAD` requests are cached. @@ -510,12 +506,10 @@ withHttpTransferCacheOptions({ Use this only when `POST` requests are **idempotent** and safe to reuse between server and client renders. ---- - ### `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` or Fetch API `credentials` set to `include` or `same-origin` are also excluded by default. +By default, these are excluded to prevent caching user‑specific responses. ```ts withHttpTransferCacheOptions({ @@ -525,6 +519,32 @@ withHttpTransferCacheOptions({ Enable only when authentication headers do **not** affect the response content (for example, public tokens for analytics APIs). +### `includeRequestsWithCredentials` + +Determines whether requests sent using `withCredentials` or Fetch API `credentials` modes (`include` or `same-origin`) are eligible for caching. +By default, these are excluded to prevent caching user‑specific responses. + +```ts +withHttpTransferCacheOptions({ + includeRequestsWithCredentials: true, +}); +``` + +Enable only when credentialed requests return responses that are safe to cache. + +### `includeNonCacheableRequests` + +Determines whether requests and responses containing `Cache-Control` directives that forbid caching (`no-store`, `no-cache`, or `private`), responses with a `Set-Cookie` header, or requests using Fetch API `cache` options (`no-store` or `no-cache`), are eligible for caching. +By default, these are excluded to respect HTTP caching controls. + +```ts +withHttpTransferCacheOptions({ + includeNonCacheableRequests: true, +}); +``` + +Enable only when you need to bypass cache-control restrictions for transfer caching. + ### Per‑request overrides You can override caching behavior for a specific request using the `transferCache` request option. diff --git a/goldens/public-api/common/http/index.api.md b/goldens/public-api/common/http/index.api.md index e944cf53d7f..843d0c1ccaf 100644 --- a/goldens/public-api/common/http/index.api.md +++ b/goldens/public-api/common/http/index.api.md @@ -1164,12 +1164,14 @@ export enum HttpStatusCode { } // @public -export type HttpTransferCacheOptions = { - includeHeaders?: string[]; +export interface HttpTransferCacheOptions { filter?: (req: HttpRequest) => boolean; + includeHeaders?: string[]; + includeNonCacheableRequests?: boolean; includePostRequests?: boolean; includeRequestsWithAuthHeaders?: boolean; -}; + includeRequestsWithCredentials?: boolean; +} // @public export interface HttpUploadProgressEvent extends HttpProgressEvent { diff --git a/packages/common/http/src/transfer_cache.ts b/packages/common/http/src/transfer_cache.ts index 5bb2a943dfe..b5308632dd0 100644 --- a/packages/common/http/src/transfer_cache.ts +++ b/packages/common/http/src/transfer_cache.ts @@ -33,28 +33,47 @@ import {HttpParams} from './params'; /** * Options to configure how TransferCache should be used to cache requests made via HttpClient. * - * @param includeHeaders Specifies which headers should be included into cached responses. No - * headers are included by default. - * @param filter A function that receives a request as an argument and returns a boolean to indicate - * whether a request should be included into the cache. - * @param includePostRequests Enables caching for POST requests. By default, only GET and HEAD - * requests are cached. This option can be enabled if POST requests are used to retrieve data - * (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` 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) * * @publicApi */ -export type HttpTransferCacheOptions = { - includeHeaders?: string[]; +export interface HttpTransferCacheOptions { + /** + * A function that receives a request as an argument and returns a boolean to indicate + * whether a request should be included into the cache. + */ filter?: (req: HttpRequest) => boolean; + + /** + * Specifies which headers should be included into cached responses. No headers are included by default. + */ + includeHeaders?: string[]; + + /** + * Enables caching for `POST` requests. By default, only `GET` and `HEAD` requests are cached. + * This option can be enabled if `POST` requests are used to retrieve data (for example using `GraphQL`). + */ includePostRequests?: boolean; + + /** + * Enables caching of requests containing `Authorization`, `Proxy-Authorization`, or `Cookie` headers. + * By default, these requests are excluded from caching. + */ includeRequestsWithAuthHeaders?: boolean; -}; + + /** + * Enables caching of requests sent using `withCredentials` or Fetch API `credentials` modes (`include` or `same-origin`). + * By default, these requests are excluded from caching. + */ + includeRequestsWithCredentials?: boolean; + + /** + * Enables caching of requests and responses with `Cache-Control` directives that forbid caching + * (such as `no-cache`, `no-store`, or `private`), responses with a `Set-Cookie` header, or requests using Fetch API `no-cache` or `no-store` modes. + * By default, these requests/responses are excluded from caching. + */ + includeNonCacheableRequests?: boolean; +} /** * If your application uses different HTTP origins to make API calls (via `HttpClient`) on the server and @@ -126,24 +145,31 @@ export const CACHE_OPTIONS = new InjectionToken( const ALLOWED_METHODS = ['GET', 'HEAD']; function canUseOrCacheRequest(req: HttpRequest, options: CacheOptions): boolean { - const {isCacheActive, ...globalOptions} = options; + const { + isCacheActive, + filter, + includePostRequests, + includeRequestsWithAuthHeaders, + includeRequestsWithCredentials, + includeNonCacheableRequests, + } = options; const {transferCache: requestOptions, method: requestMethod} = req; if ( !isCacheActive || requestOptions === false || - // Do not cache requests sent with credentials. - hasOutgoingCredentials(req) || // POST requests are allowed either globally or at request level - (requestMethod === 'POST' && !globalOptions.includePostRequests && !requestOptions) || + (requestMethod === 'POST' && !includePostRequests && !requestOptions) || (requestMethod !== 'POST' && !ALLOWED_METHODS.includes(requestMethod)) || // Do not cache requests with authentication or cookie headers unless explicitly enabled. - (!globalOptions.includeRequestsWithAuthHeaders && hasAuthHeaders(req)) || + (!includeRequestsWithAuthHeaders && hasAuthHeaders(req)) || + // Do not cache requests sent with credentials unless explicitly enabled. + (!includeRequestsWithCredentials && hasOutgoingCredentials(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 + // or Fetch API cache mode unless explicitly enabled. + (!includeNonCacheableRequests && + (hasUncacheableCacheControl(req.headers) || isNonCacheableRequest(req.cache))) || + filter?.(req) === false ) { return false; } @@ -287,11 +313,11 @@ export function transferCacheInterceptorFn( if (event instanceof HttpResponse) { const {headers, body, status, statusText} = event; - // Only cache successful HTTP responses that do not have Cache-Control - // directives that forbid shared caching (no-store or private) and do not - // carry a Set-Cookie header. A Set-Cookie header marks the response as - // user-specific. - if (hasUncacheableCacheControl(headers) || hasSetCookieHeader(headers)) { + // Only cache successful HTTP responses that are not non-cacheable. + if ( + !options.includeNonCacheableRequests && + (hasUncacheableCacheControl(headers) || hasSetCookieHeader(headers)) + ) { return; } diff --git a/packages/common/http/test/transfer_cache_spec.ts b/packages/common/http/test/transfer_cache_spec.ts index d8b1fccb91b..f66e7ac3ef6 100644 --- a/packages/common/http/test/transfer_cache_spec.ts +++ b/packages/common/http/test/transfer_cache_spec.ts @@ -960,6 +960,97 @@ describe('TransferCache', () => { }); }); + describe('caching with includeRequestsWithCredentials and includeNonCacheableRequests', () => { + beforeEach( + withBody('', () => { + TestBed.resetTestingModule(); + isStable = new BehaviorSubject(false); + + @Injectable() + class ApplicationRefPatched extends ApplicationRef { + override get isStable() { + return new BehaviorSubject(false); + } + } + + TestBed.configureTestingModule({ + declarations: [SomeComponent], + providers: [ + {provide: PLATFORM_ID, useValue: PLATFORM_SERVER_ID}, + {provide: DOCUMENT, useFactory: () => document}, + {provide: ApplicationRef, useClass: ApplicationRefPatched}, + withHttpTransferCache({ + includeRequestsWithCredentials: true, + includeNonCacheableRequests: true, + }), + provideHttpClient(), + provideHttpClientTesting(), + ], + }); + + const appRef = TestBed.inject(ApplicationRef); + appRef.bootstrap(SomeComponent); + isStable = appRef.isStable as BehaviorSubject; + }), + ); + + it(`should cache requests with credentials when 'includeRequestsWithCredentials' is 'true'`, async () => { + makeRequestAndExpectOne('/test-cred', 'foo', { + withCredentials: true, + }); + + makeRequestAndExpectNone('/test-cred'); + }); + + it(`should cache requests with included credentials mode when 'includeRequestsWithCredentials' is 'true'`, async () => { + makeRequestAndExpectOne('/test-cred', 'foo', { + credentials: 'include', + }); + + makeRequestAndExpectNone('/test-cred'); + }); + + it(`should cache requests with same-origin credentials mode when 'includeRequestsWithCredentials' is 'true'`, async () => { + makeRequestAndExpectOne('/test-cred', 'foo', { + credentials: 'same-origin', + }); + + makeRequestAndExpectNone('/test-cred'); + }); + + it(`should cache responses with Cache-Control: no-store when 'includeNonCacheableRequests' is 'true'`, async () => { + makeRequestAndExpectOne('/test-cache-control', 'foo', { + responseHeaders: {'Cache-Control': 'no-store'}, + }); + + makeRequestAndExpectNone('/test-cache-control'); + }); + + it(`should cache responses with Cache-Control: private when 'includeNonCacheableRequests' is 'true'`, async () => { + makeRequestAndExpectOne('/test-cache-control', 'foo', { + responseHeaders: {'Cache-Control': 'private'}, + }); + + makeRequestAndExpectNone('/test-cache-control'); + }); + + it(`should cache requests with Cache-Control: no-cache when 'includeNonCacheableRequests' is 'true'`, async () => { + makeRequestAndExpectOne('/test-cache-control', 'foo', { + headers: {'Cache-Control': 'no-cache'}, + }); + + makeRequestAndExpectNone('/test-cache-control'); + }); + + it(`should cache requests with cache: 'no-store' mode when 'includeNonCacheableRequests' is 'true'`, async () => { + makeRequestAndExpectOne('/test-cache-mode', 'foo', { + cache: 'no-store', + }); + + makeRequestAndExpectNone('/test-cache-mode'); + }); + }); + describe('caching with public origins', () => { beforeEach( withBody('', () => { diff --git a/packages/core/test/bundling/hydration/bundle.golden_symbols.json b/packages/core/test/bundling/hydration/bundle.golden_symbols.json index 081254f3868..8f1d3e8dabe 100644 --- a/packages/core/test/bundling/hydration/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hydration/bundle.golden_symbols.json @@ -311,7 +311,6 @@ "__getOwnPropDescs", "__getOwnPropSymbols", "__hasOwnProp", - "__objRest", "__propIsEnum", "__spreadProps", "__spreadValues",