Skip to content

Commit 9692a90

Browse files
committed
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.
1 parent d6a3403 commit 9692a90

File tree

5 files changed

+129
-107
lines changed

5 files changed

+129
-107
lines changed

goldens/circular-deps/packages.json

+1-2
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,5 @@
2828
"packages/angular/ssr/src/app.ts",
2929
"packages/angular/ssr/src/assets.ts",
3030
"packages/angular/ssr/src/manifest.ts"
31-
],
32-
["packages/angular/ssr/src/app.ts", "packages/angular/ssr/src/render.ts"]
31+
]
3332
]

packages/angular/ssr/private_export.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
export { ServerRenderContext as ɵServerRenderContext } from './src/render';
109
export { getRoutesFromAngularRouterConfig as ɵgetRoutesFromAngularRouterConfig } from './src/routes/ng-routes';
1110
export {
11+
ServerRenderContext as ɵServerRenderContext,
1212
getOrCreateAngularServerApp as ɵgetOrCreateAngularServerApp,
1313
destroyAngularServerApp as ɵdestroyAngularServerApp,
1414
} from './src/app';

packages/angular/ssr/src/app.ts

+114-7
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,24 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9+
import { StaticProvider, ɵConsole, ɵresetCompiledComponents } from '@angular/core';
10+
import { ɵSERVER_CONTEXT as SERVER_CONTEXT } from '@angular/platform-server';
911
import { ServerAssets } from './assets';
12+
import { Console } from './console';
1013
import { Hooks } from './hooks';
1114
import { getAngularAppManifest } from './manifest';
12-
import { ServerRenderContext, render } from './render';
1315
import { ServerRouter } from './routes/router';
16+
import { REQUEST, REQUEST_CONTEXT, RESPONSE_INIT } from './tokens';
17+
import { renderAngular } from './utils/ng';
18+
19+
/**
20+
* Enum representing the different contexts in which server rendering can occur.
21+
*/
22+
export enum ServerRenderContext {
23+
SSR = 'ssr',
24+
SSG = 'ssg',
25+
AppShell = 'app-shell',
26+
}
1427

1528
/**
1629
* Represents a locale-specific Angular server application managed by the server application engine.
@@ -26,15 +39,13 @@ export class AngularServerApp {
2639

2740
/**
2841
* The manifest associated with this server application.
29-
* @internal
3042
*/
31-
readonly manifest = getAngularAppManifest();
43+
private readonly manifest = getAngularAppManifest();
3244

3345
/**
3446
* An instance of ServerAsset that handles server-side asset.
35-
* @internal
3647
*/
37-
readonly assets = new ServerAssets(this.manifest);
48+
private readonly assets = new ServerAssets(this.manifest);
3849

