diff --git a/.gitignore b/.gitignore index 55c748e0b8..18b4640985 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ examples/basic-next-serverless-app/build .serverless .next sls-next-build -package-lock.json \ No newline at end of file +.serverless_nextjs +package-lock.json +.DS_Store diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000000..40c3c4a33e --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +manifest.json \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 1618af89c1..6d45c4e2b2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,11 +5,12 @@ os: - osx language: node_js node_js: - - "8" - - "11.10" + - "10.16" + - "12.7.0" install: - npm install - cd packages/serverless-nextjs-plugin && npm install && cd ../../ + - cd packages/serverless-nextjs-component && npm install && cd ../../ - cd integration/app-with-serverless-offline && npm install && cd ../../ script: - npm run lint diff --git a/jest.integration.setup.js b/jest.integration.setup.js index 5b3e36c485..461b4ef3a0 100644 --- a/jest.integration.setup.js +++ b/jest.integration.setup.js @@ -1 +1 @@ -jest.setTimeout(35000); +jest.setTimeout(35 * 1000); diff --git a/jest.setup.js b/jest.setup.js new file mode 100644 index 0000000000..781a845c62 --- /dev/null +++ b/jest.setup.js @@ -0,0 +1 @@ +jest.setTimeout(10 * 1000); diff --git a/package.json b/package.json index 14cbba83a0..e34efec21f 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ ], "scripts": { "test": "jest", + "test:watch": "jest --watch", "lint": "eslint .", "coveralls": "jest --coverage && cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js", "integration": "jest --config jest.integration.config.json --setupTestFrameworkScriptFile=./jest.integration.setup.js", @@ -32,18 +33,18 @@ "homepage": "https://github.com/danielcondemarin/serverless-nextjs-plugin#readme", "devDependencies": { "adm-zip": "^0.4.13", - "coveralls": "^3.0.3", + "coveralls": "^3.0.6", "eslint": "^5.16.0", "eslint-config-prettier": "^4.2.0", "eslint-plugin-prettier": "^3.0.1", - "jest": "^24.8.0", - "jest-when": "^2.5.0", + "jest": "^24.9.0", + "jest-when": "^2.7.0", "lerna": "^3.13.4", "next": "^8.1.0", "prettier": "^1.17.1", - "react": "^16.8.6", - "react-dom": "^16.8.6", - "serverless": "^1.42.3", + "react": "^16.9.0", + "react-dom": "^16.9.0", + "serverless": "^1.51.0", "serverless-offline": "^4.10.0" }, "jest": { @@ -55,21 +56,31 @@ "coverageDirectory": "/coverage/", "coveragePathIgnorePatterns": [ "/packages/serverless-nextjs-plugin/utils/yml/cfSchema.js", - "/packages/serverless-nextjs-plugin/utils/test" + "/packages/serverless-nextjs-plugin/utils/test", + "/.serverless_nextjs/", + "/fixtures/", + "/examples/" ], "testPathIgnorePatterns": [ "/.next/", - "/node_modules/" + "/node_modules/", + "/fixtures/" ], "modulePathIgnorePatterns": [ - "/examples/", + "/examples/", "/integration" ], "modulePaths": [ "/packages/serverless-nextjs-plugin/" + ], + "setupFiles": [ + "/jest.setup.js" ] }, "dependencies": { + "@serverless/aws-cloudfront": "^3.1.0", + "@serverless/aws-lambda": "^3.0.0", + "@serverless/aws-s3": "^3.1.0", "opencollective-postinstall": "^2.0.2" }, "collective": { diff --git a/packages/serverless-nextjs-component/.gitignore b/packages/serverless-nextjs-component/.gitignore new file mode 100644 index 0000000000..31d779ab4f --- /dev/null +++ b/packages/serverless-nextjs-component/.gitignore @@ -0,0 +1,2 @@ +.serverless_nextjs +!__tests__/fixtures/simple-app/.next \ No newline at end of file diff --git a/packages/serverless-nextjs-component/README.md b/packages/serverless-nextjs-component/README.md new file mode 100644 index 0000000000..54352cac7a --- /dev/null +++ b/packages/serverless-nextjs-component/README.md @@ -0,0 +1,107 @@ +# Serverless Nextjs Component + +A zero configuration Nextjs 9.0 [serverless component](https://github.com/serverless-components/) with full feature parity. + +[![serverless](http://public.serverless.com/badges/v3.svg)](http://www.serverless.com) +[![Build Status](https://travis-ci.org/danielcondemarin/serverless-nextjs-plugin.svg?branch=master)](https://travis-ci.org/danielcondemarin/serverless-nextjs-plugin) +[![Financial Contributors on Open Collective](https://opencollective.com/serverless-nextjs-plugin/all/badge.svg?label=financial+contributors)](https://opencollective.com/serverless-nextjs-plugin) [![npm version](https://badge.fury.io/js/serverless-nextjs-plugin.svg)](https://badge.fury.io/js/serverless-nextjs-plugin) +[![Coverage Status](https://coveralls.io/repos/github/danielcondemarin/serverless-nextjs-plugin/badge.svg?branch=master)](https://coveralls.io/github/danielcondemarin/serverless-nextjs-plugin?branch=master) +[![Codacy Badge](https://api.codacy.com/project/badge/Grade/c0d3aa2a86cb4ce98772a02015f46314)](https://www.codacy.com/app/danielcondemarin/serverless-nextjs-plugin?utm_source=github.com&utm_medium=referral&utm_content=danielcondemarin/serverless-nextjs-plugin&utm_campaign=Badge_Grade) + +## Contents + +- [Motivation](#motivation) +- [Design principles](#design-principles) +- [Features](#features) +- [Getting started](#getting-started) +- [Custom domain name](#custom-domain-name) +- [Architecture](#architecture) +- [FAQ](#faq) + +### Motivation + +Since Nextjs 8.0, [serverless mode](https://nextjs.org/blog/next-8#serverless-nextjs) was introduced which provides a new low level API which projects like this can use to deploy onto different cloud providers. This project is a better version of the [serverless plugin](https://github.com/danielcondemarin/serverless-nextjs-plugin) which focuses on addressing core issues like [next 9 support](https://github.com/danielcondemarin/serverless-nextjs-plugin/issues/101), [better development experience](https://github.com/danielcondemarin/serverless-nextjs-plugin/issues/59), [the 200 CloudFormation resource limit](https://github.com/danielcondemarin/serverless-nextjs-plugin/issues/17) and [performance](https://github.com/danielcondemarin/serverless-nextjs-plugin/issues/13). + +### Design principles + +1. Zero configuration by default + +There is no configuration needed. You can extend defaults based on your application needs. + +2. Feature parity with nextjs + +Users of this component should be able to use nextjs development tooling, aka `next dev`. It is the component's job to deploy your application ensuring parity with all of next's feature we know and love. + +3. Fast deployments / no CloudFormation resource limits. + +With a simplified architecture and no use of CloudFormation, there are no limits to how many pages you can have in your application, plus deployment times are very fast! with the exception of CloudFront propagation times of course. + +### Features + +- [x] [Server side rendered pages at the Edge](https://github.com/zeit/next.js#fetching-data-and-component-lifecycle). + Pages that need server side compute to render are hosted on Lambda@Edge. The component takes care of all the routing for you so there is no configuration needed. Because rendering happens at the CloudFront edge locations latency is very low! +- [x] [API Routes](https://nextjs.org/docs#api-routes). + Similarly to the server side rendered pages, API requests are also served from the CloudFront edge locations using Lambda@Edge. +- [x] [Dynamic pages / route segments](https://github.com/zeit/next.js/#dynamic-routing). +- [x] [Automatic prerendering](https://github.com/zeit/next.js/#automatic-prerendering). + Statically optimised pages compiled by next are served from CloudFront edge locations with low latency and cost. +- [x] [Client assets](https://github.com/zeit/next.js/#cdn-support-with-asset-prefix). + Nextjs build assets `/_next/*` served from CloudFront. +- [x] [User static / public folders](https://github.com/zeit/next.js#static-file-serving-eg-images). + Any of your assets in the static or public folders are uploaded to S3 and served from CloudFront automatically. + +### Getting started + +Add your next application to the serverless.yml: + +```yml +# serverless.yml + +myNextApplication: + component: @serverless/nextjs +``` + +And simply deploy: + +```bash +$ serverless +``` + +### Custom domain name (Coming soon!) + +In most cases you wouldn't want to use CloudFront's distribution domain to access your application. Instead, you can specify a custom domain name: + +```yml +# serverless.yml + +myNextApplication: + component: @serverless/nextjs + inputs: + domain: myfrontend.example.com +``` + +### Architecture + +The application architecture deployed by the component is the following with minor variations: + +![architecture](./arch.png) + +### FAQ + +#### Is it one monolith Lambda or one Lambda per serverless page? + +Only one Lambda is provisioned. There are a few reasons why: + +- Simplicity. One lambda responsible for server side rendering or serving the API requests is very easy to manage. On the other hand, one lambda per page is a large surface area for a web app. For example a next application with 40+ pages would have resulted in 40+ lambda functions to maintain. + +- Deployment speed. Is much faster building and deploying one lambda function. + +Of course there are tradeoffs ... An architecture using one lambda per page in theory results in lower boot times. However, the implementation of this component is designed to ensure a minimum amount of compute happens at the Lambda@Edge. + +#### How do I interact with other AWS Services within my app? + +See `examples/dynamodb-crud` for an example Todo application that interacts with DynamoDB. + +#### Should I use the [serverless-nextjs-plugin](https://github.com/danielcondemarin/serverless-nextjs-plugin/tree/master/packages/serverless-nextjs-plugin) or this component? + +Users are enocouraged to use this component instead of the `serverless-nextjs-plugin`. This component was built and designed to fix issues the plugin has like the [CloudFormation resource limit](https://github.com/danielcondemarin/serverless-nextjs-plugin/issues/17). diff --git a/packages/serverless-nextjs-component/__tests__/build.test.js b/packages/serverless-nextjs-component/__tests__/build.test.js new file mode 100644 index 0000000000..bb811a24df --- /dev/null +++ b/packages/serverless-nextjs-component/__tests__/build.test.js @@ -0,0 +1,302 @@ +const path = require("path"); +const fse = require("fs-extra"); +const execa = require("execa"); +const NextjsComponent = require("../serverless"); +const { LAMBDA_AT_EDGE_BUILD_DIR } = require("../constants"); + +jest.mock("execa"); + +const mockS3Upload = jest.fn(); +const mockS3 = jest.fn(); +jest.mock("@serverless/aws-s3", () => + jest.fn(() => { + const bucket = mockS3; + bucket.init = () => {}; + bucket.default = () => {}; + bucket.context = {}; + bucket.upload = mockS3Upload; + return bucket; + }) +); + +const mockCloudFront = jest.fn(); +jest.mock("@serverless/aws-cloudfront", () => + jest.fn(() => { + const cloudFront = mockCloudFront; + cloudFront.init = () => {}; + cloudFront.default = () => {}; + cloudFront.context = {}; + return cloudFront; + }) +); + +const mockLambda = jest.fn(); +const mockLambdaPublish = jest.fn(); +jest.mock("@serverless/aws-lambda", () => + jest.fn(() => { + const lambda = mockLambda; + lambda.init = () => {}; + lambda.default = () => {}; + lambda.context = {}; + lambda.publishVersion = mockLambdaPublish; + return lambda; + }) +); + +describe("build tests", () => { + let tmpCwd; + let manifest; + let componentOutputs; + + const fixturePath = path.join(__dirname, "./fixtures/simple-app"); + + beforeEach(async () => { + execa.mockResolvedValueOnce(); + + tmpCwd = process.cwd(); + process.chdir(fixturePath); + + mockS3.mockResolvedValue({ + name: "bucket-xyz" + }); + mockLambda.mockResolvedValueOnce({ + arn: "arn:aws:lambda:us-east-1:123456789012:function:my-func" + }); + mockLambdaPublish.mockResolvedValueOnce({ + version: "v1" + }); + mockCloudFront.mockResolvedValueOnce({ + url: "https://cloudfrontdistrib.amazonaws.com" + }); + + const component = new NextjsComponent(); + componentOutputs = await component.default(); + + manifest = await fse.readJSON( + path.join(fixturePath, `${LAMBDA_AT_EDGE_BUILD_DIR}/manifest.json`) + ); + }); + + afterEach(() => { + process.chdir(tmpCwd); + }); + + it("outputs next application url from cloudfront", () => { + expect(componentOutputs).toEqual({ + appUrl: "https://cloudfrontdistrib.amazonaws.com" + }); + }); + + describe("manifest", () => { + it("adds ssr page route", async () => { + const { + pages: { + ssr: { nonDynamic } + } + } = manifest; + + expect(nonDynamic["/customers/new"]).toEqual("pages/customers/new.js"); + }); + + it("adds ssr dynamic page route to express equivalent", async () => { + const { + pages: { + ssr: { dynamic } + } + } = manifest; + + expect(dynamic["/blog/:id"]).toEqual({ + file: "pages/blog/[id].js", + regex: "^\\/blog\\/([^\\/]+?)(?:\\/)?$" + }); + }); + + it("adds dynamic page with multiple segments to express equivalent", async () => { + const { + pages: { + ssr: { dynamic } + } + } = manifest; + + expect(dynamic["/customers/:customer/:post"]).toEqual({ + file: "pages/customers/[customer]/[post].js", + regex: "^\\/customers\\/([^\\/]+?)\\/([^\\/]+?)(?:\\/)?$" + }); + }); + + it("adds static page route", async () => { + const { + pages: { html } + } = manifest; + + expect(html["/terms"]).toEqual("pages/terms.html"); + }); + + it("adds public files", async () => { + const { publicFiles } = manifest; + + expect(publicFiles).toEqual({ + "/favicon.ico": "favicon.ico", + "/sw.js": "sw.js" + }); + }); + + it("adds the full manifest", async () => { + const { + pages: { + ssr: { dynamic, nonDynamic }, + html + } + } = manifest; + + expect(dynamic).toEqual({ + "/:root": { + file: "pages/[root].js", + regex: expect.any(String) + }, + "/blog/:id": { + file: "pages/blog/[id].js", + regex: expect.any(String) + }, + "/customers/:customer": { + file: "pages/customers/[customer].js", + regex: expect.any(String) + }, + "/customers/:customer/:post": { + file: "pages/customers/[customer]/[post].js", + regex: expect.any(String) + }, + "/customers/:customer/profile": { + file: "pages/customers/[customer]/profile.js", + regex: expect.any(String) + } + }); + + expect(nonDynamic).toEqual({ + "/customers/new": "pages/customers/new.js", + "/": "pages/index.js", + "/_app": "pages/_app.js", + "/_document": "pages/_document.js", + "/404": "pages/404.js" + }); + + expect(html).toEqual({ + "/terms": "pages/terms.html", + "/about": "pages/about.html" + }); + }); + + it("adds s3 domain", () => { + const { + cloudFrontOrigins: { staticOrigin } + } = manifest; + + expect(staticOrigin).toEqual({ + domainName: "bucket-xyz.s3.amazonaws.com" + }); + }); + }); + + describe("Lambda@Edge build files", () => { + it("copies handler file", async () => { + const files = await fse.readdir( + path.join(fixturePath, `${LAMBDA_AT_EDGE_BUILD_DIR}/`) + ); + + expect(files).toContain("index.js"); + }); + + it("copies manifest file", async () => { + const files = await fse.readdir( + path.join(fixturePath, `${LAMBDA_AT_EDGE_BUILD_DIR}/`) + ); + + expect(files).toContain("manifest.json"); + }); + + it("copies compat file", async () => { + const files = await fse.readdir( + path.join(fixturePath, `${LAMBDA_AT_EDGE_BUILD_DIR}/`) + ); + + expect(files).toContain("next-aws-cloudfront.js"); + }); + }); + + describe("assets bucket", () => { + it("uploads client build assets", () => { + expect(mockS3Upload).toBeCalledWith({ + dir: "./.next/static", + keyPrefix: "_next/static" + }); + }); + + it("uploads user static directory", () => { + expect(mockS3Upload).toBeCalledWith({ + dir: "./static", + keyPrefix: "static" + }); + }); + + it("uploads user public directory", () => { + expect(mockS3Upload).toBeCalledWith({ + dir: "./public", + keyPrefix: "public" + }); + }); + + it("uploads html pages to S3", () => { + ["terms.html", "about.html"].forEach(page => { + expect(mockS3Upload).toBeCalledWith({ + file: `./.next/serverless/pages/${page}`, + key: `static-pages/${page}` + }); + }); + }); + }); + + describe("cloudfront", () => { + it("provisions and publishes lambda@edge", () => { + expect(mockLambda).toBeCalledWith({ + description: expect.any(String), + handler: "index.handler", + code: `./${LAMBDA_AT_EDGE_BUILD_DIR}`, + role: { + service: ["lambda.amazonaws.com", "edgelambda.amazonaws.com"], + policy: { + arn: "arn:aws:iam::aws:policy/AdministratorAccess" + } + } + }); + + expect(mockLambdaPublish).toBeCalled(); + }); + + it("creates distribution", () => { + expect(mockCloudFront).toBeCalledWith({ + defaults: { + allowedHttpMethods: expect.any(Array), + ttl: 5, + "lambda@edge": { + "origin-request": + "arn:aws:lambda:us-east-1:123456789012:function:my-func:v1" // includes version + } + }, + origins: [ + { + url: "http://bucket-xyz.s3.amazonaws.com", + private: true, + pathPatterns: { + "_next/*": { + ttl: 86400 + }, + "static/*": { + ttl: 86400 + } + } + } + ] + }); + }); + }); +}); diff --git a/packages/serverless-nextjs-component/__tests__/fixtures/built-artifact/manifest.json b/packages/serverless-nextjs-component/__tests__/fixtures/built-artifact/manifest.json new file mode 100644 index 0000000000..7e13a3ecce --- /dev/null +++ b/packages/serverless-nextjs-component/__tests__/fixtures/built-artifact/manifest.json @@ -0,0 +1,45 @@ +{ + "cloudFrontOrigins": { + "staticOrigin": { + "domainName": "my-bucket.s3.amazonaws.com" + } + }, + "pages": { + "ssr": { + "dynamic": { + "/:root": { + "file": "pages/[root].js", + "regex": "^\/([^\/]+?)(?:\/)?$" + }, + "/blog/:id": { + "file": "pages/blog/[id].js", + "regex": "^\/blog\/([^\/]+?)(?:\/)?$" + }, + "/customers/:customer": { + "file": "pages/customers/[customer].js", + "regex": "^\/customers\/([^\/]+?)(?:\/)?$" + }, + "/customers/:customer/:post": { + "file": "pages/customers/[customer]/[post].js", + "regex": "^\/customers\/([^\/]+?)\/([^\/]+?)(?:\/)?$" + }, + "/customers/:customer/profile": { + "file": "pages/customers/[customer]/profile", + "regex": "^\/customers\/([^\/]+?)\/profile(?:\/)?$" + } + }, + "nonDynamic": { + "/": "pages/index.js", + "/customers": "pages/customers/index.js", + "/customers/new": "pages/customers/new.js" + } + }, + "html": { + "/terms": "pages/terms.html" + } + }, + "publicFiles": { + "/favicon.ico": "favicon.ico", + "/manifest.json": "manifest.json" + } +} diff --git a/packages/serverless-nextjs-component/__tests__/fixtures/built-artifact/pages/[root].js b/packages/serverless-nextjs-component/__tests__/fixtures/built-artifact/pages/[root].js new file mode 100644 index 0000000000..4256111021 --- /dev/null +++ b/packages/serverless-nextjs-component/__tests__/fixtures/built-artifact/pages/[root].js @@ -0,0 +1,5 @@ +module.exports = { + render: (req, res) => { + res.end("pages/[root].js"); + } +}; diff --git a/packages/serverless-nextjs-component/__tests__/fixtures/built-artifact/pages/_error.js b/packages/serverless-nextjs-component/__tests__/fixtures/built-artifact/pages/_error.js new file mode 100644 index 0000000000..d9257a2e61 --- /dev/null +++ b/packages/serverless-nextjs-component/__tests__/fixtures/built-artifact/pages/_error.js @@ -0,0 +1,5 @@ +module.exports = { + render: (req, res) => { + res.end("pages/_error.js"); + } +}; diff --git a/packages/serverless-nextjs-component/__tests__/fixtures/built-artifact/pages/api/getCustomers.js b/packages/serverless-nextjs-component/__tests__/fixtures/built-artifact/pages/api/getCustomers.js new file mode 100644 index 0000000000..54ee2e9450 --- /dev/null +++ b/packages/serverless-nextjs-component/__tests__/fixtures/built-artifact/pages/api/getCustomers.js @@ -0,0 +1,5 @@ +module.exports = { + default: (req, res) => { + res.end("pages/api/getCustomers"); + } +}; diff --git a/packages/serverless-nextjs-component/__tests__/fixtures/built-artifact/pages/blog/[id].js b/packages/serverless-nextjs-component/__tests__/fixtures/built-artifact/pages/blog/[id].js new file mode 100644 index 0000000000..5b9e614bec --- /dev/null +++ b/packages/serverless-nextjs-component/__tests__/fixtures/built-artifact/pages/blog/[id].js @@ -0,0 +1,5 @@ +module.exports = { + render: (req, res) => { + res.end("pages/blog/[id].js"); + } +}; diff --git a/packages/serverless-nextjs-component/__tests__/fixtures/built-artifact/pages/customers/[customer].js b/packages/serverless-nextjs-component/__tests__/fixtures/built-artifact/pages/customers/[customer].js new file mode 100644 index 0000000000..6ec7762fd5 --- /dev/null +++ b/packages/serverless-nextjs-component/__tests__/fixtures/built-artifact/pages/customers/[customer].js @@ -0,0 +1,5 @@ +module.exports = { + render: (req, res) => { + res.end("pages/customers/[customer].js"); + } +}; diff --git a/packages/serverless-nextjs-component/__tests__/fixtures/built-artifact/pages/customers/[customer]/[post].js b/packages/serverless-nextjs-component/__tests__/fixtures/built-artifact/pages/customers/[customer]/[post].js new file mode 100644 index 0000000000..e6032eccaf --- /dev/null +++ b/packages/serverless-nextjs-component/__tests__/fixtures/built-artifact/pages/customers/[customer]/[post].js @@ -0,0 +1,5 @@ +module.exports = { + render: (req, res) => { + res.end("pages/customers/[customer]/[post].js"); + } +}; diff --git a/packages/serverless-nextjs-component/__tests__/fixtures/built-artifact/pages/customers/[customer]/profile.js b/packages/serverless-nextjs-component/__tests__/fixtures/built-artifact/pages/customers/[customer]/profile.js new file mode 100644 index 0000000000..7fd369d285 --- /dev/null +++ b/packages/serverless-nextjs-component/__tests__/fixtures/built-artifact/pages/customers/[customer]/profile.js @@ -0,0 +1,5 @@ +module.exports = { + render: (req, res) => { + res.end("pages/[customers]/[customer]/profile.js"); + } +}; diff --git a/packages/serverless-nextjs-component/__tests__/fixtures/built-artifact/pages/customers/index.js b/packages/serverless-nextjs-component/__tests__/fixtures/built-artifact/pages/customers/index.js new file mode 100644 index 0000000000..80d58e121f --- /dev/null +++ b/packages/serverless-nextjs-component/__tests__/fixtures/built-artifact/pages/customers/index.js @@ -0,0 +1,5 @@ +module.exports = { + render: (req, res) => { + res.end("pages/customers/index.js"); + } +}; diff --git a/packages/serverless-nextjs-component/__tests__/fixtures/built-artifact/pages/customers/new.js b/packages/serverless-nextjs-component/__tests__/fixtures/built-artifact/pages/customers/new.js new file mode 100644 index 0000000000..82ad064ff8 --- /dev/null +++ b/packages/serverless-nextjs-component/__tests__/fixtures/built-artifact/pages/customers/new.js @@ -0,0 +1,5 @@ +module.exports = { + render: (req, res) => { + res.end("pages/customers/new.js"); + } +}; diff --git a/packages/serverless-nextjs-component/__tests__/fixtures/built-artifact/pages/index.js b/packages/serverless-nextjs-component/__tests__/fixtures/built-artifact/pages/index.js new file mode 100644 index 0000000000..3c36374e29 --- /dev/null +++ b/packages/serverless-nextjs-component/__tests__/fixtures/built-artifact/pages/index.js @@ -0,0 +1,5 @@ +module.exports = { + render: (req, res) => { + res.end("pages/index.js"); + } +}; diff --git a/packages/serverless-nextjs-component/__tests__/fixtures/built-artifact/pages/terms.html b/packages/serverless-nextjs-component/__tests__/fixtures/built-artifact/pages/terms.html new file mode 100644 index 0000000000..090bb0e457 --- /dev/null +++ b/packages/serverless-nextjs-component/__tests__/fixtures/built-artifact/pages/terms.html @@ -0,0 +1,3 @@ + + TERMS + diff --git a/packages/serverless-nextjs-component/__tests__/fixtures/manifest.json b/packages/serverless-nextjs-component/__tests__/fixtures/manifest.json new file mode 100644 index 0000000000..ae6ed7e9b3 --- /dev/null +++ b/packages/serverless-nextjs-component/__tests__/fixtures/manifest.json @@ -0,0 +1,50 @@ +{ + "cloudFrontOrigins": { + "ssrApi": { + "domainName": "ssr-api.execute-api.us-east-1.amazonaws.com" + }, + "staticOrigin": { + "domainName": "my-bucket.s3.amazonaws.com" + } + }, + "pages": { + "ssr": { + "dynamic": { + "/:root": { + "file": "pages/[root].js", + "regex": "/^/([^/]+?)(?:/)?$/i" + }, + "/blog/:id": { + "file": "pages/blog/[id].js", + "regex": "/^/blog/([^/]+?)(?:/)?$/i" + }, + "/customers/:customer": { + "file": "pages/customers/[customer].js", + "regex": "/^/customers/([^/]+?)(?:/)?$/i" + }, + "/customers/:customer/:post": { + "file": "/customers/:customer/:post", + "regex": "/^/customers/([^/]+?)/([^/]+?)(?:/)?$/i" + }, + "/customers/:customer/profile": { + "file": "pages/customers/[customer]/profile.js", + "regex": "/^/customers/([^/]+?)/profile(?:/)?$/i" + } + }, + "nonDynamic": { + "/": "pages/index.js", + "/customers": "pages/customers/index.js", + "/customers/new": "pages/customers/new.js", + "/api/getCustomers": "pages/api/getCustomers.js", + "/_error":"pages/_error.js" + } + }, + "html": { + "/terms": "pages/terms.html" + } + }, + "publicFiles": { + "/favicon.ico": "favicon.ico", + "/manifest.json": "manifest.json" + } +} diff --git a/packages/serverless-nextjs-component/__tests__/fixtures/simple-app/.next/serverless/pages-manifest.json b/packages/serverless-nextjs-component/__tests__/fixtures/simple-app/.next/serverless/pages-manifest.json new file mode 100644 index 0000000000..aa925a8982 --- /dev/null +++ b/packages/serverless-nextjs-component/__tests__/fixtures/simple-app/.next/serverless/pages-manifest.json @@ -0,0 +1,14 @@ +{ + "/[root]": "pages/[root].js", + "/blog/[id]": "pages/blog/[id].js", + "/customers/[customer]": "pages/customers/[customer].js", + "/customers/[customer]/[post]": "pages/customers/[customer]/[post].js", + "/customers/new": "pages/customers/new.js", + "/customers/[customer]/profile": "pages/customers/[customer]/profile.js", + "/terms": "pages/terms.html", + "/about": "pages/about.html", + "/": "pages/index.js", + "/_app": "pages/_app.js", + "/_document": "pages/_document.js", + "/404": "pages/404.js" +} diff --git a/packages/serverless-nextjs-component/__tests__/fixtures/simple-app/.next/serverless/pages/about.html b/packages/serverless-nextjs-component/__tests__/fixtures/simple-app/.next/serverless/pages/about.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/serverless-nextjs-component/__tests__/fixtures/simple-app/.next/serverless/pages/blog.js b/packages/serverless-nextjs-component/__tests__/fixtures/simple-app/.next/serverless/pages/blog.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/serverless-nextjs-component/__tests__/fixtures/simple-app/.next/serverless/pages/customers/[post].js b/packages/serverless-nextjs-component/__tests__/fixtures/simple-app/.next/serverless/pages/customers/[post].js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/serverless-nextjs-component/__tests__/fixtures/simple-app/.next/serverless/pages/terms.html b/packages/serverless-nextjs-component/__tests__/fixtures/simple-app/.next/serverless/pages/terms.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/serverless-nextjs-component/__tests__/fixtures/simple-app/public/favicon.ico b/packages/serverless-nextjs-component/__tests__/fixtures/simple-app/public/favicon.ico new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/serverless-nextjs-component/__tests__/fixtures/simple-app/public/sw.js b/packages/serverless-nextjs-component/__tests__/fixtures/simple-app/public/sw.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/serverless-nextjs-component/__tests__/fixtures/simple-app/serverless.yml b/packages/serverless-nextjs-component/__tests__/fixtures/simple-app/serverless.yml new file mode 100644 index 0000000000..90f1f0cff9 --- /dev/null +++ b/packages/serverless-nextjs-component/__tests__/fixtures/simple-app/serverless.yml @@ -0,0 +1,2 @@ +nextApp: + component: "../../../serverless" diff --git a/packages/serverless-nextjs-component/__tests__/lambda@edge-handler.test.js b/packages/serverless-nextjs-component/__tests__/lambda@edge-handler.test.js new file mode 100644 index 0000000000..5072884d09 --- /dev/null +++ b/packages/serverless-nextjs-component/__tests__/lambda@edge-handler.test.js @@ -0,0 +1,133 @@ +const { handler } = require("../lambda-at-edge-handler"); +const { createCloudFrontEvent } = require("../lib/test-utils"); + +jest.mock("../manifest.json", () => require("./fixtures/manifest.json"), { + virtual: true +}); + +const mockPageRequire = mockPagePath => { + jest.mock( + `../${mockPagePath}`, + () => require(`./fixtures/built-artifact/${mockPagePath}`), + { + virtual: true + } + ); +}; + +describe("Lambda@Edge", () => { + describe("Routing", () => { + it("serves optimised page from S3 static-pages folder", async () => { + const event = createCloudFrontEvent({ + uri: "/terms", + host: "mydistribution.cloudfront.net", + origin: { + s3: { + authMethod: "origin-access-identity", + domainName: "my-bucket.s3.amazonaws.com", + path: "" + } + } + }); + + const request = await handler(event, {}); + + expect(request.origin).toEqual({ + s3: { + authMethod: "origin-access-identity", + domainName: "my-bucket.s3.amazonaws.com", + path: "/static-pages" + } + }); + expect(request.uri).toEqual("/terms.html"); + }); + + it("serves public file from S3 /public folder", async () => { + const event = createCloudFrontEvent({ + uri: "/manifest.json", + host: "mydistribution.cloudfront.net", + origin: { + s3: { + authMethod: "origin-access-identity", + domainName: "my-bucket.s3.amazonaws.com", + path: "" + } + } + }); + + const request = await handler(event, {}); + + expect(request.origin).toEqual({ + s3: { + authMethod: "origin-access-identity", + domainName: "my-bucket.s3.amazonaws.com", + path: "/public" + } + }); + expect(request.uri).toEqual("/manifest.json"); + }); + }); + + it("renders page at the edge", async () => { + const event = createCloudFrontEvent({ + uri: "/customers", + host: "mydistribution.cloudfront.net", + origin: { + s3: { + domainName: "my-bucket.amazonaws.com" + } + } + }); + + mockPageRequire("pages/customers/index.js"); + + const response = await handler(event, {}); + + const decodedBody = new Buffer(response.body, "base64").toString("utf8"); + + expect(decodedBody).toEqual("pages/customers/index.js"); + expect(response.status).toEqual(200); + }); + + it("serves api request at the edge", async () => { + const event = createCloudFrontEvent({ + uri: "/api/getCustomers", + host: "mydistribution.cloudfront.net", + origin: { + s3: { + domainName: "my-bucket.amazonaws.com" + } + } + }); + + mockPageRequire("pages/api/getCustomers.js"); + + const response = await handler(event, {}); + + const decodedBody = new Buffer(response.body, "base64").toString("utf8"); + + expect(decodedBody).toEqual("pages/api/getCustomers"); + expect(response.status).toEqual(200); + }); + + it.only("renders 404 page if request path can't be matched to any page / api routes", async () => { + const event = createCloudFrontEvent({ + uri: "/page/does/not/exist", + host: "mydistribution.cloudfront.net", + origin: { + s3: { + domainName: "my-bucket.amazonaws.com" + } + } + }); + + mockPageRequire("pages/_error.js"); + + const response = await handler(event, {}); + + const decodedBody = new Buffer(response.body, "base64").toString("utf8"); + + expect(decodedBody).toEqual("pages/_error.js"); + expect(response.status).toEqual(200); + }); +}); diff --git a/packages/serverless-nextjs-component/__tests__/next-aws-cloudfront.request.test.js b/packages/serverless-nextjs-component/__tests__/next-aws-cloudfront.request.test.js new file mode 100644 index 0000000000..de0de91b92 --- /dev/null +++ b/packages/serverless-nextjs-component/__tests__/next-aws-cloudfront.request.test.js @@ -0,0 +1,192 @@ +const create = require("../next-aws-cloudfront"); + +describe("Request Tests", () => { + it("request url path", () => { + const { req } = create({ + request: { + uri: "/" + } + }); + + expect(req.url).toEqual("/"); + }); + + it("querystring /?x=42", () => { + const { req } = create({ + request: { + uri: "/", + querystring: "x=42" + } + }); + + expect(req.url).toEqual("/?x=42"); + }); + + // it("querystring /?x=åäö", () => { + // const {req} = create({ + // request: { + // uri: "/", + // querystring: "x=åäö" + // } + // }); + + // expect(req.url).toEqual("/?x=%C3%A5%C3%A4%C3%B6"); + // }); + + // it("querystring /?x=õ", () => { + // const {req} = create({ + // request: { + // uri: "/", + // querystring: "x=õ" + // } + // }); + + // expect(req.url).toEqual("/?x=%C3%B5"); + // }); + + // it("querystring with multiple values for same name /?x=1&x=2", () => { + // const {req} = create({ + // request: { + // uri: "/", + // querystring: "x=1&x=2" + // } + // }); + + // expect(req.url).toEqual("/?x=1&x=2"); + // }); + + // it("complicated querystring", () => { + // const { req } = create({ + // request: { + // uri: "/", + // querystring: "a=8&as=1&t=2&tk=1&url=https://example.com/õ" + // } + // }); + + // expect(req.url).toEqual( + // "/?url=https%3A%2F%2Fexample.com%2Ft%2Ft%3Fa%3D8%26as%3D1%26t%3D2%26tk%3D1%26url%3Dhttps%3A%2F%2Fexample.com%2F%C3%B5&clickSource=yes&category=cat" + // ); + // }); + + it("request method", () => { + const { req } = create({ + request: { + uri: "", + method: "GET" + } + }); + + expect(req.method).toEqual("GET"); + }); + + it("request headers", () => { + const { req } = create({ + request: { + uri: "", + headers: { + "x-custom-1": { + key: "x-cUstom-1", + value: "42" + }, + "x-custom-2": { + key: "x-custom-2", + value: "43" + } + } + } + }); + + expect(req.headers["x-custom-1"]).toEqual("42"); + expect(req.getHeader("x-custom-1")).toEqual("42"); + expect(req.headers["x-custom-2"]).toEqual("43"); + expect(req.getHeader("x-custom-2")).toEqual("43"); + + expect(req.getHeaders()).toEqual({ + "x-custom-1": "42", + "x-custom-2": "43" + }); + + expect(req.rawHeaders).toEqual(["x-cUstom-1", "42", "x-custom-2", "43"]); + }); + + it("text body", done => { + const { req } = create({ + request: { + uri: "", + body: { + data: "ok" + } + } + }); + + let data = ""; + + req.on("data", chunk => { + data += chunk; + }); + + req.on("end", () => { + expect(data).toEqual("ok"); + done(); + }); + }); + + it("text base64 body", done => { + const { req } = create({ + request: { + uri: "", + body: { + encoding: "base64", + data: Buffer.from("ok").toString("base64") + }, + headers: {} + } + }); + + let data = ""; + + req.on("data", chunk => { + data += chunk; + }); + + req.on("end", () => { + expect(data).toEqual("ok"); + done(); + }); + }); + + it("text body with encoding", done => { + const { req } = create({ + request: { + uri: "", + body: { + data: "åäöß" + }, + headers: {} + } + }); + + let data = ""; + + req.on("data", chunk => { + data += chunk; + }); + + req.on("end", () => { + expect(data).toEqual("åäöß"); + done(); + }); + }); + + it("connection", done => { + const { req } = create({ + request: { + uri: "", + headers: {} + } + }); + + expect(req.connection).toEqual({}); + done(); + }); +}); diff --git a/packages/serverless-nextjs-component/__tests__/next-aws-cloudfront.response.test.js b/packages/serverless-nextjs-component/__tests__/next-aws-cloudfront.response.test.js new file mode 100644 index 0000000000..e6c9da444f --- /dev/null +++ b/packages/serverless-nextjs-component/__tests__/next-aws-cloudfront.response.test.js @@ -0,0 +1,228 @@ +const create = require("../next-aws-cloudfront"); + +describe("Response Tests", () => { + it("statusCode writeHead 404", () => { + expect.assertions(1); + + const { responsePromise, res } = create({ + request: { + uri: "/", + headers: {} + } + }); + + res.writeHead(404); + res.end(); + + return responsePromise.then(response => { + expect(response.status).toEqual(404); + }); + }); + + it("statusCode statusCode=200", () => { + expect.assertions(1); + + const { res, responsePromise } = create({ + request: { + uri: "/", + headers: {} + } + }); + + res.statusCode = 200; + res.end(); + + return responsePromise.then(response => { + expect(response.status).toEqual(200); + }); + }); + + it("writeHead headers", () => { + expect.assertions(1); + + const { res, responsePromise } = create({ + request: { + uri: "/", + headers: {} + } + }); + + res.writeHead(200, { + "x-custom-1": "1", + "x-custom-2": "2" + }); + res.end(); + + return responsePromise.then(response => { + expect(response.headers).toEqual({ + "x-custom-1": [ + { + key: "x-custom-1", + value: "1" + } + ], + "x-custom-2": [ + { + key: "x-custom-2", + value: "2" + } + ] + }); + }); + }); + + it("writeHead ignores special CloudFront Headers", () => { + expect.assertions(1); + + const { res, responsePromise } = create({ + request: { + uri: "/", + headers: {} + } + }); + + const specialHeaders = { + "Accept-Encoding": "gzip", + "Content-Length": "1234", + "If-Modified-Since": "Wed, 21 Oct 2015 07:28:00 GMT", + "If-None-Match": "*", + "If-Range": "Wed, 21 Oct 2015 07:28:00 GMT", + "If-Unmodified-Since": "Wed, 21 Oct 2015 07:28:00 GMT", + "Transfer-Encoding": "compress", + Via: "HTTP/1.1 GWA" + }; + + res.writeHead(200, specialHeaders); + res.end(); + + return responsePromise.then(response => { + expect(response.headers).toEqual({}); + }); + }); + + it("setHeader", () => { + const { res, responsePromise } = create({ + request: { + uri: "/", + headers: {} + } + }); + + res.setHeader("x-custom-1", "1"); + res.setHeader("x-custom-2", "2"); + res.end(); + + return responsePromise.then(response => { + expect(response.headers).toEqual({ + "x-custom-1": [ + { + key: "x-custom-1", + value: "1" + } + ], + "x-custom-2": [ + { + key: "x-custom-2", + value: "2" + } + ] + }); + }); + }); + + it("setHeader ignores special CloudFront headers", () => { + const { res, responsePromise } = create({ + request: { + uri: "/", + headers: {} + } + }); + + res.setHeader("Content-Length", "123"); + res.setHeader("x-custom-2", "2"); + res.end(); + + return responsePromise.then(response => { + expect(response.headers).toEqual({ + "x-custom-2": [ + { + key: "x-custom-2", + value: "2" + } + ] + }); + }); + }); + + it("setHeader + removeHeader", () => { + const { res, responsePromise } = create({ + request: { + uri: "/", + headers: {} + } + }); + + res.setHeader("x-custom-1", "1"); + res.setHeader("x-custom-2", "2"); + res.removeHeader("x-custom-1"); + res.end(); + + return responsePromise.then(response => { + expect(response.headers).toEqual({ + "x-custom-2": [ + { + key: "x-custom-2", + value: "2" + } + ] + }); + }); + }); + + it("getHeader/s", () => { + const { res } = create({ + request: { + path: "/", + headers: {} + } + }); + res.setHeader("x-custom-1", "1"); + res.setHeader("x-custom-2", "2"); + expect(res.getHeader("x-custom-1")).toEqual("1"); + expect(res.getHeaders()).toEqual({ + "x-custom-1": "1", + "x-custom-2": "2" + }); + }); + + it(`res.write('ok')`, () => { + const { res, responsePromise } = create({ + request: { + path: "/", + headers: {} + } + }); + + res.write("ok"); + res.end(); + + return responsePromise.then(response => { + expect(response.body).toEqual("b2s="); + }); + }); + + it(`res.end('ok')`, () => { + const { res, responsePromise } = create({ + request: { + path: "/", + headers: {} + } + }); + + res.end("ok"); + + return responsePromise.then(response => { + expect(response.body).toEqual("b2s="); + }); + }); +}); diff --git a/packages/serverless-nextjs-component/arch-white.png b/packages/serverless-nextjs-component/arch-white.png new file mode 100644 index 0000000000..044a89cb40 Binary files /dev/null and b/packages/serverless-nextjs-component/arch-white.png differ diff --git a/packages/serverless-nextjs-component/arch.png b/packages/serverless-nextjs-component/arch.png new file mode 100644 index 0000000000..948d2ef327 Binary files /dev/null and b/packages/serverless-nextjs-component/arch.png differ diff --git a/packages/serverless-nextjs-component/constants.js b/packages/serverless-nextjs-component/constants.js new file mode 100644 index 0000000000..96891d2791 --- /dev/null +++ b/packages/serverless-nextjs-component/constants.js @@ -0,0 +1,3 @@ +module.exports = { + LAMBDA_AT_EDGE_BUILD_DIR: ".serverless_nextjs/lambda-at-edge" +}; diff --git a/packages/serverless-nextjs-component/examples/dynamodb-crud/.env.sample b/packages/serverless-nextjs-component/examples/dynamodb-crud/.env.sample new file mode 100644 index 0000000000..bf908a6891 --- /dev/null +++ b/packages/serverless-nextjs-component/examples/dynamodb-crud/.env.sample @@ -0,0 +1,3 @@ +AWS_ACCESS_KEY_ID=accesskey +AWS_SECRET_ACCESS_KEY=sshhh +AWS_REGION=us-west-2 \ No newline at end of file diff --git a/packages/serverless-nextjs-component/examples/dynamodb-crud/.gitignore b/packages/serverless-nextjs-component/examples/dynamodb-crud/.gitignore new file mode 100644 index 0000000000..fcdf50a348 --- /dev/null +++ b/packages/serverless-nextjs-component/examples/dynamodb-crud/.gitignore @@ -0,0 +1,5 @@ +.next +node_modules +.serverless +.serverless_nextjs +.env diff --git a/packages/serverless-nextjs-component/examples/dynamodb-crud/README.md b/packages/serverless-nextjs-component/examples/dynamodb-crud/README.md new file mode 100644 index 0000000000..193ffb01b1 --- /dev/null +++ b/packages/serverless-nextjs-component/examples/dynamodb-crud/README.md @@ -0,0 +1,56 @@ +## Description + +Example nextjs serverless app using a dynamodb database replicated across `eu-west-2` and `us-west-2`. + +## Getting started + +Install serverless-nextjs component deps + +```bash +cd ../../ +npm install +``` + +Install example project deps: + +`npm install` + +Rename `.env.sample` to `.env` and set your aws credentials. + +## Local development + +#### Provision the DynamoDB Todos Table + +Make sure you have a [running local dynamodb server](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DynamoDBLocal.html). + +Then simply run: + +`npm run dev:infra` + +#### Start the next app + +`npm run dev` +// available at http://localhost:3000 + +## Production + +#### Provision the DynamoDB Global Todos Table + +`npm run infra` + +#### Deploying + +To deploy your application to the cloud: + +`npm run deploy:up` + +#### Tearing down the application resources + +`npm run deploy:down` + +## A few notes + +- Server side the DynamoDB table is queried directly for SSR of the page +- On client side `fetch` is used to query the /api that talks to DynamoDB. Client side routing prevents having to reload every resource on the page like js, css, etc. +- Top level resources like /favicon.ico can be placed on the `public/` folder +- Images or any other user assets can be placed in the `static/` folder and are accessible via `/static/*` diff --git a/packages/serverless-nextjs-component/examples/dynamodb-crud/data/dynamodb.js b/packages/serverless-nextjs-component/examples/dynamodb-crud/data/dynamodb.js new file mode 100644 index 0000000000..48cf43a7bf --- /dev/null +++ b/packages/serverless-nextjs-component/examples/dynamodb-crud/data/dynamodb.js @@ -0,0 +1,63 @@ +const TableName = "Todos"; + +const getDynamoDBClient = () => { + // important to require the sdk here rather than a top level import + // this is to prevent the app from requiring the aws-sdk client side. + const AWS = require("aws-sdk"); + + // dynamodb is replicated across us-west-2 and eu-west-2 + // use us-west-2 for us regions or eu-west-2 for eu regions + // you can tweak this to suit your needs + const edgeRegion = process.env.AWS_REGION || "us-west-2"; + const dynamoDbRegion = edgeRegion.startsWith("us") + ? "us-west-2" + : "eu-west-2"; + + const options = { + convertEmptyValues: true, + region: dynamoDbRegion + }; + + const client = process.env.LOCAL_DYNAMO_DB_ENDPOINT + ? new AWS.DynamoDB.DocumentClient({ + ...options, + endpoint: process.env.LOCAL_DYNAMO_DB_ENDPOINT + }) + : new AWS.DynamoDB.DocumentClient(options); + + return client; +}; + +module.exports = { + readTodos: async () => { + const { Items } = await getDynamoDBClient() + .scan({ + TableName + }) + .promise(); + + return Items; + }, + getTodo: async todoId => { + const { Items } = await getDynamoDBClient() + .scan({ + TableName + }) + .promise(); + + const todo = Items.find(todo => todo.todoId == todoId); + + return todo; + }, + createTodo: async todoDescription => { + await getDynamoDBClient() + .put({ + TableName, + Item: { + todoId: Date.now(), + todoDescription + } + }) + .promise(); + } +}; diff --git a/packages/serverless-nextjs-component/examples/dynamodb-crud/data/index.js b/packages/serverless-nextjs-component/examples/dynamodb-crud/data/index.js new file mode 100644 index 0000000000..bc4b2901eb --- /dev/null +++ b/packages/serverless-nextjs-component/examples/dynamodb-crud/data/index.js @@ -0,0 +1 @@ +module.exports = require('./dynamodb') \ No newline at end of file diff --git a/packages/serverless-nextjs-component/examples/dynamodb-crud/data/mock.js b/packages/serverless-nextjs-component/examples/dynamodb-crud/data/mock.js new file mode 100644 index 0000000000..1eb7e55590 --- /dev/null +++ b/packages/serverless-nextjs-component/examples/dynamodb-crud/data/mock.js @@ -0,0 +1,16 @@ +module.exports = { + readTodos: async () => { + return [ + { todoId: 1, todoDescription: "cleaning" }, + { todoId: 2, todoDescription: "mop" }, + { todoId: 3, todoDescription: "study" }, + { todoId: 4, todoDescription: "cinema" } + ]; + }, + getTodo: async (todoId) => { + return { todoId: 1, todoDescription: "cleaning" }; + }, + createTodo: async (todoDescription) => { + return {}; + } +} \ No newline at end of file diff --git a/packages/serverless-nextjs-component/examples/dynamodb-crud/infrastructure/provisionTable.js b/packages/serverless-nextjs-component/examples/dynamodb-crud/infrastructure/provisionTable.js new file mode 100644 index 0000000000..6417cd12a2 --- /dev/null +++ b/packages/serverless-nextjs-component/examples/dynamodb-crud/infrastructure/provisionTable.js @@ -0,0 +1,58 @@ +var AWS = require("aws-sdk"); + +require("dotenv").config(); + +AWS.config.update({ + region: "us-west-2", + endpoint: process.env.LOCAL_DYNAMO_DB_ENDPOINT +}); + +let dynamodb = new AWS.DynamoDB(); + +let params = { + TableName: "Todos", + KeySchema: [ + { AttributeName: "todoId", KeyType: "HASH" } // Partition key + ], + AttributeDefinitions: [{ AttributeName: "todoId", AttributeType: "N" }], + // streams must be enabled for replicating the table + StreamSpecification: { + StreamEnabled: true, + StreamViewType: "NEW_AND_OLD_IMAGES" + }, + BillingMode: "PAY_PER_REQUEST" +}; + +(async function() { + await dynamodb.createTable(params).promise(); + + console.log("Created table in us-west-2"); + + if (!process.env.LOCAL_DYNAMO_DB_ENDPOINT) { + // only replicate in production + // dynamodb local doesn't support this operation + + AWS.config.update({ region: "eu-west-2" }); + + dynamodb = new AWS.DynamoDB(); + + await dynamodb.createTable(params).promise(); + + console.log("Created table in eu-west-2"); + + const createGlobalTableParams = { + GlobalTableName: "Todos", + ReplicationGroup: [ + { + RegionName: "us-west-2" + }, + { + RegionName: "eu-west-2" + } + ] + }; + + await dynamodb.createGlobalTable(createGlobalTableParams).promise(); + console.log("Replication completed"); + } +})(); diff --git a/packages/serverless-nextjs-component/examples/dynamodb-crud/next.config.js b/packages/serverless-nextjs-component/examples/dynamodb-crud/next.config.js new file mode 100644 index 0000000000..cb8f9188d0 --- /dev/null +++ b/packages/serverless-nextjs-component/examples/dynamodb-crud/next.config.js @@ -0,0 +1,13 @@ +module.exports = { + target: "serverless", + webpack: config => { + if (!process.env.BUNDLE_AWS_SDK) { + config.externals = config.externals || []; + config.externals.push({ "aws-sdk": "aws-sdk" }); + } else { + console.warn("Bundling aws-sdk. Only doing this in development mode"); + } + + return config; + } +}; diff --git a/packages/serverless-nextjs-component/examples/dynamodb-crud/package.json b/packages/serverless-nextjs-component/examples/dynamodb-crud/package.json new file mode 100644 index 0000000000..9dfff4b8f4 --- /dev/null +++ b/packages/serverless-nextjs-component/examples/dynamodb-crud/package.json @@ -0,0 +1,25 @@ +{ + "name": "nextjs-dynamodb-crud", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "dev:infra": "LOCAL_DYNAMO_DB_ENDPOINT=\"http://localhost:8000\" npm run infra", + "dev": "BUNDLE_AWS_SDK=1 AWS_ACCESS_KEY_ID=1 AWS_SECRET_ACCESS_KEY=1 LOCAL_DYNAMO_DB_ENDPOINT=\"http://localhost:8000\" next dev", + "infra": "node infrastructure/provisionTable", + "deploy:up": "serverless", + "deploy:down": "serverless remove" + }, + "author": "Daniel Conde Marin ", + "license": "MIT", + "dependencies": { + "aws-sdk": "^2.521.0", + "next": "^9.0.5", + "react": "^16.9.0", + "react-dom": "^16.9.0" + }, + "devDependencies": { + "dotenv": "^8.1.0", + "serverless": "^1.51.0" + } +} diff --git a/packages/serverless-nextjs-component/examples/dynamodb-crud/pages/api/todos/[id].js b/packages/serverless-nextjs-component/examples/dynamodb-crud/pages/api/todos/[id].js new file mode 100644 index 0000000000..b8453fa818 --- /dev/null +++ b/packages/serverless-nextjs-component/examples/dynamodb-crud/pages/api/todos/[id].js @@ -0,0 +1,7 @@ +import data from '../../../data' + +export default async (req, res) => { + console.log('/api/todos/[id] HIT!') + const todo = await data.getTodo(req.query.id) + res.status(200).json(todo); +}; \ No newline at end of file diff --git a/packages/serverless-nextjs-component/examples/dynamodb-crud/pages/api/todos/index.js b/packages/serverless-nextjs-component/examples/dynamodb-crud/pages/api/todos/index.js new file mode 100644 index 0000000000..af1fb6f161 --- /dev/null +++ b/packages/serverless-nextjs-component/examples/dynamodb-crud/pages/api/todos/index.js @@ -0,0 +1,6 @@ +import data from '../../../data' + +export default async (req, res) => { + console.log('/api/todos HIT!') + res.status(200).json(await data.readTodos()); +}; \ No newline at end of file diff --git a/packages/serverless-nextjs-component/examples/dynamodb-crud/pages/api/todos/new.js b/packages/serverless-nextjs-component/examples/dynamodb-crud/pages/api/todos/new.js new file mode 100644 index 0000000000..7fd54e2c38 --- /dev/null +++ b/packages/serverless-nextjs-component/examples/dynamodb-crud/pages/api/todos/new.js @@ -0,0 +1,8 @@ +import data from "../../../data"; + +export default async (req, res) => { + console.log("/api/todos/new HIT!"); + const todoDescription = JSON.parse(req.body).todoDescription; + await data.createTodo(todoDescription); + res.status(200).end("OK"); +}; diff --git a/packages/serverless-nextjs-component/examples/dynamodb-crud/pages/index.js b/packages/serverless-nextjs-component/examples/dynamodb-crud/pages/index.js new file mode 100644 index 0000000000..1dc3841fcb --- /dev/null +++ b/packages/serverless-nextjs-component/examples/dynamodb-crud/pages/index.js @@ -0,0 +1 @@ +export { default } from "./todos/list"; diff --git a/packages/serverless-nextjs-component/examples/dynamodb-crud/pages/todos/details/[id].js b/packages/serverless-nextjs-component/examples/dynamodb-crud/pages/todos/details/[id].js new file mode 100644 index 0000000000..f287e69c78 --- /dev/null +++ b/packages/serverless-nextjs-component/examples/dynamodb-crud/pages/todos/details/[id].js @@ -0,0 +1,35 @@ +import data from '../../../data' +import Link from "next/link" + +function TodoDetails(props) { + const { todo } = props; + + return
+
+

Id: {todo.todoId}

+

Description: {todo.todoDescription}

+
+ + Back to Todos List + +
+} + +TodoDetails.getInitialProps = async ({ req, query }) => { + if (req) { + // this is server side + // is fine to use aws-sdk here + const todo = await data.getTodo(query.id) + return { + todo + }; + } else { + // we are client side + // fetch via api + const response = await fetch(`/api/todos/${query.id}`) + const todo = await response.json() + return { todo } + } +} + +export default TodoDetails \ No newline at end of file diff --git a/packages/serverless-nextjs-component/examples/dynamodb-crud/pages/todos/list.js b/packages/serverless-nextjs-component/examples/dynamodb-crud/pages/todos/list.js new file mode 100644 index 0000000000..bc3415dd64 --- /dev/null +++ b/packages/serverless-nextjs-component/examples/dynamodb-crud/pages/todos/list.js @@ -0,0 +1,39 @@ +import { Fragment } from "react" +import Link from "next/link" +import data from '../../data' + +function ListTodos({ todos }) { + return + +
+ + New Todo + +
+
+} + +ListTodos.getInitialProps = async ({ req }) => { + if (req) { + // this is server side + // is fine to use aws-sdk here + return { + todos: await data.readTodos() + }; + } else { + // we are client side + // fetch via api + const response = await fetch('/api/todos') + return { todos: await response.json() } + } +} + +export default ListTodos \ No newline at end of file diff --git a/packages/serverless-nextjs-component/examples/dynamodb-crud/pages/todos/new.js b/packages/serverless-nextjs-component/examples/dynamodb-crud/pages/todos/new.js new file mode 100644 index 0000000000..f04b34e7f8 --- /dev/null +++ b/packages/serverless-nextjs-component/examples/dynamodb-crud/pages/todos/new.js @@ -0,0 +1,34 @@ +import { useState } from "react"; +import { useRouter } from "next/router"; + +function NewTodo() { + const router = useRouter(); + const [todoDescription, setTodoDescription] = useState(""); + + const handleSubmit = async event => { + event.preventDefault(); + + await fetch("/api/todos/new", { + method: "POST", + body: JSON.stringify({ todoDescription }) + }); + + router.push("/todos/list"); + }; + + return ( +
+ +
+ +
+ +
+ ); +} + +export default NewTodo; diff --git a/packages/serverless-nextjs-component/examples/dynamodb-crud/public/favicon.ico b/packages/serverless-nextjs-component/examples/dynamodb-crud/public/favicon.ico new file mode 100644 index 0000000000..006c66dcb3 Binary files /dev/null and b/packages/serverless-nextjs-component/examples/dynamodb-crud/public/favicon.ico differ diff --git a/packages/serverless-nextjs-component/examples/dynamodb-crud/public/robots.txt b/packages/serverless-nextjs-component/examples/dynamodb-crud/public/robots.txt new file mode 100644 index 0000000000..3dcd2c3d27 --- /dev/null +++ b/packages/serverless-nextjs-component/examples/dynamodb-crud/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: \ No newline at end of file diff --git a/packages/serverless-nextjs-component/examples/dynamodb-crud/serverless.yml b/packages/serverless-nextjs-component/examples/dynamodb-crud/serverless.yml new file mode 100644 index 0000000000..6dd2afc950 --- /dev/null +++ b/packages/serverless-nextjs-component/examples/dynamodb-crud/serverless.yml @@ -0,0 +1,2 @@ +nextApp: + component: "../../" diff --git a/packages/serverless-nextjs-component/examples/dynamodb-crud/static/todo-icon.png b/packages/serverless-nextjs-component/examples/dynamodb-crud/static/todo-icon.png new file mode 100644 index 0000000000..aecfd59009 Binary files /dev/null and b/packages/serverless-nextjs-component/examples/dynamodb-crud/static/todo-icon.png differ diff --git a/packages/serverless-nextjs-component/lambda-at-edge-handler.js b/packages/serverless-nextjs-component/lambda-at-edge-handler.js new file mode 100644 index 0000000000..679149fa5e --- /dev/null +++ b/packages/serverless-nextjs-component/lambda-at-edge-handler.js @@ -0,0 +1,34 @@ +const manifest = require("./manifest.json"); +const cloudFrontCompat = require("./next-aws-cloudfront"); +const router = require("./router"); + +exports.handler = async event => { + const request = event.Records[0].cf.request; + const uri = request.uri; + const { pages, publicFiles } = manifest; + + const isStaticPage = pages.html[uri]; + const isPublicFile = publicFiles[uri]; + + if (isStaticPage || isPublicFile) { + request.origin.s3.path = isStaticPage ? "/static-pages" : "/public"; + + if (isStaticPage) { + request.uri = request.uri + ".html"; + } + + return request; + } + + const pagePath = router(manifest)(uri); + + const page = require(`./${pagePath}`); + const { req, res, responsePromise } = cloudFrontCompat(event.Records[0].cf); + if (page.render) { + page.render(req, res); + } else { + page.default(req, res); + } + const response = await responsePromise; + return response; +}; diff --git a/packages/serverless-nextjs-component/lib/expressifyDynamicRoute.js b/packages/serverless-nextjs-component/lib/expressifyDynamicRoute.js new file mode 100644 index 0000000000..4af5bbbe2b --- /dev/null +++ b/packages/serverless-nextjs-component/lib/expressifyDynamicRoute.js @@ -0,0 +1,4 @@ +// converts a nextjs dynamic route /[param]/ to express style /:param/ +module.exports = dynamicRoute => { + return dynamicRoute.replace(/\[(?.*?)]/g, ":$"); +}; diff --git a/packages/serverless-nextjs-component/lib/isDynamicRoute.js b/packages/serverless-nextjs-component/lib/isDynamicRoute.js new file mode 100644 index 0000000000..6c37e07d28 --- /dev/null +++ b/packages/serverless-nextjs-component/lib/isDynamicRoute.js @@ -0,0 +1,4 @@ +module.exports = route => { + // Identify /[param]/ in route string + return /\/\[[^\/]+?\](?=\/|$)/.test(route); +}; diff --git a/packages/serverless-nextjs-component/lib/pathToRegexStr.js b/packages/serverless-nextjs-component/lib/pathToRegexStr.js new file mode 100644 index 0000000000..0003b9292d --- /dev/null +++ b/packages/serverless-nextjs-component/lib/pathToRegexStr.js @@ -0,0 +1,6 @@ +const pathToRegexp = require("path-to-regexp"); + +module.exports = path => + pathToRegexp(path) + .toString() + .replace(/\/(.*)\/\i/, "$1"); diff --git a/packages/serverless-nextjs-component/lib/test-utils.js b/packages/serverless-nextjs-component/lib/test-utils.js new file mode 100644 index 0000000000..3e2b02c80a --- /dev/null +++ b/packages/serverless-nextjs-component/lib/test-utils.js @@ -0,0 +1,24 @@ +const createCloudFrontEvent = ({ uri, host, origin }) => ({ + Records: [ + { + cf: { + request: { + uri, + headers: { + host: [ + { + key: "host", + value: host + } + ] + }, + origin + } + } + } + ] +}); + +module.exports = { + createCloudFrontEvent +}; diff --git a/packages/serverless-nextjs-component/next-aws-cloudfront.js b/packages/serverless-nextjs-component/next-aws-cloudfront.js new file mode 100644 index 0000000000..1b5696cb26 --- /dev/null +++ b/packages/serverless-nextjs-component/next-aws-cloudfront.js @@ -0,0 +1,133 @@ +const Stream = require("stream"); + +const readOnlyHeaders = { + "accept-encoding": true, + "content-length": true, + "if-modified-since": true, + "if-none-match": true, + "if-range": true, + "if-unmodified-since": true, + "transfer-encoding": true, + via: true +}; + +const toCloudFrontHeaders = headers => { + const result = {}; + + Object.keys(headers).forEach(headerName => { + if (!readOnlyHeaders[headerName.toLowerCase()]) { + result[headerName] = [ + { + key: headerName, + value: headers[headerName].toString() + } + ]; + } + }); + + return result; +}; + +module.exports = event => { + const { request: cfRequest } = event; + + const response = { + body: Buffer.from(""), + bodyEncoding: "base64", + status: 200, + statusDescription: "OK", + headers: {} + }; + + const req = new Stream.Readable(); + req.url = cfRequest.uri; + req.method = cfRequest.method; + req.rawHeaders = []; + req.headers = {}; + req.connection = {}; + + if (cfRequest.querystring) { + req.url = req.url + `?` + cfRequest.querystring; + } + + const headers = cfRequest.headers || {}; + + for (const lowercaseKey of Object.keys(headers)) { + const header = headers[lowercaseKey]; + + req.rawHeaders.push(header.key); + req.rawHeaders.push(header.value); + req.headers[lowercaseKey] = header.value; + } + + req.getHeader = name => { + return req.headers[name.toLowerCase()]; + }; + + req.getHeaders = () => { + return req.headers; + }; + + if (cfRequest.body && cfRequest.body.data) { + req.push( + cfRequest.body.data, + cfRequest.body.encoding ? "base64" : undefined + ); + } + + req.push(null); + + const res = new Stream(); + + Object.defineProperty(res, "statusCode", { + get() { + return response.statusCode; + }, + set(statusCode) { + response.statusCode = statusCode; + } + }); + + res.headers = {}; + res.writeHead = (status, headers) => { + response.status = status; + if (headers) { + res.headers = Object.assign(res.headers, headers); + } + }; + res.write = chunk => { + response.body = Buffer.concat([ + response.body, + Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + ]); + }; + + const responsePromise = new Promise(resolve => { + res.end = text => { + if (text) res.write(text); + response.body = Buffer.from(response.body).toString("base64"); + response.headers = toCloudFrontHeaders(res.headers); + + resolve(response); + }; + }); + + res.setHeader = (name, value) => { + res.headers[name] = value; + }; + res.removeHeader = name => { + delete res.headers[name]; + }; + res.getHeader = name => { + return res.headers[name.toLowerCase()]; + }; + res.getHeaders = () => { + return res.headers; + }; + + return { + req, + res, + responsePromise + }; +}; diff --git a/packages/serverless-nextjs-component/package.json b/packages/serverless-nextjs-component/package.json new file mode 100644 index 0000000000..6e24b8c1b1 --- /dev/null +++ b/packages/serverless-nextjs-component/package.json @@ -0,0 +1,39 @@ +{ + "name": "serverless-nextjs-component", + "version": "1.0.0", + "description": "Serverless nextjs powered by Serverless Components", + "main": "serverless.js", + "repository": { + "type": "git", + "url": "git+https://github.com/danielcondemarin/serverless-nextjs-plugin.git" + }, + "keywords": [ + "serverless", + "component", + "nextjs" + ], + "author": "Daniel Conde Marin ", + "license": "ISC", + "bugs": { + "url": "https://github.com/danielcondemarin/serverless-nextjs-plugin/issues" + }, + "homepage": "https://github.com/danielcondemarin/serverless-nextjs-plugin#readme", + "dependencies": { + "@serverless/aws-api-gateway": "^1.0.1", + "@serverless/aws-cloudfront": "git://github.com/danielcondemarin/aws-cloudfront.git#add-allowed-http-methods", + "@serverless/aws-lambda": "git://github.com/serverless-components/aws-lambda.git", + "@serverless/aws-s3": "git://github.com/serverless-components/aws-s3.git", + "@serverless/backend": "^3.3.0", + "@serverless/core": "^1.0.0", + "execa": "^2.0.4", + "fs-extra": "^8.1.0", + "next-aws-lambda": "^2.3.2", + "path-to-regexp": "^3.0.0" + }, + "devDependencies": { + "jest": "^24.8.0", + "next": "^9.0.4", + "react": "^16.9.0", + "react-dom": "^16.9.0" + } +} diff --git a/packages/serverless-nextjs-component/router-perf.js b/packages/serverless-nextjs-component/router-perf.js new file mode 100644 index 0000000000..70dcfa52ef --- /dev/null +++ b/packages/serverless-nextjs-component/router-perf.js @@ -0,0 +1,26 @@ +const performance = require("perf_hooks").performance; +const createRouter = require("./router"); +const manifest = { + pages: { + ssr: { + dynamic: {}, + nonDynamic: {} + } + } +}; + +for (let i = 0; i < 2000; i++) { + const route = `/blog/${i}/[id]`; + manifest.pages.ssr.dynamic[route] = { + file: "pages/blog/[id].js", + regex: "^/blog/([^/]+?)(?:/)?$" + }; +} + +const router = createRouter(manifest); + +const t1 = performance.now(); +router("/xyz"); +const t2 = performance.now(); + +console.log(t2 - t1); diff --git a/packages/serverless-nextjs-component/router.js b/packages/serverless-nextjs-component/router.js new file mode 100644 index 0000000000..a6e65074df --- /dev/null +++ b/packages/serverless-nextjs-component/router.js @@ -0,0 +1,32 @@ +module.exports = manifest => { + const { + pages: { + ssr: { dynamic, nonDynamic }, + html + } + } = manifest; + + return path => { + if (nonDynamic[path]) { + return nonDynamic[path]; + } + + if (html[path]) { + return html[path]; + } + + for (route in dynamic) { + const { file, regex } = dynamic[route]; + + const re = new RegExp(regex, "i"); + const pathMatchesRoute = re.test(path); + + if (pathMatchesRoute) { + return file; + } + } + + // path didn't match any route, return error page + return "pages/_error.js"; + }; +}; diff --git a/packages/serverless-nextjs-component/serverless.js b/packages/serverless-nextjs-component/serverless.js new file mode 100644 index 0000000000..4e55b1fc0f --- /dev/null +++ b/packages/serverless-nextjs-component/serverless.js @@ -0,0 +1,195 @@ +const { Component } = require("@serverless/core"); +const fse = require("fs-extra"); +const path = require("path"); +const execa = require("execa"); +const isDynamicRoute = require("./lib/isDynamicRoute"); +const expressifyDynamicRoute = require("./lib/expressifyDynamicRoute"); +const pathToRegexStr = require("./lib/pathToRegexStr"); +const { LAMBDA_AT_EDGE_BUILD_DIR } = require("./constants"); + +class NextjsComponent extends Component { + async default(inputs = {}) { + return this.build(inputs); + } + + readPublicFiles() { + return fse.readdir("./public"); + } + + readPagesManifest() { + return fse.readJSON("./.next/serverless/pages-manifest.json"); + } + + // do not confuse the component build manifest with nextjs pages manifest! + // they have different formats and data + getBlankBuildManifest() { + return { + pages: { + ssr: { + dynamic: {}, + nonDynamic: {} + }, + html: {} + }, + publicFiles: {}, + cloudFrontOrigins: {} + }; + } + + buildLambdaAtEdge(buildManifest) { + const copyPromises = [ + fse.copy( + path.join(__dirname, "lambda-at-edge-handler.js"), + `./${LAMBDA_AT_EDGE_BUILD_DIR}/index.js` + ), + fse.writeJson( + `./${LAMBDA_AT_EDGE_BUILD_DIR}/manifest.json`, + buildManifest + ), + fse.copy( + path.join(__dirname, "next-aws-cloudfront.js"), + `./${LAMBDA_AT_EDGE_BUILD_DIR}/next-aws-cloudfront.js` + ), + fse.copy(".next/serverless/pages", `./${LAMBDA_AT_EDGE_BUILD_DIR}/pages`), + fse.copy( + path.join(__dirname, "router.js"), + `./${LAMBDA_AT_EDGE_BUILD_DIR}/router.js` + ) + ]; + + return Promise.all(copyPromises); + } + + async build(inputs) { + await execa("next", ["build"]); + + const pagesManifest = await this.readPagesManifest(); + const buildManifest = this.getBlankBuildManifest(); + + const ssr = buildManifest.pages.ssr; + const allRoutes = Object.keys(pagesManifest); + + allRoutes.forEach(r => { + if (pagesManifest[r].endsWith(".html")) { + buildManifest.pages.html[r] = pagesManifest[r]; + } else if (isDynamicRoute(r)) { + const expressRoute = expressifyDynamicRoute(r); + ssr.dynamic[expressRoute] = { + file: pagesManifest[r], + regex: pathToRegexStr(expressRoute) + }; + } else { + ssr.nonDynamic[r] = pagesManifest[r]; + } + }); + + const publicFiles = await this.readPublicFiles(); + + publicFiles.forEach(pf => { + buildManifest.publicFiles["/" + pf] = pf; + }); + + await fse.emptyDir(`./${LAMBDA_AT_EDGE_BUILD_DIR}`); + + const bucket = await this.load("@serverless/aws-s3"); + const cloudFront = await this.load("@serverless/aws-cloudfront"); + const lambda = await this.load("@serverless/aws-lambda"); + + const bucketOutputs = await bucket({ + accelerated: true + }); + + const uploadHtmlPages = Object.values(buildManifest.pages.html).map(page => + bucket.upload({ + file: `./.next/serverless/${page}`, + key: `static-pages/${page.replace("pages/", "")}` + }) + ); + + await Promise.all([ + bucket.upload({ + dir: "./.next/static", + keyPrefix: "_next/static" + }), + bucket.upload({ + dir: "./static", + keyPrefix: "static" + }), + bucket.upload({ + dir: "./public", + keyPrefix: "public" + }), + ...uploadHtmlPages + ]); + + buildManifest.cloudFrontOrigins = { + staticOrigin: { + domainName: `${bucketOutputs.name}.s3.amazonaws.com` + } + }; + + await this.buildLambdaAtEdge(buildManifest); + + const lambdaAtEdgeOutputs = await lambda({ + description: "Lambda@Edge for Next CloudFront distribution", + handler: "index.handler", + code: `./${LAMBDA_AT_EDGE_BUILD_DIR}`, + role: { + service: ["lambda.amazonaws.com", "edgelambda.amazonaws.com"], + policy: { + arn: "arn:aws:iam::aws:policy/AdministratorAccess" + } + } + }); + + const lambdaPublishOutputs = await lambda.publishVersion(); + + const bucketUrl = `http://${bucketOutputs.name}.s3.amazonaws.com`; + + const { url } = await cloudFront({ + defaults: { + ttl: 5, + allowedHttpMethods: [ + "HEAD", + "DELETE", + "POST", + "GET", + "OPTIONS", + "PUT", + "PATCH" + ], + "lambda@edge": { + "origin-request": `${lambdaAtEdgeOutputs.arn}:${lambdaPublishOutputs.version}` + } + }, + origins: [ + { + url: bucketUrl, + private: true, + pathPatterns: { + "_next/*": { + ttl: 86400 + }, + "static/*": { + ttl: 86400 + } + } + } + ] + }); + + return { + appUrl: url + }; + } + + async remove() { + const bucket = await this.load("@serverless/aws-s3"); + const cloudfront = await this.load("@serverless/aws-cloudfront"); + + await cloudfront.remove(); + await bucket.remove(); + } +} + +module.exports = NextjsComponent; diff --git a/packages/serverless-nextjs-plugin/__mocks__/aws-sdk.js b/packages/serverless-nextjs-plugin/__mocks__/aws-sdk.js new file mode 100644 index 0000000000..36fcafa235 --- /dev/null +++ b/packages/serverless-nextjs-plugin/__mocks__/aws-sdk.js @@ -0,0 +1,126 @@ +const promisify = mockFunction => { + const mockPromise = jest.fn(() => Promise.resolve()); + mockFunction.mockReturnValue({ + promise: mockPromise + }); + + return { + mockFunction, + mockPromise + }; +}; + +const MockCloudWatchLogs = function() {}; +function MockEnvironmentCredentials() {} +function MockCloudFormation() {} + +const { + mockFunction: mockDescribeStacks, + mockPromise: mockDescribeStacksPromise +} = promisify(jest.fn()); + +const { + mockFunction: mockCreateStack, + mockPromise: mockCreateStackPromise +} = promisify(jest.fn()); + +const { + mockFunction: mockDescribeStackEvents, + mockPromise: mockDescribeStackEventsPromise +} = promisify(jest.fn()); + +const { + mockFunction: mockDescribeStackResource, + mockPromise: mockDescribeStackResourcePromise +} = promisify(jest.fn()); + +const { + mockFunction: mockValidateTemplate, + mockPromise: mockValidateTemplatePromise +} = promisify(jest.fn()); + +const { + mockFunction: mockUpdateStack, + mockPromise: mockUpdateStackPromise +} = promisify(jest.fn()); + +const { + mockFunction: mockListStackResources, + mockPromise: mockListStackResourcesPromise +} = promisify(jest.fn()); + +MockCloudFormation.prototype.describeStacks = mockDescribeStacks; +MockCloudFormation.prototype.createStack = mockCreateStack; +MockCloudFormation.prototype.describeStackEvents = mockDescribeStackEvents; +MockCloudFormation.prototype.describeStackResource = mockDescribeStackResource; +MockCloudFormation.prototype.validateTemplate = mockValidateTemplate; +MockCloudFormation.prototype.updateStack = mockUpdateStack; +MockCloudFormation.prototype.listStackResources = mockListStackResources; + +const { + mockFunction: mockListObjectsV2, + mockPromise: mockListObjectsV2Promise +} = promisify(jest.fn()); + +const S3MockUpload = promisify(jest.fn()); + +const MockSTS = function() {}; +const { + mockFunction: mockGetCallerIdentity, + mockPromise: mockGetCallerIdentityPromise +} = promisify(jest.fn()); +MockSTS.prototype.getCallerIdentity = mockGetCallerIdentity; + +const MockAPIGateway = function() {}; +const { + mockFunction: mockGetRestApis, + mockPromise: mockGetRestApisPromise +} = promisify(jest.fn()); +MockAPIGateway.prototype.getRestApis = mockGetRestApis; + +const MockSharedIniFileCredentials = function() {}; + +const MockMetadataService = function() {}; +const mockMetadataRequest = jest + .fn() + .mockImplementation((path, cb) => cb(null, {})); +MockMetadataService.prototype.request = mockMetadataRequest; + +module.exports = { + EnvironmentCredentials: MockEnvironmentCredentials, + S3: jest.fn(() => { + return { + upload: S3MockUpload.mockFunction, + listObjectsV2: mockListObjectsV2 + }; + }), + CloudFormation: MockCloudFormation, + CloudWatchLogs: MockCloudWatchLogs, + STS: MockSTS, + APIGateway: MockAPIGateway, + SharedIniFileCredentials: MockSharedIniFileCredentials, + MetadataService: MockMetadataService, + + mockDescribeStacks, + mockDescribeStacksPromise, + mockCreateStack, + mockCreateStackPromise, + mockDescribeStackEvents, + mockDescribeStackEventsPromise, + mockDescribeStackResource, + mockDescribeStackResourcePromise, + mockListObjectsV2, + mockListObjectsV2Promise, + mockGetCallerIdentity, + mockGetCallerIdentityPromise, + mockUpload: S3MockUpload.mockFunction, + mockUploadPromise: S3MockUpload.mockPromise, + mockUpdateStack, + mockUpdateStackPromise, + mockListStackResources, + mockListStackResourcesPromise, + mockGetRestApis, + mockGetRestApisPromise, + mockValidateTemplate, + mockValidateTemplatePromise +}; diff --git a/packages/serverless-nextjs-plugin/__tests__/fixtures/automatic-static-optimised-app/.next/serverless/pages/about.html b/packages/serverless-nextjs-plugin/__tests__/fixtures/automatic-static-optimised-app/.next/serverless/pages/about.html new file mode 100644 index 0000000000..18ecdcb795 --- /dev/null +++ b/packages/serverless-nextjs-plugin/__tests__/fixtures/automatic-static-optimised-app/.next/serverless/pages/about.html @@ -0,0 +1 @@ + diff --git a/packages/serverless-nextjs-plugin/__tests__/fixtures/automatic-static-optimised-app/serverless.yml b/packages/serverless-nextjs-plugin/__tests__/fixtures/automatic-static-optimised-app/serverless.yml new file mode 100644 index 0000000000..063efc8896 --- /dev/null +++ b/packages/serverless-nextjs-plugin/__tests__/fixtures/automatic-static-optimised-app/serverless.yml @@ -0,0 +1,17 @@ +service: automatic-static-optimised-app-fixture + +provider: + name: aws + runtime: nodejs8.10 + +stage: dev +region: eu-west-1 + +plugins: + - index # this works because jest modulePaths adds plugin path, see package.json + +package: + # exclude everything + # page handlers are automatically included by the plugin + exclude: + - ./** diff --git a/packages/serverless-nextjs-plugin/__tests__/fixtures/nested-next-config/app/.next/static/placeholder.js b/packages/serverless-nextjs-plugin/__tests__/fixtures/nested-next-config/app/.next/static/placeholder.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/serverless-nextjs-plugin/__tests__/fixtures/nested-page-app/.next/static/placeholder.js b/packages/serverless-nextjs-plugin/__tests__/fixtures/nested-page-app/.next/static/placeholder.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/serverless-nextjs-plugin/__tests__/fixtures/one-page-app/.next/static/client.js b/packages/serverless-nextjs-plugin/__tests__/fixtures/one-page-app/.next/static/client.js new file mode 100644 index 0000000000..535868fbb0 --- /dev/null +++ b/packages/serverless-nextjs-plugin/__tests__/fixtures/one-page-app/.next/static/client.js @@ -0,0 +1 @@ +// the contents of this file don't actually matter diff --git a/packages/serverless-nextjs-plugin/__tests__/nested-next-config.test.js b/packages/serverless-nextjs-plugin/__tests__/nested-next-config.test.js index c141786120..96bfcb6f1b 100644 --- a/packages/serverless-nextjs-plugin/__tests__/nested-next-config.test.js +++ b/packages/serverless-nextjs-plugin/__tests__/nested-next-config.test.js @@ -1,7 +1,9 @@ const nextBuild = require("next/dist/build"); const path = require("path"); const AdmZip = require("adm-zip"); -const readCloudFormationUpdateTemplate = require("../utils/test/readCloudFormationUpdateTemplate"); +const { + readUpdateTemplate +} = require("../utils/test/readServerlessCFTemplate"); const testableServerless = require("../utils/test/testableServerless"); jest.mock("next/dist/build"); @@ -16,9 +18,7 @@ describe("nested next config", () => { await testableServerless(fixturePath, "package"); - const cloudFormationUpdateTemplate = await readCloudFormationUpdateTemplate( - fixturePath - ); + const cloudFormationUpdateTemplate = await readUpdateTemplate(fixturePath); cloudFormationUpdateResources = cloudFormationUpdateTemplate.Resources; }); diff --git a/packages/serverless-nextjs-plugin/__tests__/nested-page-app.test.js b/packages/serverless-nextjs-plugin/__tests__/nested-page-app.test.js index b6714000c2..8a98852d57 100644 --- a/packages/serverless-nextjs-plugin/__tests__/nested-page-app.test.js +++ b/packages/serverless-nextjs-plugin/__tests__/nested-page-app.test.js @@ -1,7 +1,9 @@ const nextBuild = require("next/dist/build"); const path = require("path"); const AdmZip = require("adm-zip"); -const readCloudFormationUpdateTemplate = require("../utils/test/readCloudFormationUpdateTemplate"); +const { + readUpdateTemplate +} = require("../utils/test/readServerlessCFTemplate"); const testableServerless = require("../utils/test/testableServerless"); jest.mock("next/dist/build"); @@ -16,9 +18,7 @@ describe("nested page app", () => { await testableServerless(fixturePath, "package"); - const cloudFormationUpdateTemplate = await readCloudFormationUpdateTemplate( - fixturePath - ); + const cloudFormationUpdateTemplate = await readUpdateTemplate(fixturePath); cloudFormationUpdateResources = cloudFormationUpdateTemplate.Resources; }); diff --git a/packages/serverless-nextjs-plugin/__tests__/one-page-app.test.js b/packages/serverless-nextjs-plugin/__tests__/one-page-app.test.js index fadfef2ba6..6aea300648 100644 --- a/packages/serverless-nextjs-plugin/__tests__/one-page-app.test.js +++ b/packages/serverless-nextjs-plugin/__tests__/one-page-app.test.js @@ -1,7 +1,10 @@ +const fs = require("fs"); const nextBuild = require("next/dist/build"); const path = require("path"); const AdmZip = require("adm-zip"); -const readCloudFormationUpdateTemplate = require("../utils/test/readCloudFormationUpdateTemplate"); +const { + readUpdateTemplate +} = require("../utils/test/readServerlessCFTemplate"); const testableServerless = require("../utils/test/testableServerless"); jest.mock("next/dist/build"); @@ -16,26 +19,26 @@ describe("one page app", () => { await testableServerless(fixturePath, "package"); - const cloudFormationUpdateTemplate = await readCloudFormationUpdateTemplate( - fixturePath - ); + const cloudFormationUpdateTemplate = await readUpdateTemplate(fixturePath); cloudFormationUpdateResources = cloudFormationUpdateTemplate.Resources; }); describe("Assets Bucket", () => { - let assetsBucket; + describe("CF Update resources", () => { + let assetsBucket; - beforeAll(() => { - assetsBucket = cloudFormationUpdateResources.NextStaticAssetsS3Bucket; - }); + beforeAll(() => { + assetsBucket = cloudFormationUpdateResources.NextStaticAssetsS3Bucket; + }); - it("is added to the update resources", () => { - expect(assetsBucket).toBeDefined(); - }); + it("is added to the resources", () => { + expect(assetsBucket).toBeDefined(); + }); - it("has correct bucket name", () => { - expect(assetsBucket.Properties.BucketName).toEqual("onepageappbucket"); + it("has correct bucket name", () => { + expect(assetsBucket.Properties.BucketName).toEqual("onepageappbucket"); + }); }); }); diff --git a/packages/serverless-nextjs-plugin/__tests__/single-api.test.js b/packages/serverless-nextjs-plugin/__tests__/single-api.test.js index 9a64eff6e9..7bcda195d5 100644 --- a/packages/serverless-nextjs-plugin/__tests__/single-api.test.js +++ b/packages/serverless-nextjs-plugin/__tests__/single-api.test.js @@ -1,7 +1,9 @@ const nextBuild = require("next/dist/build"); const path = require("path"); const AdmZip = require("adm-zip"); -const readCloudFormationUpdateTemplate = require("../utils/test/readCloudFormationUpdateTemplate"); +const { + readUpdateTemplate +} = require("../utils/test/readServerlessCFTemplate"); const testableServerless = require("../utils/test/testableServerless"); jest.mock("next/dist/build"); @@ -16,9 +18,7 @@ describe("single api", () => { await testableServerless(fixturePath, "package"); - const cloudFormationUpdateTemplate = await readCloudFormationUpdateTemplate( - fixturePath - ); + const cloudFormationUpdateTemplate = await readUpdateTemplate(fixturePath); cloudFormationUpdateResources = cloudFormationUpdateTemplate.Resources; }); diff --git a/packages/serverless-nextjs-plugin/lib/displayServiceInfo.js b/packages/serverless-nextjs-plugin/lib/displayServiceInfo.js index 8eb425efb3..057b568708 100644 --- a/packages/serverless-nextjs-plugin/lib/displayServiceInfo.js +++ b/packages/serverless-nextjs-plugin/lib/displayServiceInfo.js @@ -5,7 +5,10 @@ const outputFinder = outputs => key => { }; const displayStackOutput = awsInfo => { - console.log(awsInfo.gatheredData.outputs); + // remove this check after deploy mocks are correctly setup + if (awsInfo.gatheredData.outputs.length === 0) { + return; + } const findOutput = outputFinder(awsInfo.gatheredData.outputs); const apiGateway = findOutput("ServiceEndpoint"); diff --git a/packages/serverless-nextjs-plugin/utils/test/readCloudFormationUpdateTemplate.js b/packages/serverless-nextjs-plugin/utils/test/readServerlessCFTemplate.js similarity index 50% rename from packages/serverless-nextjs-plugin/utils/test/readCloudFormationUpdateTemplate.js rename to packages/serverless-nextjs-plugin/utils/test/readServerlessCFTemplate.js index 2ebec7ac23..30de512384 100644 --- a/packages/serverless-nextjs-plugin/utils/test/readCloudFormationUpdateTemplate.js +++ b/packages/serverless-nextjs-plugin/utils/test/readServerlessCFTemplate.js @@ -5,10 +5,16 @@ const readJsonFile = async filePath => { return JSON.parse(str); }; -const readCloudFormationUpdateTemplate = fixturePath => { +const readUpdateTemplate = fixturePath => { return readJsonFile( `${fixturePath}/.serverless/cloudformation-template-update-stack.json` ); }; -module.exports = readCloudFormationUpdateTemplate; +const readCreateTemplate = fixturePath => { + return readJsonFile( + `${fixturePath}/.serverless/cloudformation-template-create-stack.json` + ); +}; + +module.exports = { readCreateTemplate, readUpdateTemplate }; diff --git a/packages/serverless-nextjs-plugin/utils/test/testableServerless.js b/packages/serverless-nextjs-plugin/utils/test/testableServerless.js index 603dcbd87d..e8dddfeb74 100644 --- a/packages/serverless-nextjs-plugin/utils/test/testableServerless.js +++ b/packages/serverless-nextjs-plugin/utils/test/testableServerless.js @@ -1,18 +1,96 @@ const Serverless = require("serverless"); +const { + mockDescribeStacksPromise, + mockCreateStackPromise, + mockDescribeStackEventsPromise, + mockDescribeStackResourcePromise, + mockListObjectsV2Promise, + mockGetCallerIdentityPromise, + mockUpdateStackPromise, + mockGetRestApisPromise +} = require("aws-sdk"); + +const setupMocks = () => { + // these mocks are necessary for running "serverless deploy" + + // pretend serverless stack doesn't exist first + mockDescribeStacksPromise.mockRejectedValueOnce( + new Error("Stack does not exist.") + ); + + // create stack result OK + mockCreateStackPromise.mockResolvedValue({ StackId: "MockedStack" }); + + // mock a stack event for monitorStack.js + var aYearFromNow = new Date(); + aYearFromNow.setFullYear(aYearFromNow.getFullYear() + 1); + mockDescribeStackEventsPromise.mockResolvedValue({ + StackEvents: [ + { + StackId: "MockedStack", + ResourceType: "AWS::CloudFormation::Stack", + ResourceStatus: "CREATE_COMPLETE", + Timestamp: aYearFromNow + } + ] + }); + + mockDescribeStackResourcePromise.mockResolvedValue({ + StackResourceDetail: { + StackId: "MockedStack", + PhysicalResourceId: "MockedStackPhysicalResourceId" + } + }); + + mockListObjectsV2Promise.mockResolvedValue({ Contents: [] }); + + mockGetCallerIdentityPromise.mockResolvedValue({ + Arn: "arn:aws:iam:testAcctId:testUser/xyz" + }); + + mockUpdateStackPromise.mockResolvedValueOnce({ + StackId: "MockedStack" + }); + + mockDescribeStacksPromise.mockResolvedValueOnce({ + Stacks: [ + { + StackId: "MockedStack", + Outputs: [] + } + ] + }); + + mockGetRestApisPromise.mockResolvedValueOnce({ + items: [{ id: "mockedApi" }] + }); +}; module.exports = async (servicePath, command) => { + setupMocks(); + const tmpCwd = process.cwd(); process.chdir(servicePath); - const serverless = new Serverless(); + try { + const serverless = new Serverless(); + + serverless.invocationId = "test-run"; + + process.argv[2] = command; - serverless.invocationId = "test-run"; + jest.useFakeTimers(); + setTimeout.mockImplementation(cb => cb()); - process.argv[2] = command; + await serverless.init(); + await serverless.run(); - await serverless.init(); - await serverless.run(); + jest.useRealTimers(); + } catch (err) { + console.error(`Serverless command ${command} crashed.`, err); + throw err; + } process.chdir(tmpCwd); };