Skip to content

Commit

Permalink
fix(@angular/ssr): enable serving of prerendered pages in the App Engine
Browse files Browse the repository at this point in the history
This commit implements the capability for the App Engine to serve prerendered pages directly. Previously, we relied on frameworks like Express for this functionality, which resulted in inconsistent redirects for directories where in some cases a trailing slash was added to the route.

**Note:** This change applies only when using the new SSR APIs. When using the `CommonEngine`, a 3rd party static serve middleware is still required.

(cherry picked from commit 481ccdb)
  • Loading branch information
alan-agius4 committed Nov 1, 2024
1 parent 4ae1fd2 commit df4e1d3
Show file tree
Hide file tree
Showing 22 changed files with 357 additions and 274 deletions.
3 changes: 1 addition & 2 deletions goldens/public-api/angular/ssr/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ import { EnvironmentProviders } from '@angular/core';

// @public
export class AngularAppEngine {
getPrerenderHeaders(request: Request): ReadonlyMap<string, string>;
render(request: Request, requestContext?: unknown): Promise<Response | null>;
handle(request: Request, requestContext?: unknown): Promise<Response | null>;
static ɵhooks: Hooks;
}

Expand Down
3 changes: 1 addition & 2 deletions goldens/public-api/angular/ssr/node/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@ import { Type } from '@angular/core';

// @public
export class AngularNodeAppEngine {
getPrerenderHeaders(request: IncomingMessage): ReadonlyMap<string, string>;
render(request: IncomingMessage, requestContext?: unknown): Promise<Response | null>;
handle(request: IncomingMessage, requestContext?: unknown): Promise<Response | null>;
}

