From 8a0d5ffc261027df530e079baa2015ad8ad29dfd Mon Sep 17 00:00:00 2001 From: Henry Date: Thu, 29 Apr 2021 20:21:51 +0100 Subject: [PATCH] revalidation relies on the expires header --- .../lambda-at-edge/src/default-handler.ts | 39 +++------- .../src/lib/getStaticRegenerationResponse.ts | 31 +++++++- .../src/regeneration-handler.ts | 51 +++---------- .../libs/lambda-at-edge/src/s3/s3StorePage.ts | 73 +++++++++++++++++++ 4 files changed, 123 insertions(+), 71 deletions(-) create mode 100644 packages/libs/lambda-at-edge/src/s3/s3StorePage.ts diff --git a/packages/libs/lambda-at-edge/src/default-handler.ts b/packages/libs/lambda-at-edge/src/default-handler.ts index a5b4e43a2c..ad344f7ac7 100644 --- a/packages/libs/lambda-at-edge/src/default-handler.ts +++ b/packages/libs/lambda-at-edge/src/default-handler.ts @@ -47,6 +47,7 @@ import { removeBlacklistedHeaders } from "./headers/removeBlacklistedHeaders"; import { getStaticRegenerationResponse } from "./lib/getStaticRegenerationResponse"; import { s3BucketNameFromEventRequest } from "./s3/s3BucketNameFromEventRequest"; import { triggerStaticRegeneration } from "./lib/triggerStaticRegeneration"; +import { s3StorePage } from "./s3/s3StorePage"; const basePath = RoutesManifestJson.basePath; @@ -617,6 +618,7 @@ const handleOriginResponse = async ({ const staticRegenerationResponse = getStaticRegenerationResponse({ requestedOriginUri: uri, expiresHeader: response.headers.expires?.[0]?.value || "", + lastModifiedHeader: response.headers["last-modified"]?.[0]?.value || "", manifest }); @@ -694,33 +696,16 @@ const handleOriginResponse = async ({ "passthrough" ); if (isSSG) { - const baseKey = uri - .replace(/^\//, "") - .replace(/\.(json|html)$/, "") - .replace(/^_next\/data\/[^\/]*\//, ""); - const jsonKey = `_next/data/${manifest.buildId}/${baseKey}.json`; - const htmlKey = `static-pages/${manifest.buildId}/${baseKey}.html`; - const s3JsonParams = { - Bucket: bucketName, - Key: `${s3BasePath}${jsonKey}`, - Body: JSON.stringify(renderOpts.pageData), - ContentType: "application/json", - CacheControl: "public, max-age=0, s-maxage=2678400, must-revalidate" - }; - const s3HtmlParams = { - Bucket: bucketName, - Key: `${s3BasePath}${htmlKey}`, - Body: html, - ContentType: "text/html", - CacheControl: "public, max-age=0, s-maxage=2678400, must-revalidate" - }; - const { PutObjectCommand } = await import( - "@aws-sdk/client-s3/commands/PutObjectCommand" - ); - await Promise.all([ - s3.send(new PutObjectCommand(s3JsonParams)), - s3.send(new PutObjectCommand(s3HtmlParams)) - ]); + await s3StorePage({ + html, + uri, + basePath, + bucketName: bucketName || "", + buildId: manifest.buildId, + pageData: renderOpts.pageData, + region: request.origin?.s3?.region || "", + revalidate: renderOpts.revalidate + }); } const outHeaders: OutgoingHttpHeaders = {}; Object.entries(response.headers).map(([name, headers]) => { diff --git a/packages/libs/lambda-at-edge/src/lib/getStaticRegenerationResponse.ts b/packages/libs/lambda-at-edge/src/lib/getStaticRegenerationResponse.ts index 33dc018836..1753a3ebea 100644 --- a/packages/libs/lambda-at-edge/src/lib/getStaticRegenerationResponse.ts +++ b/packages/libs/lambda-at-edge/src/lib/getStaticRegenerationResponse.ts @@ -5,6 +5,7 @@ interface StaticRegenerationResponseOptions { requestedOriginUri: string; // Header as set on the origin object expiresHeader: string; + lastModifiedHeader: string; manifest: OriginRequestDefaultHandlerManifest; } @@ -14,6 +15,15 @@ interface StaticRegenerationResponseValue { secondsRemainingUntilRevalidation: number; } +const firstRegenerateExpiryDate = ( + lastModifiedHeader: string, + initialRevalidateSeconds: number +) => { + return new Date( + new Date(lastModifiedHeader).getTime() + initialRevalidateSeconds * 1000 + ); +}; + /** * Function called within an origin response as part of the Incremental Static * Regeneration logic. Returns required headers for the response, or false if @@ -27,13 +37,26 @@ const getStaticRegenerationResponse = ( options.requestedOriginUri.replace(".html", "") ]?.initialRevalidateSeconds; - // If this page did not write a revalidate value at build time it is not an - // ISR page - if (typeof initialRevalidateSeconds !== "number") { + // ISR pages that were either previously regenerated or generated + // post-initial-build, will have an `Expires` header set. However ISR pages + // that have not been regenerated but at build-time resolved a revalidate + // property will not have an `Expires` header and therefore we check using the + // manifest. + if ( + !options.expiresHeader && + !( + options.lastModifiedHeader && typeof initialRevalidateSeconds === "number" + ) + ) { return false; } - const expiresAt = new Date(options.expiresHeader); + const expiresAt = options.expiresHeader + ? new Date(options.expiresHeader) + : firstRegenerateExpiryDate( + options.lastModifiedHeader, + initialRevalidateSeconds as number + ); // isNaN will resolve true on initial load of this page (as the expiresHeader // won't be set), in which case we trigger a regeneration now diff --git a/packages/libs/lambda-at-edge/src/regeneration-handler.ts b/packages/libs/lambda-at-edge/src/regeneration-handler.ts index 2323addb0f..971ed171ca 100644 --- a/packages/libs/lambda-at-edge/src/regeneration-handler.ts +++ b/packages/libs/lambda-at-edge/src/regeneration-handler.ts @@ -1,7 +1,6 @@ import lambdaAtEdgeCompat from "@sls-next/next-aws-cloudfront"; import { OriginRequestDefaultHandlerManifest } from "./types"; -import { S3Client } from "@aws-sdk/client-s3"; -import { buildS3RetryStrategy } from "./s3/s3RetryStrategy"; +import { s3StorePage } from "./s3/s3StorePage"; export const handler: AWSLambda.SQSHandler = async (event) => { await Promise.all( @@ -29,12 +28,6 @@ export const handler: AWSLambda.SQSHandler = async (event) => { manifestString ); - const s3 = new S3Client({ - region: cloudFrontEventRequest.origin?.s3?.region, - maxAttempts: 3, - retryStrategy: await buildS3RetryStrategy() - }); - const { req, res } = lambdaAtEdgeCompat( { request: cloudFrontEventRequest }, { enableHTTPCompression: manifest.enableHTTPCompression } @@ -50,44 +43,22 @@ export const handler: AWSLambda.SQSHandler = async (event) => { // eslint-disable-next-line @typescript-eslint/no-var-requires const page = require(`./pages${srcPath}`); - const jsonKey = `_next/data/${manifest.buildId}${baseKey}.json`; - const htmlKey = `static-pages/${manifest.buildId}${baseKey}.html`; - const { renderOpts, html } = await page.renderReqToHTML( req, res, "passthrough" ); - const revalidate = - renderOpts.revalidate ?? ssgRoute.initialRevalidateSeconds; - const expires = new Date(Date.now() + revalidate * 1000); - const s3BasePath = basePath ? `${basePath.replace(/^\//, "")}/` : ""; - const s3JsonParams = { - Bucket: bucketName, - Key: `${s3BasePath}${jsonKey}`, - Body: JSON.stringify(renderOpts.pageData), - ContentType: "application/json", - Expires: expires, - CacheControl: "public, max-age=0, s-maxage=2678400, must-revalidate" - }; - - const s3HtmlParams = { - Bucket: bucketName, - Key: `${s3BasePath}${htmlKey}`, - Body: html, - ContentType: "text/html", - Expires: expires, - CacheControl: "public, max-age=0, s-maxage=2678400, must-revalidate" - }; - - const { PutObjectCommand } = await import( - "@aws-sdk/client-s3/commands/PutObjectCommand" - ); - await Promise.all([ - s3.send(new PutObjectCommand(s3JsonParams)), - s3.send(new PutObjectCommand(s3HtmlParams)) - ]); + await s3StorePage({ + html, + uri: cloudFrontEventRequest.uri, + basePath, + bucketName: bucketName || "", + buildId: manifest.buildId, + pageData: renderOpts.pageData, + region: cloudFrontEventRequest.origin?.s3?.region || "", + revalidate: renderOpts.revalidate + }); }) ); }; diff --git a/packages/libs/lambda-at-edge/src/s3/s3StorePage.ts b/packages/libs/lambda-at-edge/src/s3/s3StorePage.ts new file mode 100644 index 0000000000..86987ed584 --- /dev/null +++ b/packages/libs/lambda-at-edge/src/s3/s3StorePage.ts @@ -0,0 +1,73 @@ +import { buildS3RetryStrategy } from "./s3RetryStrategy"; + +interface S3StorePageOptions { + basePath: string | undefined; + uri: string; + revalidate?: number | undefined; + bucketName: string; + html: string; + buildId: string; + region: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + pageData: Record; +} + +/** + * There are multiple occasions where a static/SSG page will be generated after + * the initial build. This function accepts a generated page, stores it and + * applies the appropriate headers (e.g. setting an 'Expires' header for + * regeneration). + */ +export const s3StorePage = async ( + options: S3StorePageOptions +): Promise => { + const { S3Client } = await import("@aws-sdk/client-s3/S3Client"); + + const s3 = new S3Client({ + region: options.region, + maxAttempts: 3, + retryStrategy: await buildS3RetryStrategy() + }); + + const s3BasePath = options.basePath + ? `${options.basePath.replace(/^\//, "")}/` + : ""; + const baseKey = options.uri + .replace(/^\//, "") + .replace(/\.(json|html)$/, "") + .replace(/^_next\/data\/[^\/]*\//, ""); + const jsonKey = `_next/data/${options.buildId}/${baseKey}.json`; + const htmlKey = `static-pages/${options.buildId}/${baseKey}.html`; + const cacheControl = options.revalidate + ? undefined + : "public, max-age=0, s-maxage=2678400, must-revalidate"; + const expires = options.revalidate + ? new Date(new Date().getTime() + 1000 * options.revalidate) + : undefined; + + const s3JsonParams = { + Bucket: options.bucketName, + Key: `${s3BasePath}${jsonKey}`, + Body: JSON.stringify(options.pageData), + ContentType: "application/json", + CacheControl: cacheControl, + Expires: expires + }; + + const s3HtmlParams = { + Bucket: options.bucketName, + Key: `${s3BasePath}${htmlKey}`, + Body: options.html, + ContentType: "text/html", + CacheControl: cacheControl, + Expires: expires + }; + + const { PutObjectCommand } = await import( + "@aws-sdk/client-s3/commands/PutObjectCommand" + ); + await Promise.all([ + s3.send(new PutObjectCommand(s3JsonParams)), + s3.send(new PutObjectCommand(s3HtmlParams)) + ]); +};