From ed16c0d086bb2e183b3d25131840015ae05fca9c Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Tue, 20 Oct 2026 10:09:52 +0200 Subject: [PATCH] JCU: SSR idle timer only in the browser, drain xhr2 redirect bodies Port of TUL PR 1597 (commit 6a74ddb854), written for the 9.x code. On the server the idleness timer kept every finished render in memory until it fired, and redirect responses that xhr2 follows kept their socket busy. Co-Authored-By: Claude Fable 5.1 --- src/app/core/auth/auth.effects.spec.ts | 24 +++++++++++++++++++++ src/app/core/auth/auth.effects.ts | 9 +++++++- src/app/core/services/server-xhr.service.ts | 13 +++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/app/core/auth/auth.effects.spec.ts b/src/app/core/auth/auth.effects.spec.ts index 996db991bcc..c4ee9734b3d 100644 --- a/src/app/core/auth/auth.effects.spec.ts +++ b/src/app/core/auth/auth.effects.spec.ts @@ -22,6 +22,7 @@ import { throwError as observableThrow, } from 'rxjs'; +import { environment } from '../../../environments/environment'; import { AppState, storeModuleConfig, @@ -52,6 +53,7 @@ import { RetrieveAuthMethodsErrorAction, RetrieveAuthMethodsSuccessAction, RetrieveTokenAction, + SetUserAsIdleAction, } from './auth.actions'; import { AuthEffects } from './auth.effects'; import { authReducer } from './auth.reducer'; @@ -107,6 +109,28 @@ describe('AuthEffects', () => { store = TestBed.inject(Store as any); }); + describe('trackIdleness$', () => { + let emitted: any[]; + + beforeEach(() => { + emitted = []; + actions = of({ type: StoreActionTypes.REHYDRATE }); + }); + + it('should set the user as idle when the timer runs out in the browser', fakeAsync(() => { + authEffects.trackIdleness$.subscribe((action) => emitted.push(action)); + tick(environment.auth.ui.timeUntilIdle); + expect(emitted).toEqual([new SetUserAsIdleAction()]); + })); + + it('should not start the timer on the server', fakeAsync(() => { + (authEffects as any).platformId = 'server'; + authEffects.trackIdleness$.subscribe((action) => emitted.push(action)); + tick(environment.auth.ui.timeUntilIdle); + expect(emitted).toEqual([]); + })); + }); + describe('authenticate$', () => { describe('when credentials are correct', () => { it('should return a AUTHENTICATE_SUCCESS action in response to a AUTHENTICATE action', () => { diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts index 89b4df80ece..9967fd3f879 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -1,6 +1,9 @@ +import { isPlatformBrowser } from '@angular/common'; import { + Inject, Injectable, NgZone, + PLATFORM_ID, Type, } from '@angular/core'; // import @ngrx @@ -304,6 +307,8 @@ export class AuthEffects { * @method trackIdleness */ public trackIdleness$: Observable = createEffect(() => this.actions$.pipe( + // Only in the browser: on the server this timer keeps an unfinished render in memory until it fires + filter(() => isPlatformBrowser(this.platformId)), filter((action: Action) => !IDLE_TIMER_IGNORE_TYPES.includes(action.type)), // Using switchMap the effect will stop subscribing to the previous timer if a new action comes // in, and start a new timer @@ -323,11 +328,13 @@ export class AuthEffects { * @param {AuthorizationDataService} authorizationsService * @param {AuthService} authService * @param {Store} store + * @param {object} platformId */ constructor(private actions$: Actions, private zone: NgZone, private authorizationsService: AuthorizationDataService, private authService: AuthService, - private store: Store) { + private store: Store, + @Inject(PLATFORM_ID) private platformId: object) { } } diff --git a/src/app/core/services/server-xhr.service.ts b/src/app/core/services/server-xhr.service.ts index af59172c7e7..5816db19ab0 100644 --- a/src/app/core/services/server-xhr.service.ts +++ b/src/app/core/services/server-xhr.service.ts @@ -33,6 +33,9 @@ const agentOptions: HttpAgentOptions = { const httpAgent = new HttpAgent(agentOptions); const httpsAgent = new HttpsAgent(agentOptions); +// The status codes xhr2 follows on its own. +const REDIRECT_STATUSES = [301, 302, 303, 307, 308]; + /** * Constructs the XMLHttpRequest instances used for all HttpClient requests. * Emulated by https://github.com/pwnall/node-xhr2 on the server. @@ -43,6 +46,8 @@ const httpsAgent = new HttpsAgent(agentOptions); * This was added to be able to perform a working XSRF request from the node server, as it needs to set a cookie for the XSRF token. * - Override NodeJS HTTP(S) agents to keep sessions alive between requests. * This improves SSR performance by reducing REST request overhead on the server. + * - Read and discard the body of redirect responses that xhr2 follows. Otherwise their socket stays busy + * until the REST server closes it, and keeps the SSR app that made the request in memory. * * Note that this must be provided in ServerAppModule; * it doesn't work when added as a Universal engine provider. @@ -60,6 +65,14 @@ export class ServerXhrService implements XhrFactory { httpsAgent, }); + const onHttpResponse = (xhr as any)._onHttpResponse; + (xhr as any)._onHttpResponse = function(request, response) { + if (REDIRECT_STATUSES.includes(response.statusCode)) { + response.resume(); + } + return onHttpResponse.call(this, request, response); + }; + return xhr; } }