Skip to content

Commit 192a2ae

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 (cherry picked from commit 5b35410)
1 parent a037d6a commit 192a2ae

File tree

8 files changed

+309
-31
lines changed

8 files changed

+309
-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: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
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(requestHandler(assetsReversed, assetsCache));
41+
42+
await new Promise<void>((resolve) => {
43+
server.listen(0, '127.0.0.1', resolve);
44+
});
45+
46+
const serverAddress = server.address();
47+
let address: string;
48+
if (!serverAddress) {
49+
address = '';
50+
} else if (typeof serverAddress === 'string') {
51+
address = serverAddress;
52+
} else {
53+
const { port, address: host } = serverAddress;
54+
address = `http://${host}:${port}`;
55+
}
56+
57+
return {
58+
address,
59+
close: () => {
60+
assetsCache.clear();
61+
server.unref();
62+
server.close();
63+
},
64+
};
65+
}
66+
function requestHandler(
67+
assetsReversed: Record<string, string>,
68+
assetsCache: Map<string, { mimeType: string | void; content: Buffer }>,
69+
): RequestListener<typeof IncomingMessage, typeof ServerResponse> {
70+
return (req, res) => {
71+
if (!req.url) {
72+
res.destroy(new Error('Request url was empty.'));
73+
74+
return;
75+
}
76+
77+
const { pathname } = new URL(req.url, 'resolve://');
78+
const asset = assetsReversed[pathname];
79+
if (!asset) {
80+
res.statusCode = 404;
81+
res.statusMessage = 'Asset not found.';
82+
res.end();
83+
84+
return;
85+
}
86+
87+
const cachedAsset = assetsCache.get(pathname);
88+
if (cachedAsset) {
89+
const { content, mimeType } = cachedAsset;
90+
if (mimeType) {
91+
res.setHeader('Content-Type', mimeType);
92+
}
93+
94+
res.end(content);
95+
96+
return;
97+
}
98+
99+
readFile(asset)
100+
.then((content) => {
101+
const extension = extname(pathname);
102+
const mimeType = lookupMimeType(extension);
103+
104+
assetsCache.set(pathname, {
105+
mimeType,
106+
content,
107+
});
108+
109+
if (mimeType) {
110+
res.setHeader('Content-Type', mimeType);
111+
}
112+
113+
res.end(content);
114+
})
115+
.catch((e) => res.destroy(e));
116+
};
117+
}
118+
119+
function addLeadingSlash(value: string): string {
120+
return value.charAt(0) === '/' ? value : '/' + value;
121+
}

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)