Skip to content

Commit

Permalink
feat(lambda-at-edge, nextjs-component): add new input domainRedirects (
Browse files Browse the repository at this point in the history
  • Loading branch information
dphang committed Oct 6, 2020
1 parent dea3e13 commit a12e31a
Show file tree
Hide file tree
Showing 16 changed files with 283 additions and 64 deletions.
13 changes: 12 additions & 1 deletion packages/libs/lambda-at-edge/src/api-handler.ts
Expand Up @@ -9,7 +9,11 @@ import {
RoutesManifest
} from "../types";
import { CloudFrontResultResponse, CloudFrontRequest } from "aws-lambda";
import { createRedirectResponse, getRedirectPath } from "./routing/redirector";
import {
createRedirectResponse,
getDomainRedirectPath,
getRedirectPath
} from "./routing/redirector";
import { getRewritePath } from "./routing/rewriter";

const basePath = RoutesManifestJson.basePath;
Expand Down Expand Up @@ -51,6 +55,13 @@ export const handler = async (
): Promise<CloudFrontResultResponse | CloudFrontRequest> => {
const request = event.Records[0].cf.request;
const routesManifest: RoutesManifest = RoutesManifestJson;
const buildManifest: OriginRequestApiHandlerManifest = manifest;

// Handle domain redirects e.g www to non-www domain
const domainRedirect = getDomainRedirectPath(request, buildManifest);
if (domainRedirect) {
return createRedirectResponse(domainRedirect, request.querystring, 308);
}

// Handle custom redirects
const customRedirect = getRedirectPath(request.uri, routesManifest);
Expand Down
57 changes: 53 additions & 4 deletions packages/libs/lambda-at-edge/src/build.ts
Expand Up @@ -28,6 +28,7 @@ type BuildOptions = {
cmd?: string;
useServerlessTraceTarget?: boolean;
logLambdaExecutionTimes?: boolean;
domainRedirects?: { [key: string]: string };
};

const defaultBuildOptions = {
Expand All @@ -36,7 +37,8 @@ const defaultBuildOptions = {
env: {},
cmd: "./node_modules/.bin/next",
useServerlessTraceTarget: false,
logLambdaExecutionTimes: false
logLambdaExecutionTimes: false,
domainRedirects: {}
};

class Builder {
Expand Down Expand Up @@ -329,7 +331,12 @@ class Builder {
path.join(this.dotNextDir, "BUILD_ID"),
"utf-8"
);
const { logLambdaExecutionTimes = false } = this.buildOptions;
const {
logLambdaExecutionTimes = false,
domainRedirects = {}
} = this.buildOptions;

this.normalizeDomainRedirects(domainRedirects);

const defaultBuildManifest: OriginRequestDefaultHandlerManifest = {
buildId,
Expand All @@ -345,14 +352,16 @@ class Builder {
}
},
publicFiles: {},
trailingSlash: false
trailingSlash: false,
domainRedirects: domainRedirects
};

const apiBuildManifest: OriginRequestApiHandlerManifest = {
apis: {
dynamic: {},
nonDynamic: {}
}
},
domainRedirects: domainRedirects
};

const ssrPages = defaultBuildManifest.pages.ssr;
Expand Down Expand Up @@ -492,6 +501,46 @@ class Builder {
await this.buildApiLambda(apiBuildManifest);
}
}

/**
* Normalize domain redirects by validating they are URLs and getting rid of trailing slash.
* @param domainRedirects
*/
normalizeDomainRedirects(domainRedirects: { [key: string]: string }) {
for (const key in domainRedirects) {
const destination = domainRedirects[key];

let url;
try {
url = new URL(destination);
} catch (error) {
throw new Error(
`domainRedirects: ${destination} is invalid. The URL is not in a valid URL format.`
);
}

const { origin, pathname, searchParams } = url;

if (!origin.startsWith("https://") && !origin.startsWith("http://")) {
throw new Error(
`domainRedirects: ${destination} is invalid. The URL must start with http:// or https://.`
);
}

if (Array.from(searchParams).length > 0) {
throw new Error(
`domainRedirects: ${destination} is invalid. The URL must not contain query parameters.`
);
}

let normalizedDomain = `${origin}${pathname}`;
normalizedDomain = normalizedDomain.endsWith("/")
? normalizedDomain.slice(0, -1)
: normalizedDomain;

domainRedirects[key] = normalizedDomain;
}
}
}

