diff --git a/packages/angular/build/src/builders/application/execute-post-bundle.ts b/packages/angular/build/src/builders/application/execute-post-bundle.ts index 2f4f73c69b08..31b4a9d2e97c 100644 --- a/packages/angular/build/src/builders/application/execute-post-bundle.ts +++ b/packages/angular/build/src/builders/application/execute-post-bundle.ts @@ -63,6 +63,7 @@ export async function executePostBundleSteps( const { baseHref = '/', serviceWorker, + i18nOptions, indexHtmlOptions, optimizationOptions, sourcemapOptions, @@ -114,6 +115,7 @@ export async function executePostBundleSteps( optimizationOptions.styles.inlineCritical ?? false, undefined, locale, + baseHref, ); additionalOutputFiles.push( @@ -194,6 +196,7 @@ export async function executePostBundleSteps( optimizationOptions.styles.inlineCritical ?? false, serializableRouteTreeNodeForManifest, locale, + baseHref, ); for (const chunk of serverAssetsChunks) { diff --git a/packages/angular/build/src/utils/server-rendering/manifest.ts b/packages/angular/build/src/utils/server-rendering/manifest.ts index eb13be07e5d1..a757c79561f3 100644 --- a/packages/angular/build/src/utils/server-rendering/manifest.ts +++ b/packages/angular/build/src/utils/server-rendering/manifest.ts @@ -103,6 +103,8 @@ export default { * server-side rendering and routing. * @param locale - An optional string representing the locale or language code to be used for * the application, helping with localization and rendering content specific to the locale. + * @param baseHref - The base HREF for the application. This is used to set the base URL + * for all relative URLs in the application. * * @returns An object containing: * - `manifestContent`: A string of the SSR manifest content. @@ -114,6 +116,7 @@ export function generateAngularServerAppManifest( inlineCriticalCss: boolean, routes: readonly unknown[] | undefined, locale: string | undefined, + baseHref: string, ): { manifestContent: string; serverAssetsChunks: BuildOutputFile[]; @@ -142,9 +145,10 @@ export function generateAngularServerAppManifest( export default { bootstrap: () => import('./main.server.mjs').then(m => m.default), inlineCriticalCss: ${inlineCriticalCss}, + baseHref: '${baseHref}', + locale: ${locale !== undefined ? `'${locale}'` : undefined}, routes: ${JSON.stringify(routes, undefined, 2)}, assets: new Map([\n${serverAssetsContent.join(', \n')}\n]), - locale: ${locale !== undefined ? `'${locale}'` : undefined}, }; `; diff --git a/packages/angular/build/src/utils/server-rendering/prerender.ts b/packages/angular/build/src/utils/server-rendering/prerender.ts index 6bee2c6a43e9..2c539502382c 100644 --- a/packages/angular/build/src/utils/server-rendering/prerender.ts +++ b/packages/angular/build/src/utils/server-rendering/prerender.ts @@ -165,7 +165,6 @@ export async function prerenderPages( workspaceRoot, outputFilesForWorker, assetsReversed, - appShellOptions, outputMode, appShellRoute ?? appShellOptions?.route, ); @@ -188,7 +187,6 @@ async function renderPages( workspaceRoot: string, outputFilesForWorker: Record, assetFilesForWorker: Record, - appShellOptions: AppShellOptions | undefined, outputMode: OutputMode | undefined, appShellRoute: string | undefined, ): Promise<{ @@ -224,7 +222,7 @@ async function renderPages( for (const { route, redirectTo, renderMode } of serializableRouteTreeNode) { // Remove the base href from the file output path. const routeWithoutBaseHref = addTrailingSlash(route).startsWith(baseHrefWithLeadingSlash) - ? addLeadingSlash(route.slice(baseHrefWithLeadingSlash.length - 1)) + ? addLeadingSlash(route.slice(baseHrefWithLeadingSlash.length)) : route; const outPath = posix.join(removeLeadingSlash(routeWithoutBaseHref), 'index.html'); diff --git a/packages/angular/ssr/src/app.ts b/packages/angular/ssr/src/app.ts index cc8cbc0c7dba..e21392e9f8f0 100644 --- a/packages/angular/ssr/src/app.ts +++ b/packages/angular/ssr/src/app.ts @@ -214,8 +214,7 @@ export class AngularServerApp { return null; } - const { pathname } = stripIndexHtmlFromURL(new URL(request.url)); - const assetPath = stripLeadingSlash(joinUrlParts(pathname, 'index.html')); + const assetPath = this.buildServerAssetPathFromRequest(request); if (!this.assets.hasServerAsset(assetPath)) { return null; } @@ -355,6 +354,33 @@ export class AngularServerApp { return new Response(html, responseInit); } + + /** + * Constructs the asset path on the server based on the provided HTTP request. + * + * This method processes the incoming request URL to derive a path corresponding + * to the requested asset. It ensures the path points to the correct file (e.g., + * `index.html`) and removes any base href if it is not part of the asset path. + * + * @param request - The incoming HTTP request object. + * @returns The server-relative asset path derived from the request. + */ + private buildServerAssetPathFromRequest(request: Request): string { + let { pathname: assetPath } = new URL(request.url); + if (!assetPath.endsWith('/index.html')) { + // Append "index.html" to build the default asset path. + assetPath = joinUrlParts(assetPath, 'index.html'); + } + + const { baseHref } = this.manifest; + // Check if the asset path starts with the base href and the base href is not (`/` or ``). + if (baseHref.length > 1 && assetPath.startsWith(baseHref)) { + // Remove the base href from the start of the asset path to align with server-asset expectations. + assetPath = assetPath.slice(baseHref.length); + } + + return stripLeadingSlash(assetPath); + } } let angularServerApp: AngularServerApp | undefined; diff --git a/packages/angular/ssr/src/manifest.ts b/packages/angular/ssr/src/manifest.ts index 0331ac10b0bd..f18aa01af4ea 100644 --- a/packages/angular/ssr/src/manifest.ts +++ b/packages/angular/ssr/src/manifest.ts @@ -71,6 +71,12 @@ export interface AngularAppEngineManifest { * Manifest for a specific Angular server application, defining assets and bootstrap logic. */ export interface AngularAppManifest { + /** + * The base href for the application. + * This is used to determine the root path of the application. + */ + readonly baseHref: string; + /** * A map of assets required by the server application. * Each entry in the map consists of: diff --git a/packages/angular/ssr/test/app-engine_spec.ts b/packages/angular/ssr/test/app-engine_spec.ts index 33e9741f4c9c..fd37fb5b27ee 100644 --- a/packages/angular/ssr/test/app-engine_spec.ts +++ b/packages/angular/ssr/test/app-engine_spec.ts @@ -34,15 +34,44 @@ describe('AngularAppEngine', () => { async () => { @Component({ standalone: true, - selector: `app-home-${locale}`, - template: `Home works ${locale.toUpperCase()}`, + selector: `app-ssr-${locale}`, + template: `SSR works ${locale.toUpperCase()}`, }) - class HomeComponent {} + class SSRComponent {} + + @Component({ + standalone: true, + selector: `app-ssg-${locale}`, + template: `SSG works ${locale.toUpperCase()}`, + }) + class SSGComponent {} setAngularAppTestingManifest( - [{ path: 'home', component: HomeComponent }], - [{ path: '**', renderMode: RenderMode.Server }], + [ + { path: 'ssg', component: SSGComponent }, + { path: 'ssr', component: SSRComponent }, + ], + [ + { path: 'ssg', renderMode: RenderMode.Prerender }, + { path: '**', renderMode: RenderMode.Server }, + ], '/' + locale, + { + 'ssg/index.html': { + size: 25, + hash: 'f799132d0a09e0fef93c68a12e443527700eb59e6f67fcb7854c3a60ff082fde', + text: async () => ` + + SSG page + + + + SSG works ${locale.toUpperCase()} + + + `, + }, + }, ); return { @@ -58,7 +87,7 @@ describe('AngularAppEngine', () => { appEngine = new AngularAppEngine(); }); - describe('render', () => { + describe('handle', () => { it('should return null for requests to unknown pages', async () => { const request = new Request('https://example.com/unknown/page'); const response = await appEngine.handle(request); @@ -66,21 +95,33 @@ describe('AngularAppEngine', () => { }); it('should return null for requests with unknown locales', async () => { - const request = new Request('https://example.com/es/home'); + const request = new Request('https://example.com/es/ssr'); const response = await appEngine.handle(request); expect(response).toBeNull(); }); it('should return a rendered page with correct locale', async () => { - const request = new Request('https://example.com/it/home'); + const request = new Request('https://example.com/it/ssr'); const response = await appEngine.handle(request); - expect(await response?.text()).toContain('Home works IT'); + expect(await response?.text()).toContain('SSR works IT'); }); it('should correctly render the content when the URL ends with "index.html" with correct locale', async () => { - const request = new Request('https://example.com/it/home/index.html'); + const request = new Request('https://example.com/it/ssr/index.html'); + const response = await appEngine.handle(request); + expect(await response?.text()).toContain('SSR works IT'); + }); + + it('should return a serve prerendered page with correct locale', async () => { + const request = new Request('https://example.com/it/ssg'); + const response = await appEngine.handle(request); + expect(await response?.text()).toContain('SSG works IT'); + }); + + it('should correctly serve the prerendered content when the URL ends with "index.html" with correct locale', async () => { + const request = new Request('https://example.com/it/ssg/index.html'); const response = await appEngine.handle(request); - expect(await response?.text()).toContain('Home works IT'); + expect(await response?.text()).toContain('SSG works IT'); }); it('should return null for requests to unknown pages in a locale', async () => { diff --git a/packages/angular/ssr/test/assets_spec.ts b/packages/angular/ssr/test/assets_spec.ts index 211ac128098a..fa794f4d9317 100644 --- a/packages/angular/ssr/test/assets_spec.ts +++ b/packages/angular/ssr/test/assets_spec.ts @@ -13,6 +13,7 @@ describe('ServerAsset', () => { beforeAll(() => { assetManager = new ServerAssets({ + baseHref: '/', bootstrap: undefined as never, assets: new Map( Object.entries({ diff --git a/packages/angular/ssr/test/testing-utils.ts b/packages/angular/ssr/test/testing-utils.ts index 99971ab894ef..9c48479fe038 100644 --- a/packages/angular/ssr/test/testing-utils.ts +++ b/packages/angular/ssr/test/testing-utils.ts @@ -31,6 +31,7 @@ export function setAngularAppTestingManifest( ): void { setAngularAppManifest({ inlineCriticalCss: false, + baseHref, assets: new Map( Object.entries({ ...additionalServerAssets, diff --git a/tests/legacy-cli/e2e/tests/build/server-rendering/server-routes-output-mode-server-i18n-base-href.ts b/tests/legacy-cli/e2e/tests/build/server-rendering/server-routes-output-mode-server-i18n-base-href.ts index 9e10b99b0747..c9bcc6ee5a09 100644 --- a/tests/legacy-cli/e2e/tests/build/server-rendering/server-routes-output-mode-server-i18n-base-href.ts +++ b/tests/legacy-cli/e2e/tests/build/server-rendering/server-routes-output-mode-server-i18n-base-href.ts @@ -87,17 +87,18 @@ export default async function () { // Tests responses const port = await spawnServer(); - const pathname = '/ssr'; - + const pathnamesToVerify = ['/ssr', '/ssg']; for (const { lang } of langTranslations) { - const res = await fetch(`http://localhost:${port}/base/${lang}${pathname}`); - const text = await res.text(); + for (const pathname of pathnamesToVerify) { + const res = await fetch(`http://localhost:${port}/base/${lang}${pathname}`); + const text = await res.text(); - assert.match( - text, - new RegExp(`

${lang}

`), - `Response for '${lang}${pathname}': '

${lang}

' was not matched in content.`, - ); + assert.match( + text, + new RegExp(`

${lang}

`), + `Response for '${lang}${pathname}': '

${lang}

' was not matched in content.`, + ); + } } }