Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(routing): external redirects #12979

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 8 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
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 createRouteManifest(
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
22 changes: 13 additions & 9 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,16 +292,15 @@ 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',
* },
* }
* }
* })
* ```
florian-lefebvre marked this conversation as resolved.
Show resolved Hide resolved
*/
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
Loading