Skip to content

Commit 143a608

Browse files
committed
fix(@angular-devkit/build-angular): handle HTTP requests to assets during prerendering
This commit fixes an issue were during prerendering (SSG) http requests to assets causes prerendering to fail. Closes #25720
1 parent 26c3b82 commit 143a608

File tree

8 files changed

+311
-31
lines changed

8 files changed

+311
-31
lines changed

packages/angular_devkit/build_angular/src/builders/application/execute-post-bundle.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ export async function executePostBundleSteps(
117117
appShellOptions,
118118
prerenderOptions,
119119
outputFiles,
120+
assetFiles,
120121
indexContentOutputNoCssInlining,
121122
sourcemapOptions.scripts,
122123
optimizationOptions.styles.inlineCritical,

packages/angular_devkit/build_angular/src/builders/prerender/routes-extractor-worker.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ async function extract(): Promise<string[]> {
5151
);
5252

5353
const routes: string[] = [];
54-
for await (const { route, success } of extractRoutes(bootstrapAppFnOrModule, document)) {
54+
for await (const { route, success } of extractRoutes(bootstrapAppFnOrModule, document, '')) {
5555
if (success) {
5656
routes.push(route);
5757
}

packages/angular_devkit/build_angular/src/utils/routes-extractor/extractor.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,12 @@ async function* getRoutesFromRouterConfig(
7878
export async function* extractRoutes(
7979
bootstrapAppFnOrModule: (() => Promise<ApplicationRef>) | Type<unknown>,
8080
document: string,
81+
url: string,
8182
): AsyncIterableIterator<RouterResult> {
8283
const platformRef = createPlatformFactory(platformCore, 'server', [
8384
{
8485
provide: INITIAL_CONFIG,
85-
useValue: { document, url: '' },
86+
useValue: { document, url },
8687
},
8788
{
8889
provide: ɵConsole,
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
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.io/license
7+
*/
8+
9+
import { lookup as lookupMimeType } from 'mrmime';
10+
import { readFile } from 'node:fs/promises';
11+
import { IncomingMessage, RequestListener, ServerResponse, createServer } from 'node:http';
12+
import { extname, posix } from 'node:path';
13+
import { BuildOutputAsset } from '../../tools/esbuild/bundler-execution-result';
14+
15+
/**
16+
* Start a server that can handle HTTP requests to assets.
17+
*
18+
* @example
19+
* ```ts
20+
* httpClient.get('/assets/content.json');
21+
* ```
22+
* @returns the server address.
23+
*/
24+
export async function startServer(assets: Readonly<BuildOutputAsset[]>): Promise<{
25+
address: string;
26+
close?: () => void;
27+
}> {
28+
if (Object.keys(assets).length === 0) {
29+
return {
30+
address: '',
31+
};
32+
}
33+
34+
const assetsReversed: Record<string, string> = {};
35+
for (const { source, destination } of assets) {
36+
assetsReversed[addLeadingSlash(destination.replace(/\\/g, posix.sep))] = source;
37+
}
38+
39+
const assetsCache: Map<string, { mimeType: string | void; content: Buffer }> = new Map();
40+
const server = createServer();
41+
server.on('request', requestHandler(assetsReversed, assetsCache));
42+
43+
await new Promise<void>((resolve) => {
44+
server.listen(0, '127.0.0.1', resolve);
45+
});
46+
47+
const serverAddress = server.address();
48+
let address: string;
49+
if (!serverAddress) {
50+
address = '';
51+
} else if (typeof serverAddress === 'string') {
52+
address = serverAddress;
53+
} else {
54+
const { port, address: host } = serverAddress;
55+
address = `http://${host}:${port}`;
56+
}
57+
58+
return {
59+
address,
60+
close: () => {
61+
assetsCache.clear();
62+
server.unref();
63+
server.close();
64+
},
65+
};
66+
}
67+
function requestHandler(
68+
assetsReversed: Record<string, string>,
69+
assetsCache: Map<string, { mimeType: string | void; content: Buffer }>,
70+
): RequestListener<typeof IncomingMessage, typeof ServerResponse> {
71+
return (req, res) => {
72+
if (!req.url) {
73+
res.destroy(new Error('Request url was empty.'));
74+
75+
return;
76+
}
77+
78+
const { pathname } = new URL(req.url, 'resolve://');
79+
80+
const asset = assetsReversed[pathname];
81+
if (!asset) {
82+
res.statusCode = 404;
83+
res.statusMessage = 'Asset not found.';
84+
res.end();
85+
86+
return;
87+
}
88+
89+
const cachedAsset = assetsCache.get(pathname);
90+
if (cachedAsset) {
91+
const { content, mimeType } = cachedAsset;
92+
if (mimeType) {
93+
res.setHeader('Content-Type', mimeType);
94+
}
95+
96+
res.end(content);
97+
98+
return;
99+
}
100+
101+
readFile(asset)
102+
.then((content) => {
103+
const extension = extname(pathname);
104+
const mimeType = lookupMimeType(extension);
105+
106+
assetsCache.set(pathname, {
107+
mimeType,
108+
content,
109+
});
110+
111+
if (mimeType) {
112+
res.setHeader('Content-Type', mimeType);
113+
}
114+
115+
res.end(content);
116+
})
117+
.catch((e) => res.destroy(e));
118+
};
119+
}
120+
121+
function addLeadingSlash(value: string): string {
122+
return value.charAt(0) === '/' ? value : '/' + value;
123+
}

packages/angular_devkit/build_angular/src/utils/server-rendering/prerender.ts

Lines changed: 87 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77
*/
88

99
import { readFile } from 'node:fs/promises';
10-
import { extname, join, posix } from 'node:path';
10+
import { extname, posix } from 'node:path';
1111
import Piscina from 'piscina';
1212
import { BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-context';
13+
import { BuildOutputAsset } from '../../tools/esbuild/bundler-execution-result';
1314
import { getESMLoaderArgs } from './esm-in-memory-loader/node-18-utils';
15+
import { startServer } from './prerender-server';
1416
import type { RenderResult, ServerContext } from './render-page';
1517
import type { RenderWorkerData } from './render-worker';
1618
import type {
@@ -32,6 +34,7 @@ export async function prerenderPages(
3234
appShellOptions: AppShellOptions = {},
3335
prerenderOptions: PrerenderOptions = {},
3436
outputFiles: Readonly<BuildOutputFile[]>,
37+
assets: Readonly<BuildOutputAsset[]>,
3538
document: string,
3639
sourcemap = false,
3740
inlineCriticalCss = false,
@@ -43,11 +46,10 @@ export async function prerenderPages(
4346
errors: string[];
4447
prerenderedRoutes: Set<string>;
4548
}> {
46-
const output: Record<string, string> = {};
47-
const warnings: string[] = [];
48-
const errors: string[] = [];
4949
const outputFilesForWorker: Record<string, string> = {};
5050
const serverBundlesSourceMaps = new Map<string, string>();
51+
const warnings: string[] = [];
52+
const errors: string[] = [];
5153

5254
for (const { text, path, type } of outputFiles) {
5355
const fileExt = extname(path);
@@ -74,28 +76,91 @@ export async function prerenderPages(
7476
}
7577
serverBundlesSourceMaps.clear();
7678

77-
const { routes: allRoutes, warnings: routesWarnings } = await getAllRoutes(
78-
workspaceRoot,
79-
outputFilesForWorker,
80-
document,
81-
appShellOptions,
82-
prerenderOptions,
83-
sourcemap,
84-
verbose,
85-
);
86-
87-
if (routesWarnings?.length) {
88-
warnings.push(...routesWarnings);
89-
}
79+
// Start server to handle HTTP requests to assets.
80+
// TODO: consider starting this is a seperate process to avoid any blocks to the main thread.
81+
const { address: assetsServerAddress, close: closeAssetsServer } = await startServer(assets);
82+
83+
try {
84+
// Get routes to prerender
85+
const { routes: allRoutes, warnings: routesWarnings } = await getAllRoutes(
86+
workspaceRoot,
87+
outputFilesForWorker,
88+
document,
89+
appShellOptions,
90+
prerenderOptions,
91+
sourcemap,
92+
verbose,
93+
assetsServerAddress,
94+
);
95+
96+
if (routesWarnings?.length) {
97+
warnings.push(...routesWarnings);
98+
}
99+
100+
if (allRoutes.size < 1) {
101+
return {
102+
errors,
103+
warnings,
104+
output: {},
105+
prerenderedRoutes: allRoutes,
106+
};
107+
}
108+
109+
// Render routes
110+
const {
111+
warnings: renderingWarnings,
112+
errors: renderingErrors,
113+
output,
114+
} = await renderPages(
115+
sourcemap,
116+
allRoutes,
117+
maxThreads,
118+
workspaceRoot,
119+
outputFilesForWorker,
120+
inlineCriticalCss,
121+
document,
122+
assetsServerAddress,
123+
appShellOptions,
124+
);
125+
126+
errors.push(...renderingErrors);
127+
warnings.push(...renderingWarnings);
90128

91-
if (allRoutes.size < 1) {
92129
return {
93130
errors,
94131
warnings,
95132
output,
96133
prerenderedRoutes: allRoutes,
97134
};
135+
} finally {
136+
void closeAssetsServer?.();
98137
}
138+
}
139+
140+
class RoutesSet extends Set<string> {
141+
override add(value: string): this {
142+
return super.add(addLeadingSlash(value));
143+
}
144+
}
145+
146+
async function renderPages(
147+
sourcemap: boolean,
148+
allRoutes: Set<string>,
149+
maxThreads: number,
150+
workspaceRoot: string,
151+
outputFilesForWorker: Record<string, string>,
152+
inlineCriticalCss: boolean,
153+
document: string,
154+
baseUrl: string,
155+
appShellOptions: AppShellOptions,
156+
): Promise<{
157+
output: Record<string, string>;
158+
warnings: string[];
159+
errors: string[];
160+
}> {
161+
const output: Record<string, string> = {};
162+
const warnings: string[] = [];
163+
const errors: string[] = [];
99164

100165
const workerExecArgv = getESMLoaderArgs();
101166
if (sourcemap) {
@@ -110,6 +175,7 @@ export async function prerenderPages(
110175
outputFiles: outputFilesForWorker,
111176
inlineCriticalCss,
112177
document,
178+
baseUrl,
113179
} as RenderWorkerData,
114180
execArgv: workerExecArgv,
115181
});
@@ -153,16 +219,9 @@ export async function prerenderPages(
153219
errors,
154220
warnings,
155221
output,
156-
prerenderedRoutes: allRoutes,
157222
};
158223
}
159224

160-
class RoutesSet extends Set<string> {
161-
override add(value: string): this {
162-
return super.add(addLeadingSlash(value));
163-
}
164-
}
165-
166225
async function getAllRoutes(
167226
workspaceRoot: string,
168227
outputFilesForWorker: Record<string, string>,
@@ -171,11 +230,12 @@ async function getAllRoutes(
171230
prerenderOptions: PrerenderOptions,
172231
sourcemap: boolean,
173232
verbose: boolean,
233+
assetsServerAddress: string,
174234
): Promise<{ routes: Set<string>; warnings?: string[] }> {
175235
const { routesFile, discoverRoutes } = prerenderOptions;
176236
const routes = new RoutesSet();
177-
178237
const { route: appShellRoute } = appShellOptions;
238+
179239
if (appShellRoute !== undefined) {
180240
routes.add(appShellRoute);
181241
}
@@ -204,6 +264,7 @@ async function getAllRoutes(
204264
outputFiles: outputFilesForWorker,
205265
document,
206266
verbose,
267+
url: assetsServerAddress,
207268
} as RoutesExtractorWorkerData,
208269
execArgv: workerExecArgv,
209270
});

packages/angular_devkit/build_angular/src/utils/server-rendering/render-worker.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { RenderResult, ServerContext, renderPage } from './render-page';
1313
export interface RenderWorkerData extends ESMInMemoryFileLoaderWorkerData {
1414
document: string;
1515
inlineCriticalCss?: boolean;
16+
baseUrl: string;
1617
}
1718

1819
export interface RenderOptions {
@@ -23,8 +24,15 @@ export interface RenderOptions {
2324
/**
2425
* This is passed as workerData when setting up the worker via the `piscina` package.
2526
*/
26-
const { outputFiles, document, inlineCriticalCss } = workerData as RenderWorkerData;
27+
const { outputFiles, document, inlineCriticalCss, baseUrl } = workerData as RenderWorkerData;
2728

29+
/** Renders an application based on a provided options. */
2830
export default function (options: RenderOptions): Promise<RenderResult> {
29-
return renderPage({ ...options, outputFiles, document, inlineCriticalCss });
31+
return renderPage({
32+
...options,
33+
route: baseUrl + options.route,
34+
outputFiles,
35+
document,
36+
inlineCriticalCss,
37+
});
3038
}

packages/angular_devkit/build_angular/src/utils/server-rendering/routes-extractor-worker.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import { MainServerBundleExports, RenderUtilsServerBundleExports } from './main-
1414
export interface RoutesExtractorWorkerData extends ESMInMemoryFileLoaderWorkerData {
1515
document: string;
1616
verbose: boolean;
17+
url: string;
18+
assetsServerAddress: string;
1719
}
1820

1921
export interface RoutersExtractorWorkerResult {
@@ -24,7 +26,7 @@ export interface RoutersExtractorWorkerResult {
2426
/**
2527
* This is passed as workerData when setting up the worker via the `piscina` package.
2628
*/
27-
const { document, verbose } = workerData as RoutesExtractorWorkerData;
29+
const { document, verbose, url } = workerData as RoutesExtractorWorkerData;
2830

2931
export default async function (): Promise<RoutersExtractorWorkerResult> {
3032
const { extractRoutes } = await loadEsmModule<RenderUtilsServerBundleExports>(
@@ -40,6 +42,7 @@ export default async function (): Promise<RoutersExtractorWorkerResult> {
4042
for await (const { route, success, redirect } of extractRoutes(
4143
bootstrapAppFnOrModule,
4244
document,
45+
url,
4346
)) {
4447
if (success) {
4548
routes.push(route);

0 commit comments

Comments
 (0)