export default Builder;
15 changes: 13 additions & 2 deletions packages/libs/lambda-at-edge/src/default-handler.ts
Expand Up @@ -24,7 +24,11 @@ import { performance } from "perf_hooks";
import { ServerResponse } from "http";
import jsonwebtoken from "jsonwebtoken";
import type { Readable } from "stream";
import { createRedirectResponse, getRedirectPath } from "./routing/redirector";
import {
createRedirectResponse,
getDomainRedirectPath,
getRedirectPath
} from "./routing/redirector";
import { getRewritePath } from "./routing/rewriter";

const basePath = RoutesManifestJson.basePath;
Expand Down Expand Up @@ -209,8 +213,15 @@ const handleOriginRequest = async ({
prerenderManifest: PrerenderManifestType;
routesManifest: RoutesManifest;
}) => {
const basePath = routesManifest.basePath;
const request = event.Records[0].cf.request;

// Handle domain redirects e.g www to non-www domain
const domainRedirect = getDomainRedirectPath(request, manifest);
if (domainRedirect) {
return createRedirectResponse(domainRedirect, request.querystring, 308);
}

const basePath = routesManifest.basePath;
let uri = normaliseUri(request.uri);
const { pages, publicFiles } = manifest;
const isPublicFile = publicFiles[uri];
Expand Down
31 changes: 30 additions & 1 deletion packages/libs/lambda-at-edge/src/routing/redirector.ts
@@ -1,6 +1,12 @@
import { compileDestination, matchPath } from "./matcher";
import { RedirectData, RoutesManifest } from "../../types";
import {
OriginRequestApiHandlerManifest,
OriginRequestDefaultHandlerManifest,
RedirectData,
RoutesManifest
} from "../../types";
import * as http from "http";
import { CloudFrontRequest } from "aws-lambda";

/**
* Whether this is the default trailing slash redirect.
Expand Down Expand Up @@ -115,3 +121,26 @@ export function createRedirectResponse(
}
};
}

/**
* Get a domain redirect such as redirecting www to non-www domain.
* @param request
* @param buildManifest
*/
export function getDomainRedirectPath(
request: CloudFrontRequest,
buildManifest:
| OriginRequestDefaultHandlerManifest
| OriginRequestApiHandlerManifest
): string | null {
const hostHeaders = request.headers["host"];
if (hostHeaders && hostHeaders.length > 0) {
const host = hostHeaders[0].value;
const domainRedirects = buildManifest.domainRedirects;

if (domainRedirects && domainRedirects[host]) {
return `${domainRedirects[host]}${request.uri}`;
}
}
return null;
}
Expand Up @@ -4,5 +4,8 @@
"nonDynamic": {
"/api/getCustomers": "pages/api/getCustomers.js"
}
},
"domainRedirects": {
"example.com": "https://www.example.com"
}
}
113 changes: 71 additions & 42 deletions packages/libs/lambda-at-edge/tests/api-handler/api-handler.test.ts
Expand Up @@ -30,61 +30,65 @@ const mockPageRequire = (mockPagePath: string): void => {
};