// @public
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,8 @@ export async function executePostBundleSteps(
switch (metadata.renderMode) {
case RouteRenderMode.Prerender:
case /* Legacy building mode */ undefined: {
if (!metadata.redirectTo || outputMode === OutputMode.Static) {
if (!metadata.redirectTo) {
serializableRouteTreeNodeForManifest.push(metadata);
prerenderedRoutes[metadata.route] = { headers: metadata.headers };
}
break;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export function createAngularSsrInternalMiddleware(
const webReq = new Request(createWebRequestFromNodeRequest(req), {
signal: AbortSignal.timeout(30_000),
});
const webRes = await angularServerApp.render(webReq);
const webRes = await angularServerApp.handle(webReq);
if (!webRes) {
return next();
}
Expand Down
10 changes: 3 additions & 7 deletions packages/angular/build/src/utils/server-rendering/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@
* found in the LICENSE file at https://angular.dev/license
*/

import { extname } from 'node:path';
import {
INDEX_HTML_CSR,
INDEX_HTML_SERVER,
NormalizedApplicationBuildOptions,
getLocaleBaseHref,
} from '../../builders/application/options';
Expand Down Expand Up @@ -135,11 +134,8 @@ export function generateAngularServerAppManifest(
): string {
const serverAssetsContent: string[] = [];
for (const file of [...additionalHtmlOutputFiles.values(), ...outputFiles]) {
if (
file.path === INDEX_HTML_SERVER ||
file.path === INDEX_HTML_CSR ||
(inlineCriticalCss && file.path.endsWith('.css'))
) {
const extension = extname(file.path);
if (extension === '.html' || (inlineCriticalCss && extension === '.css')) {
serverAssetsContent.push(`['${file.path}', async () => \`${escapeUnsafeChars(file.text)}\`]`);
}
}
Expand Down
50 changes: 10 additions & 40 deletions packages/angular/ssr/node/src/app-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,49 +24,19 @@ export class AngularNodeAppEngine {
private readonly angularAppEngine = new AngularAppEngine();

/**
* Renders an HTTP response based on the incoming request using the Angular server application.
* 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.
*
* The method processes the incoming request, determines the appropriate route, and prepares the
* rendering context to generate a response. If the request URL corresponds to a static file (excluding `/index.html`),
* the method returns `null`.
* @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.
*
* Example: A request to `https://www.example.com/page/index.html` will render the Angular route
* associated with `https://www.example.com/page`.
*
* @param request - The incoming HTTP request object to be rendered.
* @param requestContext - Optional additional context for the request, such as metadata or custom settings.
* @returns A promise that resolves to a `Response` object, or `null` if the request URL is for a static file
* (e.g., `./logo.png`) rather than an application route.
* @note A request to `https://www.example.com/page/index.html` will serve or render the Angular route
* corresponding to `https://www.example.com/page`.
*/
render(request: IncomingMessage, requestContext?: unknown): Promise<Response | null> {
return this.angularAppEngine.render(createWebRequestFromNodeRequest(request), requestContext);
}
async handle(request: IncomingMessage, requestContext?: unknown): Promise<Response | null> {
const webRequest = createWebRequestFromNodeRequest(request);

/**
* Retrieves HTTP headers for a request associated with statically generated (SSG) pages,
* based on the URL pathname.
*
* @param request - The incoming request object.
* @returns A `Map` containing the HTTP headers as key-value pairs.
* @note This function should be used exclusively for retrieving headers of SSG pages.
* @example
* ```typescript
* const angularAppEngine = new AngularNodeAppEngine();
*
* app.use(express.static('dist/browser', {
* setHeaders: (res, path) => {
* // Retrieve headers for the current request
* const headers = angularAppEngine.getPrerenderHeaders(res.req);
*
* // Apply the retrieved headers to the response
* for (const [key, value] of headers) {
* res.setHeader(key, value);
* }
* }
}));
* ```
*/
getPrerenderHeaders(request: IncomingMessage): ReadonlyMap<string, string> {
return this.angularAppEngine.getPrerenderHeaders(createWebRequestFromNodeRequest(request));
return this.angularAppEngine.handle(webRequest, requestContext);
}
}
9 changes: 7 additions & 2 deletions packages/angular/ssr/node/src/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,12 @@ function createRequestHeaders(nodeHeaders: IncomingHttpHeaders): Headers {
* @returns A `URL` object representing the request URL.
*/
function createRequestUrl(nodeRequest: IncomingMessage): URL {
const { headers, socket, url = '' } = nodeRequest;
const {
headers,
socket,
url = '',
originalUrl,
} = nodeRequest as IncomingMessage & { originalUrl?: string };
const protocol =
headers['x-forwarded-proto'] ?? ('encrypted' in socket && socket.encrypted ? 'https' : 'http');
const hostname = headers['x-forwarded-host'] ?? headers.host ?? headers[':authority'];
Expand All @@ -71,5 +76,5 @@ function createRequestUrl(nodeRequest: IncomingMessage): URL {
hostnameWithPort += `:${port}`;
}

return new URL(url, `${protocol}://${hostnameWithPort}`);
return new URL(originalUrl ?? url, `${protocol}://${hostnameWithPort}`);
}
54 changes: 24 additions & 30 deletions packages/angular/ssr/src/app-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,21 +53,34 @@ export class AngularAppEngine {
private readonly entryPointsCache = new Map<string, Promise<EntryPointExports>>();

/**
* Renders a response for the given HTTP request using the server application.
* 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.
*
* This method processes the request, determines the appropriate route and rendering context,
* and returns an HTTP response.
* @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.
*
* If the request URL appears to be for a file (excluding `/index.html`), the method returns `null`.
* A request to `https://www.example.com/page/index.html` will render the Angular route
* @note A request to `https://www.example.com/page/index.html` will serve or render the Angular route
* corresponding to `https://www.example.com/page`.
*/
async handle(request: Request, requestContext?: unknown): Promise<Response | null> {
const serverApp = await this.getAngularServerAppForRequest(request);

return serverApp ? serverApp.handle(request, requestContext) : 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 to be rendered.
* @param requestContext - Optional additional context for the request, such as metadata.
* @returns A promise that resolves to a Response object, or `null` if the request URL represents a file (e.g., `./logo.png`)
* rather than an application route.
* @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.
*/
async render(request: Request, requestContext?: unknown): Promise<Response | null> {
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);
Expand All @@ -82,26 +95,7 @@ export class AngularAppEngine {
const serverApp = getOrCreateAngularServerApp() as AngularServerApp;
serverApp.hooks = this.hooks;

return serverApp.render(request, requestContext);
}

/**
* Retrieves HTTP headers for a request associated with statically generated (SSG) pages,
* based on the URL pathname.
*
* @param request - The incoming request object.
* @returns A `Map` containing the HTTP headers as key-value pairs.
* @note This function should be used exclusively for retrieving headers of SSG pages.
*/
getPrerenderHeaders(request: Request): ReadonlyMap<string, string> {
if (this.manifest.staticPathsHeaders.size === 0) {
return new Map();
}

const { pathname } = stripIndexHtmlFromURL(new URL(request.url));
const headers = this.manifest.staticPathsHeaders.get(stripTrailingSlash(pathname));

return new Map(headers);
return serverApp;
}

/**
Expand Down
Loading

0 comments on commit df4e1d3

Please sign in to comment.