Implement redirects in Cloudflare (#7198)

* Implement redirects in Cloudflare

* Fix build

* Update tests b/c of new ordering

* Debug issue

* Use posix.join

* Update packages/underscore-redirects/package.json

Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>

* Update based on review comments

* Update broken test

---------

Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>
This commit is contained in:
Matthew Phillips 2023-05-25 10:53:58 -04:00 committed by GitHub
parent af2ceea276
commit 8b4d248a36
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 492 additions and 167 deletions

View file

@ -38,6 +38,7 @@
"test": "mocha --exit --timeout 30000 test/"
},
"dependencies": {
"@astrojs/underscore-redirects": "^0.1.0",
"esbuild": "^0.17.12",
"tiny-glob": "^0.2.9"
},

View file

@ -1,4 +1,5 @@
import type { AstroAdapter, AstroConfig, AstroIntegration } from 'astro';
import { createRedirectsFromAstroRoutes, type Redirects } from '@astrojs/underscore-redirects';
import esbuild from 'esbuild';
import * as fs from 'fs';
import * as os from 'os';
@ -88,7 +89,7 @@ export default function createIntegration(args?: Options): AstroIntegration {
vite.ssr.target = 'webworker';
}
},
'astro:build:done': async ({ pages }) => {
'astro:build:done': async ({ pages, routes, dir }) => {
const entryPath = fileURLToPath(new URL(_buildConfig.serverEntry, _buildConfig.server));
const entryUrl = new URL(_buildConfig.serverEntry, _config.outDir);
const buildPath = fileURLToPath(entryUrl);
@ -197,6 +198,19 @@ export default function createIntegration(args?: Options): AstroIntegration {
}
}
const redirectRoutes = routes.filter(r => r.type === 'redirect');
const trueRedirects = createRedirectsFromAstroRoutes({
config: _config,
routes: redirectRoutes,
dir,
});
if(!trueRedirects.empty()) {
await fs.promises.appendFile(
new URL('./_redirects', _config.outDir),
trueRedirects.print()
);
}
await fs.promises.writeFile(
new URL('./_routes.json', _config.outDir),
JSON.stringify(

View file

@ -11,6 +11,9 @@ describe('mode: "directory"', () => {
root: './fixtures/basics/',
output: 'server',
adapter: cloudflare({ mode: 'directory' }),
redirects: {
'/old': '/'
}
});
await fixture.build();
});
@ -19,4 +22,16 @@ describe('mode: "directory"', () => {
expect(await fixture.pathExists('../functions')).to.be.true;
expect(await fixture.pathExists('../functions/[[path]].js')).to.be.true;
});
it('generates a redirects file', async () => {
try {
let _redirects = await fixture.readFile('/_redirects');
let parts = _redirects.split(/\s+/);
expect(parts).to.deep.equal([
'/old', '/', '301'
]);
} catch {
expect(false).to.equal(true);
}
});
});

View file

@ -38,6 +38,7 @@
},
"dependencies": {
"@astrojs/webapi": "^2.1.1",
"@astrojs/underscore-redirects": "^0.1.0",
"@netlify/functions": "^1.0.0",
"esbuild": "^0.15.18"
},

View file

