Skip to content

Commit b396ce4

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 65a2660 commit b396ce4

File tree

8 files changed

+322
-30
lines changed

8 files changed

+322
-30
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: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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+
import { assertIsError } from '../error';
15+
16+
/**
17+
* Start a server that can handle HTTP requests to assets.
18+
*
19+
* @example
20+
* ```ts
21+
* httpClient.get('/assets/content.json');
22+
* ```
23+
* @returns the server address.
24+
*/
25+
export async function startServer(assets: Readonly<BuildOutputAsset[]>): Promise<{
26+
address: string;
27+
close?: () => void;
28+
}> {
29+
if (Object.keys(assets).length === 0) {
30+
return {
31+
address: '',
32+
};
33+
}
34+
35+
const assetsReversed: Record<string, string> = {};
36+
for (const { source, destination } of assets) {
37+
assetsReversed[addLeadingSlash(destination.replace(/\\/g, posix.sep))] = source;
38+
}
39+
40+
const assetsCache: Map<string, { mimeType: string | void; content: Buffer }> = new Map();
41+
const server = createServer();
42+
server.on('request', requestHandler(assetsReversed, assetsCache));
43+
44+
await new Promise<void>((resolve) => {
45+
server.listen(() => {
46+
resolve();
47+
});
48+
});
49+
50+
const serverAddress = server.address();
51+
let address: string;
52+
if (!serverAddress) {
53+
address = '';
54+
} else if (typeof serverAddress === 'string') {
55+
address = serverAddress;
56+
} else {
57+
const { family, port } = serverAddress;
58+
address = serverAddress.address;
59+
60+
if (family === 'IPv6') {
61+
address = `[${address}]`;
62+
}
63+
64+
address = `http://${address}:${port}`;
65+
}
66+
67+
return {
68+
address,
69+
close: () => {
70+
assetsCache.clear();
71+
server.unref();
72+
server.close();
73+
},
74+
};
75+
}
76+
function requestHandler(
77+
assetsReversed: Record<string, string>,
78+
assetsCache: Map<string, { mimeType: string | void; content: Buffer }>,
79+
): RequestListener<typeof IncomingMessage, typeof ServerResponse> {
80+
return (req, res) => {
81+
if (!req.url) {
82+
res.destroy(new Error('Request url was empty.'));
83+
84+
return;
85+
}
86+
87+
const { pathname } = new URL(req.url, 'resolve://');
88+
89+
const asset = assetsReversed[pathname];
90+
if (!asset) {
91+
res.statusCode = 404;
92+
res.statusMessage = 'Asset not found.';
93+
res.end();
94+
95+
return;
96+
}
97+
98+
const cachedAsset = assetsCache.get(pathname);
99+
if (cachedAsset) {
100+
const { content, mimeType } = cachedAsset;
101+
if (mimeType) {
102+
res.setHeader('Content-Type', mimeType);
103+
}
104+
105+
res.end(content);
106+
107+
return;
108+
}
109+
110+
readFile(asset)
111+
.then((content) => {
112+
const extension = extname(pathname);
113+
const mimeType = lookupMimeType(extension);
114+
115+
assetsCache.set(pathname, {
116+
mimeType,
117+
content,
118+
});
119+
120+
if (mimeType) {
121+
res.setHeader('Content-Type', mimeType);
122+
}
123+
124+
res.end(content);
125+
})
126+
.catch((e) => res.destroy(e));
127+
};
128+
}
129+
130+
function addLeadingSlash(value: string): string {
131+
return value.charAt(0) === '/' ? value : '/' + value;
132+
}

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

Lines changed: 89 additions & 25 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,93 @@ 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+
prerenderedRoutes,
114+
output,
115+
} = await renderPages(
116+
sourcemap,
117+
allRoutes,
118+
maxThreads,
119+
workspaceRoot,
120+
outputFilesForWorker,
121+
inlineCriticalCss,
122+
document,
123+
assetsServerAddress,
124+
appShellOptions,
125+
);
126+
127+
errors.push(...renderingErrors);
128+
warnings.push(...renderingWarnings);
90129

91-
if (allRoutes.size < 1) {
92130
return {
93131
errors,
94132
warnings,
95133
output,
96-
prerenderedRoutes: allRoutes,
134+
prerenderedRoutes,
97135
};
136+
} finally {
137+
void closeAssetsServer?.();
138+
}
139+
}
140+
141+
class RoutesSet extends Set<string> {
142+
override add(value: string): this {
143+
return super.add(addLeadingSlash(value));
98144
}
145+
}
146+
147+
async function renderPages(
148+
sourcemap: boolean,
149+
allRoutes: Set<string>,
150+
maxThreads: number,
151+
workspaceRoot: string,
152+
outputFilesForWorker: Record<string, string>,
153+
inlineCriticalCss: boolean,
154+
document: string,
155+
baseUrl: string,
156+
appShellOptions: AppShellOptions,
157+
): Promise<{
158+
output: Record<string, string>;
159+
warnings: string[];
160+
errors: string[];
161+
prerenderedRoutes: Set<string>;
162+
}> {
163+
const output: Record<string, string> = {};
164+
const warnings: string[] = [];
165+
const errors: string[] = [];
99166

100167
const workerExecArgv = getESMLoaderArgs();
101168
if (sourcemap) {
@@ -110,6 +177,7 @@ export async function prerenderPages(
110177
outputFiles: outputFilesForWorker,
111178
inlineCriticalCss,
112179
document,
180+
baseUrl,
113181
} as RenderWorkerData,
114182
execArgv: workerExecArgv,
115183
});
@@ -157,12 +225,6 @@ export async function prerenderPages(
157225
};
158226
}
159227

160-
class RoutesSet extends Set<string> {
161-
override add(value: string): this {
162-
return super.add(addLeadingSlash(value));
163-
}
164-
}
165-
166228
async function getAllRoutes(
167229
workspaceRoot: string,
168230
outputFilesForWorker: Record<string, string>,
@@ -171,6 +233,7 @@ async function getAllRoutes(
171233
prerenderOptions: PrerenderOptions,
172234
sourcemap: boolean,
173235
verbose: boolean,
236+
assetsServerAddress: string,
174237
): Promise<{ routes: Set<string>; warnings?: string[] }> {
175238
const { routesFile, discoverRoutes } = prerenderOptions;
176239
const routes = new RoutesSet();
@@ -204,6 +267,7 @@ async function getAllRoutes(
204267
outputFiles: outputFilesForWorker,
205268
document,
206269
verbose,
270+
url: assetsServerAddress,
207271
} as RoutesExtractorWorkerData,
208272
execArgv: workerExecArgv,
209273
});

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)