Skip to content

Commit

Permalink
feat(@angular/ssr): improve handling of aborted requests in `AngularS…
Browse files Browse the repository at this point in the history
…erverApp`

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.
  • Loading branch information
alan-agius4 committed Aug 21, 2024
1 parent d6a3403 commit 9692a90
Show file tree
Hide file tree
Showing 5 changed files with 129 additions and 107 deletions.
3 changes: 1 addition & 2 deletions goldens/circular-deps/packages.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,5 @@
"packages/angular/ssr/src/app.ts",
"packages/angular/ssr/src/assets.ts",
"packages/angular/ssr/src/manifest.ts"
],
["packages/angular/ssr/src/app.ts", "packages/angular/ssr/src/render.ts"]
]
]
2 changes: 1 addition & 1 deletion packages/angular/ssr/private_export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
121 changes: 114 additions & 7 deletions packages/angular/ssr/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -52,7 +63,50 @@ 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<Response | null> {
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<never> {
return new Promise<never>((_, reject) => {
request.signal.addEventListener(
'abort',
() => {
const abortError = new Error(
`Request for: ${request.url} was aborted.\n${request.signal.reason}`,
);
abortError.name = 'AbortError';
reject(abortError);
},
{ once: true },
);
});
}

/**
* 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,
Expand All @@ -73,7 +127,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,
);
}
}

Expand Down
95 changes: 0 additions & 95 deletions packages/angular/ssr/src/render.ts

This file was deleted.

15 changes: 13 additions & 2 deletions packages/angular/ssr/test/app_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -81,5 +80,17 @@ describe('AngularServerApp', () => {
expect(response?.headers.get('location')).toContain('http://localhost/home');
expect(response?.status).toBe(302);
});

it('should handle request abortion gracefully', async () => {
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/);
});
});
});

0 comments on commit 9692a90

Please sign in to comment.