Skip to content
Open
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: 2 additions & 0 deletions adev/src/content/guide/ssr.md
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,8 @@ withHttpTransferCacheOptions({
});
```

Angular includes `ArrayBuffer` bytes in the cache key. Requests with `Blob` or `FormData` bodies are not cached because Angular cannot derive a complete cache key for them synchronously. Caching them without their contents could cause different request bodies to share the same cached response.

Use this only when `POST` requests are **idempotent** and safe to reuse between server and client renders.

### `includeRequestsWithAuthHeaders`
Expand Down
2 changes: 1 addition & 1 deletion integration/platform-server-hydration/size.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"dist/browser/main-[hash].js": 237605,
"dist/browser/main-[hash].js": 242626,
"dist/browser/polyfills-[hash].js": 35784,
"dist/browser/event-dispatch-contract.min.js": 476
}
56 changes: 44 additions & 12 deletions packages/common/http/src/transfer_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export interface HttpTransferCacheOptions {
/**
* 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`).
* Requests with `Blob` or `FormData` bodies are not cached because Angular cannot derive a
* complete cache key for them synchronously across supported runtimes.
*/
includePostRequests?: boolean;

Expand Down Expand Up @@ -224,7 +226,11 @@ export function retrieveStateFromCache(
? mapRequestOriginUrl(req.url, originMap)
: req.url;

storeKey = makeCacheKey(req, requestUrl);
const cacheKey = makeCacheKey(req, requestUrl);
if (cacheKey === null) {
return null;
}
storeKey = cacheKey;
}

