Skip to content

Commit cdbac82

Browse files
alan-agius4dgp1130
authored andcommitted
fix(@angular/ssr): support custom headers in redirect responses
Updates createRedirectResponse to accept an optional Record<string, string> of headers, allowing custom headers to be merged into the redirect response. The Location and Vary: X-Forwarded-Prefix headers are automatically set to ensure correct routing and proxy behavior. AngularServerApp now passes relevant headers from the matched route or response context when creating a redirect.
1 parent 1dc6992 commit cdbac82

File tree

3 files changed

+130
-19
lines changed

3 files changed

+130
-19
lines changed

packages/angular/ssr/src/app.ts

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { InlineCriticalCssProcessor } from './utils/inline-critical-css';
2525
import { LRUCache } from './utils/lru-cache';
2626
import { AngularBootstrap, renderAngular } from './utils/ng';
2727
import { promiseWithAbort } from './utils/promise';
28+
import { createRedirectResponse } from './utils/redirect';
2829
import { buildPathWithParams, joinUrlParts, stripLeadingSlash } from './utils/url';
2930

3031
/**
@@ -174,7 +175,7 @@ export class AngularServerApp {
174175
return null;
175176
}
176177

177-
const { redirectTo, status, renderMode } = matchedRoute;
178+
const { redirectTo, status, renderMode, headers } = matchedRoute;
178179

179180
if (redirectTo !== undefined) {
180181
return createRedirectResponse(
@@ -183,6 +184,7 @@ export class AngularServerApp {
183184
buildPathWithParams(redirectTo, url.pathname),
184185
),
185186
status,
187+
headers,
186188
);
187189
}
188190

@@ -336,7 +338,7 @@ export class AngularServerApp {
336338
}
337339

338340
if (result.redirectTo) {
339-
return createRedirectResponse(result.redirectTo, status);
341+
return createRedirectResponse(result.redirectTo, responseInit.status, headers);
340342
}
341343

342344
if (renderMode === RenderMode.Prerender) {
@@ -552,20 +554,3 @@ function appendPreloadHintsToHtml(html: string, preload: readonly string[]): str
552554
html.slice(bodyCloseIdx),
553555
].join('\n');
554556
}
555-
556-
/**
557-
* Creates an HTTP redirect response with a specified location and status code.
558-
*
559-
* @param location - The URL to which the response should redirect.
560-
* @param status - The HTTP status code for the redirection. Defaults to 302 (Found).
561-
* See: https://developer.mozilla.org/en-US/docs/Web/API/Response/redirect_static#status
562-
* @returns A `Response` object representing the HTTP redirect.
563-
*/
564-
function createRedirectResponse(location: string, status = 302): Response {
565-
return new Response(null, {
566-
status,
567-
headers: {
568-
'Location': location,
569-
},
570-
});
571-
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
/**
10+
* An set of HTTP status codes that are considered valid for redirect responses.
11+
*/
12+
export const VALID_REDIRECT_RESPONSE_CODES: Set<number> = new Set([301, 302, 303, 307, 308]);
13+
14+
/**
15+
* Checks if the given HTTP status code is a valid redirect response code.
16+
*
17+
* @param code The HTTP status code to check.
18+
* @returns `true` if the code is a valid redirect response code, `false` otherwise.
19+
*/
20+
export function isValidRedirectResponseCode(code: number): boolean {
21+
return VALID_REDIRECT_RESPONSE_CODES.has(code);
22+
}
23+
24+
/**
25+
* Creates an HTTP redirect response with a specified location and status code.
26+
*
27+
* @param location - The URL to which the response should redirect.
28+
* @param status - The HTTP status code for the redirection. Defaults to 302 (Found).
29+
* See: https://developer.mozilla.org/en-US/docs/Web/API/Response/redirect_static#status
30+
* @param headers - Additional headers to include in the response.
31+
* @returns A `Response` object representing the HTTP redirect.
32+
*/
33+
export function createRedirectResponse(
34+
location: string,
35+
status = 302,
36+
headers?: Record<string, string>,
37+
): Response {
38+
if (ngDevMode && !isValidRedirectResponseCode(status)) {
39+
throw new Error(
40+
`Invalid redirect status code: ${status}. ` +
41+
`Please use one of the following redirect response codes: ${[...VALID_REDIRECT_RESPONSE_CODES.values()].join(', ')}.`,
42+
);
43+
}
44+
45+
const resHeaders = new Headers(headers);
46+
if (ngDevMode && resHeaders.has('location')) {
47+
// eslint-disable-next-line no-console
48+
console.warn(
49+
`Location header "${resHeaders.get('location')}" will ignored and set to "${location}".`,
50+
);
51+
}
52+
53+
let vary = resHeaders.get('Vary') ?? '';
54+
if (vary) {
55+
vary += ', ';
56+
}
57+
vary += 'X-Forwarded-Prefix';
58+
59+
resHeaders.set('Vary', vary);
60+
resHeaders.set('Location', location);
61+
62+
return new Response(null, {
63+
status,
64+
headers: resHeaders,
65+
});
66+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { createRedirectResponse } from '../../src/utils/redirect';
10+
11+
describe('Redirect Utils', () => {
12+
describe('createRedirectResponse', () => {
13+
it('should create a redirect response with default status 302', () => {
14+
const response = createRedirectResponse('/home');
15+
expect(response.status).toBe(302);
16+
expect(response.headers.get('Location')).toBe('/home');
17+
expect(response.headers.get('Vary')).toBe('X-Forwarded-Prefix');
18+
});
19+
20+
it('should create a redirect response with a custom status', () => {
21+
const response = createRedirectResponse('/home', 301);
22+
expect(response.status).toBe(301);
23+
expect(response.headers.get('Location')).toBe('/home');
24+
});
25+
26+
it('should allow providing additional headers', () => {
27+
const response = createRedirectResponse('/home', 302, { 'X-Custom': 'value' });
28+
expect(response.headers.get('X-Custom')).toBe('value');
29+
expect(response.headers.get('Location')).toBe('/home');
30+
expect(response.headers.get('Vary')).toBe('X-Forwarded-Prefix');
31+
});
32+
33+
it('should append to Vary header instead of overriding it', () => {
34+
const response = createRedirectResponse('/home', 302, {
35+
'Location': '/evil',
36+
'Vary': 'Host',
37+
});
38+
expect(response.headers.get('Location')).toBe('/home');
39+
expect(response.headers.get('Vary')).toBe('Host, X-Forwarded-Prefix');
40+
});
41+
42+
it('should warn if Location header is provided in extra headers in dev mode', () => {
43+
// @ts-expect-error accessing global
44+
globalThis.ngDevMode = true;
45+
const warnSpy = spyOn(console, 'warn');
46+
createRedirectResponse('/home', 302, { 'Location': '/evil' });
47+
expect(warnSpy).toHaveBeenCalledWith(
48+
'Location header "/evil" will ignored and set to "/home".',
49+
);
50+
});
51+
52+
it('should throw error for invalid redirect status code in dev mode', () => {
53+
// @ts-expect-error accessing global
54+
globalThis.ngDevMode = true;
55+
expect(() => createRedirectResponse('/home', 200)).toThrowError(
56+
/Invalid redirect status code: 200/,
57+
);
58+
});
59+
});
60+
});

0 commit comments

Comments
 (0)