diff --git a/packages/common/http/src/interceptor.ts b/packages/common/http/src/interceptor.ts index a73434571da99..753530a768ac0 100644 --- a/packages/common/http/src/interceptor.ts +++ b/packages/common/http/src/interceptor.ts @@ -145,12 +145,20 @@ function chainedInterceptorFn( * * @publicApi */ -export const HTTP_INTERCEPTORS = new InjectionToken('HTTP_INTERCEPTORS'); +export const HTTP_INTERCEPTORS = + new InjectionToken(ngDevMode ? 'HTTP_INTERCEPTORS' : ''); /** * A multi-provided token of `HttpInterceptorFn`s. */ -export const HTTP_INTERCEPTOR_FNS = new InjectionToken('HTTP_INTERCEPTOR_FNS'); +export const HTTP_INTERCEPTOR_FNS = + new InjectionToken(ngDevMode ? 'HTTP_INTERCEPTOR_FNS' : ''); + +/** + * A multi-provided token of `HttpInterceptorFn`s that are only set in root. + */ +export const HTTP_ROOT_INTERCEPTOR_FNS = + new InjectionToken(ngDevMode ? 'HTTP_ROOT_INTERCEPTOR_FNS' : ''); /** * Creates an `HttpInterceptorFn` which lazily initializes an interceptor chain from the legacy @@ -184,7 +192,10 @@ export class HttpInterceptorHandler extends HttpHandler { override handle(initialRequest: HttpRequest): Observable> { if (this.chain === null) { - const dedupedInterceptorFns = Array.from(new Set(this.injector.get(HTTP_INTERCEPTOR_FNS))); + const dedupedInterceptorFns = Array.from(new Set([ + ...this.injector.get(HTTP_INTERCEPTOR_FNS), + ...this.injector.get(HTTP_ROOT_INTERCEPTOR_FNS, []), + ])); // Note: interceptors are wrapped right-to-left so that final execution order is // left-to-right. That is, if `dedupedInterceptorFns` is the array `[a, b, c]`, we want to diff --git a/packages/common/http/src/transfer_cache.ts b/packages/common/http/src/transfer_cache.ts new file mode 100644 index 0000000000000..d0ca390372051 --- /dev/null +++ b/packages/common/http/src/transfer_cache.ts @@ -0,0 +1,175 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {APP_BOOTSTRAP_LISTENER, ApplicationRef, inject, InjectionToken, makeStateKey, Provider, StateKey, TransferState, ɵInitialRenderPendingTasks as InitialRenderPendingTasks} from '@angular/core'; +import {Observable, of} from 'rxjs'; +import {first, tap} from 'rxjs/operators'; + +import {HttpHeaders} from './headers'; +import {HTTP_ROOT_INTERCEPTOR_FNS, HttpHandlerFn} from './interceptor'; +import {HttpRequest} from './request'; +import {HttpEvent, HttpResponse} from './response'; + +interface TransferHttpResponse { + body: any; + headers: Record; + status?: number; + statusText?: string; + url?: string; + responseType?: HttpRequest['responseType']; +} + +const CACHE_STATE = new InjectionToken<{isCacheActive: boolean}>( + ngDevMode ? 'HTTP_TRANSFER_STATE_CACHE_STATE' : ''); + +/** + * A list of allowed HTTP methods to cache. + */ +const ALLOWED_METHODS = ['GET', 'HEAD']; + +export function transferCacheInterceptorFn( + req: HttpRequest, next: HttpHandlerFn): Observable> { + const {isCacheActive} = inject(CACHE_STATE); + + // Stop using the cache if the application has stabilized, indicating initial rendering + // is complete. + if (!isCacheActive || !ALLOWED_METHODS.includes(req.method)) { + // Cache is no longer active or method is not HEAD or GET. + // Pass the request through. + return next(req); + } + + const transferState = inject(TransferState); + const storeKey = makeCacheKey(req); + const response = transferState.get(storeKey, null); + + if (response) { + // Request found in cache. Respond using it. + let body: ArrayBuffer|Blob|string|undefined = response.body; + + switch (response.responseType) { + case 'arraybuffer': + body = new TextEncoder().encode(response.body).buffer; + break; + case 'blob': + body = new Blob([response.body]); + break; + } + + return of( + new HttpResponse({ + body, + headers: new HttpHeaders(response.headers), + status: response.status, + statusText: response.statusText, + url: response.url, + }), + ); + } + + // Request not found in cache. Make the request and cache it. + return next(req).pipe( + tap((event: HttpEvent) => { + if (event instanceof HttpResponse) { + transferState.set(storeKey, { + body: event.body, + headers: getHeadersMap(event.headers), + status: event.status, + statusText: event.statusText, + url: event.url || '', + responseType: req.responseType, + }); + } + }), + ); +} + +function getHeadersMap(headers: HttpHeaders): Record { + const headersMap: Record = {}; + + for (const key of headers.keys()) { + const values = headers.getAll(key); + if (values !== null) { + headersMap[key] = values; + } + } + + return headersMap; +} + +function makeCacheKey(request: HttpRequest): StateKey { + // make the params encoded same as a url so it's easy to identify + const {params, method, responseType, url} = request; + const encodedParams = params.keys().sort().map((k) => `${k}=${params.getAll(k)}`).join('&'); + const key = method + '.' + responseType + '.' + url + '?' + encodedParams; + + const hash = generateHash(key); + + return makeStateKey(hash); +} + +/** + * A method that returns a hash representation of a string using a variant of DJB2 hash + * algorithm. + * + * This is the same hashing logic that is used to generate component ids. + */ +function generateHash(value: string): string { + let hash = 0; + + for (const char of value) { + hash = Math.imul(31, hash) + char.charCodeAt(0) << 0; + } + + // Force positive number hash. + // 2147483647 = equivalent of Integer.MAX_VALUE. + hash += 2147483647 + 1; + + return hash.toString(); +} + +/** + * Returns the DI providers needed to enable HTTP transfer cache. + * + * By default, when using server rendering, requests are performed twice: once on the server and + * other one on the browser. + * + * When these providers are added, requests performed on the server are cached and reused during the + * bootstrapping of the application in the browser thus avoiding duplicate requests and reducing + * load time. + * + */ +export function withHttpTransferCache(): Provider[] { + return [ + {provide: CACHE_STATE, useValue: {isCacheActive: true}}, { + provide: HTTP_ROOT_INTERCEPTOR_FNS, + useValue: transferCacheInterceptorFn, + multi: true, + deps: [TransferState, CACHE_STATE] + }, + { + provide: APP_BOOTSTRAP_LISTENER, + multi: true, + useFactory: () => { + const appRef = inject(ApplicationRef); + const cacheState = inject(CACHE_STATE); + const pendingTasks = inject(InitialRenderPendingTasks); + + return () => { + const isStablePromise = appRef.isStable.pipe(first((isStable) => isStable)).toPromise(); + const pendingTasksPromise = pendingTasks.whenAllTasksComplete; + + Promise.allSettled([isStablePromise, pendingTasksPromise]).then(() => { + cacheState.isCacheActive = false; + }); + }; + }, + deps: [ApplicationRef, CACHE_STATE, InitialRenderPendingTasks] + } + ]; +} diff --git a/packages/common/http/test/BUILD.bazel b/packages/common/http/test/BUILD.bazel index d3b6516f5d146..4062110d28081 100644 --- a/packages/common/http/test/BUILD.bazel +++ b/packages/common/http/test/BUILD.bazel @@ -21,6 +21,7 @@ ts_library( "//packages/common/http/testing", "//packages/core", "//packages/core/testing", + "//packages/private/testing", "@npm//rxjs", ], ) diff --git a/packages/common/http/test/transfer_cache_spec.ts b/packages/common/http/test/transfer_cache_spec.ts new file mode 100644 index 0000000000000..a6ba4506b35dd --- /dev/null +++ b/packages/common/http/test/transfer_cache_spec.ts @@ -0,0 +1,128 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {DOCUMENT} from '@angular/common'; +import {ApplicationRef, Component} from '@angular/core'; +import {makeStateKey, TransferState} from '@angular/core/src/transfer_state'; +import {fakeAsync, flush, TestBed} from '@angular/core/testing'; +import {withBody} from '@angular/private/testing'; +import {BehaviorSubject} from 'rxjs'; + +import {HttpClient, provideHttpClient} from '../public_api'; +import {withHttpTransferCache} from '../src/transfer_cache'; +import {HttpTestingController, provideHttpClientTesting} from '../testing'; + +describe('TransferCache', () => { + @Component({selector: 'test-app-http', template: 'hello'}) + class SomeComponent { + } + + describe('withHttpTransferCache', () => { + let isStable: BehaviorSubject; + + function makeRequestAndExpectOne(url: string, body: string): void { + TestBed.inject(HttpClient).get(url).subscribe(); + TestBed.inject(HttpTestingController).expectOne(url).flush(body); + } + + function makeRequestAndExpectNone(url: string): void { + TestBed.inject(HttpClient).get(url).subscribe(); + TestBed.inject(HttpTestingController).expectNone(url); + } + + beforeEach(withBody('', () => { + TestBed.resetTestingModule(); + isStable = new BehaviorSubject(false); + + class ApplicationRefPathed extends ApplicationRef { + override isStable = new BehaviorSubject(false); + } + + TestBed.configureTestingModule({ + declarations: [SomeComponent], + providers: [ + {provide: DOCUMENT, useFactory: () => document}, + {provide: ApplicationRef, useClass: ApplicationRefPathed}, + {provide: ApplicationRef, useClass: ApplicationRefPathed}, + withHttpTransferCache(), + provideHttpClient(), + provideHttpClientTesting(), + ], + }); + + const appRef = TestBed.inject(ApplicationRef); + appRef.bootstrap(SomeComponent); + isStable = appRef.isStable as BehaviorSubject; + })); + + it('should store HTTP calls in cache when application is not stable', () => { + makeRequestAndExpectOne('/test', 'foo'); + const key = makeStateKey('432906284'); + const transferState = TestBed.inject(TransferState); + expect(transferState.get(key, null)).toEqual(jasmine.objectContaining({body: 'foo'})); + }); + + it('should stop storing HTTP calls in `TransferState` after application becomes stable', + fakeAsync(() => { + makeRequestAndExpectOne('/test-1', 'foo'); + makeRequestAndExpectOne('/test-2', 'buzz'); + + isStable.next(true); + + flush(); + + makeRequestAndExpectOne('/test-3', 'bar'); + + const transferState = TestBed.inject(TransferState); + expect(JSON.parse(transferState.toJson()) as Record).toEqual({ + '3706062792': { + 'body': 'foo', + 'headers': {}, + 'status': 200, + 'statusText': 'OK', + 'url': '/test-1', + 'responseType': 'json' + }, + '3706062823': { + 'body': 'buzz', + 'headers': {}, + 'status': 200, + 'statusText': 'OK', + 'url': '/test-2', + 'responseType': 'json' + } + }); + })); + + it(`should use calls from cache when present and application is not stable`, () => { + makeRequestAndExpectOne('/test-1', 'foo'); + // Do the same call, this time it should served from cache. + makeRequestAndExpectNone('/test-1'); + }); + + it(`should not use calls from cache when present and application is stable`, fakeAsync(() => { + makeRequestAndExpectOne('/test-1', 'foo'); + + isStable.next(true); + flush(); + // Do the same call, this time it should go through as application is stable. + makeRequestAndExpectOne('/test-1', 'foo'); + })); + + it(`should differentiate calls with different parameters`, async () => { + // make calls with different parameters. All of which should be saved in the state. + makeRequestAndExpectOne('/test-1?foo=1', 'foo'); + makeRequestAndExpectOne('/test-1', 'foo'); + makeRequestAndExpectOne('/test-1?foo=2', 'buzz'); + + makeRequestAndExpectNone('/test-1?foo=1'); + await expectAsync(TestBed.inject(HttpClient).get('/test-1?foo=1').toPromise()) + .toBeResolvedTo('foo'); + }); + }); +});