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 {