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

Commit a12e31a

Browse files
authored
feat(lambda-at-edge, nextjs-component): add new input domainRedirects (#639)
1 parent dea3e13 commit a12e31a

16 files changed

+283
-64
lines changed

packages/libs/lambda-at-edge/src/api-handler.ts

+12-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ import {
99
RoutesManifest
1010
} from "../types";
1111
import { CloudFrontResultResponse, CloudFrontRequest } from "aws-lambda";
12-
import { createRedirectResponse, getRedirectPath } from "./routing/redirector";
12+
import {
13+
createRedirectResponse,
14+
getDomainRedirectPath,
15+
getRedirectPath
16+
} from "./routing/redirector";
1317
import { getRewritePath } from "./routing/rewriter";
1418

1519
const basePath = RoutesManifestJson.basePath;
@@ -51,6 +55,13 @@ export const handler = async (
5155
): Promise<CloudFrontResultResponse | CloudFrontRequest> => {
5256
const request = event.Records[0].cf.request;
5357
const routesManifest: RoutesManifest = RoutesManifestJson;
58+
const buildManifest: OriginRequestApiHandlerManifest = manifest;
59+
60+
// Handle domain redirects e.g www to non-www domain
61+
const domainRedirect = getDomainRedirectPath(request, buildManifest);
62+
if (domainRedirect) {
63+
return createRedirectResponse(domainRedirect, request.querystring, 308);
64+
}
5465

5566
// Handle custom redirects
5667
const customRedirect = getRedirectPath(request.uri, routesManifest);

packages/libs/lambda-at-edge/src/build.ts

+53-4
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ type BuildOptions = {
2828
cmd?: string;
2929
useServerlessTraceTarget?: boolean;
3030
logLambdaExecutionTimes?: boolean;
31+
domainRedirects?: { [key: string]: string };
3132
};
3233

3334
const defaultBuildOptions = {
@@ -36,7 +37,8 @@ const defaultBuildOptions = {
3637
env: {},
3738
cmd: "./node_modules/.bin/next",
3839
useServerlessTraceTarget: false,
39-
logLambdaExecutionTimes: false
40+
logLambdaExecutionTimes: false,
41+
domainRedirects: {}
4042
};
4143

4244
class Builder {
@@ -329,7 +331,12 @@ class Builder {
329331
path.join(this.dotNextDir, "BUILD_ID"),
330332
"utf-8"
331333
);
332-
const { logLambdaExecutionTimes = false } = this.buildOptions;
334+
const {
335+
logLambdaExecutionTimes = false,
336+
domainRedirects = {}
337+
} = this.buildOptions;
338+
339+
this.normalizeDomainRedirects(domainRedirects);
333340

334341
const defaultBuildManifest: OriginRequestDefaultHandlerManifest = {
335342
buildId,
@@ -345,14 +352,16 @@ class Builder {
345352
}
346353
},
347354
publicFiles: {},
348-
trailingSlash: false
355+
trailingSlash: false,
356+
domainRedirects: domainRedirects
349357
};
350358

351359
const apiBuildManifest: OriginRequestApiHandlerManifest = {
352360
apis: {
353361
dynamic: {},
354362
nonDynamic: {}
355-
}
363+
},
364+
domainRedirects: domainRedirects
356365
};
357366

358367
const ssrPages = defaultBuildManifest.pages.ssr;
@@ -492,6 +501,46 @@ class Builder {
492501
await this.buildApiLambda(apiBuildManifest);
493502
}
494503
}
504+
505+
/**
506+
* Normalize domain redirects by validating they are URLs and getting rid of trailing slash.
507+
* @param domainRedirects
508+
*/
509+
normalizeDomainRedirects(domainRedirects: { [key: string]: string }) {
510+
for (const key in domainRedirects) {
511+
const destination = domainRedirects[key];
512+
513+
let url;
514+
try {
515+
url = new URL(destination);
516+
} catch (error) {
517+
throw new Error(
518+
`domainRedirects: ${destination} is invalid. The URL is not in a valid URL format.`
519+
);
520+
}
521+
522+
const { origin, pathname, searchParams } = url;
523+
524+
if (!origin.startsWith("https://") && !origin.startsWith("http://")) {
525+
throw new Error(
526+
`domainRedirects: ${destination} is invalid. The URL must start with http:// or https://.`
527+
);
528+
}
529+
530+
if (Array.from(searchParams).length > 0) {
531+
throw new Error(
532+
`domainRedirects: ${destination} is invalid. The URL must not contain query parameters.`
533+
);
534+
}
535+
536+
let normalizedDomain = `${origin}${pathname}`;
537+
normalizedDomain = normalizedDomain.endsWith("/")
538+
? normalizedDomain.slice(0, -1)
539+
: normalizedDomain;
540+
541+
domainRedirects[key] = normalizedDomain;
542+
}
543+
}
495544
}
496545

