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

Incremental static regeneration #1028

Merged
merged 61 commits into from
May 19, 2021
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
31f313c
wip: initial proof-of-concept incremental static regeneration
kirkness Apr 28, 2021
35cf5ff
fix: dont ts build the regeneration handler
kirkness Apr 28, 2021
65ba637
fix: dynamic paths should regenerate
kirkness Apr 28, 2021
0ee2f55
add Expires header to the regenerated S3 objects
kirkness Apr 29, 2021
6f1ce93
Merge branch 'master' into feature/incremental-static-regeneration
kirkness Apr 29, 2021
fc8bd38
use expires header rather than last-modified and a small code tidy
kirkness Apr 29, 2021
8a0d5ff
revalidation relies on the expires header
kirkness Apr 29, 2021
67cda68
fix fallback isr fallback behaviour
kirkness Apr 30, 2021
16fe62b
Merge branch 'master' into feature/incremental-static-regeneration
kirkness Apr 30, 2021
26ed898
update cdk snapshot
kirkness Apr 30, 2021
ef7d03b
remove sqs and use lambda async invocation
kirkness May 2, 2021
f4548b3
adds e2e test and serverless infrastructure
kirkness May 2, 2021
b52d762
remove uneeded isNaN check
kirkness May 2, 2021
3dc480f
remove prerenderManifest variable from handler
kirkness May 2, 2021
127698b
add e2e test for isr page with getStaticPaths
kirkness May 2, 2021
7eb5bb4
revert serverless change
kirkness May 2, 2021
a3eb635
add tests to regeneration handler
kirkness May 4, 2021
30d0e51
add tests for getStaticRegenerationResponse
kirkness May 4, 2021
c03105f
create the regeneration lambd ain the same region as the bucket
kirkness May 4, 2021
6adf890
revery async lambda changes and use SQS
kirkness May 4, 2021
9dbfb27
add sqs creation to serverless components
kirkness May 4, 2021
f0650f2
fix conflicts
kirkness May 4, 2021
57b403b
update snapshot andd mocks
kirkness May 4, 2021
43f064a
fix regeneration tests with sqs event
kirkness May 5, 2021
db31e75
Merge branch 'master' into feature/incremental-static-regeneration
kirkness May 5, 2021
6f0f45c
minor test fixes
kirkness May 5, 2021
26c8449
Merge branch 'feature/incremental-static-regeneration' of https://git…
kirkness May 5, 2021
f397f50
fix all tests
kirkness May 5, 2021
631d8c0
add cache-control header tests to default-handler
kirkness May 5, 2021
5fc9807
add tests for triggerStaticRegeneration
kirkness May 5, 2021
b8cb485
remove removeHeader call
kirkness May 5, 2021
99ec48c
remove only test
kirkness May 5, 2021
93a0826
add tests for sqs component
kirkness May 5, 2021
10f9f67
add tests to unhappy-paths in triggerStaticRegeneration
kirkness May 5, 2021
cb5ad0b
add extra test cases to sqs component
kirkness May 5, 2021
f9806d2
add case for sqs delete
kirkness May 5, 2021
3552751
check assertions against sdk api calls
kirkness May 5, 2021
09f739f
Merge branch 'master' into feature/incremental-static-regeneration
kirkness May 5, 2021
41202f3
regenerate yarn lock files
kirkness May 8, 2021
d9417b8
Merge branch 'feature/incremental-static-regeneration' of https://git…
kirkness May 8, 2021
1f757d1
chore: resolve conflicts
kirkness May 11, 2021
fbe017e
docs: add ISR related deployment permissions
kirkness May 12, 2021
027e9c1
fix: update all yarn lock files in all e2e packages
kirkness May 12, 2021
855fc88
fix: include ready checks in e2e tests for isr
kirkness May 13, 2021
436b584
fix: redirect test in trailing slash e2e tests
kirkness May 13, 2021
90ce486
fix: include isr e2e path checks in test-utils
kirkness May 13, 2021
d546e02
Merge branch 'master' into feature/incremental-static-regeneration
dphang May 13, 2021
a3c3e36
fix: dont deploy a queue if we dont need one
kirkness May 14, 2021
738335e
fix: merge conflicts
kirkness May 14, 2021
bf8038a
fix: revert normalise uri call
kirkness May 16, 2021
e49eac4
fix: update snapshot
kirkness May 16, 2021
b13cb05
fix: update regeneration fallback test
kirkness May 16, 2021
aaa0916
fix: convert secs to millis
kirkness May 16, 2021
f104d19
fix: revert 404 case
kirkness May 16, 2021
bf6ceed
fix: revert 308 case
kirkness May 16, 2021
200f195
fix: revert 404 case
kirkness May 16, 2021
e2cae31
fix: set cache folder for more reliability(??)
kirkness May 17, 2021
f4e5218
fix: 404 redirect test, update post cypress bump
kirkness May 17, 2021
2cde26b
fix: resolve conflits
kirkness May 18, 2021
3c37c3f
fix: merge conflict
kirkness May 18, 2021
7586260
Merge branch 'master' into feature/incremental-static-regeneration
dphang May 19, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/libs/lambda-at-edge/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
},
"dependencies": {
"@aws-sdk/client-s3": "1.0.0-rc.3",
"@aws-sdk/client-sqs": "1.0.0-rc.3",
"@hapi/accept": "5.0.1",
"@vercel/nft": "^0.9.3",
"cookie": "^0.4.1",
Expand Down
4 changes: 3 additions & 1 deletion packages/libs/lambda-at-edge/rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,5 +47,7 @@ export default [
{ filename: "api-handler", minify: false },
{ filename: "api-handler", minify: true },
{ filename: "image-handler", minify: false },
{ filename: "image-handler", minify: true }
{ filename: "image-handler", minify: true },
{ filename: "regeneration-handler", minify: false },
{ filename: "regeneration-handler", minify: true }
].map(generateConfig);
71 changes: 63 additions & 8 deletions packages/libs/lambda-at-edge/src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { Job } from "@vercel/nft/out/node-file-trace";
export const DEFAULT_LAMBDA_CODE_DIR = "default-lambda";
export const API_LAMBDA_CODE_DIR = "api-lambda";
export const IMAGE_LAMBDA_CODE_DIR = "image-lambda";
export const REGENERATION_LAMBDA_CODE_DIR = "regeneration-lambda";
export const ASSETS_DIR = "assets";

type BuildOptions = {
Expand Down Expand Up @@ -230,7 +231,11 @@ class Builder {
* @param shouldMinify
*/
async processAndCopyHandler(
handlerType: "api-handler" | "default-handler" | "image-handler",
handlerType:
| "api-handler"
| "default-handler"
| "image-handler"
| "regeneration-handler",
destination: string,
shouldMinify: boolean
) {
Expand All @@ -243,9 +248,9 @@ class Builder {
await fse.copy(source, destination);
}

async buildDefaultLambda(
async copyTraces(
buildManifest: OriginRequestDefaultHandlerManifest
): Promise<void[]> {
): Promise<void> {
let copyTraces: Promise<void>[] = [];

if (this.buildOptions.useServerlessTraceTarget) {
Expand Down Expand Up @@ -282,7 +287,13 @@ class Builder {
);
}

let prerenderManifest = require(join(
await Promise.all(copyTraces);
}

async buildDefaultLambda(
buildManifest: OriginRequestDefaultHandlerManifest
): Promise<void[]> {
const prerenderManifest = require(join(
this.dotNextDir,
"prerender-manifest.json"
));
Expand All @@ -292,7 +303,7 @@ class Builder {
);

return Promise.all([
...copyTraces,
this.copyTraces(buildManifest),
this.processAndCopyHandler(
"default-handler",
join(this.outputDir, DEFAULT_LAMBDA_CODE_DIR, "index.js"),
Expand Down Expand Up @@ -412,6 +423,48 @@ class Builder {
]);
}

async buildRegenerationHandler(
buildManifest: OriginRequestDefaultHandlerManifest
): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const prerenderManifest = require(join(
this.dotNextDir,
"prerender-manifest.json"
));
await Promise.all([
this.copyTraces(buildManifest),
this.processAndCopyHandler(
"regeneration-handler",
join(this.outputDir, REGENERATION_LAMBDA_CODE_DIR, "index.js"),
!!this.buildOptions.minifyHandlers
),
fse.copy(
join(this.serverlessDir, "pages"),
join(this.outputDir, REGENERATION_LAMBDA_CODE_DIR, "pages"),
{
recursive: true,
filter: (file: string) => {
const isNotPrerenderedHTMLPage = path.extname(file) !== ".html";
const isNotStaticPropsJSONFile = path.extname(file) !== ".json";
const isNotApiPage = pathToPosix(file).indexOf("pages/api") === -1;
const isDirectory = fse.statSync(file).isDirectory();
const isPrerenderedJSFile = this.isPrerenderedJSFile(
prerenderManifest,
path.relative(join(this.serverlessDir, "pages"), file)
);

return (
isNotApiPage &&
isNotPrerenderedHTMLPage &&
isNotStaticPropsJSONFile &&
(isPrerenderedJSFile || isDirectory)
);
}
}
)
]);
}

/**
* Build image optimization lambda (supported by Next.js 10)
* @param buildManifest
Expand Down Expand Up @@ -941,9 +994,9 @@ class Builder {
path.join(dotNextDirectory, "prerender-manifest.json")
);

let prerenderManifestJSONPropFileAssets: Promise<void>[] = [];
let prerenderManifestHTMLPageAssets: Promise<void>[] = [];
let fallbackHTMLPageAssets: Promise<void>[] = [];
const prerenderManifestJSONPropFileAssets: Promise<void>[] = [];
const prerenderManifestHTMLPageAssets: Promise<void>[] = [];
const fallbackHTMLPageAssets: Promise<void>[] = [];

// Copy locale-specific prerendered files if defined, otherwise use empty locale
// which would copy to root only
Expand Down Expand Up @@ -1119,6 +1172,7 @@ class Builder {
await fse.emptyDir(join(this.outputDir, DEFAULT_LAMBDA_CODE_DIR));
await fse.emptyDir(join(this.outputDir, API_LAMBDA_CODE_DIR));
await fse.emptyDir(join(this.outputDir, IMAGE_LAMBDA_CODE_DIR));
await fse.emptyDir(join(this.outputDir, REGENERATION_LAMBDA_CODE_DIR));
await fse.emptyDir(join(this.outputDir, ASSETS_DIR));

const { restoreUserConfig } = await createServerlessConfig(
Expand Down Expand Up @@ -1160,6 +1214,7 @@ class Builder {
} = await this.prepareBuildManifests(routesManifest, prerenderManifest);

await this.buildDefaultLambda(defaultBuildManifest);
await this.buildRegenerationHandler(defaultBuildManifest);

const hasAPIPages =
Object.keys(apiBuildManifest.apis.nonDynamic).length > 0 ||
Expand Down
90 changes: 85 additions & 5 deletions packages/libs/lambda-at-edge/src/default-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,8 +325,8 @@ const handleOriginRequest = async ({
const decodedUri = decodeURI(uri);
const { pages, publicFiles } = manifest;

let isPublicFile = publicFiles[decodedUri];
let isDataReq = isDataRequest(uri);
const isPublicFile = publicFiles[decodedUri];
const isDataReq = isDataRequest(uri);

// Handle redirects
// TODO: refactor redirect logic to another file since this is getting quite large
Expand Down Expand Up @@ -602,12 +602,95 @@ const handleOriginResponse = async ({
const request = event.Records[0].cf.request;
const { uri } = request;
const { status } = response;
const { region, domainName } = request.origin?.s3 || {};
const bucketName = domainName?.replace(`.s3.${region}.amazonaws.com`, "");

if (status !== "403") {
// 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";
response.statusDescription = "Not Found";
return response;
}

const initialRevalidateSeconds =
manifest.pages.ssg.nonDynamic?.[uri.replace(".html", "")]
?.initialRevalidateSeconds;
const lastModifiedHeaderString =
response.headers?.["last-modified"]?.[0]?.value;
const lastModifiedAt = lastModifiedHeaderString
? new Date(lastModifiedHeaderString)
: null;
if (typeof initialRevalidateSeconds === "number" && lastModifiedAt) {
const createdAgo =
// LastModified should always be defined
(Date.now() - (lastModifiedAt.getTime() || Date.now())) / 1000;

const timeToRevalidate = Math.floor(
initialRevalidateSeconds - createdAgo
);

response.headers["cache-control"] = [
{
key: "Cache-Control",
value:
timeToRevalidate < 0
? "public, max-age=0, s-maxage=0, must-revalidate"
: `public, max-age=0, s-maxage=${timeToRevalidate}, must-revalidate`
}
];

if (timeToRevalidate < 0) {
const { SQSClient, SendMessageCommand } = await import(
"@aws-sdk/client-sqs"
);
const sqs = new SQSClient({
region,
maxAttempts: 3,
retryStrategy: await buildS3RetryStrategy()
});
await sqs.send(
new SendMessageCommand({
QueueUrl: `https://sqs.${region}.amazonaws.com/${bucketName}.fifo`,
MessageBody: uri,
MessageAttributes: {
BucketRegion: {
DataType: "String",
StringValue: region
},
BucketName: {
DataType: "String",
StringValue: bucketName
},
CloudFrontEventRequest: {
DataType: "String",
StringValue: JSON.stringify(request)
},
Manifest: {
DataType: "String",
StringValue: JSON.stringify(manifest)
},
...(basePath
? {
BasePath: {
DataType: "String",
StringValue: basePath
}
}
: {})
},
// We only want to trigger the regeneration once for every previous
// update. This will prevent the case where this page is being
// requested again whilst its already started to regenerate.
MessageDeduplicationId: lastModifiedAt.getTime().toString(),
// Only deduplicate based on the object, i.e. we can generate
// different pages in parallel, just not the same one
MessageGroupId: uri
})
);
}
}

return response;
}

Expand All @@ -616,9 +699,6 @@ const handleOriginResponse = async ({
return response;
}

const { domainName, region } = request.origin!.s3!;
const bucketName = domainName.replace(`.s3.${region}.amazonaws.com`, "");

// Lazily import only S3Client to reduce init times until actually needed
const { S3Client } = await import("@aws-sdk/client-s3/S3Client");

Expand Down
86 changes: 86 additions & 0 deletions packages/libs/lambda-at-edge/src/regeneration-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import lambdaAtEdgeCompat from "@sls-next/next-aws-cloudfront";
import { OriginRequestDefaultHandlerManifest } from "./types";
import { S3Client } from "@aws-sdk/client-s3";
import { buildS3RetryStrategy } from "./s3/s3RetryStrategy";

export const handler: AWSLambda.SQSHandler = async (event) => {
await Promise.all(
event.Records.map(async (record) => {
const bucketName = record.messageAttributes.BucketName?.stringValue;
const bucketRegion = record.messageAttributes.BucketRegion?.stringValue;
const manifestString = record.messageAttributes.Manifest?.stringValue;
const basePath = record.messageAttributes.BasePath?.stringValue;
const cloudFrontEventRequestString =
record.messageAttributes.CloudFrontEventRequest?.stringValue;
if (
!bucketName ||
!bucketRegion ||
!cloudFrontEventRequestString ||
!manifestString
) {
throw new Error(
"Expected BucketName, BucketRegion, CloudFrontEventRequest & EnableHTTPCompression message attributes"
);
}
const cloudFrontEventRequest: AWSLambda.CloudFrontRequest = JSON.parse(
cloudFrontEventRequestString
);
const manifest: OriginRequestDefaultHandlerManifest = JSON.parse(
manifestString
);

const s3 = new S3Client({
region: cloudFrontEventRequest.origin?.s3?.region,
maxAttempts: 3,
retryStrategy: await buildS3RetryStrategy()
});

const { req, res } = lambdaAtEdgeCompat(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks to be just regular Lambda, so this should not be needed, right? You can just create a Node req, res in that case?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct! Good catch.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On second thought, the reason this is here is due to the fact that the original CloudFront event is actually passed through the queue to this lambda (the cloudFrontEventRequest variable), and therefore although this is a standard lambda we are still serving the original CloudFront event. The ideal change would be to standardizee the request object throughout, which would be nice perhaps using some of the principles in the serverless-http package. However, might slightly sit outside the scope of this PR. Keen to know your thoughts @dphang!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, makes sense for now. I think it would be good to have a generic event since in the future this can be used for regular Lambda traffic (and maybe other providers)

{ request: cloudFrontEventRequest },
{ enableHTTPCompression: manifest.enableHTTPCompression }
);

const baseKey = cloudFrontEventRequest.uri
.replace(/^\//, "")
.replace(/\.(json|html)$/, "")
.replace(/^_next\/data\/[^\/]*\//, "");

// eslint-disable-next-line @typescript-eslint/no-var-requires
const page = require(`./pages/${baseKey}`);

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 s3BasePath = basePath ? `${basePath.replace(/^\//, "")}/` : "";
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))
]);
})
);
};
3 changes: 2 additions & 1 deletion packages/libs/lambda-at-edge/tsconfig.build.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"node_modules",
"./src/api-handler.ts",
"./src/default-handler.ts",
"./src/image-handler.ts"
"./src/image-handler.ts",
"./src/regeneration-handler.ts",
]
}
9 changes: 7 additions & 2 deletions packages/libs/lambda-at-edge/tsconfig.bundle.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,10 @@
"allowJs": true,
"resolveJsonModule": true
},
"include": ["./src/default-handler.ts", "./src/api-handler.ts", "./src/image-handler.ts"]
}
"include": [
"./src/default-handler.ts",
"./src/api-handler.ts",
"./src/image-handler.ts",
"./src/regeneration-handler.ts",
]
}
Loading