From e7bed6aa8d0a8f7fd332e1d0c37d8dc67ec3aa65 Mon Sep 17 00:00:00 2001 From: stan-sack Date: Sun, 10 May 2020 01:38:05 +1000 Subject: [PATCH] feat(serverless-component): allow custom configuration for Cloudfront (#282) --- .gitignore | 1 + README.md | 29 +- package-lock.json | 51 +++ .../__tests__/custom-inputs.test.js | 407 ++++++++++++------ .../next.config.js | 3 + .../package.json | 32 ++ .../pages/about.js | 3 + .../pages/post/[slug].js | 8 + .../serverless.yml | 11 + packages/serverless-component/serverless.js | 149 ++++++- 10 files changed, 557 insertions(+), 137 deletions(-) create mode 100644 packages/serverless-component/examples/app-with-custom-caching-config/next.config.js create mode 100644 packages/serverless-component/examples/app-with-custom-caching-config/package.json create mode 100644 packages/serverless-component/examples/app-with-custom-caching-config/pages/about.js create mode 100644 packages/serverless-component/examples/app-with-custom-caching-config/pages/post/[slug].js create mode 100644 packages/serverless-component/examples/app-with-custom-caching-config/serverless.yml diff --git a/.gitignore b/.gitignore index 3015fb6399..1d50bb5c8b 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ sls-next-build yarn.lock dist .vscode +.env diff --git a/README.md b/README.md index d48b2b508f..0c0b9c0ab3 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ A zero configuration Nextjs 9.0 [serverless component](https://github.com/server - [Getting started](#getting-started) - [Lambda@Edge configuration](#lambda-at-edge-configuration) - [Custom domain name](#custom-domain-name) +- [Custom CloudFront configuration](#custom-cloudfront-configuration) - [AWS Permissions](#aws-permissions) - [Architecture](#architecture) - [Inputs](#inputs) @@ -98,8 +99,9 @@ In most cases you wouldn't want to use CloudFront's distribution domain to acces You can use any domain name but you must be using AWS Route53 for your DNS hosting. To migrate DNS records from an existing domain follow the instructions [here](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/MigratingDNS.html). The requirements to use a custom domain name: - * Route53 must include a _hosted zone_ for your domain (e.g. `mydomain.com`) with a set of nameservers. - * You must update the nameservers listed with your domain name registrar (e.g. namecheap, godaddy, etc.) with those provided for your new _hosted zone_. + +- Route53 must include a _hosted zone_ for your domain (e.g. `mydomain.com`) with a set of nameservers. +- You must update the nameservers listed with your domain name registrar (e.g. namecheap, godaddy, etc.) with those provided for your new _hosted zone_. The serverless next.js component will automatically generate an SSL certificate and create a new record to point to your CloudFront distribution. @@ -123,6 +125,28 @@ myNextApplication: domain: ["sub", "example.com"] # [ sub-domain, domain ] ``` +### Custom CloudFront configuration + +To specify your own CloudFront inputs, just add any [aws-cloudfront inputs](https://github.com/serverless-components/aws-cloudfront#3-configure) under `cloudfront`: + +```yml +# serverless.yml + +myNextApplication: + component: serverless-next.js + inputs: + cloudfront: + my-page/*: + ttl: 0 + forward: + cookies: "all" + queryString: false + my-other-page: + viewerProtocolPolicy: redirect-to-https +``` + +This is particularly useful for caching any of your next.js pages at CloudFront's edge locations. See [this](/https://github.com/danielcondemarin/serverless-next.js/tree/master/packages/serverless-component/examples/app-with-custom-caching-config) for an example application with custom cache configuration. + ### AWS Permissions By default the Lambda@Edge functions run using AWSLambdaBasicExecutionRole which only allows uploading logs to CloudWatch. If you need permissions beyond this, like for example access to DynamoDB or any other AWS resource you will need your own custom policy arn: @@ -229,6 +253,7 @@ The fourth cache behaviour handles next API requests `api/*`. | build.cwd | `string` | `./` | Override the current working directory | | build.enabled | `boolean` | `true` | Same as passing `build:false` but from within the config | | build.env | `object` | `{}` | Add additional environment variables to the script | +| cloudfront | `object` | `{}` | Inputs to be passed to [aws-cloudfront](https://github.com/serverless-components/aws-cloudfront) | Custom inputs can be configured like this: diff --git a/package-lock.json b/package-lock.json index 7c63c15984..74157ce624 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4142,6 +4142,57 @@ } } }, + "@sls-next/s3-static-assets": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@sls-next/s3-static-assets/-/s3-static-assets-1.0.1.tgz", + "integrity": "sha512-LjGb/p9ZhWeUdv7V3nzlxgcErE5lpajjw/l9R90hr7SJgLdUF9OZyiNs6TlxQQ2tJwGMclf0YoXTRHE0l1MyNg==", + "dev": true, + "requires": { + "aws-sdk": "^2.664.0", + "fs-extra": "^9.0.0", + "klaw": "^3.0.0", + "mime-types": "^2.1.27" + }, + "dependencies": { + "fs-extra": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.0.0.tgz", + "integrity": "sha512-pmEYSk3vYsG/bF651KPUXZ+hvjpgWYw/Gc7W9NFUe3ZVLczKKWIij3IKpOrQcdw4TILtibFslZ0UmR8Vvzig4g==", + "dev": true, + "requires": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^1.0.0" + } + }, + "jsonfile": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.0.1.tgz", + "integrity": "sha512-jR2b5v7d2vIOust+w3wtFKZIfpC2pnRmFAhAC/BuweZFQR8qZzxH1OyrQ10HmdVYiXWkYUqPVsz91cG7EL2FBg==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.6", + "universalify": "^1.0.0" + } + }, + "klaw": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/klaw/-/klaw-3.0.0.tgz", + "integrity": "sha512-0Fo5oir+O9jnXu5EefYbVK+mHMBeEVEy2cmctR1O1NECcCkPRreJKrS6Qt/j3KC2C148Dfo9i3pCmCMsdqGr0g==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.9" + } + }, + "universalify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-1.0.0.tgz", + "integrity": "sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug==", + "dev": true + } + } + }, "@szmarczak/http-timer": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz", diff --git a/packages/serverless-component/__tests__/custom-inputs.test.js b/packages/serverless-component/__tests__/custom-inputs.test.js index da36664d4e..440232b36a 100644 --- a/packages/serverless-component/__tests__/custom-inputs.test.js +++ b/packages/serverless-component/__tests__/custom-inputs.test.js @@ -11,6 +11,7 @@ const { DEFAULT_LAMBDA_CODE_DIR, API_LAMBDA_CODE_DIR } = require("../constants"); +const { cleanupFixtureDirectory } = require("../lib/test-utils"); const createNextComponent = inputs => { const component = new NextjsComponent(inputs); @@ -23,49 +24,63 @@ const createNextComponent = inputs => { return component; }; +const mockServerlessComponentDependencies = ({ expectedDomain }) => { + mockS3.mockResolvedValue({ + name: "bucket-xyz" + }); + + mockLambda.mockResolvedValue({ + arn: "arn:aws:lambda:us-east-1:123456789012:function:my-func" + }); + + mockLambdaPublish.mockResolvedValue({ + version: "v1" + }); + + mockCloudFront.mockResolvedValueOnce({ + url: "https://cloudfrontdistrib.amazonaws.com" + }); + + mockDomain.mockResolvedValueOnce({ + domains: [expectedDomain] + }); +}; + describe("Custom inputs", () => { - let tmpCwd; let componentOutputs; let consoleWarnSpy; beforeEach(() => { + // mock out remove to prevent fixture files from being wiped out jest.spyOn(fse, "remove").mockImplementation(() => { return; }); + consoleWarnSpy = jest.spyOn(console, "warn").mockReturnValue(); }); afterEach(() => { + fse.remove.mockRestore(); consoleWarnSpy.mockRestore(); }); - describe.each([ - [["dev", "example.com"], "https://dev.example.com"], - [["www", "example.com"], "https://www.example.com"], - [[undefined, "example.com"], "https://www.example.com"], - [["example.com"], "https://www.example.com"], - ["example.com", "https://www.example.com"] - ])("Custom domain", (inputDomains, expectedDomain, memory) => { + describe.each` + inputDomains | expectedDomain + ${["dev", "example.com"]} | ${"https://dev.example.com"} + ${["www", "example.com"]} | ${"https://www.example.com"} + ${"example.com"} | ${"https://www.example.com"} + ${[undefined, "example.com"]} | ${"https://www.example.com"} + ${"example.com"} | ${"https://www.example.com"} + `("Custom domain", ({ inputDomains, expectedDomain }) => { const fixturePath = path.join(__dirname, "./fixtures/generic-fixture"); + let tmpCwd; beforeEach(async () => { tmpCwd = process.cwd(); process.chdir(fixturePath); - mockS3.mockResolvedValue({ - name: "bucket-xyz" - }); - mockLambda.mockResolvedValue({ - arn: "arn:aws:lambda:us-east-1:123456789012:function:my-func" - }); - mockLambdaPublish.mockResolvedValue({ - version: "v1" - }); - mockCloudFront.mockResolvedValueOnce({ - url: "https://cloudfrontdistrib.amazonaws.com" - }); - mockDomain.mockResolvedValueOnce({ - domains: [expectedDomain] + mockServerlessComponentDependencies({ + expectedDomain }); const component = createNextComponent(); @@ -80,9 +95,10 @@ describe("Custom inputs", () => { afterEach(() => { process.chdir(tmpCwd); + return cleanupFixtureDirectory(fixturePath); }); - it("uses @serverless/domain to provision custom domain", async () => { + it("uses @serverless/domain to provision custom domain", () => { const { domain, subdomain } = obtainDomains(inputDomains); expect(mockDomain).toBeCalledWith({ @@ -109,29 +125,24 @@ describe("Custom inputs", () => { ); }); - it("outputs custom domain url", async () => { + it("outputs custom domain url", () => { expect(componentOutputs.appUrl).toEqual(expectedDomain); }); }); describe.each` nextConfigDir | nextStaticDir | fixturePath - ${undefined} | ${undefined} | ${path.join(__dirname, "./fixtures/simple-app")} - ${"simple-app"} | ${undefined} | ${path.join(__dirname, "./fixtures")} ${"nextConfigDir"} | ${"nextStaticDir"} | ${path.join(__dirname, "./fixtures/split-app")} `( "nextConfigDir=$nextConfigDir, nextStaticDir=$nextStaticDir", ({ fixturePath, ...inputs }) => { + let tmpCwd; + beforeEach(async () => { + tmpCwd = process.cwd(); process.chdir(fixturePath); - mockS3.mockResolvedValue({ - name: "bucket-xyz" - }); - - mockCloudFront.mockResolvedValueOnce({ - url: "https://cloudfrontdistrib.amazonaws.com" - }); + mockServerlessComponentDependencies({}); const component = createNextComponent(); @@ -141,6 +152,11 @@ describe("Custom inputs", () => { }); }); + afterEach(() => { + process.chdir(tmpCwd); + return cleanupFixtureDirectory(fixturePath); + }); + it("uploads static assets to S3 correctly", () => { expect(mockUpload).toBeCalledWith( expect.objectContaining({ @@ -176,16 +192,14 @@ describe("Custom inputs", () => { `( "input=inputPublicDirectoryCache, expected=$expectedPublicDirectoryCache", ({ publicDirectoryCache, expected }) => { - beforeEach(async () => { - process.chdir(path.join(__dirname, "./fixtures/simple-app")); + let tmpCwd; + let fixturePath = path.join(__dirname, "./fixtures/simple-app"); - mockS3.mockResolvedValue({ - name: "bucket-xyz" - }); + beforeEach(async () => { + tmpCwd = process.cwd(); + process.chdir(fixturePath); - mockCloudFront.mockResolvedValueOnce({ - url: "https://cloudfrontdistrib.amazonaws.com" - }); + mockServerlessComponentDependencies({}); const component = createNextComponent(); @@ -194,6 +208,11 @@ describe("Custom inputs", () => { }); }); + afterEach(() => { + process.chdir(tmpCwd); + return cleanupFixtureDirectory(fixturePath); + }); + it(`sets the ${expected} Cache-Control header on ${publicDirectoryCache}`, () => { expect(mockUpload).toBeCalledWith( expect.objectContaining({ @@ -217,13 +236,13 @@ describe("Custom inputs", () => { ] ])("Lambda memory input", (inputMemory, expectedMemory) => { const fixturePath = path.join(__dirname, "./fixtures/generic-fixture"); + let tmpCwd; beforeEach(async () => { + tmpCwd = process.cwd(); process.chdir(fixturePath); - mockCloudFront.mockResolvedValueOnce({ - url: "https://cloudfrontdistrib.amazonaws.com" - }); + mockServerlessComponentDependencies({}); const component = createNextComponent({ memory: inputMemory @@ -233,10 +252,16 @@ describe("Custom inputs", () => { memory: inputMemory }); }); + + afterEach(() => { + process.chdir(tmpCwd); + return cleanupFixtureDirectory(fixturePath); + }); + it(`sets default lambda memory to ${expectedMemory.defaultMemory} and api lambda memory to ${expectedMemory.apiMemory}`, () => { const { defaultMemory, apiMemory } = expectedMemory; - // Default Lambda + // default Lambda expect(mockLambda).toBeCalledWith( expect.objectContaining({ code: path.join(fixturePath, DEFAULT_LAMBDA_CODE_DIR), @@ -244,7 +269,7 @@ describe("Custom inputs", () => { }) ); - // Api Lambda + // api Lambda expect(mockLambda).toBeCalledWith( expect.objectContaining({ code: path.join(fixturePath, API_LAMBDA_CODE_DIR), @@ -254,17 +279,15 @@ describe("Custom inputs", () => { }); }); - describe.each([ - [undefined, { defaultTimeout: 10, apiTimeout: 10 }], - [{}, { defaultTimeout: 10, apiTimeout: 10 }], - [40, { defaultTimeout: 40, apiTimeout: 40 }], - [{ defaultLambda: 20 }, { defaultTimeout: 20, apiTimeout: 10 }], - [{ apiLambda: 20 }, { defaultTimeout: 10, apiTimeout: 20 }], - [ - { defaultLambda: 15, apiLambda: 20 }, - { defaultTimeout: 15, apiTimeout: 20 } - ] - ])("Lambda timeout input", (inputTimeout, expectedTimeout) => { + describe.each` + inputTimeout | expectedTimeout + ${undefined} | ${{ defaultTimeout: 10, apiTimeout: 10 }} + ${{}} | ${{ defaultTimeout: 10, apiTimeout: 10 }} + ${40} | ${{ defaultTimeout: 40, apiTimeout: 40 }} + ${{ defaultLambda: 20 }} | ${{ defaultTimeout: 20, apiTimeout: 10 }} + ${{ apiLambda: 20 }} | ${{ defaultTimeout: 10, apiTimeout: 20 }} + ${{ defaultLambda: 15, apiLambda: 20 }} | ${{ defaultTimeout: 15, apiTimeout: 20 }} + `("Input timeout options", ({ inputTimeout, expectedTimeout }) => { let tmpCwd; const fixturePath = path.join(__dirname, "./fixtures/generic-fixture"); @@ -272,9 +295,7 @@ describe("Custom inputs", () => { tmpCwd = process.cwd(); process.chdir(fixturePath); - mockCloudFront.mockResolvedValueOnce({ - url: "https://cloudfrontdistrib.amazonaws.com" - }); + mockServerlessComponentDependencies({}); const component = createNextComponent(); @@ -285,12 +306,12 @@ describe("Custom inputs", () => { afterEach(() => { process.chdir(tmpCwd); + return cleanupFixtureDirectory(fixturePath); }); it(`sets default lambda timeout to ${expectedTimeout.defaultTimeout} and api lambda timeout to ${expectedTimeout.apiTimeout}`, () => { const { defaultTimeout, apiTimeout } = expectedTimeout; - // Default Lambda expect(mockLambda).toBeCalledWith( expect.objectContaining({ code: path.join(fixturePath, DEFAULT_LAMBDA_CODE_DIR), @@ -298,7 +319,6 @@ describe("Custom inputs", () => { }) ); - // Api Lambda expect(mockLambda).toBeCalledWith( expect.objectContaining({ code: path.join(fixturePath, API_LAMBDA_CODE_DIR), @@ -308,31 +328,23 @@ describe("Custom inputs", () => { }); }); - describe.each([ - [undefined, { defaultName: undefined, apiName: undefined }], - [{}, { defaultName: undefined, apiName: undefined }], - ["fooFunction", { defaultName: "fooFunction", apiName: "fooFunction" }], - [ - { defaultLambda: "fooFunction" }, - { defaultName: "fooFunction", apiName: undefined } - ], - [ - { apiLambda: "fooFunction" }, - { defaultName: undefined, apiName: "fooFunction" } - ], - [ - { defaultLambda: "fooFunction", apiLambda: "barFunction" }, - { defaultName: "fooFunction", apiName: "barFunction" } - ] - ])("Lambda name input", (inputName, expectedName) => { + describe.each` + inputName | expectedName + ${undefined} | ${{ defaultName: undefined, apiName: undefined }} + ${{}} | ${{ defaultName: undefined, apiName: undefined }} + ${"fooFunction"} | ${{ defaultName: "fooFunction", apiName: "fooFunction" }} + ${{ defaultLambda: "fooFunction" }} | ${{ defaultName: "fooFunction", apiName: undefined }} + ${{ apiLambda: "fooFunction" }} | ${{ defaultName: undefined, apiName: "fooFunction" }} + ${{ defaultLambda: "fooFunction", apiLambda: "barFunction" }} | ${{ defaultName: "fooFunction", apiName: "barFunction" }} + `("Lambda name input", ({ inputName, expectedName }) => { + let tmpCwd; const fixturePath = path.join(__dirname, "./fixtures/generic-fixture"); beforeEach(async () => { + tmpCwd = process.cwd(); process.chdir(fixturePath); - mockCloudFront.mockResolvedValueOnce({ - url: "https://cloudfrontdistrib.amazonaws.com" - }); + mockServerlessComponentDependencies({}); const component = createNextComponent(); @@ -340,10 +352,15 @@ describe("Custom inputs", () => { name: inputName }); }); + + afterEach(() => { + process.chdir(tmpCwd); + return cleanupFixtureDirectory(fixturePath); + }); + it(`sets default lambda name to ${expectedName.defaultName} and api lambda name to ${expectedName.apiName}`, () => { const { defaultName, apiName } = expectedName; - // Default Lambda const expectedDefaultObject = { code: path.join(fixturePath, DEFAULT_LAMBDA_CODE_DIR) }; @@ -353,7 +370,6 @@ describe("Custom inputs", () => { expect.objectContaining(expectedDefaultObject) ); - // Api Lambda const expectedApiObject = { code: path.join(fixturePath, API_LAMBDA_CODE_DIR) }; @@ -366,20 +382,70 @@ describe("Custom inputs", () => { }); describe.each([ - [undefined, {}], // no input - [{}, {}], // empty input + // no input + [undefined, {}], + // empty input + [{}, {}], + // ignores origin-request and origin-response triggers as they're reserved by serverless-next.js [ - { defaults: { ttl: 500, "lambda@edge": "ignored value" } }, - { defaults: { ttl: 500 } } // expecting lambda@edge value to be ignored + { + defaults: { + ttl: 500, + "lambda@edge": { + "origin-request": "ignored", + "origin-response": "also ignored" + } + } + }, + { defaults: { ttl: 500 } } + ], + // allow lamdba@edge triggers other than origin-request and origin-response + [ + { + defaults: { + ttl: 500, + "lambda@edge": { + "viewer-request": "used value" + } + } + }, + { + defaults: { + ttl: 500, + "lambda@edge": { "viewer-request": "used value" } + } + } ], [ { defaults: { forward: { headers: "X" } } }, { defaults: { forward: { headers: "X" } } } ], + // ignore custom lambda@edge origin-request trigger set on the api cache behaviour [ - { api: { ttl: 500, "lambda@edge": "ignored value" } }, - { api: { ttl: 500 } } // expecting lambda@edge value to be ignored + { + "api/*": { + ttl: 500, + "lambda@edge": { "origin-request": "ignored value" } + } + }, + { "api/*": { ttl: 500 } } + ], + // allow other lambda@edge triggers on the api cache behaviour + [ + { + "api/*": { + ttl: 500, + "lambda@edge": { "origin-response": "used value" } + } + }, + { + "api/*": { + ttl: 500, + "lambda@edge": { "origin-response": "used value" } + } + } ], + // custom origins and expanding relative URLs to full S3 origin [ { origins: [ @@ -397,63 +463,121 @@ describe("Custom inputs", () => { { url: "http://bucket-xyz.s3.amazonaws.com/diff-relative" } ] } + ], + // custom page cache behaviours + [ + { + "/terms": { + ttl: 5500, + "misc-param": "misc-value", + "lambda@edge": { + "origin-request": "ignored value" + } + } + }, + { + "/terms": { + ttl: 5500, + "misc-param": "misc-value" + } + } + ], + [ + { + "/customers/stan-sack": { + ttl: 5500 + } + }, + { + "/customers/stan-sack": { + ttl: 5500 + } + } ] ])("Custom cloudfront inputs", (inputCloudfrontConfig, expectedInConfig) => { + let tmpCwd; const fixturePath = path.join(__dirname, "./fixtures/generic-fixture"); - const defaultCloudfrontInputs = expectedInConfig.defaults || {}; - const apiCloudfrontInputs = expectedInConfig.api || {}; - const originCloudfrontInputs = expectedInConfig.origins || []; + const { origins = [], defaults = {}, ...other } = expectedInConfig; + + const expectedDefaultCacheBehaviour = { + ...defaults, + "lambda@edge": { + "origin-request": + "arn:aws:lambda:us-east-1:123456789012:function:my-func:v1", + ...defaults["lambda@edge"] + } + }; + + const expectedApiCacheBehaviour = { + ...expectedInConfig["api/*"], + allowedHttpMethods: [ + "HEAD", + "DELETE", + "POST", + "GET", + "OPTIONS", + "PUT", + "PATCH" + ], + "lambda@edge": { + "origin-request": + "arn:aws:lambda:us-east-1:123456789012:function:my-func:v1", + ...(expectedInConfig["api/*"] && + expectedInConfig["api/*"]["lambda@edge"]) + } + }; + + let customPageCacheBehaviours = {}; + Object.entries(other).forEach(([path, cacheBehaviour]) => { + customPageCacheBehaviours[path] = { + ...cacheBehaviour, + "lambda@edge": { + "origin-request": + "arn:aws:lambda:us-east-1:123456789012:function:my-func:v1", + ...(cacheBehaviour && cacheBehaviour["lambda@edge"]) + } + }; + }); + const cloudfrontConfig = { defaults: { ttl: 0, allowedHttpMethods: ["HEAD", "GET"], - ...defaultCloudfrontInputs, forward: { cookies: "all", - queryString: true, - ...defaultCloudfrontInputs.forward + queryString: true }, - "lambda@edge": { - "origin-request": - "arn:aws:lambda:us-east-1:123456789012:function:my-func:v1" - } + ...expectedDefaultCacheBehaviour }, origins: [ { pathPatterns: { - "_next/*": { ttl: 86400 }, + ...customPageCacheBehaviours, + "_next/*": { + ...customPageCacheBehaviours["_next/*"], + ttl: 86400 + }, "api/*": { - allowedHttpMethods: [ - "HEAD", - "DELETE", - "POST", - "GET", - "OPTIONS", - "PUT", - "PATCH" - ], ttl: 0, - "lambda@edge": { - "origin-request": - "arn:aws:lambda:us-east-1:123456789012:function:my-func:v1" - }, - ...apiCloudfrontInputs + ...expectedApiCacheBehaviour }, - "static/*": { ttl: 86400 } + "static/*": { + ...customPageCacheBehaviours["static/*"], + ttl: 86400 + } }, private: true, url: "http://bucket-xyz.s3.amazonaws.com" }, - ...originCloudfrontInputs + ...origins ] }; beforeEach(async () => { + tmpCwd = process.cwd(); process.chdir(fixturePath); - mockCloudFront.mockResolvedValueOnce({ - url: "https://cloudfrontdistrib.amazonaws.com" - }); + mockServerlessComponentDependencies({}); const component = createNextComponent(); @@ -462,10 +586,53 @@ describe("Custom inputs", () => { }); }); + afterEach(() => { + process.chdir(tmpCwd); + return cleanupFixtureDirectory(fixturePath); + }); + it("Sets cloudfront options if present", () => { expect(mockCloudFront).toBeCalledWith( expect.objectContaining(cloudfrontConfig) ); }); }); + + describe.each` + cloudFrontInput | expectedErrorMessage + ${{ "some-invalid-page-route": { ttl: 100 } }} | ${'Could not find next.js pages for "some-invalid-page-route"'} + ${{ "/api": { ttl: 100 } }} | ${'route "/api" is not supported'} + ${{ api: { ttl: 100 } }} | ${'route "api" is not supported'} + ${{ "api/test": { ttl: 100 } }} | ${'route "api/test" is not supported'} + `( + "Invalid cloudfront inputs", + ({ cloudFrontInput, expectedErrorMessage }) => { + const fixturePath = path.join(__dirname, "./fixtures/generic-fixture"); + let tmpCwd; + + beforeEach(async () => { + tmpCwd = process.cwd(); + process.chdir(fixturePath); + + mockServerlessComponentDependencies({}); + }); + + afterEach(() => { + process.chdir(tmpCwd); + return cleanupFixtureDirectory(fixturePath); + }); + + it("throws the correct error", async () => { + expect.assertions(1); + + try { + await createNextComponent().default({ + cloudfront: cloudFrontInput + }); + } catch (err) { + expect(err.message).toContain(expectedErrorMessage); + } + }); + } + ); }); diff --git a/packages/serverless-component/examples/app-with-custom-caching-config/next.config.js b/packages/serverless-component/examples/app-with-custom-caching-config/next.config.js new file mode 100644 index 0000000000..aacc3fcd6a --- /dev/null +++ b/packages/serverless-component/examples/app-with-custom-caching-config/next.config.js @@ -0,0 +1,3 @@ +module.exports = { + target: "serverless" +} diff --git a/packages/serverless-component/examples/app-with-custom-caching-config/package.json b/packages/serverless-component/examples/app-with-custom-caching-config/package.json new file mode 100644 index 0000000000..1b779812e7 --- /dev/null +++ b/packages/serverless-component/examples/app-with-custom-caching-config/package.json @@ -0,0 +1,32 @@ +{ + "name": "app-with-custom-caching-config", + "version": "1.0.0", + "description": "Example app to show different cache configurations you can use with serverless-next.js", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/danielcondemarin/serverless-next.js.git" + }, + "keywords": [ + "Cache", + "Serverless", + "Next.js" + ], + "author": "Daniel Conde ", + "license": "ISC", + "bugs": { + "url": "https://github.com/danielcondemarin/serverless-next.js/issues" + }, + "homepage": "https://github.com/danielcondemarin/serverless-next.js#readme", + "dependencies": { + "next": "^9.3.6", + "react": "^16.13.1", + "react-dom": "^16.13.1" + }, + "devDependencies": { + "serverless": "^1.70.0" + } +} diff --git a/packages/serverless-component/examples/app-with-custom-caching-config/pages/about.js b/packages/serverless-component/examples/app-with-custom-caching-config/pages/about.js new file mode 100644 index 0000000000..662df5c7b7 --- /dev/null +++ b/packages/serverless-component/examples/app-with-custom-caching-config/pages/about.js @@ -0,0 +1,3 @@ +export default function About() { + return
About page updated
; +} diff --git a/packages/serverless-component/examples/app-with-custom-caching-config/pages/post/[slug].js b/packages/serverless-component/examples/app-with-custom-caching-config/pages/post/[slug].js new file mode 100644 index 0000000000..46c88520bb --- /dev/null +++ b/packages/serverless-component/examples/app-with-custom-caching-config/pages/post/[slug].js @@ -0,0 +1,8 @@ +import { useRouter } from "next/router"; + +export default () => { + const router = useRouter(); + const { slug } = router.query; + + return
Blog post updated: {slug}
; +}; diff --git a/packages/serverless-component/examples/app-with-custom-caching-config/serverless.yml b/packages/serverless-component/examples/app-with-custom-caching-config/serverless.yml new file mode 100644 index 0000000000..2ae4b1a243 --- /dev/null +++ b/packages/serverless-component/examples/app-with-custom-caching-config/serverless.yml @@ -0,0 +1,11 @@ +nextApp: + component: ../../ + inputs: + cloudfront: + # the cache settings below will be applied to the CloudFront distribution + # the pages will be cached at CloudFront's edge locations + /post/*: + ttl: 86400 # 1 day cache + /about: + ttl: 31536000 # 1 year + diff --git a/packages/serverless-component/serverless.js b/packages/serverless-component/serverless.js index 2d41958e2e..9941289887 100644 --- a/packages/serverless-component/serverless.js +++ b/packages/serverless-component/serverless.js @@ -23,6 +23,85 @@ class NextjsComponent extends Component { ); } + validatePathPatterns(pathPatterns, buildManifest) { + let stillToMatch = new Set(pathPatterns); + if (stillToMatch.size !== pathPatterns.length) { + throw Error("Duplicate path declared in cloudfront configuration"); + } + + // there wont be a page path for this so we can remove it + stillToMatch.delete("api/*"); + // check for other api like paths + for (const path of stillToMatch) { + if (/^(\/?api\/.*|\/?api)$/.test(path)) { + throw Error( + `Setting custom cache behaviour for api/ route "${path}" is not supported` + ); + } + } + + // theres a lot of n^2 code running below but n is small and it only runs once so we can + // accept it + + // setup containers for the paths we're going to be matching against + + // for dynamic routes + let manifestRegex = []; + // for static routes + let manifestPaths = new Set(); + + // extract paths to validate against from build manifest + const ssrDynamic = buildManifest.pages.ssr.dynamic || {}; + const ssrNonDynamic = buildManifest.pages.ssr.nonDynamic || {}; + const htmlDynamic = buildManifest.pages.html.dynamic || {}; + const htmlNonDynamic = buildManifest.pages.html.nonDynamic || {}; + + // dynamic paths to check. We use their regex to match against our input yaml + Object.entries({ + ...ssrDynamic, + ...htmlDynamic + }).map(([, { regex }]) => { + manifestRegex.push(new RegExp(regex)); + }); + + // static paths to check + Object.entries({ + ...ssrNonDynamic, + ...htmlNonDynamic + }).map(([path]) => { + manifestPaths.add(path); + }); + + // first we check if the path patterns match any of the dynamic page regex. + // paths with stars (*) shouldnt cause any issues because the regex will treat these + // as characters. + manifestRegex.forEach(re => { + for (const path of stillToMatch) { + if (re.test(path)) { + stillToMatch.delete(path); + } + } + }); + + // now we check the remaining unmatched paths against the non dynamic paths + // and use the path as regex so that we are testing * + for (const pathToMatch of stillToMatch) { + for (const path of manifestPaths) { + if (new RegExp(pathToMatch).test(path)) { + stillToMatch.delete(pathToMatch); + } + } + } + + if (stillToMatch.size > 0) { + throw Error( + `CloudFront input failed validation. Could not find next.js pages for "${[ + ...stillToMatch + ]}"` + ); + } + } + async readApiBuildManifest(nextConfigPath) { const path = join( nextConfigPath, @@ -75,6 +154,8 @@ class NextjsComponent extends Component { ? path.resolve(inputs.nextStaticDir) : nextConfigPath; + const customCloudFrontConfig = inputs.cloudfront || {}; + const [defaultBuildManifest, apiBuildManifest] = await Promise.all([ this.readDefaultBuildManifest(nextConfigPath), this.readApiBuildManifest(nextConfigPath) @@ -129,12 +210,13 @@ class NextjsComponent extends Component { }; } }; - // - // Parse origins from inputs - const inputOrigins = ( - (inputs.cloudfront && inputs.cloudfront.origins) || - [] - ).map(expandRelativeUrls); + + // parse origins from inputs + let inputOrigins = []; + if (inputs.cloudfront && inputs.cloudfront.origins) { + inputOrigins = inputs.cloudfront.origins.map(expandRelativeUrls); + delete inputs.cloudfront.origins; + } const cloudFrontOrigins = [ { @@ -199,8 +281,7 @@ class NextjsComponent extends Component { apiEdgeLambdaOutputs = await apiEdgeLambda(apiEdgeLambdaInput); apiEdgeLambdaPublishOutputs = await apiEdgeLambda.publishVersion(); - const apiCloudfrontInputs = - (inputs.cloudfront && inputs.cloudfront.api) || {}; + cloudFrontOrigins[0].pathPatterns["api/*"] = { ttl: 0, allowedHttpMethods: [ @@ -212,7 +293,6 @@ class NextjsComponent extends Component { "PUT", "PATCH" ], - ...apiCloudfrontInputs, // lambda@edge key is last and therefore cannot be overridden "lambda@edge": { "origin-request": `${apiEdgeLambdaOutputs.arn}:${apiEdgeLambdaPublishOutputs.version}` @@ -246,20 +326,59 @@ class NextjsComponent extends Component { const defaultEdgeLambdaPublishOutputs = await defaultEdgeLambda.publishVersion(); - const defaultCloudfrontInputs = - (inputs.cloudfront && inputs.cloudfront.defaults) || {}; + let defaultCloudfrontInputs; + if (inputs.cloudfront && inputs.cloudfront.defaults) { + defaultCloudfrontInputs = inputs.cloudfront.defaults; + delete inputs.cloudfront.defaults; + } else { + defaultCloudfrontInputs = {}; + } + + // validate that the custom config paths match generated paths in the manifest + this.validatePathPatterns( + Object.keys(customCloudFrontConfig), + defaultBuildManifest + ); + + // add any custom cloudfront configuration + // this includes overrides for _next, static and api + Object.entries(customCloudFrontConfig).map(([path, config]) => { + cloudFrontOrigins[0].pathPatterns[path] = { + // spread the existing value if there is one + ...cloudFrontOrigins[0].pathPatterns[path], + // spread custom config + ...config, + "lambda@edge": { + ...(config["lambda@edge"] || {}), + "origin-request": + // dont set if the path is static or _next + // spread the supplied overrides + !["static/*", "_next/*"].includes(path) && + `${defaultEdgeLambdaOutputs.arn}:${defaultEdgeLambdaPublishOutputs.version}` + } + }; + }); + + // make sure that origin-response is not set. + // this is reserved for serverless-next.js usage + let defaultLambdaAtEdgeConfig = { + ...(defaultCloudfrontInputs["lambda@edge"] || {}) + }; + delete defaultLambdaAtEdgeConfig["origin-response"]; + const cloudFrontOutputs = await cloudFront({ defaults: { ttl: 0, - allowedHttpMethods: ["HEAD", "GET"], - ...defaultCloudfrontInputs, forward: { cookies: "all", queryString: true, ...defaultCloudfrontInputs.forward }, - // lambda@edge key is last and therefore cannot be overridden + ...defaultCloudfrontInputs, + // everything after here cant be overriden + allowedHttpMethods: ["HEAD", "GET"], "lambda@edge": { + ...defaultLambdaAtEdgeConfig, "origin-request": `${defaultEdgeLambdaOutputs.arn}:${defaultEdgeLambdaPublishOutputs.version}` } }, @@ -268,7 +387,7 @@ class NextjsComponent extends Component { let appUrl = cloudFrontOutputs.url; - // Create domain + // create domain const { domain, subdomain } = obtainDomains(inputs.domain); if (domain) { const domainComponent = await this.load("@serverless/domain");