Skip to content

Commit f26e1b4

Browse files
alan-agius4clydin
authored andcommitted
fix(@angular/build): add timeout to route extraction
This commit introduces a 30-second timeout for route extraction. (cherry picked from commit aed726f)
1 parent 8b83650 commit f26e1b4

File tree

8 files changed

+218
-123
lines changed

8 files changed

+218
-123
lines changed

Diff for: packages/angular/build/src/utils/server-rendering/routes-extractor-worker.ts

+6-6
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,12 @@ async function extractRoutes(): Promise<RoutersExtractorWorkerResult> {
3535
const { ɵextractRoutesAndCreateRouteTree: extractRoutesAndCreateRouteTree } =
3636
await loadEsmModuleFromMemory('./main.server.mjs');
3737

38-
const { routeTree, appShellRoute, errors } = await extractRoutesAndCreateRouteTree(
39-
serverURL,
40-
undefined /** manifest */,
41-
outputMode !== undefined /** invokeGetPrerenderParams */,
42-
outputMode === OutputMode.Server /** includePrerenderFallbackRoutes */,
43-
);
38+
const { routeTree, appShellRoute, errors } = await extractRoutesAndCreateRouteTree({
39+
url: serverURL,
40+
invokeGetPrerenderParams: outputMode !== undefined,
41+
includePrerenderFallbackRoutes: outputMode === OutputMode.Server,
42+
signal: AbortSignal.timeout(30_000),
43+
});
4444

4545
return {
4646
errors,

Diff for: packages/angular/ssr/src/app.ts

+5-26
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { sha256 } from './utils/crypto';
2424
import { InlineCriticalCssProcessor } from './utils/inline-critical-css';
2525
import { LRUCache } from './utils/lru-cache';
2626
import { AngularBootstrap, renderAngular } from './utils/ng';
27+
import { promiseWithAbort } from './utils/promise';
2728
import {
2829
buildPathWithParams,
2930
joinUrlParts,
@@ -182,10 +183,11 @@ export class AngularServerApp {
182183
}
183184
}
184185

185-
return Promise.race([
186-
this.waitForRequestAbort(request),
186+
return promiseWithAbort(
187187
this.handleRendering(request, matchedRoute, requestContext),
188-
]);
188+
request.signal,
189+
`Request for: ${request.url}`,
190+
);
189191
}
190192

191193
/**
@@ -353,29 +355,6 @@ export class AngularServerApp {
353355

354356
return new Response(html, responseInit);
355357
}
356-
357-
/**
358-
* Returns a promise that rejects if the request is aborted.
359-
*
360-
* @param request - The HTTP request object being monitored for abortion.
361-
* @returns A promise that never resolves and rejects with an `AbortError`
362-
* if the request is aborted.
363-
*/
364-
private waitForRequestAbort(request: Request): Promise<never> {
365-
return new Promise<never>((_, reject) => {
366-
request.signal.addEventListener(
367-
'abort',
368-
() => {
369-
const abortError = new Error(
370-
`Request for: ${request.url} was aborted.\n${request.signal.reason}`,
371-
);
372-
abortError.name = 'AbortError';
373-
reject(abortError);
374-
},
375-
{ once: true },
376-
);
377-
});
378-
}
379358
}
380359

381360
let angularServerApp: AngularServerApp | undefined;

Diff for: packages/angular/ssr/src/routes/ng-routes.ts

