diff --git a/packages/angular/ssr/src/app.ts b/packages/angular/ssr/src/app.ts index dd2873abc2dc..57370136f0af 100644 --- a/packages/angular/ssr/src/app.ts +++ b/packages/angular/ssr/src/app.ts @@ -24,12 +24,7 @@ import { ServerRouter } from './routes/router'; import { AngularBootstrap, renderAngular } from './utils/ng'; import { promiseWithAbort } from './utils/promise'; import { createRedirectResponse } from './utils/redirect'; -import { - buildPathWithParams, - buildQueryWithParams, - joinUrlParts, - stripLeadingSlash, -} from './utils/url'; +import { buildUrlWithParams, joinUrlParts, stripLeadingSlash } from './utils/url'; import { getFirstHeaderValue } from './utils/validation'; /** @@ -174,12 +169,12 @@ export class AngularServerApp { if (redirectTo !== undefined) { const location = joinUrlParts( getFirstHeaderValue(request.headers.get('X-Forwarded-Prefix')) ?? '', - buildPathWithParams(redirectTo, url.pathname), + buildUrlWithParams(redirectTo, url.pathname, relativeRedirect ? undefined : url.search), ); // Match Angular router behavior: relative redirects keep the query string. return createRedirectResponse( - relativeRedirect ? location + url.search : buildQueryWithParams(location, url.searchParams), + relativeRedirect ? location + url.search : location, status, headers, ); diff --git a/packages/angular/ssr/src/routes/ng-routes.ts b/packages/angular/ssr/src/routes/ng-routes.ts index 4176560b88ba..5bb05528d742 100644 --- a/packages/angular/ssr/src/routes/ng-routes.ts +++ b/packages/angular/ssr/src/routes/ng-routes.ts @@ -178,12 +178,22 @@ async function* handleRoute(options: { const parentRoutes = new Map(); + if (typeof redirectTo === 'string') { + let routeSegments: string[] | undefined; + // Reference the route parameters by their position in the URL (see `buildUrlWithParams`). + metadata.redirectTo = resolveRedirectTo(parentRoute, route, redirectTo, (parameter) => { + routeSegments ??= joinUrlParts(baseHref, currentRoutePath).split('/'); + + return `*${routeSegments.lastIndexOf(parameter)}`; + }); + metadata.relativeRedirect = redirectTo[0] !== '/' ? true : undefined; + } + if (metadata.renderMode === RenderMode.Prerender) { for await (const result of handleSSGRoute( serverConfigRouteTree, route, parentRoute, - baseHref, metadata, parentInjector, invokeGetPrerenderParams, @@ -195,31 +205,16 @@ async function* handleRoute(options: { parentRoutes.set(result.route, result); } } - } else if (redirectTo !== undefined) { - if (metadata.status && !isValidRedirectResponseCode(metadata.status)) { - yield { - error: - `The '${metadata.status}' status code is not a valid redirect response code. ` + - `Please use one of the following redirect response codes: ${[...VALID_REDIRECT_RESPONSE_CODES.values()].join(', ')}.`, - }; - } else if (typeof redirectTo === 'string') { - // Reference the route parameters by their position in the URL (see `buildPathWithParams`). - const routeSegments = joinUrlParts(baseHref, currentRoutePath).split('/'); - parentRoutes.set(metadata.route, { - ...metadata, - redirectTo: resolveRedirectTo( - parentRoute, - replaceRedirectParams( - route, - redirectTo, - (parameter) => `*${routeSegments.lastIndexOf(parameter)}`, - ), - ), - relativeRedirect: redirectTo[0] !== '/' ? true : undefined, - }); - } else { - parentRoutes.set(metadata.route, metadata); - } + } else if ( + redirectTo !== undefined && + metadata.status && + !isValidRedirectResponseCode(metadata.status) + ) { + yield { + error: + `The '${metadata.status}' status code is not a valid redirect response code. ` + + `Please use one of the following redirect response codes: ${[...VALID_REDIRECT_RESPONSE_CODES.values()].join(', ')}.`, + }; } else { parentRoutes.set(metadata.route, metadata); } @@ -436,7 +431,6 @@ function appendPreloadToMetadata( * @param serverConfigRouteTree - The tree representing the server's routing setup. * @param route - The route configuration. * @param parentRoute - The path of the parent route. - * @param baseHref - The base href of the application. * @param metadata - The metadata associated with the route tree node. * @param parentInjector - The dependency injection container for the parent route. * @param invokeGetPrerenderParams - A flag indicating whether to invoke the `getPrerenderParams` function. @@ -447,7 +441,6 @@ async function* handleSSGRoute( serverConfigRouteTree: RouteTree | undefined, route: Route, parentRoute: string, - baseHref: string, metadata: ServerConfigRouteTreeNodeMetadata, parentInjector: Injector, invokeGetPrerenderParams: boolean, @@ -466,21 +459,6 @@ async function* handleSSGRoute( delete meta['getPrerenderParams']; } - const redirectTo = typeof route.redirectTo === 'string' ? route.redirectTo : undefined; - if (redirectTo !== undefined) { - // Reference the route parameters by their position in the URL (see `buildPathWithParams`). - const routeSegments = joinUrlParts(baseHref, currentRoutePath).split('/'); - meta.redirectTo = resolveRedirectTo( - parentRoute, - replaceRedirectParams( - route, - redirectTo, - (parameter) => `*${routeSegments.lastIndexOf(parameter)}`, - ), - ); - meta.relativeRedirect = redirectTo[0] !== '/' ? true : undefined; - } - const isCatchAllRoute = CATCH_ALL_REGEXP.test(currentRoutePath); if ( (isCatchAllRoute && !getPrerenderParams) || @@ -522,6 +500,7 @@ async function* handleSSGRoute( } } + const redirectTo = typeof route.redirectTo === 'string' ? route.redirectTo : undefined; const parameters = await runInInjectionContext(parentInjector, () => getPrerenderParams()); try { for (const params of parameters) { @@ -538,11 +517,9 @@ async function* handleSSGRoute( ? undefined : resolveRedirectTo( parentRoute.replace(URL_PARAMETER_GLOBAL_REGEXP, replacer), - replaceRedirectParams( - route, - redirectTo, - (parameter) => params[parameter.slice(1)], - ), + route, + redirectTo, + (parameter) => params[parameter.slice(1)], ), }; } @@ -595,69 +572,55 @@ function handlePrerenderParamsReplacement( } /** - * Resolves the `redirectTo` property for a given route. + * Resolves the `redirectTo` property for a given route, replacing any `:id` route parameters + * in its path and resolving relative paths against `parentRoute`. * - * This function processes the `redirectTo` property to ensure that it correctly - * resolves relative to the parent route path. If `redirectTo` is an absolute path, - * it is returned as is. If it is a relative path, it is resolved based on the parent route path, - * and, like in the Angular router, its query and fragment are ignored. + * Like in the Angular router, only whole path segments starting with `:` are parameters, only + * parameters defined in the path of the redirecting route itself can be used, and the query and + * fragment of relative redirects are ignored. * * @param parentRoute - The path of the parent route. - * @param redirectTo - The target path for redirection. - * @returns The resolved redirect path as a string. - */ -function resolveRedirectTo(parentRoute: string, redirectTo: string): string { - if (redirectTo[0] === '/') { - // If the redirectTo path is absolute, return it as is. - return redirectTo; - } - - // Match Angular router behavior: replace all segments matched by the route. - const [path] = redirectTo.split(/[?#]/, 1); - - return joinUrlParts(parentRoute.replace(URL_PARAMETER_GLOBAL_REGEXP, '*'), path); -} - -/** - * Replaces the route parameters, such as `:id`, in the path of `redirectTo`. - * - * Like in the Angular router, only whole path segments starting with `:` are parameters, and only - * the parameters defined in the path of the redirecting route itself can be used. - * * @param route - The redirecting route. * @param redirectTo - The target path for redirection. - * @param resolve - A function that returns the replacement for a parameter, such as `:id`. - * @returns The redirect path with its route parameters replaced. + * @param resolveParam - A function that returns the replacement for a parameter, such as `:id`. + * @returns The resolved redirect path as a string. * @throws If `redirectTo` uses a parameter that is not defined in the path of the route. */ -function replaceRedirectParams( +function resolveRedirectTo( + parentRoute: string, route: Route, redirectTo: string, - resolve: (parameter: string) => string, + resolveParam: (parameter: string) => string, ): string { - if (route.matcher) { - // The parameters of a custom matcher are only known at runtime. + if (redirectTo[0] === '/' && (route.matcher || !redirectTo.includes('/:'))) { return redirectTo; } - const routeSegments = route.path?.split('/') ?? []; const [path] = redirectTo.split(/[?#]/, 1); - const resolvedPath = path - .split('/') - .map((segment) => { - if (segment[0] !== ':') { - return segment; - } + let resolvedPath = path; + + // The parameters of a custom matcher are only known at runtime. + if (!route.matcher && path.includes(':')) { + const routeSegments = route.path?.split('/') ?? []; + resolvedPath = path + .split('/') + .map((segment) => { + if (segment[0] !== ':') { + return segment; + } - if (!routeSegments.includes(segment)) { - throw new Error(`Cannot redirect to '${redirectTo}'. Cannot find '${segment}'.`); - } + if (!routeSegments.includes(segment)) { + throw new Error(`Cannot redirect to '${redirectTo}'. Cannot find '${segment}'.`); + } - return resolve(segment); - }) - .join('/'); + return resolveParam(segment); + }) + .join('/'); + } - return resolvedPath + redirectTo.slice(path.length); + return redirectTo[0] === '/' + ? resolvedPath + redirectTo.slice(path.length) + : joinUrlParts(parentRoute.replace(URL_PARAMETER_GLOBAL_REGEXP, '*'), resolvedPath); } /** diff --git a/packages/angular/ssr/src/utils/url.ts b/packages/angular/ssr/src/utils/url.ts index 016ecece91cb..ad6226bd500e 100644 --- a/packages/angular/ssr/src/utils/url.ts +++ b/packages/angular/ssr/src/utils/url.ts @@ -6,6 +6,8 @@ * found in the LICENSE file at https://angular.dev/license */ +import { DefaultUrlSerializer } from '@angular/router'; + /** * Removes the trailing slash from a URL if it exists. * @@ -153,123 +155,78 @@ export function stripIndexHtmlFromURL(url: URL): URL { } /** - * Matches a `*` placeholder that makes up a whole path segment. + * Cached instance of `DefaultUrlSerializer` used for query parameter parsing and serialization. */ -const POSITIONAL_PLACEHOLDER_REGEXP = /^\*(\d+)$/; +let urlSerializer: DefaultUrlSerializer | undefined; /** - * Resolves `*` placeholders in a path template by mapping them to corresponding segments - * from a base path. This is useful for constructing paths dynamically based on a given base path. - * - * The function processes the `toPath` string, replacing each `*` placeholder with - * the corresponding segment from the `fromPath`, and each `*` placeholder with the - * segment at position `n` of the `fromPath`. If the `toPath` contains no placeholders, - * it is returned as-is. Invalid `toPath` formats (not starting with `/`) will throw an error. + * Resolves `*` and `*` placeholders in the path of `toUrl` from `fromPath`, and `:name` + * placeholders in the query of `toUrl` from `fromSearch`, matching Angular router redirect semantics. * - * @param toPath - A path template string that may contain `*` placeholders. Each `*` is replaced - * by the corresponding segment from the `fromPath`. Static paths (e.g., `/static/path`) are returned - * directly without placeholder replacement. - * @param fromPath - A base path string, split into segments, that provides values for - * replacing `*` placeholders in the `toPath`. - * @returns A resolved path string with `*` placeholders replaced by segments from the `fromPath`, - * or the `toPath` returned unchanged if it contains no placeholders. + * - Each `*` path segment is replaced with the segment at the same index in `fromPath`. + * - Each `*` path segment is replaced with the 1-based segment at position `n` in `fromPath`. + * - When `fromSearch` is provided, each query parameter whose value is `:name` is replaced with the + * values of `name` from `fromSearch`, or `undefined` if missing. Repeated query parameters are not + * resolved, matching the Angular router. * - * @throws If the `toPath` does not start with a `/`, indicating an invalid path format. + * @param toUrl - A URL template string starting with `/`. + * @param fromPath - A base path string starting with `/` that provides segments for path placeholders. + * @param fromSearch - Optional query string (e.g. `url.search`) that provides values for query placeholders. + * @returns The URL with its path and query placeholders resolved. * * @example - * ```typescript - * // Example with placeholders resolved - * const resolvedPath = buildPathWithParams('/*\/details', '/123/abc'); - * console.log(resolvedPath); // Outputs: '/123/details' - * - * // Example with a positional placeholder - * const positionalPath = buildPathWithParams('/user/*1', '/123/abc'); - * console.log(positionalPath); // Outputs: '/user/123' - * - * // Example with a static path - * const staticPath = buildPathWithParams('/static/path', '/base/unused'); - * console.log(staticPath); // Outputs: '/static/path' + * ```ts + * buildUrlWithParams('/*\/details', '/123/abc'); // '/123/details' + * buildUrlWithParams('/user/*2/*1', '/a/b'); // '/user/b/a' + * buildUrlWithParams('/find?q=:x', '/search', '?x=cats'); // '/find?q=cats' * ``` */ -export function buildPathWithParams(toPath: string, fromPath: string): string { - if (toPath[0] !== '/') { - throw new Error(`Invalid toPath: The string must start with a '/'. Received: '${toPath}'`); +export function buildUrlWithParams(toUrl: string, fromPath: string, fromSearch?: string): string { + if (toUrl[0] !== '/') { + throw new Error(`Invalid toUrl: The string must start with a '/'. Received: '${toUrl}'`); } if (fromPath[0] !== '/') { throw new Error(`Invalid fromPath: The string must start with a '/'. Received: '${fromPath}'`); } - const [path] = toPath.split(/[?#]/, 1); - if (!path.includes('/*')) { - return toPath; + if (!toUrl.includes('/*') && (fromSearch === undefined || !toUrl.includes('=:'))) { + return toUrl; } - const fromPathParts = fromPath.split('/'); - const toPathParts = path.split('/'); - const resolvedParts = toPathParts.map((part, index) => - part === '*' - ? fromPathParts[index] - : part.replace(POSITIONAL_PLACEHOLDER_REGEXP, (_, position) => fromPathParts[position]), - ); + const [path] = toUrl.split(/[?#]/, 1); + let resolvedPath = path; + let suffix = toUrl.slice(path.length); - return joinUrlParts(...resolvedParts) + toPath.slice(path.length); -} - -/** - * Resolves `:name` placeholders in the query of a URL, like the Angular router does for redirects. - * - * Each query parameter whose value is a `:name` placeholder gets the values of the `name` - * query parameter of `fromParams`, or `undefined` if `fromParams` has no such parameter. - * Like in the Angular router, placeholders of repeated query parameters are not resolved. - * - * @param toUrl - A URL whose query may contain `:name` placeholders. - * @param fromParams - The query parameters that provide the values for the placeholders. - * @returns The URL with its query placeholders resolved. - * - * @example - * ```typescript - * buildQueryWithParams('/find?q=:x', new URLSearchParams('x=cats')); // returns '/find?q=cats' - * ``` - */ -export function buildQueryWithParams(toUrl: string, fromParams: URLSearchParams): string { - const [, path, query, fragment] = /^([^?#]*)\?([^#]*)(.*)$/.exec(toUrl) ?? []; - if (query === undefined) { - return toUrl; + if (path.includes('/*')) { + const fromPathParts = fromPath.split('/'); + resolvedPath = joinUrlParts( + ...path + .split('/') + .map((part, index) => + part[0] === '*' + ? (fromPathParts[part.length === 1 ? index : +part.slice(1)] ?? part) + : part, + ), + ); } - const params = query.split('&'); - const names = params.map((param) => param.split('=', 1)[0]); - const resolvedQuery = params - .map((param, index) => { - const name = names[index]; - const value = param.slice(name.length + 1); - if (value[0] !== ':' || names.indexOf(name) !== names.lastIndexOf(name)) { - return param; - } - - const sourceValues = fromParams.getAll(value.slice(1)); + if (fromSearch !== undefined && suffix[0] === '?' && suffix.includes('=:')) { + urlSerializer ??= new DefaultUrlSerializer(); + const toTree = urlSerializer.parse(suffix); + const actualParams = fromSearch ? urlSerializer.parse(fromSearch).queryParams : {}; - return (sourceValues.length ? sourceValues : ['undefined']) - .map((sourceValue) => `${name}=${encodeQueryValue(sourceValue)}`) - .join('&'); - }) - .join('&'); + for (const [key, value] of Object.entries(toTree.queryParams)) { + if (typeof value === 'string' && value[0] === ':') { + toTree.queryParams[key] = actualParams[value.slice(1)]; + } + } - return `${path}?${resolvedQuery}${fragment}`; -} + // `serialize(toTree)` returns `/?...#...` because `toTree.root` is empty. + suffix = urlSerializer.serialize(toTree).slice(1); + } -/** - * Encodes a query parameter value like the Angular router (`DefaultUrlSerializer`), which keeps - * `@`, `:`, `$`, `,` and `;` as is. - * - * @param value - The query parameter value to encode. - * @returns The encoded query parameter value. - */ -function encodeQueryValue(value: string): string { - return encodeURIComponent(value).replace(/%(40|3A|24|2C|3B)/g, (match) => - decodeURIComponent(match), - ); + return resolvedPath + suffix; } const MATRIX_PARAMS_REGEX = /;[^/]+/g; diff --git a/packages/angular/ssr/test/utils/url_spec.ts b/packages/angular/ssr/test/utils/url_spec.ts index e8dc93ccf2a2..8e22edb7226f 100644 --- a/packages/angular/ssr/test/utils/url_spec.ts +++ b/packages/angular/ssr/test/utils/url_spec.ts @@ -9,8 +9,7 @@ import { addLeadingSlash, addTrailingSlash, - buildPathWithParams, - buildQueryWithParams, + buildUrlWithParams, joinUrlParts, stripIndexHtmlFromURL, stripLeadingSlash, @@ -165,12 +164,12 @@ describe('URL Utils', () => { }); }); - describe('buildPathWithParams', () => { - it('should return the same URL when there are no placeholders in the toPath', () => { + describe('buildUrlWithParams', () => { + it('should return the same URL when there are no placeholders in the toUrl', () => { const fromPath = '/base/path'; - const toPath = '/static/path'; + const toUrl = '/static/path'; - const result = buildPathWithParams(toPath, fromPath); + const result = buildUrlWithParams(toUrl, fromPath); // Since there are no placeholders, the URL remains the same. expect(result.toString()).toBe('/static/path'); @@ -178,51 +177,52 @@ describe('URL Utils', () => { it('should replace placeholders with corresponding segments from the base URL path', () => { const fromPath = '/base/path'; - const toPath = '/*/*/details'; + const toUrl = '/*/*/details'; - const result = buildPathWithParams(toPath, fromPath); + const result = buildUrlWithParams(toUrl, fromPath); expect(result.toString()).toBe('/base/path/details'); }); it('should replace positional placeholders with the segments at those positions', () => { - expect(buildPathWithParams('/user/*2/*1', '/a/b')).toBe('/user/b/a'); + expect(buildUrlWithParams('/user/*2/*1', '/a/b')).toBe('/user/b/a'); }); it('should replace a positional placeholder followed by a query or fragment', () => { - expect(buildPathWithParams('/user/*1?returnTo=/a//b/', '/a')).toBe('/user/a?returnTo=/a//b/'); - expect(buildPathWithParams('/user/*1#top', '/a')).toBe('/user/a#top'); + expect(buildUrlWithParams('/user/*1?returnTo=/a//b/', '/a')).toBe('/user/a?returnTo=/a//b/'); + expect(buildUrlWithParams('/user/*1#top', '/a')).toBe('/user/a#top'); }); - it('should throw an error if the toPath does not start with "/"', () => { + it('should throw an error if the toUrl does not start with "/"', () => { const fromPath = '/base/path'; - const toPath = 'details'; + const toUrl = 'details'; - // This should throw an error because toPath doesn't start with "/" + // This should throw an error because toUrl doesn't start with "/" expect(() => { - buildPathWithParams(toPath, fromPath); - }).toThrowError(`Invalid toPath: The string must start with a '/'. Received: 'details'`); + buildUrlWithParams(toUrl, fromPath); + }).toThrowError(`Invalid toUrl: The string must start with a '/'. Received: 'details'`); }); - }); - describe('buildQueryWithParams', () => { it('should replace query placeholders with the values of the source parameters', () => { - const fromParams = new URLSearchParams('term=big cats&term=a:b@c&page=2'); - expect(buildQueryWithParams('/search?q=:term&p=:page&static=a=:b#top', fromParams)).toBe( - '/search?q=big%20cats&q=a:b@c&p=2&static=a=:b#top', - ); + expect( + buildUrlWithParams( + '/search?q=:term&p=:page&static=a=:b#top', + '/from', + '?term=big+cats&term=a:b@c&page=2', + ), + ).toBe('/search?q=big%20cats&q=a:b@c&p=2&static=a%3D:b#top'); }); it('should set query parameters whose source parameter is missing to undefined', () => { - const fromParams = new URLSearchParams(); - expect(buildQueryWithParams('/search?q=:term&static=1', fromParams)).toBe( + expect(buildUrlWithParams('/search?q=:term&static=1', '/from', '')).toBe( '/search?q=undefined&static=1', ); }); it('should not replace placeholders of repeated query parameters', () => { - const fromParams = new URLSearchParams('a=1&b=2'); - expect(buildQueryWithParams('/search?q=:a&q=:b', fromParams)).toBe('/search?q=:a&q=:b'); + expect(buildUrlWithParams('/search?q=:a&q=:b', '/from', '?a=1&b=2')).toBe( + '/search?q=:a&q=:b', + ); }); });