Skip to content

Commit e621712

Browse files
ematipicoflorian-lefebvresarah11918yanthomasdev
authored
feat(routing): external redirects (#12979)
Co-authored-by: florian-lefebvre <69633530+florian-lefebvre@users.noreply.github.com> Co-authored-by: sarah11918 <5098874+sarah11918@users.noreply.github.com> Co-authored-by: yanthomasdev <61414485+yanthomasdev@users.noreply.github.com>
1 parent 0f3be31 commit e621712

File tree

7 files changed

+114
-42
lines changed

7 files changed

+114
-42
lines changed

.changeset/light-pants-smoke.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'astro': minor
3+
---
4+
5+
Adds support for redirecting to external sites with the [`redirects`](https://docs.astro.build/en/reference/configuration-reference/#redirects) configuration option.
6+
7+
Now, you can redirect routes either internally to another path or externally by providing a URL beginning with `http` or `https`:
8+
9+
```js
10+
// astro.config.mjs
11+
import {defineConfig} from "astro/config"
12+
13+
export default defineConfig({
14+
redirects: {
15+
"/blog": "https://example.com/blog",
16+
"/news": {
17+
status: 302,
18+
destination: "https://example.com/news"
19+
}
20+
}
21+
})
22+
```

packages/astro/src/core/errors/errors-data.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -995,6 +995,20 @@ export const RedirectWithNoLocation = {
995995
name: 'RedirectWithNoLocation',
996996
title: 'A redirect must be given a location with the `Location` header.',
997997
} satisfies ErrorData;
998+
999+
/**
1000+
* @docs
1001+
* @see
1002+
* - [Astro.redirect](https://docs.astro.build/en/reference/api-reference/#redirect)
1003+
* @description
1004+
* An external redirect must start with http or https, and must be a valid URL.
1005+
*/
1006+
export const UnsupportedExternalRedirect = {
1007+
name: 'UnsupportedExternalRedirect',
1008+
title: 'Unsupported or malformed URL.',
1009+
message: 'An external redirect must start with http or https, and must be a valid URL.',
1010+
} satisfies ErrorData;
1011+
9981012
/**
9991013
* @docs
10001014
* @see

packages/astro/src/core/redirects/render.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
1+
import type { RedirectConfig } from '../../types/public/index.js';
12
import type { RenderContext } from '../render-context.js';
23

4+
export function redirectIsExternal(redirect: RedirectConfig): boolean {
5+
if (typeof redirect === 'string') {
6+
return redirect.startsWith('http://') || redirect.startsWith('https://');
7+
} else {
8+
return (
9+
redirect.destination.startsWith('http://') || redirect.destination.startsWith('https://')
10+
);
11+
}
12+
}
13+
314
export async function renderRedirect(renderContext: RenderContext) {
415
const {
516
request: { method },
@@ -9,6 +20,13 @@ export async function renderRedirect(renderContext: RenderContext) {
920
const status =
1021
redirectRoute && typeof redirect === 'object' ? redirect.status : method === 'GET' ? 301 : 308;
1122
const headers = { location: encodeURI(redirectRouteGenerate(renderContext)) };
23+
if (redirect && redirectIsExternal(redirect)) {
24+
if (typeof redirect === 'string') {
25+
return Response.redirect(redirect, status);
26+
} else {
27+
return Response.redirect(redirect.destination, status);
28+
}
29+
}
1230
return new Response(null, { status, headers });
1331
}
1432

@@ -21,13 +39,16 @@ function redirectRouteGenerate(renderContext: RenderContext): string {
2139
if (typeof redirectRoute !== 'undefined') {
2240
return redirectRoute?.generate(params) || redirectRoute?.pathname || '/';
2341
} else if (typeof redirect === 'string') {
24-
// TODO: this logic is duplicated between here and manifest/create.ts
25-
let target = redirect;
26-
for (const param of Object.keys(params)) {
27-
const paramValue = params[param]!;
28-
target = target.replace(`[${param}]`, paramValue).replace(`[...${param}]`, paramValue);
42+
if (redirectIsExternal(redirect)) {
43+
return redirect;
44+
} else {
45+
let target = redirect;
46+
for (const param of Object.keys(params)) {
47+
const paramValue = params[param]!;
48+
target = target.replace(`[${param}]`, paramValue).replace(`[...${param}]`, paramValue);
49+
}
50+
return target;
2951
}
30-
return target;
3152
} else if (typeof redirect === 'undefined') {
3253
return '/';
3354
}

packages/astro/src/core/routing/manifest/create.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ import { getPrerenderDefault } from '../../../prerender/utils.js';
1414
import type { AstroConfig } from '../../../types/public/config.js';
1515
import type { RouteData, RoutePart } from '../../../types/public/internal.js';
1616
import { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from '../../constants.js';
17-
import { MissingIndexForInternationalization } from '../../errors/errors-data.js';
17+
import {
18+
MissingIndexForInternationalization,
19+
UnsupportedExternalRedirect,
20+
} from '../../errors/errors-data.js';
1821
import { AstroError } from '../../errors/index.js';
1922
import { removeLeadingForwardSlash, slash } from '../../path.js';
2023
import { injectServerIslandRoute } from '../../server-islands/endpoint.js';
@@ -314,7 +317,6 @@ function createInjectedRoutes({ settings, cwd }: CreateRouteManifestParams): Rou
314317
function createRedirectRoutes(
315318
{ settings }: CreateRouteManifestParams,
316319
routeMap: Map<string, RouteData>,
317-
logger: Logger,
318320
): RouteData[] {
319321
const { config } = settings;
320322
const trailingSlash = config.trailingSlash;
@@ -348,11 +350,12 @@ function createRedirectRoutes(
348350
destination = to.destination;
349351
}
350352

351-
if (/^https?:\/\//.test(destination)) {
352-
logger.warn(
353-
'redirects',
354-
`Redirecting to an external URL is not officially supported: ${from} -> ${destination}`,
355-
);
353+
// URLs that don't start with leading slash should be considered external
354+
if (!destination.startsWith('/')) {
355+
// check if the link starts with http or https; if not, log a warning
356+
if (!/^https?:\/\//.test(destination) && !URL.canParse(destination)) {
357+
throw new AstroError(UnsupportedExternalRedirect);
358+
}
356359
}
357360

358361
routes.push({
@@ -480,7 +483,7 @@ export async function createRoutesList(
480483
routeMap.set(route.route, route);
481484
}
482485

483-
const redirectRoutes = createRedirectRoutes(params, routeMap, logger);
486+
const redirectRoutes = createRedirectRoutes(params, routeMap);
484487

485488
// we remove the file based routes that were deemed redirects
486489
const filteredFiledBasedRoutes = fileBasedRoutes.filter((fileBasedRoute) => {

packages/astro/src/types/public/config.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -264,16 +264,21 @@ export interface ViteUserConfig extends OriginalViteUserConfig {
264264
* and the value is the path to redirect to.
265265
*
266266
* You can redirect both static and dynamic routes, but only to the same kind of route.
267-
* For example you cannot have a `'/article': '/blog/[...slug]'` redirect.
267+
* For example, you cannot have a `'/article': '/blog/[...slug]'` redirect.
268268
*
269269
*
270270
* ```js
271-
* {
271+
* export default defineConfig({
272272
* redirects: {
273-
* '/old': '/new',
274-
* '/blog/[...slug]': '/articles/[...slug]',
275-
* }
276-
* }
273+
* '/old': '/new',
274+
* '/blog/[...slug]': '/articles/[...slug]',
275+
* '/about': 'https://example.com/about',
276+
* '/news': {
277+
* status: 302,
278+
* destination: 'https://example.com/news'
279+
* }
280+
* }
281+
* })
277282
* ```
278283
*
279284
*
@@ -287,14 +292,14 @@ export interface ViteUserConfig extends OriginalViteUserConfig {
287292
* You can customize the [redirection status code](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#redirection_messages) using an object in the redirect config:
288293
*
289294
* ```js
290-
* {
295+
* export default defineConfig({
291296
* redirects: {
292297
* '/other': {
293298
* status: 302,
294299
* destination: '/place',
295300
* },
296301
* }
297-
* }
302+
* })
298303
* ```
299304
*/
300305
redirects?: Record<string, RedirectConfig>;

packages/astro/test/redirects.test.js

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,12 @@ describe('Astro.redirect', () => {
3636
assert.equal(response.headers.get('location'), '/login');
3737
});
3838

39-
// ref: https://github.com/withastro/astro/pull/9287#discussion_r1420739810
40-
it.skip('Ignores external redirect', async () => {
39+
it('Allows external redirect', async () => {
4140
const app = await fixture.loadTestAdapterApp();
4241
const request = new Request('http://example.com/external/redirect');
4342
const response = await app.render(request);
44-
assert.equal(response.status, 404);
45-
assert.equal(response.headers.get('location'), null);
43+
assert.equal(response.status, 301);
44+
assert.equal(response.headers.get('location'), 'https://example.com/');
4645
});
4746

4847
it('Warns when used inside a component', async () => {
@@ -131,6 +130,7 @@ describe('Astro.redirect', () => {
131130
'/more/old/[dynamic]': '/more/[dynamic]',
132131
'/more/old/[dynamic]/[route]': '/more/[dynamic]/[route]',
133132
'/more/old/[...spread]': '/more/new/[...spread]',
133+
'/external/redirect': 'https://example.com/',
134134
},
135135
});
136136
await fixture.build();
@@ -208,6 +208,12 @@ describe('Astro.redirect', () => {
208208
assert.equal(html.includes('http-equiv="refresh'), true);
209209
assert.equal(html.includes('url=/more/new/welcome/world'), true);
210210
});
211+
212+
it('supports redirecting to an external destination', async () => {
213+
const html = await fixture.readFile('/external/redirect/index.html');
214+
assert.equal(html.includes('http-equiv="refresh'), true);
215+
assert.equal(html.includes('url=https://example.com/'), true);
216+
});
211217
});
212218

213219
describe('dev', () => {

packages/underscore-redirects/src/astro.ts

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -36,23 +36,23 @@ export function createRedirectsFromAstroRoutes({
3636
dir,
3737
buildOutput,
3838
assets,
39-
}: CreateRedirectsFromAstroRoutesParams) {
39+
}: CreateRedirectsFromAstroRoutesParams): Redirects {
4040
const base =
4141
config.base && config.base !== '/'
4242
? config.base.endsWith('/')
4343
? config.base.slice(0, -1)
4444
: config.base
4545
: '';
46-
const _redirects = new Redirects();
46+
const redirects = new Redirects();
4747

4848
for (const [route, dynamicTarget = ''] of routeToDynamicTargetMap) {
4949
const distURL = assets.get(route.pattern);
5050
// A route with a `pathname` is as static route.
5151
if (route.pathname) {
5252
if (route.redirect) {
53-
// A redirect route without dynami§c parts. Get the redirect status
53+
// A redirect route without dynamic parts. Get the redirect status
5454
// from the user if provided.
55-
_redirects.add({
55+
redirects.add({
5656
dynamic: false,
5757
input: `${base}${route.pathname}`,
5858
target: typeof route.redirect === 'object' ? route.redirect.destination : route.redirect,
@@ -65,16 +65,18 @@ export function createRedirectsFromAstroRoutes({
6565
// If this is a static build we don't want to add redirects to the HTML file.
6666
if (buildOutput === 'static') {
6767
continue;
68-
} else if (distURL) {
69-
_redirects.add({
68+
}
69+
70+
if (distURL) {
71+
redirects.add({
7072
dynamic: false,
7173
input: `${base}${route.pathname}`,
7274
target: prependForwardSlash(distURL.toString().replace(dir.toString(), '')),
7375
status: 200,
7476
weight: 2,
7577
});
7678
} else {
77-
_redirects.add({
79+
redirects.add({
7880
dynamic: false,
7981
input: `${base}${route.pathname}`,
8082
target: dynamicTarget,
@@ -83,7 +85,7 @@ export function createRedirectsFromAstroRoutes({
8385
});
8486

8587
if (route.pattern === '/404') {
86-
_redirects.add({
88+
redirects.add({
8789
dynamic: true,
8890
input: '/*',
8991
target: dynamicTarget,
@@ -100,22 +102,21 @@ export function createRedirectsFromAstroRoutes({
100102
// This route was prerendered and should be forwarded to the HTML file.
101103
if (distURL) {
102104
const targetRoute = route.redirectRoute ?? route;
103-
const targetPattern = generateDynamicPattern(targetRoute);
104-
let target = targetPattern;
105+
let target = generateDynamicPattern(targetRoute);
105106
if (config.build.format === 'directory') {
106107
target = pathJoin(target, 'index.html');
107108
} else {
108109
target += '.html';
109110
}
110-
_redirects.add({
111+
redirects.add({
111112
dynamic: true,
112113
input: `${base}${pattern}`,
113114
target,
114115
status: route.type === 'redirect' ? 301 : 200,
115116
weight: 1,
116117
});
117118
} else {
118-
_redirects.add({
119+
redirects.add({
119120
dynamic: true,
120121
input: `${base}${pattern}`,
121122
target: dynamicTarget,
@@ -126,7 +127,7 @@ export function createRedirectsFromAstroRoutes({
126127
}
127128
}
128129

129-
return _redirects;
130+
return redirects;
130131
}
131132

132133
/**
@@ -135,7 +136,7 @@ export function createRedirectsFromAstroRoutes({
135136
* With stars replacing spread and :id syntax replacing [id]
136137
*/
137138
function generateDynamicPattern(route: IntegrationResolvedRoute) {
138-
const pattern =
139+
return (
139140
'/' +
140141
route.segments
141142
.map(([part]) => {
@@ -150,8 +151,8 @@ function generateDynamicPattern(route: IntegrationResolvedRoute) {
150151
return part.content;
151152
}
152153
})
153-
.join('/');
154-
return pattern;
154+
.join('/')
155+
);
155156
}
156157

157158
function prependForwardSlash(str: string) {

0 commit comments

Comments
 (0)