diff --git a/packages/libs/lambda-at-edge/src/default-handler.ts b/packages/libs/lambda-at-edge/src/default-handler.ts
index 0d79be48ce..b5f57f4678 100644
--- a/packages/libs/lambda-at-edge/src/default-handler.ts
+++ b/packages/libs/lambda-at-edge/src/default-handler.ts
@@ -9,6 +9,7 @@ import lambdaAtEdgeCompat from "@sls-next/next-aws-cloudfront";
import {
CloudFrontOrigin,
CloudFrontRequest,
+ CloudFrontResponse,
CloudFrontResultResponse,
CloudFrontS3Origin
} from "aws-lambda";
@@ -603,6 +604,7 @@ const handleOriginResponse = async ({
const { uri } = request;
const { status } = response;
if (status !== "403") {
+ setCacheControl(request, response);
// Set 404 status code for 404.html page. We do not need normalised URI as it will always be "/404.html"
if (uri.endsWith("/404.html")) {
response.status = "404";
@@ -655,6 +657,12 @@ const handleOriginResponse = async ({
"passthrough"
);
if (isSSG) {
+ const cacheControl = renderOpts.revalidate
+ ? undefined
+ : "public, max-age=0, s-maxage=2678400, must-revalidate";
+ const expires = renderOpts.revalidate
+ ? new Date(new Date().getTime() + 1000 * renderOpts.revalidate)
+ : undefined;
const baseKey = uri
.replace(/^\//, "")
.replace(/\.(json|html)$/, "")
@@ -666,14 +674,16 @@ const handleOriginResponse = async ({
Key: `${s3BasePath}${jsonKey}`,
Body: JSON.stringify(renderOpts.pageData),
ContentType: "application/json",
- CacheControl: "public, max-age=0, s-maxage=2678400, must-revalidate"
+ CacheControl: cacheControl,
+ Expires: expires
};
const s3HtmlParams = {
Bucket: bucketName,
Key: `${s3BasePath}${htmlKey}`,
Body: html,
ContentType: "text/html",
- CacheControl: "public, max-age=0, s-maxage=2678400, must-revalidate"
+ CacheControl: cacheControl,
+ Expires: expires
};
const { PutObjectCommand } = await import(
"@aws-sdk/client-s3/commands/PutObjectCommand"
@@ -790,6 +800,37 @@ const isOriginResponse = (
return event.Records[0].cf.config.eventType === "origin-response";
};
+const setCacheControl = (
+ request: CloudFrontRequest,
+ response: CloudFrontResponse
+) => {
+ if (!response.headers || !request.origin?.s3) {
+ return false;
+ }
+ const expiresHeader = response.headers["expires"];
+ if (!expiresHeader) {
+ return false;
+ }
+ const expires = new Date(expiresHeader[0].value);
+ const maxAge = Math.floor((expires.getTime() - new Date().getTime()) / 1000);
+ if (maxAge <= 0) {
+ response.headers["cache-control"] = [
+ {
+ key: "Cache-Control",
+ value: "public, max-age=0, s-maxage=0, must-revalidate"
+ }
+ ];
+ return true;
+ }
+ response.headers["cache-control"] = [
+ {
+ key: "Cache-Control",
+ value: `public, max-age=0, s-maxage=${maxAge}, must-revalidate`
+ }
+ ];
+ return false;
+};
+
const hasFallbackForUri = (
uri: string,
manifest: OriginRequestDefaultHandlerManifest,
diff --git a/packages/libs/lambda-at-edge/tests/default-handler/default-handler-origin-response.test.ts b/packages/libs/lambda-at-edge/tests/default-handler/default-handler-origin-response.test.ts
index c0749623df..5f196f99f5 100644
--- a/packages/libs/lambda-at-edge/tests/default-handler/default-handler-origin-response.test.ts
+++ b/packages/libs/lambda-at-edge/tests/default-handler/default-handler-origin-response.test.ts
@@ -166,24 +166,113 @@ describe("Lambda@Edge origin response", () => {
expect(decodedBody).toEqual("
Rendered Page
");
expect(cfResponse.status).toEqual(200);
- expect(s3Client.send).toHaveBeenNthCalledWith(1, {
- Command: "PutObjectCommand",
- Bucket: "my-bucket.s3.amazonaws.com",
- Key: "_next/data/build-id/fallback-blocking/not-yet-built.json",
- Body: JSON.stringify({
- page: "pages/fallback-blocking/[slug].js"
- }),
- ContentType: "application/json",
- CacheControl: "public, max-age=0, s-maxage=2678400, must-revalidate"
+ expect(s3Client.send).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({
+ Command: "PutObjectCommand",
+ Bucket: "my-bucket.s3.amazonaws.com",
+ Key: "_next/data/build-id/fallback-blocking/not-yet-built.json",
+ Body: JSON.stringify({
+ page: "pages/fallback-blocking/[slug].js"
+ }),
+ ContentType: "application/json"
+ })
+ );
+ expect(s3Client.send).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({
+ Command: "PutObjectCommand",
+ Bucket: "my-bucket.s3.amazonaws.com",
+ Key: "static-pages/build-id/fallback-blocking/not-yet-built.html",
+ Body: "Rendered Page
",
+ ContentType: "text/html"
+ })
+ );
+ });
+
+ it("uploads with revalidate-based expires", async () => {
+ const event = createCloudFrontEvent({
+ uri: "/fallback-blocking/not-yet-built.html",
+ host: "mydistribution.cloudfront.net",
+ config: { eventType: "origin-response" } as any,
+ response: {
+ headers: {},
+ status: "403"
+ } as any
});
- expect(s3Client.send).toHaveBeenNthCalledWith(2, {
- Command: "PutObjectCommand",
- Bucket: "my-bucket.s3.amazonaws.com",
- Key: "static-pages/build-id/fallback-blocking/not-yet-built.html",
- Body: "Rendered Page
",
- ContentType: "text/html",
- CacheControl: "public, max-age=0, s-maxage=2678400, must-revalidate"
+
+ mockPageRequire("pages/fallback-blocking/[slug].js");
+
+ await handler(event);
+
+ expect(
+ (s3Client.send as jest.Mock).mock.calls[0][0].Expires.getTime()
+ ).toBeGreaterThan(new Date().getTime());
+ expect(
+ (s3Client.send as jest.Mock).mock.calls[0][0].Expires.getTime()
+ ).toBeLessThan(new Date().getTime() + 300000);
+ expect(
+ (s3Client.send as jest.Mock).mock.calls[1][0].Expires.getTime()
+ ).toBeGreaterThan(new Date().getTime());
+ expect(
+ (s3Client.send as jest.Mock).mock.calls[1][0].Expires.getTime()
+ ).toBeLessThan(new Date().getTime() + 300000);
+ });
+
+ it("serves fresh page with caching", async () => {
+ const event = createCloudFrontEvent({
+ uri: "/fallback-blocking/fresh.html",
+ host: "mydistribution.cloudfront.net",
+ config: { eventType: "origin-response" } as any,
+ response: {
+ headers: {
+ expires: [
+ {
+ key: "Expires",
+ value: new Date(new Date().getTime() + 30000).toUTCString()
+ }
+ ]
+ },
+ status: "200"
+ } as any
+ });
+
+ const response = await handler(event);
+
+ const headers = response.headers as CloudFrontHeaders;
+ // s-maxage should be about 29, but could go lower if tests run slow
+ const prefix = "public, max-age=0, s-maxage=";
+ const maxAge = parseInt(
+ headers["cache-control"][0].value.slice(prefix.length)
+ );
+ expect(maxAge).toBeGreaterThan(20);
+ expect(maxAge).toBeLessThan(30);
+ });
+
+ it("serves stale page with no caching", async () => {
+ const event = createCloudFrontEvent({
+ uri: "/fallback-blocking/stale.html",
+ host: "mydistribution.cloudfront.net",
+ config: { eventType: "origin-response" } as any,
+ response: {
+ headers: {
+ expires: [
+ {
+ key: "Expires",
+ value: "Wed, 21 Apr 2021 04:47:27 GMT"
+ }
+ ]
+ },
+ status: "200"
+ } as any
});
+
+ const response = await handler(event);
+
+ const headers = response.headers as CloudFrontHeaders;
+ expect(headers["cache-control"][0].value).toEqual(
+ "public, max-age=0, s-maxage=0, must-revalidate"
+ );
});
it("renders and uploads HTML and JSON for fallback SSG data requests", async () => {
diff --git a/packages/libs/lambda-at-edge/tests/shared-fixtures/built-artifact/pages/fallback-blocking/[slug].js b/packages/libs/lambda-at-edge/tests/shared-fixtures/built-artifact/pages/fallback-blocking/[slug].js
index 5768347d05..25289044d3 100644
--- a/packages/libs/lambda-at-edge/tests/shared-fixtures/built-artifact/pages/fallback-blocking/[slug].js
+++ b/packages/libs/lambda-at-edge/tests/shared-fixtures/built-artifact/pages/fallback-blocking/[slug].js
@@ -9,7 +9,8 @@ module.exports = {
renderOpts: {
pageData: {
page: "pages/fallback-blocking/[slug].js"
- }
+ },
+ revalidate: 300
}
});
}
diff --git a/packages/libs/s3-static-assets/src/index.ts b/packages/libs/s3-static-assets/src/index.ts
index 800abb12fa..3723a9bddc 100644
--- a/packages/libs/s3-static-assets/src/index.ts
+++ b/packages/libs/s3-static-assets/src/index.ts
@@ -7,19 +7,25 @@ import {
SERVER_NO_CACHE_CACHE_CONTROL_HEADER,
SERVER_CACHE_CONTROL_HEADER
} from "./lib/constants";
+import getPageName from "./lib/getPageName";
import S3ClientFactory, { Credentials } from "./lib/s3";
import pathToPosix from "./lib/pathToPosix";
-import { PrerenderManifest } from "next/dist/build/index";
+import { PrerenderManifest, SsgRoute } from "next/dist/build/index";
import getPublicAssetCacheControl, {
PublicDirectoryCache
} from "./lib/getPublicAssetCacheControl";
+type PrerenderRoutes = {
+ [path: string]: SsgRoute;
+};
+
type UploadStaticAssetsOptions = {
bucketName: string;
basePath: string;
nextConfigDir: string;
nextStaticDir?: string;
credentials: Credentials;
+ prerenderRoutes?: PrerenderRoutes;
publicDirectoryCache?: PublicDirectoryCache;
};
@@ -29,6 +35,7 @@ type AssetDirectoryFileCachePoliciesOptions = {
// .i.e. by default .serverless_nextjs
serverlessBuildOutDir: string;
nextStaticDir?: string;
+ prerenderRoutes: PrerenderRoutes;
publicDirectoryCache?: PublicDirectoryCache;
};
@@ -38,13 +45,19 @@ type AssetDirectoryFileCachePoliciesOptions = {
const getAssetDirectoryFileCachePolicies = (
options: AssetDirectoryFileCachePoliciesOptions
): Array<{
- cacheControl: string | undefined;
+ cacheControl?: string;
+ expires?: Date;
path: {
relative: string;
absolute: string;
};
}> => {
- const { basePath, publicDirectoryCache, serverlessBuildOutDir } = options;
+ const {
+ basePath,
+ prerenderRoutes,
+ publicDirectoryCache,
+ serverlessBuildOutDir
+ } = options;
const normalizedBasePath = basePath ? basePath.slice(1) : "";
@@ -75,20 +88,39 @@ const getAssetDirectoryFileCachePolicies = (
// Upload Next.js data files
- const nextDataFiles = readDirectoryFiles(
- path.join(assetsOutputDirectory, normalizedBasePath, "_next", "data")
+ const nextDataDir = path.join(
+ assetsOutputDirectory,
+ normalizedBasePath,
+ "_next",
+ "data"
);
+ const nextDataFiles = readDirectoryFiles(nextDataDir);
- const nextDataFilesUploads = nextDataFiles.map((fileItem) => ({
- path: fileItem.path,
- cacheControl: SERVER_CACHE_CONTROL_HEADER
- }));
+ const nextDataFilesUploads = nextDataFiles.map((fileItem) => {
+ const route = prerenderRoutes[getPageName(fileItem.path, nextDataDir)];
+ if (route && route.initialRevalidateSeconds) {
+ const expires = new Date(
+ new Date().getTime() + 1000 * route.initialRevalidateSeconds
+ );
+ return {
+ path: fileItem.path,
+ expires
+ };
+ }
+ return {
+ path: fileItem.path,
+ cacheControl: SERVER_CACHE_CONTROL_HEADER
+ };
+ });
// Upload Next.js HTML pages
- const htmlPages = readDirectoryFiles(
- path.join(assetsOutputDirectory, normalizedBasePath, "static-pages")
+ const htmlDir = path.join(
+ assetsOutputDirectory,
+ normalizedBasePath,
+ "static-pages"
);
+ const htmlPages = readDirectoryFiles(htmlDir);
const htmlPagesUploads = htmlPages.map((fileItem) => {
// Dynamic fallback HTML pages should never be cached as it will override actual pages once generated and stored in S3.
@@ -98,12 +130,23 @@ const getAssetDirectoryFileCachePolicies = (
path: fileItem.path,
cacheControl: SERVER_NO_CACHE_CACHE_CONTROL_HEADER
};
- } else {
+ }
+
+ const route = prerenderRoutes[getPageName(fileItem.path, htmlDir)];
+ if (route && route.initialRevalidateSeconds) {
+ const expires = new Date(
+ new Date().getTime() + 1000 * route.initialRevalidateSeconds
+ );
return {
path: fileItem.path,
- cacheControl: SERVER_CACHE_CONTROL_HEADER
+ expires
};
}
+
+ return {
+ path: fileItem.path,
+ cacheControl: SERVER_CACHE_CONTROL_HEADER
+ };
});
// Upload user static and public files
@@ -132,14 +175,14 @@ const getAssetDirectoryFileCachePolicies = (
...htmlPagesUploads,
...publicAndStaticUploads,
buildIdUpload
- ].map(({ cacheControl, path: absolutePath }) => ({
- cacheControl,
+ ].map(({ path: absolutePath, ...rest }) => ({
path: {
// Path relative to the assets folder, used for the S3 upload key
relative: path.relative(assetsOutputDirectory, absolutePath),
// Absolute path of local asset
absolute: absolutePath
- }
+ },
+ ...rest
}));
};
@@ -155,11 +198,14 @@ const uploadStaticAssetsFromBuild = async (
bucketName,
credentials,
basePath,
+ prerenderRoutes,
publicDirectoryCache,
nextConfigDir
} = options;
+
const files = getAssetDirectoryFileCachePolicies({
basePath,
+ prerenderRoutes: prerenderRoutes ?? {},
publicDirectoryCache,
serverlessBuildOutDir: path.join(nextConfigDir, ".serverless_nextjs")
});
@@ -173,7 +219,8 @@ const uploadStaticAssetsFromBuild = async (
s3.uploadFile({
s3Key: pathToPosix(file.path.relative),
filePath: file.path.absolute,
- cacheControl: file.cacheControl
+ cacheControl: file.cacheControl,
+ expires: file.expires
})
)
);
diff --git a/packages/libs/s3-static-assets/src/lib/getPageName.ts b/packages/libs/s3-static-assets/src/lib/getPageName.ts
new file mode 100644
index 0000000000..979d51830d
--- /dev/null
+++ b/packages/libs/s3-static-assets/src/lib/getPageName.ts
@@ -0,0 +1,8 @@
+const getPageName = (file: string, base: string): string => {
+ const relative = file.slice(base.length + 1);
+ const withoutBuildId = relative.split("/", 2)[1];
+ const withoutExtension = withoutBuildId.replace(/\.(html|json)$/, "");
+ return `/${withoutExtension}`;
+};
+
+export default getPageName;
diff --git a/packages/libs/s3-static-assets/src/lib/s3.ts b/packages/libs/s3-static-assets/src/lib/s3.ts
index f6feac7e58..5a9b28d99e 100644
--- a/packages/libs/s3-static-assets/src/lib/s3.ts
+++ b/packages/libs/s3-static-assets/src/lib/s3.ts
@@ -12,6 +12,7 @@ type S3ClientFactoryOptions = {
type UploadFileOptions = {
filePath: string;
cacheControl?: string;
+ expires?: Date;
s3Key?: string;
};
@@ -73,7 +74,7 @@ export default async ({
uploadFile: async (
options: UploadFileOptions
): Promise => {
- const { filePath, cacheControl, s3Key } = options;
+ const { filePath, cacheControl, expires, s3Key } = options;
const fileBody = await fse.readFile(filePath);
@@ -83,7 +84,8 @@ export default async ({
Key: s3Key || filePath,
Body: fileBody,
ContentType: getMimeType(filePath),
- CacheControl: cacheControl || undefined
+ CacheControl: cacheControl || undefined,
+ Expires: expires
})
.promise();
},
diff --git a/packages/libs/s3-static-assets/tests/fixtures/app-basic-upload-from-build-basepath/.serverless_nextjs/assets/basepath/static-pages/index.html b/packages/libs/s3-static-assets/tests/fixtures/app-basic-upload-from-build-basepath/.serverless_nextjs/assets/basepath/static-pages/zsWqBqLjpgRmswfQomanp/index.html
similarity index 100%
rename from packages/libs/s3-static-assets/tests/fixtures/app-basic-upload-from-build-basepath/.serverless_nextjs/assets/basepath/static-pages/index.html
rename to packages/libs/s3-static-assets/tests/fixtures/app-basic-upload-from-build-basepath/.serverless_nextjs/assets/basepath/static-pages/zsWqBqLjpgRmswfQomanp/index.html
diff --git a/packages/libs/s3-static-assets/tests/fixtures/app-basic-upload-from-build/.serverless_nextjs/assets/_next/data/zsWqBqLjpgRmswfQomanp/revalidate.json b/packages/libs/s3-static-assets/tests/fixtures/app-basic-upload-from-build/.serverless_nextjs/assets/_next/data/zsWqBqLjpgRmswfQomanp/revalidate.json
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/packages/libs/s3-static-assets/tests/fixtures/app-basic-upload-from-build/.serverless_nextjs/assets/static-pages/zsWqBqLjpgRmswfQomanp/revalidate.html b/packages/libs/s3-static-assets/tests/fixtures/app-basic-upload-from-build/.serverless_nextjs/assets/static-pages/zsWqBqLjpgRmswfQomanp/revalidate.html
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/packages/libs/s3-static-assets/tests/upload-assets-from-build.test.ts b/packages/libs/s3-static-assets/tests/upload-assets-from-build.test.ts
index e365c16d16..633265d7c0 100644
--- a/packages/libs/s3-static-assets/tests/upload-assets-from-build.test.ts
+++ b/packages/libs/s3-static-assets/tests/upload-assets-from-build.test.ts
@@ -43,6 +43,13 @@ const upload = (
secretAccessKey: "fake-secret-key",
sessionToken: "fake-session-token"
},
+ prerenderRoutes: {
+ "/revalidate": {
+ initialRevalidateSeconds: 60,
+ srcRoute: "/revalidate",
+ dataRoute: "/_next/data/zsWqBqLjpgRmswfQomanp/revalidate.json"
+ }
+ },
publicDirectoryCache: publicAssetCache
});
};
@@ -163,6 +170,27 @@ describe.each`
);
});
+ it("uploads revalidate HTML pages with expires instead of cache-control", async () => {
+ expect(mockUpload).toBeCalledWith(
+ expect.objectContaining({
+ Key: "static-pages/zsWqBqLjpgRmswfQomanp/revalidate.html",
+ ContentType: "text/html"
+ })
+ );
+ const call = mockUpload.mock.calls.find(
+ (call) =>
+ call[0].Key === "static-pages/zsWqBqLjpgRmswfQomanp/revalidate.html"
+ );
+ expect(call[0]).toHaveProperty("Expires");
+ expect(call[0].CacheControl).toEqual(undefined);
+ expect(new Date(call[0].Expires).getTime()).toBeGreaterThan(
+ new Date().getTime()
+ );
+ expect(new Date(call[0].Expires).getTime()).toBeLessThan(
+ new Date().getTime() + 60000
+ );
+ });
+
it("uploads staticProps JSON files in _next/data", async () => {
expect(mockUpload).toBeCalledWith(
expect.objectContaining({
@@ -197,6 +225,27 @@ describe.each`
);
});
+ it("uploads revalidate _next/data JSON with expires instead of cache-control", async () => {
+ expect(mockUpload).toBeCalledWith(
+ expect.objectContaining({
+ Key: "_next/data/zsWqBqLjpgRmswfQomanp/revalidate.json",
+ ContentType: "application/json"
+ })
+ );
+ const call = mockUpload.mock.calls.find(
+ (call) =>
+ call[0].Key === "_next/data/zsWqBqLjpgRmswfQomanp/revalidate.json"
+ );
+ expect(call[0]).toHaveProperty("Expires");
+ expect(call[0].CacheControl).toEqual(undefined);
+ expect(new Date(call[0].Expires).getTime()).toBeGreaterThan(
+ new Date().getTime()
+ );
+ expect(new Date(call[0].Expires).getTime()).toBeLessThan(
+ new Date().getTime() + 60000
+ );
+ });
+
it("uploads files in the public folder", async () => {
expect(mockUpload).toBeCalledWith(
expect.objectContaining({
diff --git a/packages/serverless-components/nextjs-component/src/component.ts b/packages/serverless-components/nextjs-component/src/component.ts
index 8b9339fcfc..bb2d51c66f 100644
--- a/packages/serverless-components/nextjs-component/src/component.ts
+++ b/packages/serverless-components/nextjs-component/src/component.ts
@@ -347,6 +347,7 @@ class NextjsComponent extends Component {
nextConfigDir: nextConfigPath,
nextStaticDir: nextStaticPath,
credentials: this.context.credentials.aws,
+ prerenderRoutes: defaultBuildManifest.pages.ssg.nonDynamic,
publicDirectoryCache: inputs.publicDirectoryCache
});
} else {