diff --git a/packages/angular/build/src/builders/application/execute-build.ts b/packages/angular/build/src/builders/application/execute-build.ts index 2a18068cc10e..6a46d7ce732a 100644 --- a/packages/angular/build/src/builders/application/execute-build.ts +++ b/packages/angular/build/src/builders/application/execute-build.ts @@ -7,7 +7,6 @@ */ import { BuilderContext } from '@angular-devkit/architect'; -import assert from 'node:assert'; import { SourceFileCache } from '../../tools/esbuild/angular/source-file-cache'; import { generateBudgetStats } from '../../tools/esbuild/budget-stats'; import { @@ -36,7 +35,6 @@ import { optimizeChunks } from './chunk-optimizer'; import { executePostBundleSteps } from './execute-post-bundle'; import { inlineI18n, loadActiveTranslations } from './i18n'; import { NormalizedApplicationBuildOptions } from './options'; -import { OutputMode } from './schema'; import { createComponentStyleBundler, setupBundlerContexts } from './setup-bundling'; // eslint-disable-next-line max-lines-per-function @@ -213,7 +211,7 @@ export async function executeBuild( if (serverEntryPoint) { executionResult.addOutputFile( SERVER_APP_ENGINE_MANIFEST_FILENAME, - generateAngularServerAppEngineManifest(i18nOptions, baseHref, undefined), + generateAngularServerAppEngineManifest(i18nOptions, baseHref), BuildOutputFileType.ServerRoot, ); } @@ -246,26 +244,11 @@ export async function executeBuild( executionResult.assetFiles.push(...result.additionalAssets); } - if (serverEntryPoint) { - const prerenderedRoutes = executionResult.prerenderedRoutes; - - // Regenerate the manifest to append prerendered routes data. This is only needed if SSR is enabled. - if (outputMode === OutputMode.Server && Object.keys(prerenderedRoutes).length) { - const manifest = executionResult.outputFiles.find( - (f) => f.path === SERVER_APP_ENGINE_MANIFEST_FILENAME, - ); - assert(manifest, `${SERVER_APP_ENGINE_MANIFEST_FILENAME} was not found in output files.`); - manifest.contents = new TextEncoder().encode( - generateAngularServerAppEngineManifest(i18nOptions, baseHref, prerenderedRoutes), - ); - } - - executionResult.addOutputFile( - 'prerendered-routes.json', - JSON.stringify({ routes: prerenderedRoutes }, null, 2), - BuildOutputFileType.Root, - ); - } + executionResult.addOutputFile( + 'prerendered-routes.json', + JSON.stringify({ routes: executionResult.prerenderedRoutes }, null, 2), + BuildOutputFileType.Root, + ); // Write metafile if stats option is enabled if (options.stats) { diff --git a/packages/angular/build/src/utils/server-rendering/manifest.ts b/packages/angular/build/src/utils/server-rendering/manifest.ts index ae8afb8fc875..1265bd110915 100644 --- a/packages/angular/build/src/utils/server-rendering/manifest.ts +++ b/packages/angular/build/src/utils/server-rendering/manifest.ts @@ -50,13 +50,10 @@ function escapeUnsafeChars(str: string): string { * includes settings for inlining locales and determining the output structure. * @param baseHref - The base HREF for the application. This is used to set the base URL * for all relative URLs in the application. - * @param perenderedRoutes - A record mapping static paths to their associated data. - * @returns A string representing the content of the SSR server manifest for App Engine. */ export function generateAngularServerAppEngineManifest( i18nOptions: NormalizedApplicationBuildOptions['i18nOptions'], baseHref: string | undefined, - perenderedRoutes: PrerenderedRoutesRecord | undefined = {}, ): string { const entryPointsContent: string[] = []; @@ -78,25 +75,10 @@ export function generateAngularServerAppEngineManifest( entryPointsContent.push(`['', () => import('./${MAIN_SERVER_OUTPUT_FILENAME}')]`); } - const staticHeaders: string[] = []; - for (const [path, { headers }] of Object.entries(perenderedRoutes)) { - if (!headers) { - continue; - } - - const headersValues: string[] = []; - for (const [name, value] of Object.entries(headers)) { - headersValues.push(`['${name}', '${encodeURIComponent(value)}']`); - } - - staticHeaders.push(`['${path}', [${headersValues.join(', ')}]]`); - } - const manifestContent = ` export default { basePath: '${baseHref ?? '/'}', entryPoints: new Map([${entryPointsContent.join(', \n')}]), - staticPathsHeaders: new Map([${staticHeaders.join(', \n')}]), }; `; @@ -136,7 +118,9 @@ export function generateAngularServerAppManifest( for (const file of [...additionalHtmlOutputFiles.values(), ...outputFiles]) { const extension = extname(file.path); if (extension === '.html' || (inlineCriticalCss && extension === '.css')) { - serverAssetsContent.push(`['${file.path}', async () => \`${escapeUnsafeChars(file.text)}\`]`); + serverAssetsContent.push( + `['${file.path}', { size: ${file.size}, hash: '${file.hash}', text: async () => \`${escapeUnsafeChars(file.text)}\`}]`, + ); } } diff --git a/packages/angular/ssr/src/app.ts b/packages/angular/ssr/src/app.ts index 65ace593e03f..9af81cf424ea 100644 --- a/packages/angular/ssr/src/app.ts +++ b/packages/angular/ssr/src/app.ts @@ -20,12 +20,6 @@ import { LRUCache } from './utils/lru-cache'; import { AngularBootstrap, renderAngular } from './utils/ng'; import { joinUrlParts, stripIndexHtmlFromURL, stripLeadingSlash } from './utils/url'; -/** - * The default maximum age in seconds. - * Represents the total number of seconds in a 365-day period. - */ -const DEFAULT_MAX_AGE = 365 * 24 * 60 * 60; - /** * Maximum number of critical CSS entries the cache can store. * This value determines the capacity of the LRU (Least Recently Used) cache, which stores critical CSS for pages. @@ -188,18 +182,19 @@ export class AngularServerApp { return null; } - // TODO(alanagius): handle etags - - const content = await this.assets.getServerAsset(assetPath); - - return new Response(content, { - headers: { - 'Content-Type': 'text/html;charset=UTF-8', - // 30 days in seconds - 'Cache-Control': `max-age=${DEFAULT_MAX_AGE}`, - ...headers, - }, - }); + const { text, hash, size } = this.assets.getServerAsset(assetPath); + const etag = `"${hash}"`; + + return request.headers.get('if-none-match') === etag + ? new Response(undefined, { status: 304, statusText: 'Not Modified' }) + : new Response(await text(), { + headers: { + 'Content-Length': size.toString(), + 'ETag': etag, + 'Content-Type': 'text/html;charset=UTF-8', + ...headers, + }, + }); } /** @@ -309,8 +304,10 @@ export class AngularServerApp { }, ); } else if (renderMode === RenderMode.Client) { - // Serve the client-side rendered version if the route is configured for CSR. - return new Response(await this.assets.getServerAsset('index.csr.html'), responseInit); + return new Response( + await this.assets.getServerAsset('index.csr.html').text(), + responseInit, + ); } } @@ -327,7 +324,7 @@ export class AngularServerApp { }); } - let html = await assets.getIndexServerHtml(); + let html = await assets.getIndexServerHtml().text(); // Skip extra microtask if there are no pre hooks. if (hooks.has('html:transform:pre')) { html = await hooks.run('html:transform:pre', { html, url }); @@ -348,7 +345,7 @@ export class AngularServerApp { this.inlineCriticalCssProcessor ??= new InlineCriticalCssProcessor((path: string) => { const fileName = path.split('/').pop() ?? path; - return this.assets.getServerAsset(fileName); + return this.assets.getServerAsset(fileName).text(); }); // TODO(alanagius): remove once Node.js version 18 is no longer supported. diff --git a/packages/angular/ssr/src/assets.ts b/packages/angular/ssr/src/assets.ts index 7dff917645b8..5e72647b0bdc 100644 --- a/packages/angular/ssr/src/assets.ts +++ b/packages/angular/ssr/src/assets.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import { AngularAppManifest } from './manifest'; +import { AngularAppManifest, ServerAsset } from './manifest'; /** * Manages server-side assets. @@ -22,17 +22,17 @@ export class ServerAssets { /** * Retrieves the content of a server-side asset using its path. * - * @param path - The path to the server asset. - * @returns A promise that resolves to the asset content as a string. - * @throws Error If the asset path is not found in the manifest, an error is thrown. + * @param path - The path to the server asset within the manifest. + * @returns The server asset associated with the provided path, as a `ServerAsset` object. + * @throws Error - Throws an error if the asset does not exist. */ - async getServerAsset(path: string): Promise { + getServerAsset(path: string): ServerAsset { const asset = this.manifest.assets.get(path); if (!asset) { throw new Error(`Server asset '${path}' does not exist.`); } - return asset(); + return asset; } /** @@ -46,12 +46,12 @@ export class ServerAssets { } /** - * Retrieves and caches the content of 'index.server.html'. + * Retrieves the asset for 'index.server.html'. * - * @returns A promise that resolves to the content of 'index.server.html'. - * @throws Error If there is an issue retrieving the asset. + * @returns The `ServerAsset` object for 'index.server.html'. + * @throws Error - Throws an error if 'index.server.html' does not exist. */ - getIndexServerHtml(): Promise { + getIndexServerHtml(): ServerAsset { return this.getServerAsset('index.server.html'); } } diff --git a/packages/angular/ssr/src/manifest.ts b/packages/angular/ssr/src/manifest.ts index 004c7e82bbb5..f376295e6ea7 100644 --- a/packages/angular/ssr/src/manifest.ts +++ b/packages/angular/ssr/src/manifest.ts @@ -10,9 +10,26 @@ 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. + * Represents of a server asset stored in the manifest. */ -export type ServerAsset = () => Promise; +export interface ServerAsset { + /** + * Retrieves the text content of the asset. + * + * @returns A promise that resolves to the asset's content as a string. + */ + text: () => Promise; + + /** + * A hash string representing the asset's content. + */ + hash: string; + + /** + * The size of the asset's content in bytes. + */ + size: number; +} /** * Represents the exports of an Angular server application entry point. diff --git a/packages/angular/ssr/src/routes/ng-routes.ts b/packages/angular/ssr/src/routes/ng-routes.ts index 9e5279297e14..eaf92e419b39 100644 --- a/packages/angular/ssr/src/routes/ng-routes.ts +++ b/packages/angular/ssr/src/routes/ng-routes.ts @@ -516,7 +516,7 @@ export async function extractRoutesAndCreateRouteTree( includePrerenderFallbackRoutes = true, ): Promise<{ routeTree: RouteTree; errors: string[] }> { const routeTree = new RouteTree(); - const document = await new ServerAssets(manifest).getIndexServerHtml(); + const document = await new ServerAssets(manifest).getIndexServerHtml().text(); const bootstrap = await manifest.bootstrap(); const { baseHref, routes, errors } = await getRoutesFromAngularRouterConfig( bootstrap, diff --git a/packages/angular/ssr/test/app_spec.ts b/packages/angular/ssr/test/app_spec.ts index e4d1832283d0..c6ed44413ad9 100644 --- a/packages/angular/ssr/test/app_spec.ts +++ b/packages/angular/ssr/test/app_spec.ts @@ -72,17 +72,21 @@ describe('AngularServerApp', () => { ], undefined, { - 'home-ssg/index.html': async () => - ` - - SSG home page - - - - Home SSG works - - - `, + 'home-ssg/index.html': { + text: async () => + ` + + SSG home page + + + + Home SSG works + + + `, + size: 28, + hash: 'f799132d0a09e0fef93c68a12e443527700eb59e6f67fcb7854c3a60ff082fde', + }, }, ); @@ -183,7 +187,40 @@ describe('AngularServerApp', () => { const response = await app.handle(new Request('http://localhost/home-ssg')); const headers = response?.headers.entries() ?? []; expect(Object.fromEntries(headers)).toEqual({ - 'cache-control': 'max-age=31536000', + 'etag': '"f799132d0a09e0fef93c68a12e443527700eb59e6f67fcb7854c3a60ff082fde"', + 'content-length': '28', + 'x-some-header': 'value', + 'content-type': 'text/html;charset=UTF-8', + }); + }); + + it('should return 304 Not Modified when ETag matches', async () => { + const url = 'http://localhost/home-ssg'; + + const initialResponse = await app.handle(new Request(url)); + const etag = initialResponse?.headers.get('etag'); + + expect(etag).toBeDefined(); + + const conditionalResponse = await app.handle( + new Request(url, { + headers: { + 'If-None-Match': etag as string, + }, + }), + ); + + // Check that the response status is 304 Not Modified + expect(conditionalResponse?.status).toBe(304); + expect(await conditionalResponse?.text()).toBe(''); + }); + + it('should return configured headers for pages with specific header settings', async () => { + const response = await app.handle(new Request('http://localhost/home-ssg')); + const headers = response?.headers.entries() ?? []; + expect(Object.fromEntries(headers)).toEqual({ + 'etag': '"f799132d0a09e0fef93c68a12e443527700eb59e6f67fcb7854c3a60ff082fde"', + 'content-length': '28', 'x-some-header': 'value', 'content-type': 'text/html;charset=UTF-8', }); diff --git a/packages/angular/ssr/test/assets_spec.ts b/packages/angular/ssr/test/assets_spec.ts index ea827118896b..211ac128098a 100644 --- a/packages/angular/ssr/test/assets_spec.ts +++ b/packages/angular/ssr/test/assets_spec.ts @@ -16,26 +16,34 @@ describe('ServerAsset', () => { bootstrap: undefined as never, assets: new Map( Object.entries({ - 'index.server.html': async () => 'Index', - 'index.other.html': async () => 'Other', + 'index.server.html': { + text: async () => 'Index', + size: 18, + hash: 'f799132d0a09e0fef93c68a12e443527700eb59e6f67fcb7854c3a60ff082fde', + }, + 'index.other.html': { + text: async () => 'Other', + size: 18, + hash: '4a455a99366921d396f5d51c7253c4678764f5e9487f2c27baaa0f33553c8ce3', + }, }), ), }); }); it('should retrieve and cache the content of index.server.html', async () => { - const content = await assetManager.getIndexServerHtml(); + const content = await assetManager.getIndexServerHtml().text(); expect(content).toBe('Index'); }); - it('should throw an error if the asset path does not exist', async () => { - await expectAsync(assetManager.getServerAsset('nonexistent.html')).toBeRejectedWithError( + it('should throw an error if the asset path does not exist', () => { + expect(() => assetManager.getServerAsset('nonexistent.html')).toThrowError( "Server asset 'nonexistent.html' does not exist.", ); }); it('should retrieve the content of index.other.html', async () => { - const content = await assetManager.getServerAsset('index.other.html'); - expect(content).toBe('Other'); + const asset = await assetManager.getServerAsset('index.other.html').text(); + expect(asset).toBe('Other'); }); }); diff --git a/packages/angular/ssr/test/testing-utils.ts b/packages/angular/ssr/test/testing-utils.ts index 005b108585d3..be4a52d09d0f 100644 --- a/packages/angular/ssr/test/testing-utils.ts +++ b/packages/angular/ssr/test/testing-utils.ts @@ -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, ServerAsset, setAngularAppManifest } from '../src/manifest'; +import { ServerAsset, setAngularAppManifest } from '../src/manifest'; import { ServerRoute, provideServerRoutesConfig } from '../src/routes/route-config'; /** @@ -34,8 +34,10 @@ export function setAngularAppTestingManifest( assets: new Map( Object.entries({ ...additionalServerAssets, - 'index.server.html': async () => - ` + 'index.server.html': { + size: 25, + hash: 'f799132d0a09e0fef93c68a12e443527700eb59e6f67fcb7854c3a60ff082fde', + text: async () => ` SSR page @@ -45,8 +47,12 @@ export function setAngularAppTestingManifest( `, - 'index.csr.html': async () => - ` + }, + 'index.csr.html': { + size: 25, + hash: 'f799132d0a09e0fef93c68a12e443527700eb59e6f67fcb7854c3a60ff082fde', + text: async () => + ` CSR page @@ -56,6 +62,7 @@ export function setAngularAppTestingManifest( `, + }, }), ), bootstrap: async () => () => {