+62-42
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { ServerAssets } from '../assets';
1414
import { Console } from '../console';
1515
import { AngularAppManifest, getAngularAppManifest } from '../manifest';
1616
import { AngularBootstrap, isNgModule } from '../utils/ng';
17+
import { promiseWithAbort } from '../utils/promise';
1718
import { addTrailingSlash, joinUrlParts, stripLeadingSlash } from '../utils/url';
1819
import {
1920
PrerenderFallback,
@@ -521,60 +522,79 @@ export async function getRoutesFromAngularRouterConfig(
521522
* Asynchronously extracts routes from the Angular application configuration
522523
* and creates a `RouteTree` to manage server-side routing.
523524
*
524-
* @param url - The URL for server-side rendering. The URL is used to configure `ServerPlatformLocation`. This configuration is crucial
525-
* for ensuring that API requests for relative paths succeed, which is essential for accurate route extraction.
526-
* See:
527-
* - https://github.com/angular/angular/blob/d608b857c689d17a7ffa33bbb510301014d24a17/packages/platform-server/src/location.ts#L51
528-
* - https://github.com/angular/angular/blob/6882cc7d9eed26d3caeedca027452367ba25f2b9/packages/platform-server/src/http.ts#L44
529-
* @param manifest - An optional `AngularAppManifest` that contains the application's routing and configuration details.
530-
* If not provided, the default manifest is retrieved using `getAngularAppManifest()`.
531-
* @param invokeGetPrerenderParams - A boolean flag indicating whether to invoke `getPrerenderParams` for parameterized SSG routes
532-
* to handle prerendering paths. Defaults to `false`.
533-
* @param includePrerenderFallbackRoutes - A flag indicating whether to include fallback routes in the result. Defaults to `true`.
525+
* @param options - An object containing the following options:
526+
* - `url`: The URL for server-side rendering. The URL is used to configure `ServerPlatformLocation`. This configuration is crucial
527+
* for ensuring that API requests for relative paths succeed, which is essential for accurate route extraction.
528+
* See:
529+
* - https://github.com/angular/angular/blob/d608b857c689d17a7ffa33bbb510301014d24a17/packages/platform-server/src/location.ts#L51
530+
* - https://github.com/angular/angular/blob/6882cc7d9eed26d3caeedca027452367ba25f2b9/packages/platform-server/src/http.ts#L44
531+
* - `manifest`: An optional `AngularAppManifest` that contains the application's routing and configuration details.
532+
* If not provided, the default manifest is retrieved using `getAngularAppManifest()`.
533+
* - `invokeGetPrerenderParams`: A boolean flag indicating whether to invoke `getPrerenderParams` for parameterized SSG routes
534+
* to handle prerendering paths. Defaults to `false`.
535+
* - `includePrerenderFallbackRoutes`: A flag indicating whether to include fallback routes in the result. Defaults to `true`.
536+
* - `signal`: An optional `AbortSignal` that can be used to abort the operation.
534537
*
535538
* @returns A promise that resolves to an object containing:
536539
* - `routeTree`: A populated `RouteTree` containing all extracted routes from the Angular application.
537540
* - `appShellRoute`: The specified route for the app-shell, if configured.
538541
* - `errors`: An array of strings representing any errors encountered during the route extraction process.
539542
*/
540-
export async function extractRoutesAndCreateRouteTree(
541-
url: URL,
542-
manifest: AngularAppManifest = getAngularAppManifest(),
543-
invokeGetPrerenderParams = false,
544-
includePrerenderFallbackRoutes = true,
545-
): Promise<{ routeTree: RouteTree; appShellRoute?: string; errors: string[] }> {
546-
const routeTree = new RouteTree();
547-
const document = await new ServerAssets(manifest).getIndexServerHtml().text();
548-
const bootstrap = await manifest.bootstrap();
549-
const { baseHref, appShellRoute, routes, errors } = await getRoutesFromAngularRouterConfig(
550-
bootstrap,
551-
document,
543+
export function extractRoutesAndCreateRouteTree(options: {
544+
url: URL;
545+
manifest?: AngularAppManifest;
546+
invokeGetPrerenderParams?: boolean;
547+
includePrerenderFallbackRoutes?: boolean;
548+
signal?: AbortSignal;
549+
}): Promise<{ routeTree: RouteTree; appShellRoute?: string; errors: string[] }> {
550+
const {
552551
url,
553-
invokeGetPrerenderParams,
554-
includePrerenderFallbackRoutes,
555-
);
552+
manifest = getAngularAppManifest(),
553+
invokeGetPrerenderParams = false,
554+
includePrerenderFallbackRoutes = true,
555+
signal,
556+
} = options;
556557

557-
for (const { route, ...metadata } of routes) {
558-
if (metadata.redirectTo !== undefined) {
559-
metadata.redirectTo = joinUrlParts(baseHref, metadata.redirectTo);
560-
}
558+
async function extract(): Promise<{
559+
appShellRoute: string | undefined;
560+
routeTree: RouteTree<{}>;
561+
errors: string[];
562+
}> {
563+
const routeTree = new RouteTree();
564+
const document = await new ServerAssets(manifest).getIndexServerHtml().text();
565+
const bootstrap = await manifest.bootstrap();
566+
const { baseHref, appShellRoute, routes, errors } = await getRoutesFromAngularRouterConfig(
567+
bootstrap,
568+
document,
569+
url,
570+
invokeGetPrerenderParams,
571+
includePrerenderFallbackRoutes,
572+
);
573+
574+
for (const { route, ...metadata } of routes) {
575+
if (metadata.redirectTo !== undefined) {
576+
metadata.redirectTo = joinUrlParts(baseHref, metadata.redirectTo);
577+
}
561578

562-
// Remove undefined fields
563-
// Helps avoid unnecessary test updates
564-
for (const [key, value] of Object.entries(metadata)) {
565-
if (value === undefined) {
566-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
567-
delete (metadata as any)[key];
579+
// Remove undefined fields
580+
// Helps avoid unnecessary test updates
581+
for (const [key, value] of Object.entries(metadata)) {
582+
if (value === undefined) {
583+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
584+
delete (metadata as any)[key];
585+
}
568586
}
587+
588+
const fullRoute = joinUrlParts(baseHref, route);
589+
routeTree.insert(fullRoute, metadata);
569590
}
570591

571-
const fullRoute = joinUrlParts(baseHref, route);
572-
routeTree.insert(fullRoute, metadata);
592+
return {
593+
appShellRoute,
594+
routeTree,
595+
errors,
596+
};
573597
}
574598

575-
return {
576-
appShellRoute,
577-
routeTree,
578-
errors,
579-
};
599+
return signal ? promiseWithAbort(extract(), signal, 'Routes extraction') : extract();
580600
}

Diff for: packages/angular/ssr/src/routes/router.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export class ServerRouter {
5454

5555
// Create and store a new promise for the build process.
5656
// This prevents concurrent builds by re-using the same promise.
57-
ServerRouter.#extractionPromise ??= extractRoutesAndCreateRouteTree(url, manifest)
57+
ServerRouter.#extractionPromise ??= extractRoutesAndCreateRouteTree({ url, manifest })
5858
.then(({ routeTree, errors }) => {
5959
if (errors.length > 0) {
6060
throw new Error(

Diff for: packages/angular/ssr/src/utils/promise.ts

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
/**
10+
* Creates a promise that resolves with the result of the provided `promise` or rejects with an
11+
* `AbortError` if the `AbortSignal` is triggered before the promise resolves.
12+
*
13+
* @param promise - The promise to monitor for completion.
14+
* @param signal - An `AbortSignal` used to monitor for an abort event. If the signal is aborted,
15+
* the returned promise will reject.
16+
* @param errorMessagePrefix - A custom message prefix to include in the error message when the operation is aborted.
17+
* @returns A promise that either resolves with the value of the provided `promise` or rejects with
18+
* an `AbortError` if the `AbortSignal` is triggered.
19+
*
20+
* @throws {AbortError} If the `AbortSignal` is triggered before the `promise` resolves.
21+
*/
22+
export function promiseWithAbort<T>(
23+
promise: Promise<T>,
24+
signal: AbortSignal,
25+
errorMessagePrefix: string,
26+
): Promise<T> {
27+
return new Promise<T>((resolve, reject) => {
28+
const abortHandler = () => {
29+
reject(
30+
new DOMException(`${errorMessagePrefix} was aborted.\n${signal.reason}`, 'AbortError'),
31+
);
32+
};
33+
34+
// Check for abort signal
35+
if (signal.aborted) {
36+
abortHandler();
37+
38+
return;
39+
}
40+
41+
signal.addEventListener('abort', abortHandler, { once: true });
42+
43+
promise
44+
.then(resolve)
45+
.catch(reject)
46+
.finally(() => {
47+
signal.removeEventListener('abort', abortHandler);
48+
});
49+
});
50+
}

Diff for: packages/angular/ssr/test/app_spec.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,12 @@ describe('AngularServerApp', () => {
139139
controller.abort();
140140
});
141141

142-
await expectAsync(app.handle(request)).toBeRejectedWithError(/Request for: .+ was aborted/);
142+
try {
143+
await app.handle(request);
144+
throw new Error('Should not be called.');
145+
} catch (e) {
146+
expect(e).toBeInstanceOf(DOMException);
147+
}
143148
});
144149

145150
it('should return configured headers for pages with specific header settings', async () => {

0 commit comments

Comments
 (0)