@ -1,20 +1,6 @@
import type { AstroConfig, RouteData, ValidRedirectStatus } from 'astro';
import fs from 'fs';
export type RedirectDefinition = {
dynamic: boolean;
input: string;
target: string;
weight: 0 | 1;
status: 200 | 404 | ValidRedirectStatus;
};
function getRedirectStatus(route: RouteData): ValidRedirectStatus {
if(typeof route.redirect === 'object') {
return route.redirect.status;
}
return 301;
}
import type { AstroConfig, RouteData } from 'astro';
import { createRedirectsFromAstroRoutes } from '@astrojs/underscore-redirects';
import fs from 'node:fs';
export async function createRedirects(
config: AstroConfig,
@ -23,151 +9,17 @@ export async function createRedirects(
entryFile: string,
type: 'functions' | 'edge-functions' | 'builders' | 'static'
) {
const _redirectsURL = new URL('./_redirects', dir);
const kind = type ?? 'functions';
const dynamicTarget = `/.netlify/${kind}/${entryFile}`;
const _redirectsURL = new URL('./_redirects', dir);
const definitions: RedirectDefinition[] = [];
for (const route of routes) {
if (route.pathname) {
if(route.redirect) {
definitions.push({
dynamic: false,
input: route.pathname,
target: typeof route.redirect === 'object' ? route.redirect.destination : route.redirect,
status: getRedirectStatus(route),
weight: 1
});
continue;
}
if(kind === 'static') {
continue;
}
else if (route.distURL) {
definitions.push({
dynamic: false,
input: route.pathname,
target: prependForwardSlash(route.distURL.toString().replace(dir.toString(), '')),
status: 200,
weight: 1,
});
} else {
definitions.push({
dynamic: false,
input: route.pathname,
target: `/.netlify/${kind}/${entryFile}`,
status: 200,
weight: 1,
});
if (route.route === '/404') {
definitions.push({
dynamic: true,
input: '/*',
target: `/.netlify/${kind}/${entryFile}`,
status: 404,
weight: 0,
});
}
}
} else {
const pattern = generateDynamicPattern(route);
if (route.distURL) {
const targetRoute = route.redirectRoute ?? route;
const targetPattern = generateDynamicPattern(targetRoute);
const target =
`${targetPattern}` + (config.build.format === 'directory' ? '/index.html' : '.html');
definitions.push({
dynamic: true,
input: pattern,
target,
status: route.type === 'redirect' ? 301 : 200,
weight: 1,
});
} else {
definitions.push({
dynamic: true,
input: pattern,
target: `/.netlify/${kind}/${entryFile}`,
status: 200,
weight: 1,
});
}
}
}
let _redirects = prettify(definitions);
const _redirects = createRedirectsFromAstroRoutes({
config, routes, dir, dynamicTarget
});
const content = _redirects.print();
// Always use appendFile() because the redirects file could already exist,
// e.g. due to a `/public/_redirects` file that got copied to the output dir.
// If the file does not exist yet, appendFile() automatically creates it.
await fs.promises.appendFile(_redirectsURL, _redirects, 'utf-8');
}
function generateDynamicPattern(route: RouteData) {
const pattern =
'/' +
route.segments
.map(([part]) => {
//(part.dynamic ? '*' : part.content)
if (part.dynamic) {
if (part.spread) {
return '*';
} else {
return ':' + part.content;
}
} else {
return part.content;
}
})
.join('/');
return pattern;
}
function prettify(definitions: RedirectDefinition[]) {
let minInputLength = 4,
minTargetLength = 4;
definitions.sort((a, b) => {
// Find the longest input, so we can format things nicely
if (a.input.length > minInputLength) {
minInputLength = a.input.length;
}
if (b.input.length > minInputLength) {
minInputLength = b.input.length;
}
// Same for the target
if (a.target.length > minTargetLength) {
minTargetLength = a.target.length;
}
if (b.target.length > minTargetLength) {
minTargetLength = b.target.length;
}
// Sort dynamic routes on top
return b.weight - a.weight;
});
let _redirects = '';
// Loop over the definitions
definitions.forEach((defn, i) => {
// Figure out the number of spaces to add. We want at least 4 spaces
// after the input. This ensure that all targets line up together.
let inputSpaces = minInputLength - defn.input.length + 4;
let targetSpaces = minTargetLength - defn.target.length + 4;
_redirects +=
(i === 0 ? '' : '\n') +
defn.input +
' '.repeat(inputSpaces) +
defn.target +
' '.repeat(Math.abs(targetSpaces)) +
defn.status;
});
return _redirects;
}
function prependForwardSlash(str: string) {
return str[0] === '/' ? str : '/' + str;
await fs.promises.appendFile(_redirectsURL, content, 'utf-8');
}

View file

@ -28,12 +28,11 @@ describe('SSG - Redirects', () => {
let redirects = await fixture.readFile('/_redirects');
let parts = redirects.split(/\s+/);
expect(parts).to.deep.equal([
'/other', '/', '301',
'/', '/.netlify/functions/entry', '200',
// This uses the dynamic Astro.redirect, so we don't know that it's a redirect
// until runtime. This is correct!
'/nope', '/.netlify/functions/entry', '200',
'/', '/.netlify/functions/entry', '200',
'/other', '/', '301',
// A real route
'/team/articles/*', '/.netlify/functions/entry', '200',

View file

@ -27,13 +27,14 @@ describe('SSG - Redirects', () => {
it('Creates a redirects file', async () => {
let redirects = await fixture.readFile('/_redirects');
console.log(redirects)
let parts = redirects.split(/\s+/);
expect(parts).to.deep.equal([
'/blog/*', '/team/articles/*/index.html', '301',
'/two', '/', '302',
'/other', '/', '301',
'/nope', '/', '301',
'/team/articles/*', '/team/articles/*/index.html', '200'
'/other', '/', '301',
'/two', '/', '302',
'/team/articles/*', '/team/articles/*/index.html', '200',
'/blog/*', '/team/articles/*/index.html', '301',
]);
});
});

View file

@ -0,0 +1,42 @@
{
"name": "@astrojs/underscore-redirects",
"description": "Utilities to generate _redirects files in Astro projects",
"version": "0.1.0",
"type": "module",
"author": "withastro",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/withastro/astro.git",
"directory": "packages/underscore-redirects"
},
"bugs": "https://github.com/withastro/astro/issues",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": "./dist/index.js"
},
"files": [
"dist"
],
"scripts": {
"prepublish": "pnpm build",
"build": "astro-scripts build \"src/**/*.ts\" && tsc -p tsconfig.json",
"build:ci": "astro-scripts build \"src/**/*.ts\"",
"postbuild": "astro-scripts copy \"src/**/*.js\"",
"dev": "astro-scripts dev \"src/**/*.ts\"",
"test": "mocha --exit --timeout 20000"
},
"devDependencies": {
"astro": "workspace:*",
"astro-scripts": "workspace:*",
"@types/chai": "^4.3.1",
"@types/mocha": "^9.1.1",
"chai": "^4.3.6",
"mocha": "^9.2.2"
},
"keywords": [
"astro",
"astro-component"
]
}

View file

@ -0,0 +1,3 @@
# @astrojs/underscore-redirects
These are internal helpers used by core Astro packages. This package does not follow semver and should not be used externally.

View file

@ -0,0 +1,145 @@
import type { AstroConfig, RouteData, ValidRedirectStatus } from 'astro';
import { Redirects } from './redirects.js';
import { posix } from 'node:path';
const pathJoin = posix.join;
function getRedirectStatus(route: RouteData): ValidRedirectStatus {
if(typeof route.redirect === 'object') {
return route.redirect.status;
}
return 301;
}
interface CreateRedirectsFromAstroRoutesParams {
config: Pick<AstroConfig, 'output' | 'build'>;
routes: RouteData[];
dir: URL;
dynamicTarget?: string;
}
/**
* Takes a set of routes and creates a Redirects object from them.
*/
export function createRedirectsFromAstroRoutes({
config,
routes,
dir,
dynamicTarget = '',
}: CreateRedirectsFromAstroRoutesParams) {
const output = config.output;
const _redirects = new Redirects();
for (const route of routes) {
// A route with a `pathname` is as static route.
if (route.pathname) {
if(route.redirect) {
// A redirect route without dynamic parts. Get the redirect status
// from the user if provided.
_redirects.add({
dynamic: false,
input: route.pathname,
target: typeof route.redirect === 'object' ? route.redirect.destination : route.redirect,
status: getRedirectStatus(route),
weight: 2
});
continue;
}
// If this is a static build we don't want to add redirects to the HTML file.
if(output === 'static') {
continue;
}
else if (route.distURL) {
_redirects.add({
dynamic: false,
input: route.pathname,
target: prependForwardSlash(route.distURL.toString().replace(dir.toString(), '')),
status: 200,
weight: 2,
});
} else {
_redirects.add({
dynamic: false,
input: route.pathname,
target: dynamicTarget,
status: 200,
weight: 2,
});
if (route.route === '/404') {
_redirects.add({
dynamic: true,
input: '/*',
target: dynamicTarget,
status: 404,
weight: 0,
});
}
}
} else {
// This is the dynamic route code. This generates a pattern from a dynamic
// route formatted with *s in place of the Astro dynamic/spread syntax.
const pattern = generateDynamicPattern(route);
// This route was prerendered and should be forwarded to the HTML file.
if (route.distURL) {
const targetRoute = route.redirectRoute ?? route;
const targetPattern = generateDynamicPattern(targetRoute);
let target = targetPattern;
if(config.build.format === 'directory') {
target = pathJoin(target, 'index.html');
} else {
target += '.html';
}
_redirects.add({
dynamic: true,
input: pattern,
target,
status: route.type === 'redirect' ? 301 : 200,
weight: 1,
});
} else {
_redirects.add({
dynamic: true,
input: pattern,
target: dynamicTarget,
status: 200,
weight: 1,
});
}
}
}
return _redirects;
}
/**
* Converts an Astro dynamic route into one formatted like:
* /team/articles/*
* With stars replacing spread and :id syntax replacing [id]
*/
function generateDynamicPattern(route: RouteData) {
const pattern =
'/' +
route.segments
.map(([part]) => {
//(part.dynamic ? '*' : part.content)
if (part.dynamic) {
if (part.spread) {
return '*';
} else {
return ':' + part.content;
}
} else {
return part.content;
}
})
.join('/');
return pattern;
}
function prependForwardSlash(str: string) {
return str[0] === '/' ? str : '/' + str;
}

View file

@ -0,0 +1,8 @@
export {
Redirects,
type RedirectDefinition
} from './redirects.js';
export {
createRedirectsFromAstroRoutes
} from './astro.js';

View file

@ -0,0 +1,36 @@
import type { RedirectDefinition } from './redirects';
/**
* Pretty print a list of definitions into the output format. Keeps
* things readable for humans. Ex:
* /nope / 301
* /other / 301
* /two / 302
* /team/articles/* /team/articles/*\/index.html 200
* /blog/* /team/articles/*\/index.html 301
*/
export function print(
definitions: RedirectDefinition[],
minInputLength: number,
minTargetLength: number
) {
let _redirects = '';
// Loop over the definitions
for(let i = 0; i < definitions.length; i++) {
let definition = definitions[i];
// Figure out the number of spaces to add. We want at least 4 spaces
// after the input. This ensure that all targets line up together.
let inputSpaces = minInputLength - definition.input.length + 4;
let targetSpaces = minTargetLength - definition.target.length + 4;
_redirects +=
(i === 0 ? '' : '\n') +
definition.input +
' '.repeat(inputSpaces) +
definition.target +
' '.repeat(Math.abs(targetSpaces)) +
definition.status;
}
return _redirects;
}

View file

@ -0,0 +1,69 @@
import { print } from './print.js';
export type RedirectDefinition = {
dynamic: boolean;
input: string;
target: string;
// Allows specifying a weight to the definition.
// This allows insertion of definitions out of order but having
// a priority once inserted.
weight: number;
status: number;
};
export class Redirects {
public definitions: RedirectDefinition[] = [];
public minInputLength = 4;
public minTargetLength = 4;
/**
* Adds a new definition by inserting it into the list of definitions
* prioritized by the given weight. This keeps higher priority definitions
* At the top of the list once printed.
*/
add(definition: RedirectDefinition) {
// Find the longest input, so we can format things nicely
if (definition.input.length > this.minInputLength) {
this.minInputLength = definition.input.length;
}
// Same for the target
if (definition.target.length > this.minTargetLength) {
this.minTargetLength = definition.target.length;
}
binaryInsert(this.definitions, definition, (a, b) => {
return a.weight > b.weight;
});
}
print(): string {
return print(this.definitions, this.minInputLength, this.minTargetLength);
}
empty(): boolean {
return this.definitions.length === 0;
}
}
function binaryInsert<T>(sorted: T[], item: T, comparator: (a: T, b: T) => boolean) {
if(sorted.length === 0) {
sorted.push(item);
return 0;
}
let low = 0, high = sorted.length - 1, mid = 0;
while (low <= high) {
mid = low + (high - low >> 1);
if(comparator(sorted[mid], item)) {
low = mid + 1;
} else {
high = mid -1;
}
}
if(comparator(sorted[mid], item)) {
mid++;
}
sorted.splice(mid, 0, item);
return mid;
}

View file

@ -0,0 +1,25 @@
import { createRedirectsFromAstroRoutes } from '../dist/index.js';
import { expect } from 'chai';
describe('Astro', () => {
const serverConfig = {
output: 'server',
build: { format: 'directory' }
};
it('Creates a Redirects object from routes', () => {
const routes = [
{ pathname: '/', distURL: new URL('./index.html', import.meta.url), segments: [] },
{ pathname: '/one', distURL: new URL('./one/index.html', import.meta.url), segments: [] }
];
const dynamicTarget = './.adapter/dist/entry.mjs';
const _redirects = createRedirectsFromAstroRoutes({
config: serverConfig,
routes,
dir: new URL(import.meta.url),
dynamicTarget
});
expect(_redirects.definitions).to.have.a.lengthOf(2);
});
});

View file

@ -0,0 +1,44 @@
import { Redirects } from '../dist/index.js';
import { expect } from 'chai';
describe('Printing', () => {
it('Formats long lines in a pretty way', () => {
const _redirects = new Redirects();
_redirects.add({
dynamic: false,
input: '/a',
target: '/b',
weight: 0,
status: 200
});
_redirects.add({
dynamic: false,
input: '/some-pretty-long-input-line',
target: '/b',
weight: 0,
status: 200
});
let out = _redirects.print();
let [lineOne, lineTwo] = out.split('\n');
expect(lineOne.indexOf('/b')).to.equal(lineTwo.indexOf('/b'), 'destinations lined up');
expect(lineOne.indexOf('200')).to.equal(lineTwo.indexOf('200'), 'statuses lined up');
});
it('Properly prints dynamic routes', () => {
const _redirects = new Redirects();
_redirects.add({
dynamic: true,
input: '/pets/:cat',
target: '/pets/:cat/index.html',
status: 200,
weight: 1
});
let out = _redirects.print();
let parts = out.split(/\s+/);
expect(parts).to.deep.equal([
'/pets/:cat', '/pets/:cat/index.html', '200',
])
});
});

View file

@ -0,0 +1,32 @@
import { Redirects } from '../dist/index.js';
import { expect } from 'chai';
describe('Weight', () => {
it('Puts higher weighted definitions on top', () => {
const _redirects = new Redirects();
_redirects.add({
dynamic: false,
input: '/a',
target: '/b',
weight: 0,
status: 200
});
_redirects.add({
dynamic: false,
input: '/c',
target: '/d',
weight: 0,
status: 200
});
_redirects.add({
dynamic: false,
input: '/e',
target: '/f',
weight: 1,
status: 200
});
const firstDefn = _redirects.definitions[0];
expect(firstDefn.weight).to.equal(1);
expect(firstDefn.input).to.equal('/e');
});
});

View file

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src"],
"compilerOptions": {
"allowJs": true,
"target": "ES2021",
"module": "ES2022",
"outDir": "./dist"
}
}

View file

@ -3630,6 +3630,9 @@ importers:
packages/integrations/cloudflare:
dependencies:
'@astrojs/underscore-redirects':
specifier: ^0.1.0
version: link:../../underscore-redirects
esbuild:
specifier: ^0.17.12
version: 0.17.12
@ -4360,6 +4363,9 @@ importers:
packages/integrations/netlify:
dependencies:
'@astrojs/underscore-redirects':
specifier: ^0.1.0
version: link:../../underscore-redirects
'@astrojs/webapi':
specifier: ^2.1.1
version: link:../../webapi
@ -5219,6 +5225,27 @@ importers:
specifier: ^9.2.2
version: 9.2.2
packages/underscore-redirects:
devDependencies:
'@types/chai':
specifier: ^4.3.1
version: 4.3.3
'@types/mocha':
specifier: ^9.1.1
version: 9.1.1
astro:
specifier: workspace:*
version: link:../astro
astro-scripts:
specifier: workspace:*
version: link:../../scripts
chai:
specifier: ^4.3.6
version: 4.3.6
mocha:
specifier: ^9.2.2
version: 9.2.2
packages/webapi:
dependencies:
undici:
@ -8783,11 +8810,12 @@ packages:
/@types/chai-subset@1.3.3:
resolution: {integrity: sha512-frBecisrNGz+F4T6bcc+NLeolfiojh5FxW2klu669+8BARtyQv2C/GkNW6FUodVe4BroGMP/wER/YDGc7rEllw==}
dependencies:
'@types/chai': 4.3.3
'@types/chai': 4.3.5
dev: false
/@types/chai@4.3.3:
resolution: {integrity: sha512-hC7OMnszpxhZPduX+m+nrx+uFoLkWOMiR4oa/AZF3MuSETYTZmFfJAHqZEM8MVlvfG7BEUcgvtwoCTxBp6hm3g==}
dev: true
/@types/chai@4.3.5:
resolution: {integrity: sha512-mEo1sAde+UCE6b2hxn332f1g1E8WfYRu6p5SvTKr2ZKC1f7gFJXk4h5PyGP9Dt6gCaG8y8XhwnXWC6Iy2cmBng==}