Skip to content

Commit

Permalink
feat(routing): external redirects (#12979)
Browse files Browse the repository at this point in the history
Co-authored-by: florian-lefebvre <[email protected]>
Co-authored-by: sarah11918 <[email protected]>
Co-authored-by: yanthomasdev <[email protected]>
  • Loading branch information
4 people authored Jan 29, 2025
1 parent 0f3be31 commit e621712
Show file tree
Hide file tree
Showing 7 changed files with 114 additions and 42 deletions.
22 changes: 22 additions & 0 deletions .changeset/light-pants-smoke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'astro': minor
---

Adds support for redirecting to external sites with the [`redirects`](https://docs.astro.build/en/reference/configuration-reference/#redirects) configuration option.

Now, you can redirect routes either internally to another path or externally by providing a URL beginning with `http` or `https`:

```js
// astro.config.mjs
import {defineConfig} from "astro/config"

export default defineConfig({
redirects: {
"/blog": "https://example.com/blog",
"/news": {
status: 302,
destination: "https://example.com/news"
}
}
})
```
14 changes: 14 additions & 0 deletions packages/astro/src/core/errors/errors-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -995,6 +995,20 @@ export const RedirectWithNoLocation = {
name: 'RedirectWithNoLocation',
title: 'A redirect must be given a location with the `Location` header.',
} satisfies ErrorData;

/**
* @docs
* @see
* - [Astro.redirect](https://docs.astro.build/en/reference/api-reference/#redirect)
* @description
* An external redirect must start with http or https, and must be a valid URL.
*/
export const UnsupportedExternalRedirect = {
name: 'UnsupportedExternalRedirect',
title: 'Unsupported or malformed URL.',
message: 'An external redirect must start with http or https, and must be a valid URL.',
} satisfies ErrorData;

/**
* @docs
* @see
Expand Down
33 changes: 27 additions & 6 deletions packages/astro/src/core/redirects/render.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
import type { RedirectConfig } from '../../types/public/index.js';
import type { RenderContext } from '../render-context.js';

export function redirectIsExternal(redirect: RedirectConfig): boolean {
if (typeof redirect === 'string') {
return redirect.startsWith('http://') || redirect.startsWith('https://');
} else {
return (
redirect.destination.startsWith('http://') || redirect.destination.startsWith('https://')
);
}
}

export async function renderRedirect(renderContext: RenderContext) {
const {
request: { method },
Expand All @@ -9,6 +20,13 @@ export async function renderRedirect(renderContext: RenderContext) {
const status =
redirectRoute && typeof redirect === 'object' ? redirect.status : method === 'GET' ? 301 : 308;
const headers = { location: encodeURI(redirectRouteGenerate(renderContext)) };
if (redirect && redirectIsExternal(redirect)) {
if (typeof redirect === 'string') {
return Response.redirect(redirect, status);
} else {
return Response.redirect(redirect.destination, status);
}
}
return new Response(null, { status, headers });
}

Expand All @@ -21,13 +39,16 @@ function redirectRouteGenerate(renderContext: RenderContext): string {
if (typeof redirectRoute !== 'undefined') {
return redirectRoute?.generate(params) || redirectRoute?.pathname || '/';
} else if (typeof redirect === 'string') {
// TODO: this logic is duplicated between here and manifest/create.ts
let target = redirect;
for (const param of Object.keys(params)) {
const paramValue = params[param]!;
target = target.replace(`[${param}]`, paramValue).replace(`[...${param}]`, paramValue);
if (redirectIsExternal(redirect)) {
return redirect;
} else {
let target = redirect;
for (const param of Object.keys(params)) {
const paramValue = params[param]!;
target = target.replace(`[${param}]`, paramValue).replace(`[...${param}]`, paramValue);
}
return target;
}
return target;
} else if (typeof redirect === 'undefined') {
return '/';
}
Expand Down
19 changes: 11 additions & 8 deletions packages/astro/src/core/routing/manifest/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ import { getPrerenderDefault } from '../../../prerender/utils.js';
import type { AstroConfig } from '../../../types/public/config.js';
import type { RouteData, RoutePart } from '../../../types/public/internal.js';
import { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from '../../constants.js';
import { MissingIndexForInternationalization } from '../../errors/errors-data.js';
import {
MissingIndexForInternationalization,
UnsupportedExternalRedirect,
} from '../../errors/errors-data.js';
import { AstroError } from '../../errors/index.js';
import { removeLeadingForwardSlash, slash } from '../../path.js';
import { injectServerIslandRoute } from '../../server-islands/endpoint.js';
Expand Down Expand Up @@ -314,7 +317,6 @@ function createInjectedRoutes({ settings, cwd }: CreateRouteManifestParams): Rou
function createRedirectRoutes(
{ settings }: CreateRouteManifestParams,
routeMap: Map<string, RouteData>,
logger: Logger,
): RouteData[] {
const { config } = settings;
const trailingSlash = config.trailingSlash;
Expand Down Expand Up @@ -348,11 +350,12 @@ function createRedirectRoutes(
destination = to.destination;
}

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

routes.push({
Expand Down Expand Up @@ -480,7 +483,7 @@ export async function createRoutesList(
routeMap.set(route.route, route);
}

const redirectRoutes = createRedirectRoutes(params, routeMap, logger);
const redirectRoutes = createRedirectRoutes(params, routeMap);

// we remove the file based routes that were deemed redirects
const filteredFiledBasedRoutes = fileBasedRoutes.filter((fileBasedRoute) => {
Expand Down
21 changes: 13 additions & 8 deletions packages/astro/src/types/public/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,16 +264,21 @@ export interface ViteUserConfig extends OriginalViteUserConfig {
* and the value is the path to redirect to.
*
* You can redirect both static and dynamic routes, but only to the same kind of route.
* For example you cannot have a `'/article': '/blog/[...slug]'` redirect.
* For example, you cannot have a `'/article': '/blog/[...slug]'` redirect.
*
*
* ```js
* {
* export default defineConfig({
* redirects: {
* '/old': '/new',
* '/blog/[...slug]': '/articles/[...slug]',
* }
* }
* '/old': '/new',
* '/blog/[...slug]': '/articles/[...slug]',
* '/about': 'https://example.com/about',
* '/news': {
* status: 302,
* destination: 'https://example.com/news'
* }
* }
* })
* ```
*
*
Expand All @@ -287,14 +292,14 @@ export interface ViteUserConfig extends OriginalViteUserConfig {
* 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:
*
* ```js
* {
* export default defineConfig({
* redirects: {
* '/other': {
* status: 302,
* destination: '/place',
* },
* }
* }
* })
* ```
*/
redirects?: Record<string, RedirectConfig>;
Expand Down
14 changes: 10 additions & 4 deletions packages/astro/test/redirects.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,12 @@ describe('Astro.redirect', () => {
assert.equal(response.headers.get('location'), '/login');
});

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

it('Warns when used inside a component', async () => {
Expand Down Expand Up @@ -131,6 +130,7 @@ describe('Astro.redirect', () => {
'/more/old/[dynamic]': '/more/[dynamic]',
'/more/old/[dynamic]/[route]': '/more/[dynamic]/[route]',
'/more/old/[...spread]': '/more/new/[...spread]',
'/external/redirect': 'https://example.com/',
},
});
await fixture.build();
Expand Down Expand Up @@ -208,6 +208,12 @@ describe('Astro.redirect', () => {
assert.equal(html.includes('http-equiv="refresh'), true);
assert.equal(html.includes('url=/more/new/welcome/world'), true);
});

it('supports redirecting to an external destination', async () => {
const html = await fixture.readFile('/external/redirect/index.html');
assert.equal(html.includes('http-equiv="refresh'), true);
assert.equal(html.includes('url=https://example.com/'), true);
});
});

describe('dev', () => {
Expand Down
33 changes: 17 additions & 16 deletions packages/underscore-redirects/src/astro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,23 +36,23 @@ export function createRedirectsFromAstroRoutes({
dir,
buildOutput,
assets,
}: CreateRedirectsFromAstroRoutesParams) {
}: CreateRedirectsFromAstroRoutesParams): Redirects {
const base =
config.base && config.base !== '/'
? config.base.endsWith('/')
? config.base.slice(0, -1)
: config.base
: '';
const _redirects = new Redirects();
const redirects = new Redirects();

for (const [route, dynamicTarget = ''] of routeToDynamicTargetMap) {
const distURL = assets.get(route.pattern);
// A route with a `pathname` is as static route.
if (route.pathname) {
if (route.redirect) {
// A redirect route without dynami§c parts. Get the redirect status
// A redirect route without dynamic parts. Get the redirect status
// from the user if provided.
_redirects.add({
redirects.add({
dynamic: false,
input: `${base}${route.pathname}`,
target: typeof route.redirect === 'object' ? route.redirect.destination : route.redirect,
Expand All @@ -65,16 +65,18 @@ export function createRedirectsFromAstroRoutes({
// If this is a static build we don't want to add redirects to the HTML file.
if (buildOutput === 'static') {
continue;
} else if (distURL) {
_redirects.add({
}

if (distURL) {
redirects.add({
dynamic: false,
input: `${base}${route.pathname}`,
target: prependForwardSlash(distURL.toString().replace(dir.toString(), '')),
status: 200,
weight: 2,
});
} else {
_redirects.add({
redirects.add({
dynamic: false,
input: `${base}${route.pathname}`,
target: dynamicTarget,
Expand All @@ -83,7 +85,7 @@ export function createRedirectsFromAstroRoutes({
});

if (route.pattern === '/404') {
_redirects.add({
redirects.add({
dynamic: true,
input: '/*',
target: dynamicTarget,
Expand All @@ -100,22 +102,21 @@ export function createRedirectsFromAstroRoutes({
// This route was prerendered and should be forwarded to the HTML file.
if (distURL) {
const targetRoute = route.redirectRoute ?? route;
const targetPattern = generateDynamicPattern(targetRoute);
let target = targetPattern;
let target = generateDynamicPattern(targetRoute);
if (config.build.format === 'directory') {
target = pathJoin(target, 'index.html');
} else {
target += '.html';
}
_redirects.add({
redirects.add({
dynamic: true,
input: `${base}${pattern}`,
target,
status: route.type === 'redirect' ? 301 : 200,
weight: 1,
});
} else {
_redirects.add({
redirects.add({
dynamic: true,
input: `${base}${pattern}`,
target: dynamicTarget,
Expand All @@ -126,7 +127,7 @@ export function createRedirectsFromAstroRoutes({
}
}

return _redirects;
return redirects;
}

/**
Expand All @@ -135,7 +136,7 @@ export function createRedirectsFromAstroRoutes({
* With stars replacing spread and :id syntax replacing [id]
*/
function generateDynamicPattern(route: IntegrationResolvedRoute) {
const pattern =
return (
'/' +
route.segments
.map(([part]) => {
Expand All @@ -150,8 +151,8 @@ function generateDynamicPattern(route: IntegrationResolvedRoute) {
return part.content;
}
})
.join('/');
return pattern;
.join('/')
);
}

function prependForwardSlash(str: string) {
Expand Down

0 comments on commit e621712

Please sign in to comment.