describe("API lambda handler", () => {
it("serves api request", async () => {
const event = createCloudFrontEvent({
uri: "/api/getCustomers",
host: "mydistribution.cloudfront.net",
origin: {
s3: {
domainName: "my-bucket.s3.amazonaws.com"
describe("API routes", () => {
it("serves api request", async () => {
const event = createCloudFrontEvent({
uri: "/api/getCustomers",
host: "mydistribution.cloudfront.net",
origin: {
s3: {
domainName: "my-bucket.s3.amazonaws.com"
}
}
}
});
});

mockPageRequire("pages/api/getCustomers.js");
mockPageRequire("pages/api/getCustomers.js");

const response = (await handler(event)) as CloudFrontResponseResult;
const response = (await handler(event)) as CloudFrontResponseResult;

const decodedBody = new Buffer(response.body, "base64").toString("utf8");
const decodedBody = new Buffer(response.body, "base64").toString("utf8");

expect(decodedBody).toEqual("pages/api/getCustomers");
expect(response.status).toEqual(200);
});
expect(decodedBody).toEqual("pages/api/getCustomers");
expect(response.status).toEqual(200);
});

it("returns 404 for not-found api routes", async () => {
const event = createCloudFrontEvent({
uri: "/foo/bar",
host: "mydistribution.cloudfront.net",
origin: {
s3: {
domainName: "my-bucket.s3.amazonaws.com"
it("returns 404 for not-found api routes", async () => {
const event = createCloudFrontEvent({
uri: "/foo/bar",
host: "mydistribution.cloudfront.net",
origin: {
s3: {
domainName: "my-bucket.s3.amazonaws.com"
}
}
}
});
});

mockPageRequire("pages/api/getCustomers.js");
mockPageRequire("pages/api/getCustomers.js");

const response = (await handler(event)) as CloudFrontResponseResult;
const response = (await handler(event)) as CloudFrontResponseResult;

expect(response.status).toEqual("404");
expect(response.status).toEqual("404");
});
});

describe("Custom Redirects", () => {
let runRedirectTest = async (
path: string,
expectedRedirect: string,
statusCode: number,
querystring?: string
): Promise<void> => {
await runRedirectTestWithHandler(
handler,
path,
expectedRedirect,
statusCode,
querystring
);
};
let runRedirectTest = async (
path: string,
expectedRedirect: string,
statusCode: number,
querystring?: string,
host?: string
): Promise<void> => {
await runRedirectTestWithHandler(
handler,
path,
expectedRedirect,
statusCode,
querystring,
host
);
};

describe("Custom Redirects", () => {
it.each`
path | expectedRedirect | expectedRedirectStatusCode
${"/api/deprecated/getCustomers"} | ${"/api/getCustomers"} | ${308}
Expand All @@ -100,6 +104,31 @@ describe("API lambda handler", () => {
);
});

describe("Domain Redirects", () => {
it.each`
path | querystring | expectedRedirect | expectedRedirectStatusCode
${"/"} | ${""} | ${"https://www.example.com/"} | ${308}
${"/"} | ${"a=1234"} | ${"https://www.example.com/?a=1234"} | ${308}
${"/terms"} | ${""} | ${"https://www.example.com/terms"} | ${308}
`(
"redirects path $path to $expectedRedirect, expectedRedirectStatusCode: $expectedRedirectStatusCode",
async ({
path,
querystring,
expectedRedirect,
expectedRedirectStatusCode
}) => {
await runRedirectTest(
path,
expectedRedirect,
expectedRedirectStatusCode,
querystring,
"example.com" // Override host to test a domain redirect from host example.com -> https://www.example.com
);
}
);
});

describe("Custom Rewrites", () => {
it.each`
path | expectedJs | expectedBody | expectedStatus
Expand Down
17 changes: 15 additions & 2 deletions packages/libs/lambda-at-edge/tests/build/build.test.ts
Expand Up @@ -29,7 +29,13 @@ describe("Builder Tests", () => {
});
fseEmptyDirSpy = jest.spyOn(fse, "emptyDir");

const builder = new Builder(fixturePath, outputDir);
const builder = new Builder(fixturePath, outputDir, {
domainRedirects: {
"example.com": "https://www.example.com",
"another.com": "https://www.another.com/",
"www.other.com": "https://other.com"
}
});
await builder.build();

defaultBuildManifest = await fse.readJSON(
Expand Down Expand Up @@ -77,7 +83,8 @@ describe("Builder Tests", () => {
ssr: { dynamic, nonDynamic },
html
},
trailingSlash
trailingSlash,
domainRedirects
} = defaultBuildManifest;

expect(removeNewLineChars(buildId)).toEqual("test-build-id");
Expand Down Expand Up @@ -132,6 +139,12 @@ describe("Builder Tests", () => {
});

expect(trailingSlash).toBe(false);

expect(domainRedirects).toEqual({
"example.com": "https://www.example.com",
"another.com": "https://www.another.com",
"www.other.com": "https://other.com"
});
});
});

Expand Down
Expand Up @@ -63,5 +63,8 @@
"/favicon.ico": "favicon.ico",
"/manifest.json": "manifest.json"
},
"trailingSlash": false
"trailingSlash": false,
"domainRedirects": {
"example.com": "https://www.example.com"
}
}

0 comments on commit a12e31a

Please sign in to comment.