497546
export default Builder;

packages/libs/lambda-at-edge/src/default-handler.ts

+13-2
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@ import { performance } from "perf_hooks";
2424
import { ServerResponse } from "http";
2525
import jsonwebtoken from "jsonwebtoken";
2626
import type { Readable } from "stream";
27-
import { createRedirectResponse, getRedirectPath } from "./routing/redirector";
27+
import {
28+
createRedirectResponse,
29+
getDomainRedirectPath,
30+
getRedirectPath
31+
} from "./routing/redirector";
2832
import { getRewritePath } from "./routing/rewriter";
2933

3034
const basePath = RoutesManifestJson.basePath;
@@ -209,8 +213,15 @@ const handleOriginRequest = async ({
209213
prerenderManifest: PrerenderManifestType;
210214
routesManifest: RoutesManifest;
211215
}) => {
212-
const basePath = routesManifest.basePath;
213216
const request = event.Records[0].cf.request;
217+
218+
// Handle domain redirects e.g www to non-www domain
219+
const domainRedirect = getDomainRedirectPath(request, manifest);
220+
if (domainRedirect) {
221+
return createRedirectResponse(domainRedirect, request.querystring, 308);
222+
}
223+
224+
const basePath = routesManifest.basePath;
214225
let uri = normaliseUri(request.uri);
215226
const { pages, publicFiles } = manifest;
216227
const isPublicFile = publicFiles[uri];

packages/libs/lambda-at-edge/src/routing/redirector.ts

+30-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { compileDestination, matchPath } from "./matcher";
2-
import { RedirectData, RoutesManifest } from "../../types";
2+
import {
3+
OriginRequestApiHandlerManifest,
4+
OriginRequestDefaultHandlerManifest,
5+
RedirectData,
6+
RoutesManifest
7+
} from "../../types";
38
import * as http from "http";
9+
import { CloudFrontRequest } from "aws-lambda";
410

511
/**
612
* Whether this is the default trailing slash redirect.
@@ -115,3 +121,26 @@ export function createRedirectResponse(
115121
}
116122
};
117123
}
124+
125+
/**
126+
* Get a domain redirect such as redirecting www to non-www domain.
127+
* @param request
128+
* @param buildManifest
129+
*/
130+
export function getDomainRedirectPath(
131+
request: CloudFrontRequest,
132+
buildManifest:
133+
| OriginRequestDefaultHandlerManifest
134+
| OriginRequestApiHandlerManifest
135+
): string | null {
136+
const hostHeaders = request.headers["host"];
137+
if (hostHeaders && hostHeaders.length > 0) {
138+
const host = hostHeaders[0].value;
139+
const domainRedirects = buildManifest.domainRedirects;
140+
141+
if (domainRedirects && domainRedirects[host]) {
142+
return `${domainRedirects[host]}${request.uri}`;
143+
}
144+
}
145+
return null;
146+
}

packages/libs/lambda-at-edge/tests/api-handler/api-build-manifest.json

+3
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,8 @@
44
"nonDynamic": {
55
"/api/getCustomers": "pages/api/getCustomers.js"
66
}
7+
},
8+
"domainRedirects": {
9+
"example.com": "https://www.example.com"
710
}
811
}

packages/libs/lambda-at-edge/tests/api-handler/api-handler.test.ts

+71-42
Original file line numberDiff line numberDiff line change
@@ -30,61 +30,65 @@ const mockPageRequire = (mockPagePath: string): void => {
3030
};
3131

