From dff598bfc26e6c56e95d87a6dbb7e6bdd19f4227 Mon Sep 17 00:00:00 2001 From: Jan Varho Date: Thu, 15 Apr 2021 07:15:26 +0300 Subject: [PATCH] fix(lambda-at-edge): fix SSG fallback with basepath (#992) --- .../lambda-at-edge/src/default-handler.ts | 34 +-- ...dler-with-basepath-origin-response.test.ts | 223 ++++++++++++++++++ ...handler-with-basepath-with-locales.test.ts | 4 +- .../default-handler-with-basepath.test.ts | 6 +- 4 files changed, 246 insertions(+), 21 deletions(-) create mode 100644 packages/libs/lambda-at-edge/tests/default-handler/default-handler-with-basepath-origin-response.test.ts diff --git a/packages/libs/lambda-at-edge/src/default-handler.ts b/packages/libs/lambda-at-edge/src/default-handler.ts index c3bbdf2e8e..295757d4a6 100644 --- a/packages/libs/lambda-at-edge/src/default-handler.ts +++ b/packages/libs/lambda-at-edge/src/default-handler.ts @@ -495,10 +495,12 @@ const handleOriginRequest = async ({ // 1. URI routes to _error.js // 2. URI is not unmatched, but it's not in prerendered routes nor is for an SSG fallback, i.e this is an SSR data request, we need to SSR render the JSON break S3Check; + } else { + // Otherwise, this is an SSG data request, so continue to get to try to get the JSON from S3. + // For fallback SSG, this will fail the first time but the origin response handler will render and store in S3. + s3Origin.path = basePath; + request.uri = uri; } - - // Otherwise, this is an SSG data request, so continue to get to try to get the JSON from S3. - // For fallback SSG, this will fail the first time but the origin response handler will render and store in S3. } addS3HostHeader(request, normalisedS3DomainName); @@ -582,10 +584,11 @@ const handleOriginResponse = async ({ }) => { const response = event.Records[0].cf.response; const request = event.Records[0].cf.request; + const { uri } = request; const { status } = response; 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 (request.uri === "/404.html") { + if (uri === "/404.html") { response.status = "404"; response.statusDescription = "Not Found"; } @@ -597,7 +600,6 @@ const handleOriginResponse = async ({ return response; } - const uri = normaliseUri(request.uri, routesManifest); const { domainName, region } = request.origin!.s3!; const bucketName = domainName.replace(`.s3.${region}.amazonaws.com`, ""); @@ -609,6 +611,7 @@ const handleOriginResponse = async ({ maxAttempts: 3, retryStrategy: await buildS3RetryStrategy() }); + const s3BasePath = basePath ? `${basePath.replace(/^\//, "")}/` : ""; let pagePath; if ( isDataRequest(uri) && @@ -629,23 +632,20 @@ const handleOriginResponse = async ({ "passthrough" ); if (isSSG) { + const jsonKey = uri.replace(/^\//, ""); + const htmlKey = uri + .replace(/^\/?_next\/data/, "static-pages") + .replace(/.json$/, ".html"); const s3JsonParams = { Bucket: bucketName, - Key: `${basePath}${basePath === "" ? "" : "/"}${uri.replace( - /^\//, - "" - )}`, + 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: `${basePath}${basePath === "" ? "" : "/"}static-pages/${ - manifest.buildId - }/${request.uri - .replace(`/_next/data/${manifest.buildId}/`, "") - .replace(".json", ".html")}`, + Key: `${s3BasePath}${htmlKey}`, Body: html, ContentType: "text/html", CacheControl: "public, max-age=0, s-maxage=2678400, must-revalidate" @@ -667,9 +667,9 @@ const handleOriginResponse = async ({ if (!hasFallback) return response; // If route has fallback, return that page from S3, otherwise return 404 page - const s3Key = `${basePath}${basePath === "" ? "" : "/"}static-pages/${ - manifest.buildId - }${hasFallback.fallback || "/404.html"}`; + const s3Key = `${s3BasePath}static-pages/${manifest.buildId}${ + hasFallback.fallback || "/404.html" + }`; const { GetObjectCommand } = await import( "@aws-sdk/client-s3/commands/GetObjectCommand" diff --git a/packages/libs/lambda-at-edge/tests/default-handler/default-handler-with-basepath-origin-response.test.ts b/packages/libs/lambda-at-edge/tests/default-handler/default-handler-with-basepath-origin-response.test.ts new file mode 100644 index 0000000000..62d6322053 --- /dev/null +++ b/packages/libs/lambda-at-edge/tests/default-handler/default-handler-with-basepath-origin-response.test.ts @@ -0,0 +1,223 @@ +import { handler } from "../../src/default-handler"; +import { createCloudFrontEvent } from "../test-utils"; +import { + CloudFrontResultResponse, + CloudFrontHeaders, + CloudFrontResponse +} from "aws-lambda"; +import { S3Client } from "@aws-sdk/client-s3/S3Client"; + +jest.mock("@aws-sdk/client-s3/S3Client", () => + require("../mocks/s3/aws-sdk-s3-client.mock") +); + +jest.mock("@aws-sdk/client-s3/commands/GetObjectCommand", () => + require("../mocks/s3/aws-sdk-s3-client-get-object-command.mock") +); + +jest.mock("@aws-sdk/client-s3/commands/PutObjectCommand", () => + require("../mocks/s3/aws-sdk-s3-client-put-object-command.mock") +); + +jest.mock( + "../../src/manifest.json", + () => require("./default-build-manifest.json"), + { + virtual: true + } +); + +jest.mock( + "../../src/prerender-manifest.json", + () => require("./prerender-manifest.json"), + { + virtual: true + } +); + +jest.mock( + "../../src/routes-manifest.json", + () => require("./default-basepath-routes-manifest.json"), + { + virtual: true + } +); + +const mockPageRequire = (mockPagePath: string): void => { + jest.mock( + `../../src/${mockPagePath}`, + () => require(`../shared-fixtures/built-artifact/${mockPagePath}`), + { + virtual: true + } + ); +}; + +describe("Lambda@Edge origin response", () => { + let s3Client: S3Client; + beforeEach(() => { + s3Client = new S3Client({}); + }); + describe("Fallback pages", () => { + it("serves fallback page from S3", async () => { + const event = createCloudFrontEvent({ + uri: "/tests/prerender-manifest-fallback/not-yet-built", + host: "mydistribution.cloudfront.net", + config: { eventType: "origin-response" } as any, + response: { + status: "403" + } as any + }); + + const result = await handler(event); + const response = result as CloudFrontResponse; + + expect(s3Client.send).toHaveBeenCalledWith({ + Command: "GetObjectCommand", + Bucket: "my-bucket.s3.amazonaws.com", + Key: + "basepath/static-pages/build-id/tests/prerender-manifest-fallback/[fallback].html" + }); + + expect(response).toEqual({ + status: "200", + statusDescription: "OK", + headers: { + "cache-control": [ + { + key: "Cache-Control", + value: "public, max-age=0, s-maxage=0, must-revalidate" // Fallback page shouldn't be cached as it will override the path for a just generated SSG page. + } + ], + "content-type": [ + { + key: "Content-Type", + value: "text/html" + } + ] + }, + body: "S3Body" + }); + }); + + it("serves 404 page from S3 for fallback: false", async () => { + const event = createCloudFrontEvent({ + uri: "/tests/prerender-manifest/[staticPageName]", + host: "mydistribution.cloudfront.net", + config: { eventType: "origin-response" } as any, + response: { + status: "403" + } as any + }); + + const result = await handler(event); + const response = result as CloudFrontResponse; + + expect(s3Client.send).toHaveBeenCalledWith({ + Command: "GetObjectCommand", + Bucket: "my-bucket.s3.amazonaws.com", + Key: "basepath/static-pages/build-id/404.html" + }); + + expect(response).toEqual({ + status: "404", + statusDescription: "Not Found", + headers: { + "cache-control": [ + { + key: "Cache-Control", + value: "public, max-age=0, s-maxage=2678400, must-revalidate" + } + ], + "content-type": [ + { + key: "Content-Type", + value: "text/html" + } + ] + }, + body: "S3Body" + }); + }); + + it("renders and uploads HTML and JSON for fallback SSG data requests", async () => { + const event = createCloudFrontEvent({ + uri: "/_next/data/build-id/fallback/not-yet-built.json", + host: "mydistribution.cloudfront.net", + config: { eventType: "origin-response" } as any, + response: { + headers: {}, + status: "403" + } as any + }); + + mockPageRequire("pages/fallback/[slug].js"); + + const response = await handler(event); + + const cfResponse = response as CloudFrontResultResponse; + const decodedBody = Buffer.from( + cfResponse.body as string, + "base64" + ).toString("utf8"); + + const headers = response.headers as CloudFrontHeaders; + expect(headers["content-type"][0].value).toEqual("application/json"); + expect(JSON.parse(decodedBody)).toEqual({ + page: "pages/fallback/[slug].js" + }); + expect(cfResponse.status).toEqual(200); + + expect(s3Client.send).toHaveBeenNthCalledWith(1, { + Command: "PutObjectCommand", + Bucket: "my-bucket.s3.amazonaws.com", + Key: "basepath/_next/data/build-id/fallback/not-yet-built.json", + Body: JSON.stringify({ + page: "pages/fallback/[slug].js" + }), + ContentType: "application/json", + CacheControl: "public, max-age=0, s-maxage=2678400, must-revalidate" + }); + expect(s3Client.send).toHaveBeenNthCalledWith(2, { + Command: "PutObjectCommand", + Bucket: "my-bucket.s3.amazonaws.com", + Key: "basepath/static-pages/build-id/fallback/not-yet-built.html", + Body: "
Rendered Page
", + ContentType: "text/html", + CacheControl: "public, max-age=0, s-maxage=2678400, must-revalidate" + }); + }); + }); + + describe("SSR data requests", () => { + it("does not upload to S3", async () => { + const event = createCloudFrontEvent({ + uri: "/_next/data/build-id/customers/index.json", + host: "mydistribution.cloudfront.net", + config: { eventType: "origin-response" } as any, + response: { + headers: {}, + status: "403" + } as any + }); + + mockPageRequire("pages/customers/[customer].js"); + + const response = await handler(event); + + const cfResponse = response as CloudFrontResultResponse; + const decodedBody = Buffer.from( + cfResponse.body as string, + "base64" + ).toString("utf8"); + + const headers = response.headers as CloudFrontHeaders; + expect(headers["content-type"][0].value).toEqual("application/json"); + expect(JSON.parse(decodedBody)).toEqual({ + page: "pages/customers/[customer].js" + }); + expect(cfResponse.status).toEqual(200); + expect(s3Client.send).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/libs/lambda-at-edge/tests/default-handler/default-handler-with-basepath-with-locales.test.ts b/packages/libs/lambda-at-edge/tests/default-handler/default-handler-with-basepath-with-locales.test.ts index 43000fe335..622de83106 100644 --- a/packages/libs/lambda-at-edge/tests/default-handler/default-handler-with-basepath-with-locales.test.ts +++ b/packages/libs/lambda-at-edge/tests/default-handler/default-handler-with-basepath-with-locales.test.ts @@ -413,11 +413,11 @@ describe("Lambda@Edge", () => { s3: { authMethod: "origin-access-identity", domainName: "my-bucket.s3.amazonaws.com", - path: "", + path: "/basepath", region: "us-east-1" } }); - expect(request.uri).toEqual(path); + expect(request.uri).toEqual(path.slice(9)); } ); diff --git a/packages/libs/lambda-at-edge/tests/default-handler/default-handler-with-basepath.test.ts b/packages/libs/lambda-at-edge/tests/default-handler/default-handler-with-basepath.test.ts index 1ddc3649b1..88cecd2bd2 100644 --- a/packages/libs/lambda-at-edge/tests/default-handler/default-handler-with-basepath.test.ts +++ b/packages/libs/lambda-at-edge/tests/default-handler/default-handler-with-basepath.test.ts @@ -125,6 +125,8 @@ describe("Lambda@Edge", () => { ${"/basepath/users/test/catch/all"} | ${"/users/[...user].html"} ${"/basepath/john/123"} | ${"/[username]/[id].html"} ${"/basepath/tests/prerender-manifest/example-static-page"} | ${"/tests/prerender-manifest/example-static-page.html"} + ${"/basepath/tests/prerender-manifest-fallback/not-built"} | ${"/tests/prerender-manifest-fallback/not-built.html"} + ${"/basepath/preview"} | ${"/preview.html"} `( "serves page $expectedPage from S3 for path $path", async ({ path, expectedPage }) => { @@ -386,11 +388,11 @@ describe("Lambda@Edge", () => { s3: { authMethod: "origin-access-identity", domainName: "my-bucket.s3.amazonaws.com", - path: "", + path: "/basepath", region: "us-east-1" } }); - expect(request.uri).toEqual(path); + expect(request.uri).toEqual(path.slice(9)); } );