forked from angular/angular-cli
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapp-engine.ts
More file actions
298 lines (260 loc) · 11 KB
/
app-engine.ts
File metadata and controls
298 lines (260 loc) · 11 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import type { AngularServerApp, getOrCreateAngularServerApp } from './app';
import { Hooks } from './hooks';
import { getPotentialLocaleIdFromUrl, getPreferredLocale } from './i18n';
import { EntryPointExports, getAngularAppEngineManifest } from './manifest';
import { createRedirectResponse } from './utils/redirect';
import { joinUrlParts } from './utils/url';
import { cloneRequestAndPatchHeaders, validateRequest } from './utils/validation';
/**
* Options for the Angular server application engine.
*/
export interface AngularAppEngineOptions {
/**
* A set of allowed hostnames for the server application.
*/
allowedHosts?: readonly string[];
}
/**
* Angular server application engine.
* Manages Angular server applications (including localized ones), handles rendering requests,
* and optionally transforms index HTML before rendering.
*
* @remarks This class should be instantiated once and used as a singleton across the server-side
* application to ensure consistent handling of rendering requests and resource management.
*/
export class AngularAppEngine {
/**
* A flag to enable or disable the rendering of prerendered routes.
*
* Typically used during development to avoid prerendering all routes ahead of time,
* allowing them to be rendered on the fly as requested.
*
* @private
*/
static ɵallowStaticRouteRender = false;
/**
* A flag to enable or disable the allowed hosts check.
*
* Typically used during development to avoid the allowed hosts check.
*
* @private
*/
static ɵdisableAllowedHostsCheck = false;
/**
* Hooks for extending or modifying the behavior of the server application.
* These hooks are used by the Angular CLI when running the development server and
* provide extensibility points for the application lifecycle.
*
* @private
*/
static ɵhooks: Hooks = /* #__PURE__*/ new Hooks();
/**
* The manifest for the server application.
*/
private readonly manifest = getAngularAppEngineManifest();
/**
* A set of allowed hostnames for the server application.
*/
private readonly allowedHosts: ReadonlySet<string>;
/**
* A map of supported locales from the server application's manifest.
*/
private readonly supportedLocales: ReadonlyArray<string> = Object.keys(
this.manifest.supportedLocales,
);
/**
* A cache that holds entry points, keyed by their potential locale string.
*/
private readonly entryPointsCache = new Map<string, Promise<EntryPointExports>>();
/**
* Creates a new instance of the Angular server application engine.
* @param options Options for the Angular server application engine.
*/
constructor(options?: AngularAppEngineOptions) {
this.allowedHosts = new Set([...(options?.allowedHosts ?? []), ...this.manifest.allowedHosts]);
}
/**
* Handles an incoming HTTP request by serving prerendered content, performing server-side rendering,
* or delivering a static file for client-side rendered routes based on the `RenderMode` setting.
*
* @param request - The HTTP request to handle.
* @param requestContext - Optional context for rendering, such as metadata associated with the request.
* @returns A promise that resolves to the resulting HTTP response object, or `null` if no matching Angular route is found.
*
* @remarks A request to `https://www.example.com/page/index.html` will serve or render the Angular route
* corresponding to `https://www.example.com/page`.
*
* @remarks
* To prevent potential Server-Side Request Forgery (SSRF), this function verifies the hostname
* of the `request.url` against a list of authorized hosts.
* If the hostname is not recognized a 400 Bad Request is returned.
*
* Resolution:
* Authorize your hostname by configuring `allowedHosts` in `angular.json` in:
* `projects.[project-name].architect.build.options.security.allowedHosts`.
* Alternatively, you pass it directly through the configuration options of `AngularAppEngine`.
*
* For more information see: https://angular.dev/best-practices/security#preventing-server-side-request-forgery-ssrf
*/
async handle(request: Request, requestContext?: unknown): Promise<Response | null> {
const allowedHost = this.allowedHosts;
const disableAllowedHostsCheck = AngularAppEngine.ɵdisableAllowedHostsCheck;
try {
validateRequest(request, allowedHost, disableAllowedHostsCheck);
} catch (error) {
return this.handleValidationError(request.url, error as Error);
}
// Clone request with patched headers to prevent unallowed host header access.
const { request: securedRequest, onError: onHeaderValidationError } = disableAllowedHostsCheck
? { request, onError: null }
: cloneRequestAndPatchHeaders(request, allowedHost);
const serverApp = await this.getAngularServerAppForRequest(securedRequest);
if (serverApp) {
const promises: Promise<Response | null>[] = [];
if (onHeaderValidationError) {
promises.push(
onHeaderValidationError.then((error) =>
this.handleValidationError(securedRequest.url, error),
),
);
}
promises.push(serverApp.handle(securedRequest, requestContext));
return Promise.race(promises);
}
if (this.supportedLocales.length > 1) {
// Redirect to the preferred language if i18n is enabled.
return this.redirectBasedOnAcceptLanguage(securedRequest);
}
return null;
}
/**
* Handles requests for the base path when i18n is enabled.
* Redirects the user to a locale-specific path based on the `Accept-Language` header.
*
* @param request The incoming request.
* @returns A `Response` object with a 302 redirect, or `null` if i18n is not enabled
* or the request is not for the base path.
*/
private redirectBasedOnAcceptLanguage(request: Request): Response | null {
const { basePath, supportedLocales } = this.manifest;
// If the request is not for the base path, it's not our responsibility to handle it.
const { pathname } = new URL(request.url);
if (pathname !== basePath) {
return null;
}
// For requests to the base path (typically '/'), attempt to extract the preferred locale
// from the 'Accept-Language' header.
const preferredLocale = getPreferredLocale(
request.headers.get('Accept-Language') || '*',
this.supportedLocales,
);
if (preferredLocale) {
const subPath = supportedLocales[preferredLocale];
if (subPath !== undefined) {
const prefix = request.headers.get('X-Forwarded-Prefix') ?? '';
return createRedirectResponse(
joinUrlParts(prefix, pathname, subPath),
302,
// Use a 302 redirect as language preference may change.
{ 'Vary': 'Accept-Language' },
);
}
}
return null;
}
/**
* Retrieves the Angular server application instance for a given request.
*
* This method checks if the request URL corresponds to an Angular application entry point.
* If so, it initializes or retrieves an instance of the Angular server application for that entry point.
* Requests that resemble file requests (except for `/index.html`) are skipped.
*
* @param request - The incoming HTTP request object.
* @returns A promise that resolves to an `AngularServerApp` instance if a valid entry point is found,
* or `null` if no entry point matches the request URL.
*/
private async getAngularServerAppForRequest(request: Request): Promise<AngularServerApp | null> {
// Skip if the request looks like a file but not `/index.html`.
const url = new URL(request.url);
const entryPoint = await this.getEntryPointExportsForUrl(url);
if (!entryPoint) {
return null;
}
// Note: Using `instanceof` is not feasible here because `AngularServerApp` will
// be located in separate bundles, making `instanceof` checks unreliable.
const ɵgetOrCreateAngularServerApp =
entryPoint.ɵgetOrCreateAngularServerApp as typeof getOrCreateAngularServerApp;
const serverApp = ɵgetOrCreateAngularServerApp({
allowStaticRouteRender: AngularAppEngine.ɵallowStaticRouteRender,
hooks: AngularAppEngine.ɵhooks,
});
return serverApp;
}
/**
* Retrieves the exports for a specific entry point, caching the result.
*
* @param potentialLocale - The locale string used to find the corresponding entry point.
* @returns A promise that resolves to the entry point exports or `undefined` if not found.
*/
private getEntryPointExports(potentialLocale: string): Promise<EntryPointExports> | undefined {
const cachedEntryPoint = this.entryPointsCache.get(potentialLocale);
if (cachedEntryPoint) {
return cachedEntryPoint;
}
const { entryPoints } = this.manifest;
const entryPoint = entryPoints[potentialLocale];
if (!entryPoint) {
return undefined;
}
const entryPointExports = entryPoint();
this.entryPointsCache.set(potentialLocale, entryPointExports);
return entryPointExports;
}
/**
* Retrieves the entry point for a given URL by determining the locale and mapping it to
* the appropriate application bundle.
*
* This method determines the appropriate entry point and locale for rendering the application by examining the URL.
* If there is only one entry point available, it is returned regardless of the URL.
* Otherwise, the method extracts a potential locale identifier from the URL and looks up the corresponding entry point.
*
* @param url - The URL of the request.
* @returns A promise that resolves to the entry point exports or `undefined` if not found.
*/
private getEntryPointExportsForUrl(url: URL): Promise<EntryPointExports> | undefined {
const { basePath, supportedLocales } = this.manifest;
if (this.supportedLocales.length === 1) {
return this.getEntryPointExports(supportedLocales[this.supportedLocales[0]]);
}
const potentialLocale = getPotentialLocaleIdFromUrl(url, basePath);
return this.getEntryPointExports(potentialLocale) ?? this.getEntryPointExports('');
}
/**
* Handles validation errors by logging the error and returning an appropriate response.
*
* @param url - The URL of the request.
* @param error - The validation error to handle.
* @returns A `Response` object with a 400 status code.
*/
private handleValidationError(url: string, error: Error): Response {
const errorMessage = error.message;
// eslint-disable-next-line no-console
console.error(
`ERROR: Bad Request ("${url}").\n` +
errorMessage +
'\n\nFor more information, see https://angular.dev/best-practices/security#preventing-server-side-request-forgery-ssrf',
);
return new Response(errorMessage, {
status: 400,
statusText: 'Bad Request',
headers: { 'Content-Type': 'text/plain' },
});
}
}