Skip to content

Commit

Permalink
fixup! fix(@angular/ssr): enable serving of prerendered pages in the …
Browse files Browse the repository at this point in the history
…App Engine
  • Loading branch information
alan-agius4 committed Oct 31, 2024
1 parent b1c6fd6 commit 94396da
Show file tree
Hide file tree
Showing 7 changed files with 113 additions and 64 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,6 @@ export async function executePostBundleSteps(
}
case RouteRenderMode.Server:
case RouteRenderMode.Client:
case RouteRenderMode.AppShell:
serializableRouteTreeNodeForManifest.push(metadata);

break;
Expand Down
6 changes: 3 additions & 3 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,7 +134,8 @@ export function generateAngularServerAppManifest(
): string {
const serverAssetsContent: string[] = [];
for (const file of [...additionalHtmlOutputFiles.values(), ...outputFiles]) {
if (file.path.endsWith('.html') || (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
5 changes: 1 addition & 4 deletions packages/angular/ssr/node/src/app-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,6 @@ export class AngularNodeAppEngine {
async process(request: IncomingMessage, requestContext?: unknown): Promise<Response | null> {
const webRequest = createWebRequestFromNodeRequest(request);

return (
(await this.angularAppEngine.serve(webRequest)) ??
(await this.angularAppEngine.render(webRequest, requestContext))
);
return this.angularAppEngine.process(webRequest, requestContext);
}
}
5 changes: 1 addition & 4 deletions packages/angular/ssr/src/app-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,8 @@ export class AngularAppEngine {
*/
async process(request: Request, requestContext?: unknown): Promise<Response | null> {
const serverApp = await this.getAngularServerAppForRequest(request);
if (!serverApp) {
return null;
}

return (await serverApp.serve(request)) ?? (await serverApp.render(request, requestContext));
return serverApp ? serverApp.process(request, requestContext) : null;
}

/**
Expand Down
149 changes: 100 additions & 49 deletions packages/angular/ssr/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ServerAssets } from './assets';
import { Hooks } from './hooks';
import { getAngularAppManifest } from './manifest';
import { RenderMode } from './routes/route-config';
import { RouteTreeNodeMetadata } from './routes/route-tree';
import { ServerRouter } from './routes/router';
import { sha256 } from './utils/crypto';
import { InlineCriticalCssProcessor } from './utils/inline-critical-css';
Expand All @@ -21,9 +22,9 @@ import { joinUrlParts, stripIndexHtmlFromURL, stripLeadingSlash } from './utils/

/**
* The default maximum age in seconds.
* Represents the total number of seconds in a 30-day period.
* Represents the total number of seconds in a 365-day period.
*/
const DEFAULT_MAX_AGE = 30 * 24 * 60 * 60;
const DEFAULT_MAX_AGE = 365 * 24 * 60 * 60;

/**
* Maximum number of critical CSS entries the cache can store.
Expand Down Expand Up @@ -107,10 +108,7 @@ export class AngularServerApp {
* @returns A promise that resolves to the HTTP response object resulting from the rendering, or null if no match is found.
*/
render(request: Request, requestContext?: unknown): Promise<Response | null> {
return Promise.race([
this.createAbortPromise(request),
this.handleRendering(request, /** isSsrMode */ true, requestContext),
]);
return this.handleAbortableRendering(request, /** isSsrMode */ true, undefined, requestContext);
}

/**
Expand All @@ -125,10 +123,7 @@ export class AngularServerApp {
renderStatic(url: URL, signal?: AbortSignal): Promise<Response | null> {
const request = new Request(url, { signal });

return Promise.race([
this.createAbortPromise(request),
this.handleRendering(request, /** isSsrMode */ false),
]);
return this.handleAbortableRendering(request, /** isSsrMode */ false);
}

/**
Expand All @@ -143,10 +138,62 @@ export class AngularServerApp {
* if available, or `null` if the request does not match a prerendered route or asset.
*/
async serve(request: Request): Promise<Response | null> {
const url = stripIndexHtmlFromURL(new URL(request.url));
return this.handleServe(request);
}

/**
* Handles incoming HTTP requests by serving prerendered content or rendering the page.
*
* This method first attempts to serve a prerendered page. If the prerendered page is not available,
* it falls back to rendering the requested page using server-side rendering. The function returns
* a promise that resolves to the appropriate HTTP response.
*
* @param request - The incoming HTTP request to be processed.
* @param requestContext - Optional additional context for rendering, such as request metadata.
* @returns A promise that resolves to the HTTP response object resulting from the request handling,
* or null if no matching content is found.
*/
async process(request: Request, requestContext?: unknown): Promise<Response | null> {
const url = new URL(request.url);
this.router ??= await ServerRouter.from(this.manifest, url);

const matchedRoute = this.router.match(new URL(request.url));
const matchedRoute = this.router.match(url);
if (!matchedRoute) {
// Not a known Angular route.
return null;
}

return (
(await this.handleServe(request, matchedRoute)) ??
this.handleAbortableRendering(request, /** isSsrMode */ true, matchedRoute, requestContext)
);
}

/**
* Retrieves the matched route for the incoming request based on the request URL.
*
* @param request - The incoming HTTP request to match against routes.
* @returns A promise that resolves to the matched route metadata or `undefined` if no route matches.
*/
private async getMatchedRoute(request: Request): Promise<RouteTreeNodeMetadata | undefined> {
this.router ??= await ServerRouter.from(this.manifest, new URL(request.url));

return this.router.match(new URL(request.url));
}

/**
* Handles serving a prerendered static asset if available for the matched route.
*
* @param request - The incoming HTTP request for serving a static page.
* @param matchedRoute - Optional parameter representing the metadata of the matched route for rendering.
* If not provided, the method attempts to find a matching route based on the request URL.
* @returns A promise that resolves to a `Response` object if the prerendered page is found, or `null`.
*/
private async handleServe(
request: Request,
matchedRoute?: RouteTreeNodeMetadata,
): Promise<Response | null> {
matchedRoute ??= await this.getMatchedRoute(request);
if (!matchedRoute) {
return null;
}
Expand All @@ -156,7 +203,8 @@ export class AngularServerApp {
return null;
}

const assetPath = stripLeadingSlash(joinUrlParts(url.pathname, 'index.html'));
const { pathname } = stripIndexHtmlFromURL(new URL(request.url));
const assetPath = stripLeadingSlash(joinUrlParts(pathname, 'index.html'));
if (!this.assets.hasServerAsset(assetPath)) {
return null;
}
Expand All @@ -176,41 +224,43 @@ export class AngularServerApp {
}

/**
* Handles incoming HTTP requests by serving prerendered content or rendering the page.
*
* This method first attempts to serve a prerendered page. If the prerendered page is not available,
* it falls back to rendering the requested page using server-side rendering. The function returns
* a promise that resolves to the appropriate HTTP response.
* Handles the server-side rendering process for the given HTTP request, allowing for abortion
* of the rendering if the request is aborted. This method matches the request URL to a route
* and performs rendering if a matching route is found.
*
* @param request - The incoming HTTP request to be processed.
* @param request - The incoming HTTP request to be processed. It includes a signal to monitor
* for abortion events.
* @param isSsrMode - A boolean indicating whether the rendering is performed in server-side
* rendering (SSR) mode.
* @param matchedRoute - Optional parameter representing the metadata of the matched route for
* rendering. If not provided, the method attempts to find a matching route based on the request URL.
* @param requestContext - Optional additional context for rendering, such as request metadata.
* @returns A promise that resolves to the HTTP response object resulting from the request handling,
* or null if no matching content is found.
*/
async process(request: Request, requestContext?: unknown): Promise<Response | null> {
return (await this.serve(request)) ?? (await this.render(request, requestContext));
}

/**
* Creates a promise that rejects when the request is aborted.
*
* @param request - The HTTP request to monitor for abortion.
* @returns A promise that never resolves but rejects with an `AbortError` if the request is aborted.
* @returns A promise that resolves to the rendered response, or null if no matching route is found.
* If the request is aborted, the promise will reject with an `AbortError`.
*/
private createAbortPromise(request: Request): Promise<never> {
return new Promise<never>((_, reject) => {
request.signal.addEventListener(
'abort',
() => {
const abortError = new Error(
`Request for: ${request.url} was aborted.\n${request.signal.reason}`,
);
abortError.name = 'AbortError';
reject(abortError);
},
{ once: true },
);
});
private async handleAbortableRendering(
request: Request,
isSsrMode: boolean,
matchedRoute?: RouteTreeNodeMetadata,
requestContext?: unknown,
): Promise<Response | null> {
return Promise.race([
new Promise<never>((_, reject) => {
request.signal.addEventListener(
'abort',
() => {
const abortError = new Error(
`Request for: ${request.url} was aborted.\n${request.signal.reason}`,
);
abortError.name = 'AbortError';
reject(abortError);
},
{ once: true },
);
}),
this.handleRendering(request, isSsrMode, matchedRoute, requestContext),
]);
}

/**
Expand All @@ -219,25 +269,26 @@ export class AngularServerApp {
*
* @param request - The incoming HTTP request to be processed.
* @param isSsrMode - A boolean indicating whether the rendering is performed in server-side rendering (SSR) mode.
* @param matchedRoute - Optional parameter representing the metadata of the matched route for rendering.
* If not provided, the method attempts to find a matching route based on the request URL.
* @param requestContext - Optional additional context for rendering, such as request metadata.
*
* @returns A promise that resolves to the rendered response, or null if no matching route is found.
*/
private async handleRendering(
request: Request,
isSsrMode: boolean,
matchedRoute?: RouteTreeNodeMetadata,
requestContext?: unknown,
): Promise<Response | null> {
const url = new URL(request.url);
this.router ??= await ServerRouter.from(this.manifest, url);

const matchedRoute = this.router.match(url);
matchedRoute ??= await this.getMatchedRoute(request);
if (!matchedRoute) {
// Not a known Angular route.
return null;
}

const { redirectTo, status } = matchedRoute;
const url = new URL(request.url);

if (redirectTo !== undefined) {
// Note: The status code is validated during route extraction.
// 302 Found is used by default for redirections
Expand Down
7 changes: 6 additions & 1 deletion packages/angular/ssr/src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
import type { SerializableRouteTreeNode } from './routes/route-tree';
import { AngularBootstrap } from './utils/ng';

/**
* A function that returns a promise resolving to the file contents of the asset.
*/
export type ServerAsset = () => Promise<string>;

/**
* Represents the exports of an Angular server application entry point.
*/
Expand Down Expand Up @@ -55,7 +60,7 @@ export interface AngularAppManifest {
* - `key`: The path of the asset.
* - `value`: A function returning a promise that resolves to the file contents of the asset.
*/
readonly assets: ReadonlyMap<string, () => Promise<string>>;
readonly assets: ReadonlyMap<string, ServerAsset>;

/**
* The bootstrap mechanism for the server application.
Expand Down
4 changes: 2 additions & 2 deletions packages/angular/ssr/test/testing-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { Component, provideExperimentalZonelessChangeDetection } from '@angular/
import { bootstrapApplication } from '@angular/platform-browser';
import { provideServerRendering } from '@angular/platform-server';
import { RouterOutlet, Routes, provideRouter } from '@angular/router';
import { AngularAppManifest, setAngularAppManifest } from '../src/manifest';
import { AngularAppManifest, ServerAsset, setAngularAppManifest } from '../src/manifest';
import { ServerRoute, provideServerRoutesConfig } from '../src/routes/route-config';

/**
Expand All @@ -27,7 +27,7 @@ export function setAngularAppTestingManifest(
routes: Routes,
serverRoutes: ServerRoute[],
baseHref = '',
additionalServerAssets: Record<string, () => Promise<string>> = {},
additionalServerAssets: Record<string, ServerAsset> = {},
): void {
setAngularAppManifest({
inlineCriticalCss: false,
Expand Down

0 comments on commit 94396da

Please sign in to comment.