3950
/**
4051
* The router instance used for route matching and handling.
@@ -52,7 +63,50 @@ export class AngularServerApp {
5263
*
5364
* @returns A promise that resolves to the HTTP response object resulting from the rendering, or null if no match is found.
5465
*/
55-
async render(
66+
render(
67+
request: Request,
68+
requestContext?: unknown,
69+
serverContext: ServerRenderContext = ServerRenderContext.SSR,
70+
): Promise<Response | null> {
71+
return Promise.race([
72+
this.createAbortPromise(request),
73+
this.handleRendering(request, requestContext, serverContext),
74+
]);
75+
}
76+
77+
/**
78+
* Creates a promise that rejects when the request is aborted.
79+
*
80+
* @param request - The HTTP request to monitor for abortion.
81+
* @returns A promise that never resolves but rejects with an `AbortError` if the request is aborted.
82+
*/
83+
private createAbortPromise(request: Request): Promise<never> {
84+
return new Promise<never>((_, reject) => {
85+
request.signal.addEventListener(
86+
'abort',
87+
() => {
88+
const abortError = new Error(
89+
`Request for: ${request.url} was aborted.\n${request.signal.reason}`,
90+
);
91+
abortError.name = 'AbortError';
92+
reject(abortError);
93+
},
94+
{ once: true },
95+
);
96+
});
97+
}
98+
99+
/**
100+
* Handles the server-side rendering process for the given HTTP request.
101+
* This method matches the request URL to a route and performs rendering if a matching route is found.
102+
*
103+
* @param request - The incoming HTTP request to be processed.
104+
* @param requestContext - Optional additional context for rendering, such as request metadata.
105+
* @param serverContext - The rendering context. Defaults to server-side rendering (SSR).
106+
*
107+
* @returns A promise that resolves to the rendered response, or null if no matching route is found.
108+
*/
109+
private async handleRendering(
56110
request: Request,
57111
requestContext?: unknown,
58112
serverContext: ServerRenderContext = ServerRenderContext.SSR,
@@ -73,7 +127,60 @@ export class AngularServerApp {
73127
return Response.redirect(new URL(redirectTo, url), 302);
74128
}
75129

76-
return render(this, request, serverContext, requestContext);
130+
const isSsrMode = serverContext === ServerRenderContext.SSR;
131+
const responseInit: ResponseInit = {};
132+
const platformProviders: StaticProvider = [
133+
{
134+
provide: SERVER_CONTEXT,
135+
useValue: serverContext,
136+
},
137+
];
138+
139+
if (isSsrMode) {
140+
platformProviders.push(
141+
{
142+
provide: REQUEST,
143+
useValue: request,
144+
},
145+
{
146+
provide: REQUEST_CONTEXT,
147+
useValue: requestContext,
148+
},
149+
{
150+
provide: RESPONSE_INIT,
151+
useValue: responseInit,
152+
},
153+
);
154+
}
155+
156+
if (typeof ngDevMode === 'undefined' || ngDevMode) {
157+
// Need to clean up GENERATED_COMP_IDS map in `@angular/core`.
158+
// Otherwise an incorrect component ID generation collision detected warning will be displayed in development.
159+
// See: https://github.com/angular/angular-cli/issues/25924
160+
ɵresetCompiledComponents();
161+
}
162+
163+
// An Angular Console Provider that does not print a set of predefined logs.
164+
platformProviders.push({
165+
provide: ɵConsole,
166+
// Using `useClass` would necessitate decorating `Console` with `@Injectable`,
167+
// which would require switching from `ts_library` to `ng_module`. This change
168+
// would also necessitate various patches of `@angular/bazel` to support ESM.
169+
useFactory: () => new Console(),
170+
});
171+
172+
const { manifest, hooks, assets } = this;
173+
174+
let html = await assets.getIndexServerHtml();
175+
// Skip extra microtask if there are no pre hooks.
176+
if (hooks.has('html:transform:pre')) {
177+
html = await hooks.run('html:transform:pre', { html });
178+
}
179+
180+
return new Response(
181+
await renderAngular(html, manifest.bootstrap(), new URL(request.url), platformProviders),
182+
responseInit,
183+
);
77184
}
78185
}
79186

packages/angular/ssr/src/render.ts

-95
This file was deleted.

packages/angular/ssr/test/app_spec.ts

+13-2
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,7 @@ import '@angular/compiler';
1212
/* eslint-enable import/no-unassigned-import */
1313

1414
import { Component } from '@angular/core';
15-
import { AngularServerApp, destroyAngularServerApp } from '../src/app';
16-
import { ServerRenderContext } from '../src/render';
15+
import { AngularServerApp, ServerRenderContext, destroyAngularServerApp } from '../src/app';
1716
import { setAngularAppTestingManifest } from './testing-utils';
1817

1918
describe('AngularServerApp', () => {
@@ -81,5 +80,17 @@ describe('AngularServerApp', () => {
8180
expect(response?.headers.get('location')).toContain('http://localhost/home');
8281
expect(response?.status).toBe(302);
8382
});
83+
84+
it('should handle request abortion gracefully', async () => {
85+
const controller = new AbortController();
86+
const request = new Request('http://localhost/home', { signal: controller.signal });
87+
88+
// Schedule the abortion of the request in the next microtask
89+
queueMicrotask(() => {
90+
controller.abort();
91+
});
92+
93+
await expectAsync(app.render(request)).toBeRejectedWithError(/Request for: .+ was aborted/);
94+
});
8495
});
8596
});

0 commit comments

Comments
 (0)