const response = transferState.get(storeKey, null);
Expand Down Expand Up @@ -291,6 +297,10 @@ export function transferCacheInterceptorFn(
? mapRequestOriginUrl(req.url, originMap)
: req.url;
const storeKey = makeCacheKey(req, requestUrl);
if (storeKey === null) {
// Requests without a complete body key bypass the transfer cache to prevent collisions.
return next(req);
}

const cachedResponse = retrieveStateFromCache(
req,
Expand Down Expand Up @@ -410,21 +420,43 @@ function sortAndConcatParams(params: HttpParams | URLSearchParams): string {
function makeCacheKey(
request: HttpRequest<any>,
mappedRequestUrl: string,
): StateKey<TransferHttpResponse> {
): StateKey<TransferHttpResponse> | null {
const {params, method, responseType} = request;
const encodedParams = sortAndConcatParams(params);

let serializedBody = request.serializeBody();
if (serializedBody instanceof URLSearchParams) {
serializedBody = sortAndConcatParams(serializedBody);
} else if (typeof serializedBody !== 'string') {
serializedBody = '';
const serializedBody = request.serializeBody();
let bodyType: string;
let bodyForCacheKey: string;
if (serializedBody === null) {
bodyType = 'null';
bodyForCacheKey = '';
} else if (serializedBody instanceof URLSearchParams) {
bodyType = 'urlSearchParams';
bodyForCacheKey = sortAndConcatParams(serializedBody);
} else if (serializedBody instanceof ArrayBuffer) {
bodyType = 'arrayBuffer';
// Hash the bytes directly to avoid creating a larger base64 cache-key input.
bodyForCacheKey = generateHash(new Uint8Array(serializedBody));
} else if (typeof serializedBody === 'string') {
bodyType = 'string';
bodyForCacheKey = serializedBody;
} else {
// Blob and FormData cannot provide a complete cache key synchronously. Skipping them
// prevents different request bodies from sharing a cache entry.
return null;
}

// Joining with `|` lets a shifted field boundary (url `/a` + body `b|c` vs url `/a|b` + body `c`)
// collapse to the same string and thus the same hash. `\0` cannot occur in a valid url or in
// encoded params, so the field boundaries can't be forged by field content.
const key = [method, responseType, mappedRequestUrl, serializedBody, encodedParams].join('\0');
const key = [
method,
responseType,
mappedRequestUrl,
bodyType,
bodyForCacheKey,
encodedParams,
].join('\0');
const hash = generateHash(key);

return makeStateKey(hash);
Expand Down Expand Up @@ -585,7 +617,7 @@ const SHA256_ROUND_CONSTANTS = /* @__PURE__ */ new Uint32Array([
let textEncoder: TextEncoder | undefined;

/**
* Generates a SHA-256 hash representation of a string.
* Generates a SHA-256 hash representation of a string or byte array.
*
* Note: A custom synchronous SHA-256 implementation is used here because the Web Crypto API
* (`crypto.subtle.digest`) is strictly asynchronous (Promise-based), whereas the transfer cache
Expand All @@ -597,9 +629,9 @@ let textEncoder: TextEncoder | undefined;
* cached response to legitimate users. SHA-256 provides strong cryptographic collision resistance,
* preventing cache key collision attacks.
*/
export function generateHash(value: string): string {
textEncoder ??= new TextEncoder();
const inputBytes = textEncoder.encode(value);
export function generateHash(value: string | Uint8Array): string {
const inputBytes =
typeof value === 'string' ? (textEncoder ??= new TextEncoder()).encode(value) : value;

// Initial hash values (first 32 bits of the fractional parts of the square roots of the first 8 primes 2..19):
let hashState0 = 0x6a09e667;
Expand Down
74 changes: 72 additions & 2 deletions packages/common/http/test/transfer_cache_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -541,15 +541,15 @@ describe('TransferCache', () => {

const transferState = TestBed.inject(TransferState);
expect(JSON.parse(transferState.toJson()) as Record<string, unknown>).toEqual({
'd501aa2d57b63a95df74e3b0558782b71b077974e968ed303cd30b27e4b70702': {
'0b6e4c00c4fc4ff7474413ec4eee9ad06986ba020ea9435ede66801414fe12ee': {
[BODY]: 'foo',
[HEADERS]: {},
[STATUS]: 200,
[STATUS_TEXT]: 'OK',
[REQ_URL]: '/test-1',
[RESPONSE_TYPE]: 'json',
},
'ceddc6689dc1f2fc3a0b8c364b6e00a79b99a149f27e84da87cec03d44c150c8': {
'16af864024ff480eb7b907c8d06909577f956909b85e2a02d0602ca8b5c4bb4f': {
[BODY]: 'buzz',
[HEADERS]: {},
[STATUS]: 200,
Expand Down Expand Up @@ -926,6 +926,76 @@ describe('TransferCache', () => {
});
});

it('should differentiate POST requests with ArrayBuffer bodies', () => {
const firstBody = new Uint8Array([1, 2, 3]).buffer;
const equivalentBody = new Uint8Array([1, 2, 3]).buffer;
const differentBody = new Uint8Array([4, 5, 6]).buffer;

makeRequestAndExpectOne('/test-arraybuffer-body', 'first', {
method: 'POST',
transferCache: true,
body: firstBody,
});
const cachedResponse = makeRequestAndExpectNone('/test-arraybuffer-body', 'POST', {
transferCache: true,
body: equivalentBody,
});
expect(cachedResponse.body).toBe('first');
makeRequestAndExpectOne('/test-arraybuffer-body', 'second', {
method: 'POST',
transferCache: true,
body: differentBody,
});
});

it('should differentiate POST requests with null and empty string bodies', () => {
makeRequestAndExpectOne('/test-empty-body', 'null-body', {
method: 'POST',
transferCache: true,
body: null,
});
makeRequestAndExpectNone('/test-empty-body', 'POST', {
transferCache: true,
body: null,
});
makeRequestAndExpectOne('/test-empty-body', 'empty-string-body', {
method: 'POST',
transferCache: true,
body: '',
});
});

it('should not cache POST requests with Blob bodies', () => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking into it a bit more, we could try wrapping it in observables and modifying the interceptor in transferCache, but that would be extra work and would further increase the bundle size.

I'm not sure if it's worthwhile or if we should revisit it later.

const body = new Blob(['test']);

makeRequestAndExpectOne('/test-blob-body', 'first', {
method: 'POST',
transferCache: true,
body,
});
makeRequestAndExpectOne('/test-blob-body', 'second', {
method: 'POST',
transferCache: true,
body,
});
});

it('should not cache POST requests with FormData bodies', () => {
const body = new FormData();
body.set('field', 'value');

makeRequestAndExpectOne('/test-form-data-body', 'first', {
method: 'POST',
transferCache: true,
body,
});
makeRequestAndExpectOne('/test-form-data-body', 'second', {
method: 'POST',
transferCache: true,
body,
});
});

describe('caching in browser context', () => {
beforeEach(() => {
globalThis['ngServerMode'] = false;
Expand Down