Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion packages/angular/ssr/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
32 changes: 25 additions & 7 deletions packages/angular/ssr/src/utils/ng.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -110,9 +111,13 @@ export async function renderAngular(
} 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 = constructDecodedUrl({ pathname, search, hash });
const urlToRenderString = constructDecodedUrl(urlToRender);
const finalUrl = constructDecodedUrl({ pathname, search, hash }, requestPrefix);
const urlToRenderString = constructDecodedUrl(urlToRender, requestPrefix);

if (urlToRenderString !== finalUrl) {
redirectTo = [pathname, search, hash].join('');
Expand Down Expand Up @@ -186,10 +191,23 @@ function asyncDestroyPlatform(platformRef: PlatformRef): Promise<void> {
* - `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 }): string {
const joinedUrl = [stripTrailingSlash(url.pathname), url.search, url.hash].join('');
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(joinedUrl);
return decodeURIComponent(urlParts.join(''));
}
14 changes: 0 additions & 14 deletions packages/angular/ssr/test/app-engine_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,19 +269,5 @@ describe('AngularAppEngine', () => {
const response = await appEngine.handle(request);
expect(await response?.text()).toContain('Home works');
});

it('should work with encoded characters', async () => {
const request = new Request('https://example.com/home?email=xyz%40xyz.com');
const response = await appEngine.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('https://example.com/[email protected]');
const response = await appEngine.handle(request);
expect(response?.status).toBe(200);
expect(await response?.text()).toContain('Home works');
});
});
});
55 changes: 54 additions & 1 deletion packages/angular/ssr/test/app_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -124,6 +125,14 @@ describe('AngularServerApp', () => {
hash: 'f799132d0a09e0fef93c68a12e443527700eb59e6f67fcb7854c3a60ff082fde',
},
},
undefined,
undefined,
[
{
provide: APP_BASE_HREF,
useFactory: () => inject(REQUEST)?.headers.get('X-Forwarded-Prefix'),
},
],
);

app = new AngularServerApp();
Expand Down Expand Up @@ -309,6 +318,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/[email protected]');
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);
});
});
});
});
});