From 7569d0dd6777609ab3a930e5f8b42b7e568aeaa6 Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Wed, 21 Aug 2024 12:25:33 +0000 Subject: [PATCH] feat(@angular/ssr): improve handling of aborted requests in `AngularServerApp` Introduce support for handling request signal abortions in the `AngularServerApp`. This is particularly useful in the development server integration where a 30-second timeout is enforced for requests/responses. --- packages/angular/ssr/private_export.ts | 2 +- packages/angular/ssr/src/app.ts | 115 +++++++++++++++++++++++-- packages/angular/ssr/src/render.ts | 95 -------------------- packages/angular/ssr/test/app_spec.ts | 16 +++- 4 files changed, 123 insertions(+), 105 deletions(-) delete mode 100644 packages/angular/ssr/src/render.ts diff --git a/packages/angular/ssr/private_export.ts b/packages/angular/ssr/private_export.ts index 1246dc8991e1..4d53ca3e58f1 100644 --- a/packages/angular/ssr/private_export.ts +++ b/packages/angular/ssr/private_export.ts @@ -6,9 +6,9 @@ * found in the LICENSE file at https://angular.dev/license */ -export { ServerRenderContext as ɵServerRenderContext } from './src/render'; export { getRoutesFromAngularRouterConfig as ɵgetRoutesFromAngularRouterConfig } from './src/routes/ng-routes'; export { + ServerRenderContext as ɵServerRenderContext, getOrCreateAngularServerApp as ɵgetOrCreateAngularServerApp, destroyAngularServerApp as ɵdestroyAngularServerApp, } from './src/app'; diff --git a/packages/angular/ssr/src/app.ts b/packages/angular/ssr/src/app.ts index 81f9fdd66628..bd5bb91bfa47 100644 --- a/packages/angular/ssr/src/app.ts +++ b/packages/angular/ssr/src/app.ts @@ -6,11 +6,24 @@ * found in the LICENSE file at https://angular.dev/license */ +import { StaticProvider, ɵConsole, ɵresetCompiledComponents } from '@angular/core'; +import { ɵSERVER_CONTEXT as SERVER_CONTEXT } from '@angular/platform-server'; import { ServerAssets } from './assets'; +import { Console } from './console'; import { Hooks } from './hooks'; import { getAngularAppManifest } from './manifest'; -import { ServerRenderContext, render } from './render'; import { ServerRouter } from './routes/router'; +import { REQUEST, REQUEST_CONTEXT, RESPONSE_INIT } from './tokens'; +import { renderAngular } from './utils/ng'; + +/** + * Enum representing the different contexts in which server rendering can occur. + */ +export enum ServerRenderContext { + SSR = 'ssr', + SSG = 'ssg', + AppShell = 'app-shell', +} /** * Represents a locale-specific Angular server application managed by the server application engine. @@ -26,15 +39,13 @@ export class AngularServerApp { /** * The manifest associated with this server application. - * @internal */ - readonly manifest = getAngularAppManifest(); + private readonly manifest = getAngularAppManifest(); /** * An instance of ServerAsset that handles server-side asset. - * @internal */ - readonly assets = new ServerAssets(this.manifest); + private readonly assets = new ServerAssets(this.manifest); /** * The router instance used for route matching and handling. @@ -52,7 +63,44 @@ export class AngularServerApp { * * @returns A promise that resolves to the HTTP response object resulting from the rendering, or null if no match is found. */ - async render( + render( + request: Request, + requestContext?: unknown, + serverContext: ServerRenderContext = ServerRenderContext.SSR, + ): Promise { + return Promise.race([ + this.createAbortPromise(request), + this.handleRendering(request, requestContext, serverContext), + ]); + } + + /** + * Creates a promise that rejects when the request is aborted. + * + * @param request - The HTTP request to monitor for abortion. + * @returns A promise that never resolves but rejects with an `AbortError` if the request is aborted. + */ + private createAbortPromise(request: Request): Promise { + return new Promise((_, reject) => { + request.signal.onabort = function () { + const abortError = new Error(`Request for: ${request.url} was aborted.\n${this.reason}`); + abortError.name = 'AbortError'; + reject(abortError); + }; + }); + } + + /** + * Handles the server-side rendering process for the given HTTP request. + * This method matches the request URL to a route and performs rendering if a matching route is found. + * + * @param request - The incoming HTTP request to be processed. + * @param requestContext - Optional additional context for rendering, such as request metadata. + * @param serverContext - The rendering context. Defaults to server-side rendering (SSR). + * + * @returns A promise that resolves to the rendered response, or null if no matching route is found. + */ + private async handleRendering( request: Request, requestContext?: unknown, serverContext: ServerRenderContext = ServerRenderContext.SSR, @@ -73,7 +121,60 @@ export class AngularServerApp { return Response.redirect(new URL(redirectTo, url), 302); } - return render(this, request, serverContext, requestContext); + const isSsrMode = serverContext === ServerRenderContext.SSR; + const responseInit: ResponseInit = {}; + const platformProviders: StaticProvider = [ + { + provide: SERVER_CONTEXT, + useValue: serverContext, + }, + ]; + + if (isSsrMode) { + platformProviders.push( + { + provide: REQUEST, + useValue: request, + }, + { + provide: REQUEST_CONTEXT, + useValue: requestContext, + }, + { + provide: RESPONSE_INIT, + useValue: responseInit, + }, + ); + } + + if (typeof ngDevMode === 'undefined' || ngDevMode) { + // Need to clean up GENERATED_COMP_IDS map in `@angular/core`. + // Otherwise an incorrect component ID generation collision detected warning will be displayed in development. + // See: https://github.com/angular/angular-cli/issues/25924 + ɵresetCompiledComponents(); + } + + // An Angular Console Provider that does not print a set of predefined logs. + platformProviders.push({ + provide: ɵConsole, + // Using `useClass` would necessitate decorating `Console` with `@Injectable`, + // which would require switching from `ts_library` to `ng_module`. This change + // would also necessitate various patches of `@angular/bazel` to support ESM. + useFactory: () => new Console(), + }); + + const { manifest, hooks, assets } = this; + + let html = await assets.getIndexServerHtml(); + // Skip extra microtask if there are no pre hooks. + if (hooks.has('html:transform:pre')) { + html = await hooks.run('html:transform:pre', { html }); + } + + return new Response( + await renderAngular(html, manifest.bootstrap(), new URL(request.url), platformProviders), + responseInit, + ); } } diff --git a/packages/angular/ssr/src/render.ts b/packages/angular/ssr/src/render.ts deleted file mode 100644 index 4dee1195d249..000000000000 --- a/packages/angular/ssr/src/render.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * @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.dev/license - */ - -import { StaticProvider, ɵConsole, ɵresetCompiledComponents } from '@angular/core'; -import { ɵSERVER_CONTEXT as SERVER_CONTEXT } from '@angular/platform-server'; -import type { AngularServerApp } from './app'; -import { Console } from './console'; -import { REQUEST, REQUEST_CONTEXT, RESPONSE_INIT } from './tokens'; -import { renderAngular } from './utils/ng'; - -/** - * Enum representing the different contexts in which server rendering can occur. - */ -export enum ServerRenderContext { - SSR = 'ssr', - SSG = 'ssg', - AppShell = 'app-shell', -} - -/** - * Renders an Angular server application to produce a response for the given HTTP request. - * Supports server-side rendering (SSR), static site generation (SSG), or app shell rendering. - * - * @param app - The server application instance to render. - * @param request - The incoming HTTP request object. - * @param serverContext - Context specifying the rendering mode. - * @param requestContext - Optional additional context for the request, such as metadata. - * @returns A promise that resolves to a response object representing the rendered content. - */ -export async function render( - app: AngularServerApp, - request: Request, - serverContext: ServerRenderContext, - requestContext?: unknown, -): Promise { - const isSsrMode = serverContext === ServerRenderContext.SSR; - const responseInit: ResponseInit = {}; - const platformProviders: StaticProvider = [ - { - provide: SERVER_CONTEXT, - useValue: serverContext, - }, - ]; - - if (isSsrMode) { - platformProviders.push( - { - provide: REQUEST, - useValue: request, - }, - { - provide: REQUEST_CONTEXT, - useValue: requestContext, - }, - { - provide: RESPONSE_INIT, - useValue: responseInit, - }, - ); - } - - if (typeof ngDevMode === 'undefined' || ngDevMode) { - // Need to clean up GENERATED_COMP_IDS map in `@angular/core`. - // Otherwise an incorrect component ID generation collision detected warning will be displayed in development. - // See: https://github.com/angular/angular-cli/issues/25924 - ɵresetCompiledComponents(); - } - - // An Angular Console Provider that does not print a set of predefined logs. - platformProviders.push({ - provide: ɵConsole, - // Using `useClass` would necessitate decorating `Console` with `@Injectable`, - // which would require switching from `ts_library` to `ng_module`. This change - // would also necessitate various patches of `@angular/bazel` to support ESM. - useFactory: () => new Console(), - }); - - const { manifest, hooks, assets } = app; - - let html = await assets.getIndexServerHtml(); - // Skip extra microtask if there are no pre hooks. - if (hooks.has('html:transform:pre')) { - html = await hooks.run('html:transform:pre', { html }); - } - - return new Response( - await renderAngular(html, manifest.bootstrap(), new URL(request.url), platformProviders), - responseInit, - ); -} diff --git a/packages/angular/ssr/test/app_spec.ts b/packages/angular/ssr/test/app_spec.ts index 85bb0b6024fd..70cfd23809c7 100644 --- a/packages/angular/ssr/test/app_spec.ts +++ b/packages/angular/ssr/test/app_spec.ts @@ -12,8 +12,7 @@ import '@angular/compiler'; /* eslint-enable import/no-unassigned-import */ import { Component } from '@angular/core'; -import { AngularServerApp, destroyAngularServerApp } from '../src/app'; -import { ServerRenderContext } from '../src/render'; +import { AngularServerApp, ServerRenderContext, destroyAngularServerApp } from '../src/app'; import { setAngularAppTestingManifest } from './testing-utils'; describe('AngularServerApp', () => { @@ -81,5 +80,18 @@ describe('AngularServerApp', () => { expect(response?.headers.get('location')).toContain('http://localhost/home'); expect(response?.status).toBe(302); }); + + it('should handle request abortion gracefully', async () => { + // Create a request with a signal that will automatically abort after 1ms + const controller = new AbortController(); + const request = new Request('http://localhost/home', { signal: controller.signal }); + + // Schedule the abortion of the request in the next microtask + queueMicrotask(() => { + controller.abort(); + }); + + await expectAsync(app.render(request)).toBeRejectedWithError(/Request for: .+ was aborted/); + }); }); });