3232
describe("API lambda handler", () => {
33-
it("serves api request", async () => {
34-
const event = createCloudFrontEvent({
35-
uri: "/api/getCustomers",
36-
host: "mydistribution.cloudfront.net",
37-
origin: {
38-
s3: {
39-
domainName: "my-bucket.s3.amazonaws.com"
33+
describe("API routes", () => {
34+
it("serves api request", async () => {
35+
const event = createCloudFrontEvent({
36+
uri: "/api/getCustomers",
37+
host: "mydistribution.cloudfront.net",
38+
origin: {
39+
s3: {
40+
domainName: "my-bucket.s3.amazonaws.com"
41+
}
4042
}
41-
}
42-
});
43+
});
4344

44-
mockPageRequire("pages/api/getCustomers.js");
45+
mockPageRequire("pages/api/getCustomers.js");
4546

46-
const response = (await handler(event)) as CloudFrontResponseResult;
47+
const response = (await handler(event)) as CloudFrontResponseResult;
4748

48-
const decodedBody = new Buffer(response.body, "base64").toString("utf8");
49+
const decodedBody = new Buffer(response.body, "base64").toString("utf8");
4950

50-
expect(decodedBody).toEqual("pages/api/getCustomers");
51-
expect(response.status).toEqual(200);
52-
});
51+
expect(decodedBody).toEqual("pages/api/getCustomers");
52+
expect(response.status).toEqual(200);
53+
});
5354

54-
it("returns 404 for not-found api routes", async () => {
55-
const event = createCloudFrontEvent({
56-
uri: "/foo/bar",
57-
host: "mydistribution.cloudfront.net",
58-
origin: {
59-
s3: {
60-
domainName: "my-bucket.s3.amazonaws.com"
55+
it("returns 404 for not-found api routes", async () => {
56+
const event = createCloudFrontEvent({
57+
uri: "/foo/bar",
58+
host: "mydistribution.cloudfront.net",
59+
origin: {
60+
s3: {
61+
domainName: "my-bucket.s3.amazonaws.com"
62+
}
6163
}
62-
}
63-
});
64+
});
6465

65-
mockPageRequire("pages/api/getCustomers.js");
66+
mockPageRequire("pages/api/getCustomers.js");
6667

67-
const response = (await handler(event)) as CloudFrontResponseResult;
68+
const response = (await handler(event)) as CloudFrontResponseResult;
6869

69-
expect(response.status).toEqual("404");
70+
expect(response.status).toEqual("404");
71+
});
7072
});
7173

72-
describe("Custom Redirects", () => {
73-
let runRedirectTest = async (
74-
path: string,
75-
expectedRedirect: string,
76-
statusCode: number,
77-
querystring?: string
78-
): Promise<void> => {
79-
await runRedirectTestWithHandler(
80-
handler,
81-
path,
82-
expectedRedirect,
83-
statusCode,
84-
querystring
85-
);
86-
};
74+
let runRedirectTest = async (
75+
path: string,
76+
expectedRedirect: string,
77+
statusCode: number,
78+
querystring?: string,
79+
host?: string
80+
): Promise<void> => {
81+
await runRedirectTestWithHandler(
82+
handler,
83+
path,
84+
expectedRedirect,
85+
statusCode,
86+
querystring,
87+
host
88+
);
89+
};
8790

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

107+
describe("Domain Redirects", () => {
108+
it.each`
109+
path | querystring | expectedRedirect | expectedRedirectStatusCode
110+
${"/"} | ${""} | ${"https://www.example.com/"} | ${308}
111+
${"/"} | ${"a=1234"} | ${"https://www.example.com/?a=1234"} | ${308}
112+
${"/terms"} | ${""} | ${"https://www.example.com/terms"} | ${308}
113+
`(
114+
"redirects path $path to $expectedRedirect, expectedRedirectStatusCode: $expectedRedirectStatusCode",
115+
async ({
116+
path,
117+
querystring,
118+
expectedRedirect,
119+
expectedRedirectStatusCode
120+
}) => {
121+
await runRedirectTest(
122+
path,
123+
expectedRedirect,
124+
expectedRedirectStatusCode,
125+
querystring,
126+
"example.com" // Override host to test a domain redirect from host example.com -> https://www.example.com
127+
);
128+
}
129+
);
130+
});
131+
103132
describe("Custom Rewrites", () => {
104133
it.each`
105134
path | expectedJs | expectedBody | expectedStatus

packages/libs/lambda-at-edge/tests/build/build.test.ts

+15-2
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,13 @@ describe("Builder Tests", () => {
2929
});
3030
fseEmptyDirSpy = jest.spyOn(fse, "emptyDir");
3131

32-
const builder = new Builder(fixturePath, outputDir);
32+
const builder = new Builder(fixturePath, outputDir, {
33+
domainRedirects: {
34+
"example.com": "https://www.example.com",
35+
"another.com": "https://www.another.com/",
36+
"www.other.com": "https://other.com"
37+
}
38+
});
3339
await builder.build();
3440

3541
defaultBuildManifest = await fse.readJSON(
@@ -77,7 +83,8 @@ describe("Builder Tests", () => {
7783
ssr: { dynamic, nonDynamic },
7884
html
7985
},
80-
trailingSlash
86+
trailingSlash,
87+
domainRedirects
8188
} = defaultBuildManifest;
8289

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

134141
expect(trailingSlash).toBe(false);
142+
143+
expect(domainRedirects).toEqual({
144+
"example.com": "https://www.example.com",
145+
"another.com": "https://www.another.com",
146+
"www.other.com": "https://other.com"
147+
});
135148
});
136149
});
137150

packages/libs/lambda-at-edge/tests/default-handler/default-build-manifest-with-404.json

+4-1
Original file line numberDiff line numberDiff line change
@@ -63,5 +63,8 @@
6363
"/favicon.ico": "favicon.ico",
6464
"/manifest.json": "manifest.json"
6565
},
66-
"trailingSlash": false
66+
"trailingSlash": false,
67+
"domainRedirects": {
68+
"example.com": "https://www.example.com"
69+
}
6770
}

0 commit comments

Comments
 (0)