Skip to content
This repository was archived by the owner on Jan 28, 2025. It is now read-only.

feat(lambda-at-edge): support custom redirects #627

Merged
merged 11 commits into from
Sep 29, 2020
119 changes: 110 additions & 9 deletions packages/e2e-tests/next-app/cypress/integration/redirects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,28 @@ describe("Redirects Tests", () => {

describe("Pages redirect to non-trailing slash path", () => {
[
{ path: "/ssr-page/" },
{ path: "/ssg-page/" },
{ path: "/errored-page/" },
{ path: "/errored-page-new-ssr/" },
{ path: "/unmatched/" }
].forEach(({ path }) => {
{ path: "/ssr-page/", expectedStatus: 200 },
{ path: "/ssg-page/", expectedStatus: 200 },
{ path: "/errored-page/", expectedStatus: 500 },
{ path: "/errored-page-new-ssr/", expectedStatus: 500 },
{ path: "/unmatched/", expectedStatus: 404 }
].forEach(({ path, expectedStatus }) => {
it(`redirects page ${path}`, () => {
cy.ensureRouteHasStatusCode(path, 308);

const redirectedPath = path.slice(0, -1);

// Verify redirect response
cy.verifyPermanentRedirect(path, redirectedPath);
cy.verifyRedirect(path, redirectedPath, 308);

// Verify status after following redirect
cy.request({
url: path,
followRedirect: true,
failOnStatusCode: false
}).then((response) => {
expect(response.status).to.equal(expectedStatus);
});

// Visit to follow redirect
cy.visit(path, { failOnStatusCode: false });
Expand All @@ -34,7 +43,7 @@ describe("Redirects Tests", () => {
const redirectedPath = path.slice(0, -1);

// Verify redirect response
cy.verifyPermanentRedirect(path, redirectedPath);
cy.verifyRedirect(path, redirectedPath, 308);

// We can't use visit to follow redirect as it expects HTML content, not files.
cy.request(path).then((response) => {
Expand All @@ -56,7 +65,7 @@ describe("Redirects Tests", () => {
const redirectedPath = fullPath.slice(0, -1);

// Verify redirect response
cy.verifyPermanentRedirect(fullPath, redirectedPath);
cy.verifyRedirect(fullPath, redirectedPath, 308);

// We can't use visit to follow redirect as it expects HTML content, not files.
cy.request(fullPath).then((response) => {
Expand All @@ -65,4 +74,96 @@ describe("Redirects Tests", () => {
});
});
});

describe("Custom redirects defined in next.config.js", () => {
[
{
path: "/permanent-redirect",
expectedRedirect: "/ssr-page",
expectedStatus: 200,
expectedRedirectStatus: 308
},
{
path: "/permanent-redirect?a=123",
expectedRedirect: "/ssr-page?a=123",
expectedStatus: 200,
expectedRedirectStatus: 308
},
{
path: "/temporary-redirect",
expectedRedirect: "/ssg-page",
expectedStatus: 200,
expectedRedirectStatus: 307
},
{
path: "/wildcard-redirect-1/a/b/c/d",
expectedRedirect: "/ssg-page",
expectedStatus: 200,
expectedRedirectStatus: 308
},
{
path: "/wildcard-redirect-1/a",
expectedRedirect: "/ssg-page",
expectedStatus: 200,
expectedRedirectStatus: 308
},
{
path: "/wildcard-redirect-2/a", // Redirects but the destination serves a 404
expectedRedirect: "/wildcard-redirect-2-dest/a",
expectedStatus: 404,
expectedRedirectStatus: 308
},
{
path: "/regex-redirect-1/1234",
expectedRedirect: "/ssg-page",
expectedStatus: 200,
expectedRedirectStatus: 308
},
{
path: "/regex-redirect-1/abcd", // Not a redirect as the regex is for numbers only
expectedRedirect: null,
expectedStatus: null,
expectedRedirectStatus: null
},
{
path: "/regex-redirect-2/12345", // Redirects but the destination serves a 404
expectedRedirect: "/regex-redirect-2-dest/12345",
expectedStatus: 404,
expectedRedirectStatus: 308
},
{
path: "/custom-status-code-redirect",
expectedRedirect: "/ssr-page",
expectedStatus: 200,
expectedRedirectStatus: 302
}
].forEach(
({ path, expectedRedirect, expectedStatus, expectedRedirectStatus }) => {
it(`redirects path ${path} to ${expectedRedirect}, redirect status: ${expectedRedirectStatus}`, () => {
if (expectedRedirect) {
// Verify redirect response
cy.verifyRedirect(path, expectedRedirect, expectedRedirectStatus);

// Follow redirect without failing on status code
cy.request({
url: path,
followRedirect: true,
failOnStatusCode: false
}).then((response) => {
expect(response.status).to.equal(expectedStatus);
});
} else {
// If no redirect is expected, expect a 404 instead
cy.request({
url: path,
followRedirect: false,
failOnStatusCode: false
}).then((response) => {
expect(response.status).to.equal(404);
});
}
});
}
);
});
});
19 changes: 13 additions & 6 deletions packages/e2e-tests/next-app/cypress/support/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,10 @@ declare namespace Cypress {
path: string | RegExp,
status: number
) => Cypress.Chainable<JQuery>;
verifyPermanentRedirect: (
verifyRedirect: (
path: string,
redirectedPath: string
redirectedPath: string,
redirectStatusCode: number
) => Cypress.Chainable<JQuery>;
verifyResponseCacheStatus: (
response: Cypress.Response,
Expand Down Expand Up @@ -110,12 +111,18 @@ Cypress.Commands.add(
);

Cypress.Commands.add(
"verifyPermanentRedirect",
(path: string, redirectedPath: string) => {
"verifyRedirect",
(path: string, redirectedPath: string, redirectStatusCode: number) => {
cy.request({ url: path, followRedirect: false }).then((response) => {
expect(response.status).to.equal(308);
expect(response.status).to.equal(redirectStatusCode);
expect(response.headers["location"]).to.equal(redirectedPath);
expect(response.headers["refresh"]).to.equal(`0;url=${redirectedPath}`);

if (redirectStatusCode === 308) {
// IE11 compatibility
expect(response.headers["refresh"]).to.equal(`0;url=${redirectedPath}`);
} else {
expect(response.headers["refresh"]).to.be.undefined;
}
});
}
);
Expand Down
41 changes: 41 additions & 0 deletions packages/e2e-tests/next-app/next.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
module.exports = {
async redirects() {
return [
{
source: "/permanent-redirect",
destination: "/ssr-page",
permanent: true
},
{
source: "/temporary-redirect",
destination: "/ssg-page",
permanent: false
},
{
source: "/custom-status-code-redirect",
destination: "/ssr-page",
statusCode: 302
},
{
source: "/wildcard-redirect-1/:slug*",
destination: "/ssg-page",
permanent: true
},
{
source: "/wildcard-redirect-2/:slug*",
destination: "/wildcard-redirect-2-dest/:slug*",
permanent: true
},
{
source: "/regex-redirect-1/:slug(\\d{1,})",
destination: "/ssg-page",
permanent: true
},
{
source: "/regex-redirect-2/:slug(\\d{1,})",
destination: "/regex-redirect-2-dest/:slug",
permanent: true
}
];
}
};
22 changes: 20 additions & 2 deletions packages/libs/lambda-at-edge/src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@ import path from "path";
import { getSortedRoutes } from "./lib/sortedRoutes";
import {
OriginRequestDefaultHandlerManifest,
OriginRequestApiHandlerManifest
OriginRequestApiHandlerManifest,
RoutesManifest
} from "../types";
import isDynamicRoute from "./lib/isDynamicRoute";
import pathToPosix from "./lib/pathToPosix";
import expressifyDynamicRoute from "./lib/expressifyDynamicRoute";
import pathToRegexStr from "./lib/pathToRegexStr";
import normalizeNodeModules from "./lib/normalizeNodeModules";
import createServerlessConfig from "./lib/createServerlessConfig";
import { isTrailingSlashRedirect } from "./routing/redirector";

export const DEFAULT_LAMBDA_CODE_DIR = "default-lambda";
export const API_LAMBDA_CODE_DIR = "api-lambda";
Expand Down Expand Up @@ -159,6 +161,22 @@ class Builder {
return false;
}

/**
* Process and copy RoutesManifest.
* @param source
* @param destination
*/
async processAndCopyRoutesManifest(source: string, destination: string) {
const routesManifest = require(source) as RoutesManifest;

// Remove default trailing slash redirects as they are already handled without regex matching.
routesManifest.redirects = routesManifest.redirects.filter((redirect) => {
return !isTrailingSlashRedirect(redirect, routesManifest.basePath);
});

await fse.writeFile(destination, JSON.stringify(routesManifest));
}

async buildDefaultLambda(
buildManifest: OriginRequestDefaultHandlerManifest
): Promise<void[]> {
Expand Down Expand Up @@ -245,7 +263,7 @@ class Builder {
join(this.dotNextDir, "prerender-manifest.json"),
join(this.outputDir, DEFAULT_LAMBDA_CODE_DIR, "prerender-manifest.json")
),
fse.copy(
this.processAndCopyRoutesManifest(
join(this.dotNextDir, "routes-manifest.json"),
join(this.outputDir, DEFAULT_LAMBDA_CODE_DIR, "routes-manifest.json")
)
Expand Down
Loading