diff --git a/CHANGELOG.md b/CHANGELOG.md index 4be84a714b12..75cf55112e3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ + + +# 20.3.12 (2025-11-25) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------- | +| [25bb7e65c](https://github.com/angular/angular-cli/commit/25bb7e65c4fc7e401c658126c53b0b7a13d62965) | fix | ensure correct URL joining for prerender routes | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [cceb86296](https://github.com/angular/angular-cli/commit/cceb862969e541a5f54b689a6439e32773eafe65) | fix | handle `X-Forwarded-Prefix` and `APP_BASE_HREF` in redirects | +| [1abe68ad8](https://github.com/angular/angular-cli/commit/1abe68ad87f9b892734117a087b5775068bd232b) | fix | prevent redirect loop with encoded query parameters | + + + # 20.3.11 (2025-11-19) diff --git a/package.json b/package.json index 8c277a5e0c02..a4d839e71740 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@angular/devkit-repo", - "version": "20.3.11", + "version": "20.3.12", "private": true, "description": "Software Development Kit for Angular", "keywords": [ diff --git a/packages/angular/build/src/builders/application/options.ts b/packages/angular/build/src/builders/application/options.ts index 1b3a15b8cd56..25bd87253357 100644 --- a/packages/angular/build/src/builders/application/options.ts +++ b/packages/angular/build/src/builders/application/options.ts @@ -25,7 +25,7 @@ import { loadPostcssConfiguration, } from '../../utils/postcss-configuration'; import { getProjectRootPaths, normalizeDirectoryPath } from '../../utils/project-metadata'; -import { urlJoin } from '../../utils/url'; +import { addTrailingSlash, joinUrlParts } from '../../utils/url'; import { Schema as ApplicationBuilderOptions, ExperimentalPlatform, @@ -681,7 +681,9 @@ export function getLocaleBaseHref( const baseHrefSuffix = localeData.baseHref ?? localeData.subPath + '/'; - return baseHrefSuffix !== '' ? urlJoin(baseHref, baseHrefSuffix) : undefined; + return baseHrefSuffix !== '' + ? addTrailingSlash(joinUrlParts(baseHref, baseHrefSuffix)) + : undefined; } /** diff --git a/packages/angular/build/src/utils/server-rendering/prerender.ts b/packages/angular/build/src/utils/server-rendering/prerender.ts index 5ece379ec9c0..f33f851f10c4 100644 --- a/packages/angular/build/src/utils/server-rendering/prerender.ts +++ b/packages/angular/build/src/utils/server-rendering/prerender.ts @@ -14,7 +14,7 @@ import { BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundle import { BuildOutputAsset } from '../../tools/esbuild/bundler-execution-result'; import { assertIsError } from '../error'; import { toPosixPath } from '../path'; -import { urlJoin } from '../url'; +import { addLeadingSlash, addTrailingSlash, joinUrlParts, stripLeadingSlash } from '../url'; import { WorkerPool } from '../worker-pool'; import { IMPORT_EXEC_ARGV } from './esm-in-memory-loader/utils'; import { SERVER_APP_MANIFEST_FILENAME } from './manifest'; @@ -240,7 +240,7 @@ async function renderPages( ? addLeadingSlash(route.slice(baseHrefPathnameWithLeadingSlash.length)) : route; - const outPath = posix.join(removeLeadingSlash(routeWithoutBaseHref), 'index.html'); + const outPath = stripLeadingSlash(posix.join(routeWithoutBaseHref, 'index.html')); if (typeof redirectTo === 'string') { output[outPath] = { content: generateRedirectStaticPage(redirectTo), appShellRoute: false }; @@ -298,7 +298,7 @@ async function getAllRoutes( let appShellRoute: string | undefined; if (appShellOptions) { - appShellRoute = urlJoin(baseHref, appShellOptions.route); + appShellRoute = joinUrlParts(baseHref, appShellOptions.route); routes.push({ renderMode: RouteRenderMode.Prerender, @@ -311,7 +311,7 @@ async function getAllRoutes( for (const route of routesFromFile) { routes.push({ renderMode: RouteRenderMode.Prerender, - route: urlJoin(baseHref, route.trim()), + route: joinUrlParts(baseHref, route.trim()), }); } } @@ -369,15 +369,3 @@ async function getAllRoutes( void renderWorker.destroy(); } } - -function addLeadingSlash(value: string): string { - return value[0] === '/' ? value : '/' + value; -} - -function addTrailingSlash(url: string): string { - return url[url.length - 1] === '/' ? url : `${url}/`; -} - -function removeLeadingSlash(value: string): string { - return value[0] === '/' ? value.slice(1) : value; -} diff --git a/packages/angular/build/src/utils/url.ts b/packages/angular/build/src/utils/url.ts index d3f1e5791276..9edbfb3a3de5 100644 --- a/packages/angular/build/src/utils/url.ts +++ b/packages/angular/build/src/utils/url.ts @@ -6,11 +6,117 @@ * found in the LICENSE file at https://angular.dev/license */ -export function urlJoin(...parts: string[]): string { - const [p, ...rest] = parts; +/** + * Removes the trailing slash from a URL if it exists. + * + * @param url - The URL string from which to remove the trailing slash. + * @returns The URL string without a trailing slash. + * + * @example + * ```js + * stripTrailingSlash('path/'); // 'path' + * stripTrailingSlash('/path'); // '/path' + * stripTrailingSlash('/'); // '/' + * stripTrailingSlash(''); // '' + * ``` + */ +export function stripTrailingSlash(url: string): string { + // Check if the last character of the URL is a slash + return url.length > 1 && url[url.length - 1] === '/' ? url.slice(0, -1) : url; +} + +/** + * Removes the leading slash from a URL if it exists. + * + * @param url - The URL string from which to remove the leading slash. + * @returns The URL string without a leading slash. + * + * @example + * ```js + * stripLeadingSlash('/path'); // 'path' + * stripLeadingSlash('/path/'); // 'path/' + * stripLeadingSlash('/'); // '/' + * stripLeadingSlash(''); // '' + * ``` + */ +export function stripLeadingSlash(url: string): string { + // Check if the first character of the URL is a slash + return url.length > 1 && url[0] === '/' ? url.slice(1) : url; +} + +/** + * Adds a leading slash to a URL if it does not already have one. + * + * @param url - The URL string to which the leading slash will be added. + * @returns The URL string with a leading slash. + * + * @example + * ```js + * addLeadingSlash('path'); // '/path' + * addLeadingSlash('/path'); // '/path' + * ``` + */ +export function addLeadingSlash(url: string): string { + // Check if the URL already starts with a slash + return url[0] === '/' ? url : `/${url}`; +} + +/** + * Adds a trailing slash to a URL if it does not already have one. + * + * @param url - The URL string to which the trailing slash will be added. + * @returns The URL string with a trailing slash. + * + * @example + * ```js + * addTrailingSlash('path'); // 'path/' + * addTrailingSlash('path/'); // 'path/' + * ``` + */ +export function addTrailingSlash(url: string): string { + // Check if the URL already end with a slash + return url[url.length - 1] === '/' ? url : `${url}/`; +} + +/** + * Joins URL parts into a single URL string. + * + * This function takes multiple URL segments, normalizes them by removing leading + * and trailing slashes where appropriate, and then joins them into a single URL. + * + * @param parts - The parts of the URL to join. Each part can be a string with or without slashes. + * @returns The joined URL string, with normalized slashes. + * + * @example + * ```js + * joinUrlParts('path/', '/to/resource'); // '/path/to/resource' + * joinUrlParts('/path/', 'to/resource'); // '/path/to/resource' + * joinUrlParts('http://localhost/path/', 'to/resource'); // 'http://localhost/path/to/resource' + * joinUrlParts('', ''); // '/' + * ``` + */ +export function joinUrlParts(...parts: string[]): string { + const normalizeParts: string[] = []; + for (const part of parts) { + if (part === '') { + // Skip any empty parts + continue; + } + + let normalizedPart = part; + if (part[0] === '/') { + normalizedPart = normalizedPart.slice(1); + } + if (part[part.length - 1] === '/') { + normalizedPart = normalizedPart.slice(0, -1); + } + if (normalizedPart !== '') { + normalizeParts.push(normalizedPart); + } + } + + const protocolMatch = normalizeParts.length && /^https?:\/\//.test(normalizeParts[0]); + const joinedParts = normalizeParts.join('/'); - // Remove trailing slash from first part - // Join all parts with `/` - // Dedupe double slashes from path names - return p.replace(/\/$/, '') + ('/' + rest.join('/')).replace(/\/\/+/g, '/'); + return protocolMatch ? joinedParts : addLeadingSlash(joinedParts); } diff --git a/packages/angular/ssr/src/app.ts b/packages/angular/ssr/src/app.ts index 4895866d715b..ee14f8a26105 100644 --- a/packages/angular/ssr/src/app.ts +++ b/packages/angular/ssr/src/app.ts @@ -175,8 +175,15 @@ export class AngularServerApp { } const { redirectTo, status, renderMode } = matchedRoute; + if (redirectTo !== undefined) { - return createRedirectResponse(buildPathWithParams(redirectTo, url.pathname), status); + return createRedirectResponse( + joinUrlParts( + request.headers.get('X-Forwarded-Prefix') ?? '', + buildPathWithParams(redirectTo, url.pathname), + ), + status, + ); } if (renderMode === RenderMode.Prerender) { diff --git a/packages/angular/ssr/src/utils/ng.ts b/packages/angular/ssr/src/utils/ng.ts index 44f17781be56..a35beeff835b 100644 --- a/packages/angular/ssr/src/utils/ng.ts +++ b/packages/angular/ssr/src/utils/ng.ts @@ -6,10 +6,11 @@ * found in the LICENSE file at https://angular.dev/license */ -import { PlatformLocation } from '@angular/common'; +import { APP_BASE_HREF, PlatformLocation } from '@angular/common'; import { ApplicationRef, type PlatformRef, + REQUEST, type StaticProvider, type Type, ɵConsole, @@ -23,7 +24,7 @@ import { } from '@angular/platform-server'; import { ActivatedRoute, Router } from '@angular/router'; import { Console } from '../console'; -import { stripIndexHtmlFromURL, stripTrailingSlash } from './url'; +import { addTrailingSlash, joinUrlParts, stripIndexHtmlFromURL, stripTrailingSlash } from './url'; /** * Represents the bootstrap mechanism for an Angular application. @@ -107,13 +108,19 @@ export async function renderAngular( if (!routerIsProvided) { hasNavigationError = false; - } else if (lastSuccessfulNavigation) { + } else if (lastSuccessfulNavigation?.finalUrl) { hasNavigationError = false; + + const requestPrefix = + envInjector.get(APP_BASE_HREF, null, { optional: true }) ?? + envInjector.get(REQUEST, null, { optional: true })?.headers.get('X-Forwarded-Prefix'); + const { pathname, search, hash } = envInjector.get(PlatformLocation); - const finalUrl = [stripTrailingSlash(pathname), search, hash].join(''); + const finalUrl = constructDecodedUrl({ pathname, search, hash }, requestPrefix); + const urlToRenderString = constructDecodedUrl(urlToRender, requestPrefix); - if (urlToRender.href !== new URL(finalUrl, urlToRender.origin).href) { - redirectTo = finalUrl; + if (urlToRenderString !== finalUrl) { + redirectTo = [pathname, search, hash].join(''); } } @@ -171,3 +178,36 @@ function asyncDestroyPlatform(platformRef: PlatformRef): Promise { }, 0); }); } + +/** + * Constructs a decoded URL string from its components, ensuring consistency for comparison. + * + * This function takes a URL-like object (containing `pathname`, `search`, and `hash`), + * strips the trailing slash from the pathname, joins the components, and then decodes + * the entire string. This normalization is crucial for accurately comparing URLs + * that might differ only in encoding or trailing slashes. + * + * @param url - An object containing the URL components: + * - `pathname`: The path of the URL. + * - `search`: The query string of the URL (including '?'). + * - `hash`: The hash fragment of the URL (including '#'). + * @param prefix - An optional prefix (e.g., `APP_BASE_HREF`) to prepend to the pathname + * if it is not already present. + * @returns The constructed and decoded URL string. + */ +function constructDecodedUrl( + url: { pathname: string; search: string; hash: string }, + prefix?: string | null, +): string { + const { pathname, hash, search } = url; + const urlParts: string[] = []; + if (prefix && !addTrailingSlash(pathname).startsWith(addTrailingSlash(prefix))) { + urlParts.push(joinUrlParts(prefix, pathname)); + } else { + urlParts.push(stripTrailingSlash(pathname)); + } + + urlParts.push(search, hash); + + return decodeURIComponent(urlParts.join('')); +} diff --git a/packages/angular/ssr/src/utils/url.ts b/packages/angular/ssr/src/utils/url.ts index 9b5edede7f8e..faabf15b1bd4 100644 --- a/packages/angular/ssr/src/utils/url.ts +++ b/packages/angular/ssr/src/utils/url.ts @@ -220,3 +220,18 @@ export function stripMatrixParams(pathname: string): string { // This regex finds all occurrences of a semicolon followed by any characters return pathname.includes(';') ? pathname.replace(MATRIX_PARAMS_REGEX, '') : pathname; } + +/** + * Constructs a decoded URL string from its components. + * + * This function joins the pathname (with trailing slash removed), search, and hash, + * and then decodes the result. + * + * @param pathname - The path of the URL. + * @param search - The query string of the URL (including '?'). + * @param hash - The hash fragment of the URL (including '#'). + * @returns The constructed and decoded URL string. + */ +export function constructUrl(pathname: string, search: string, hash: string): string { + return decodeURIComponent([stripTrailingSlash(pathname), search, hash].join('')); +} diff --git a/packages/angular/ssr/test/app_spec.ts b/packages/angular/ssr/test/app_spec.ts index b3ea250cae41..edf51e5e65fa 100644 --- a/packages/angular/ssr/test/app_spec.ts +++ b/packages/angular/ssr/test/app_spec.ts @@ -11,7 +11,8 @@ import '@angular/compiler'; /* eslint-enable import/no-unassigned-import */ -import { Component, inject } from '@angular/core'; +import { APP_BASE_HREF } from '@angular/common'; +import { Component, REQUEST, inject } from '@angular/core'; import { CanActivateFn, Router } from '@angular/router'; import { AngularServerApp } from '../src/app'; import { RenderMode } from '../src/routes/route-config'; @@ -125,6 +126,14 @@ describe('AngularServerApp', () => { hash: 'f799132d0a09e0fef93c68a12e443527700eb59e6f67fcb7854c3a60ff082fde', }, }, + undefined, + undefined, + [ + { + provide: APP_BASE_HREF, + useFactory: () => inject(REQUEST)?.headers.get('X-Forwarded-Prefix'), + }, + ], ); app = new AngularServerApp(); @@ -310,6 +319,50 @@ describe('AngularServerApp', () => { expect(response?.headers.get('location')).toBe('/redirect-via-guard?filter=test'); expect(response?.status).toBe(302); }); + + it('should work with encoded characters', async () => { + const request = new Request('http://localhost/home?email=xyz%40xyz.com'); + const response = await app.handle(request); + expect(response?.status).toBe(200); + expect(await response?.text()).toContain('Home works'); + }); + + it('should work with decoded characters', async () => { + const request = new Request('http://localhost/home?email=xyz@xyz.com'); + const response = await app.handle(request); + expect(response?.status).toBe(200); + expect(await response?.text()).toContain('Home works'); + }); + + describe('APP_BASE_HREF / X-Forwarded-Prefix', () => { + const headers = new Headers({ 'X-Forwarded-Prefix': '/base/' }); + + it('should return a rendered page for known paths', async () => { + const request = new Request('https://example.com/home', { headers }); + const response = await app.handle(request); + expect(await response?.text()).toContain('Home works'); + }); + + it('returns a 302 status and redirects to the correct location when `redirectTo` is a function', async () => { + const response = await app.handle( + new Request('http://localhost/redirect-to-function', { + headers, + }), + ); + expect(response?.headers.get('location')).toBe('/base/home'); + expect(response?.status).toBe(302); + }); + + it('returns a 302 status and redirects to the correct location when `redirectTo` is a string', async () => { + const response = await app.handle( + new Request('http://localhost/redirect', { + headers, + }), + ); + expect(response?.headers.get('location')).toBe('/base/home'); + expect(response?.status).toBe(302); + }); + }); }); }); });