├── .gitignore ├── CHANGELOG.md ├── package.json ├── LICENSE ├── resources.yml ├── README.md └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | # package directories 2 | /node_modules/ 3 | jspm_packages 4 | build 5 | coverage 6 | .nyc_output 7 | coverage 8 | .coverage-cache 9 | 10 | *.log 11 | .DS_STORE 12 | .vscode 13 | 14 | # Serverless directories 15 | .serverless 16 | .eslintcache 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.9.5] - 2019-02-21 4 | ### Added 5 | - .gitignore file [#23](https://github.com/Droplr/serverless-api-cloudfront/pull/23) 6 | - MinimumProtocolVersion [#25](https://github.com/Droplr/serverless-api-cloudfront/pull/25) 7 | ### Fixed 8 | - Missing bound in dependencies [#24](https://github.com/Droplr/serverless-api-cloudfront/pull/24) 9 | - PriceClass documentation [#14](https://github.com/Droplr/serverless-api-cloudfront/pull/14) 10 | - Incorrect node version [#26](https://github.com/Droplr/serverless-api-cloudfront/pull/26) 11 | - Headers documentation [#27](https://github.com/Droplr/serverless-api-cloudfront/pull/27) 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serverless-api-cloudfront", 3 | "version": "0.9.5", 4 | "engines": { 5 | "node": ">=6.4" 6 | }, 7 | "description": "CloudFront distribution in front of your API Gateway", 8 | "author": "Droplr, Inc.", 9 | "license": "MIT", 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/Droplr/serverless-api-cloudfront" 13 | }, 14 | "keywords": [ 15 | "serverless", 16 | "serverless plugin", 17 | "api gateway", 18 | "cloudfront", 19 | "api gateway", 20 | "lambda", 21 | "aws", 22 | "aws lambda", 23 | "amazon web services", 24 | "serverless.com" 25 | ], 26 | "main": "index.js", 27 | "dependencies": { 28 | "chalk": "2.4.2", 29 | "js-yaml": "3.13.1", 30 | "lodash": "4.17.21" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Droplr, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /resources.yml: -------------------------------------------------------------------------------- 1 | --- 2 | Resources: 3 | ApiDistribution: 4 | Type: AWS::CloudFront::Distribution 5 | Properties: 6 | DistributionConfig: 7 | Origins: 8 | - Id: ApiGateway 9 | DomainName: 10 | Fn::Join: 11 | - "" 12 | - - Ref: ApiGatewayRestApi 13 | - ".execute-api." 14 | - Ref: AWS::Region 15 | - ".amazonaws.com" 16 | CustomOriginConfig: 17 | HTTPPort: '80' 18 | HTTPSPort: '443' 19 | OriginProtocolPolicy: https-only 20 | OriginSSLProtocols: [ "TLSv1", "TLSv1.1", "TLSv1.2" ] 21 | OriginPath: "/dev" 22 | Enabled: true 23 | HttpVersion: http2 24 | Comment: cdn for api gateway 25 | Aliases: 26 | - domain.tld 27 | PriceClass: PriceClass_All 28 | DefaultCacheBehavior: 29 | AllowedMethods: 30 | - DELETE 31 | - GET 32 | - HEAD 33 | - OPTIONS 34 | - PATCH 35 | - POST 36 | - PUT 37 | CachedMethods: 38 | - HEAD 39 | - GET 40 | ForwardedValues: 41 | QueryString: true 42 | Headers: [] 43 | Cookies: 44 | Forward: all 45 | MinTTL: '0' 46 | DefaultTTL: '0' 47 | TargetOriginId: ApiGateway 48 | ViewerProtocolPolicy: redirect-to-https 49 | CustomErrorResponses: [] 50 | ViewerCertificate: 51 | AcmCertificateArn: arn 52 | SslSupportMethod: sni-only 53 | Logging: 54 | IncludeCookies: 'false' 55 | Bucket: '' 56 | Prefix: '' 57 | WebACLId: waf-id 58 | 59 | Outputs: 60 | ApiDistribution: 61 | Value: 62 | Fn::GetAtt: [ ApiDistribution, DomainName ] 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # serverless-api-cloudfront 2 | 3 | [![serverless](http://public.serverless.com/badges/v3.svg)](http://www.serverless.com) 4 | [![npm version](https://badge.fury.io/js/serverless-api-cloudfront.svg)](https://badge.fury.io/js/serverless-api-cloudfront) 5 | [![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/Droplr/serverless-api-cloudfront/master/LICENSE) 6 | [![npm downloads](https://img.shields.io/npm/dt/serverless-api-cloudfront.svg?style=flat)](https://www.npmjs.com/package/serverless-api-cloudfront) 7 | 8 | Automatically creates properly configured AWS CloudFront distribution that routes traffic 9 | to API Gateway. 10 | 11 | Due to limitations of API Gateway Custom Domains, we realized that setting self-managed CloudFront distribution is much more powerful. 12 | 13 | **:zap: Pros** 14 | 15 | - Allows you to set-up custom domain for your API Gateway 16 | - Enables CDN caching of resources - so you don't waste Lambda invocations or API Gateway traffic 17 | for serving static files (just set proper Cache-Control in API responses) 18 | - Much more CloudWatch statistics of API usage (like bandwidth metrics) 19 | - Real world [access log](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/AccessLogs.html) - out of the box, API Gateway currently does not provide any kind of real "apache-like" access logs for your invocations 20 | - [Web Application Firewall](https://aws.amazon.com/waf/) support - enable AWS WAF to protect your API from security threats 21 | 22 | ## Installation 23 | 24 | ``` 25 | $ npm install --save-dev serverless-api-cloudfront 26 | ``` 27 | 28 | ## Configuration 29 | 30 | * All apiCloudFront configuration parameters are optional - e.g. don't provide ACM Certificate ARN 31 | to use default CloudFront certificate (which works only for default cloudfront.net domain). 32 | * This plugin **does not** set-up automatically Route53 for newly created CloudFront distribution. 33 | After creating CloudFront distribution, manually add Route53 ALIAS record pointing to your 34 | CloudFront domain name. 35 | * First deployment may be quite long (e.g. 10 min) as Serverless is waiting for 36 | CloudFormation to deploy CloudFront distribution. 37 | 38 | ``` 39 | # add in your serverless.yml 40 | 41 | plugins: 42 | - serverless-api-cloudfront 43 | 44 | custom: 45 | apiCloudFront: 46 | domain: my-custom-domain.com 47 | certificate: arn:aws:acm:us-east-1:000000000000:certificate/00000000-1111-2222-3333-444444444444 48 | waf: 00000000-0000-0000-0000-000000000000 49 | compress: true 50 | logging: 51 | bucket: my-bucket.s3.amazonaws.com 52 | prefix: my-prefix 53 | cookies: none 54 | headers: 55 | - x-api-key 56 | querystring: 57 | - page 58 | - per_page 59 | priceClass: PriceClass_100 60 | minimumProtocolVersion: TLSv1 61 | ``` 62 | 63 | ### Notes 64 | 65 | * `domain` can be list, so if you want to add more domains, instead string you list multiple ones: 66 | 67 | ``` 68 | domain: 69 | - my-custom-domain.com 70 | - secondary-custom-domain.com 71 | ``` 72 | 73 | * `cookies` can be *all* (default), *none* or a list that lists the cookies to whitelist 74 | ``` 75 | cookies: 76 | - FirstCookieName 77 | - SecondCookieName 78 | ``` 79 | 80 | * [`headers`][headers-default-cache] can be *all*, *none* (default) or a list of headers ([see CloudFront custom behaviour][headers-list]): 81 | 82 | ``` 83 | headers: all 84 | ``` 85 | 86 | [headers-default-cache]: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-cloudfront-distribution-defaultcachebehavior.html#cfn-cloudfront-distribution-defaultcachebehavior-forwardedvalues 87 | [headers-list]: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/RequestAndResponseBehaviorCustomOrigin.html#request-custom-headers-behavior 88 | 89 | * `querystring` can be *all* (default), *none* or a list, in which case all querystring parameters are forwarded, but cache is based on the list: 90 | 91 | ``` 92 | querystring: all 93 | ``` 94 | 95 | * [`priceClass`][price-class] can be `PriceClass_All` (default), `PriceClass_100` or `PriceClass_200`: 96 | 97 | 98 | ``` 99 | priceClass: PriceClass_All 100 | ``` 101 | 102 | [price-class]: https://docs.aws.amazon.com/cloudfront/latest/APIReference/API_GetDistributionConfig.html#cloudfront-GetDistributionConfig-response-PriceClass 103 | 104 | * [`minimumProtocolVersion`][minimum-protocol-version] can be `TLSv1` (default), `TLSv1_2016`, `TLSv1.1_2016`, `TLSv1.2_2018` or `SSLv3`: 105 | 106 | ``` 107 | minimumProtocolVersion: TLSv1 108 | ``` 109 | 110 | [minimum-protocol-version]: https://docs.aws.amazon.com/cloudfront/latest/APIReference/API_ViewerCertificate.html#cloudfront-Type-ViewerCertificate-MinimumProtocolVersion 111 | 112 | 113 | ### IAM Policy 114 | 115 | In order to make this plugin work as expected a few additional IAM Policies might be needed on your AWS profile. 116 | 117 | More specifically this plugin needs the following policies attached: 118 | 119 | * `cloudfront:CreateDistribution` 120 | * `cloudfront:GetDistribution` 121 | * `cloudfront:UpdateDistribution` 122 | * `cloudfront:DeleteDistribution` 123 | * `cloudfront:TagResource` 124 | 125 | You can read more about IAM profiles and policies in the [Serverless documentation](https://serverless.com/framework/docs/providers/aws/guide/credentials#creating-aws-access-keys). 126 | 127 | ## Error troubleshooting 128 | 129 | * Make sure you have at least one http event otherwise you'll get ```The CloudFormation template is invalid: Template format error: Unresolved resource dependencies [ApiGatewayRestApi] in the Resources block of the template``` 130 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const _ = require('lodash'); 3 | const chalk = require('chalk'); 4 | const yaml = require('js-yaml'); 5 | const fs = require('fs'); 6 | 7 | class ServerlessApiCloudFrontPlugin { 8 | constructor(serverless, options) { 9 | this.serverless = serverless; 10 | this.options = options; 11 | 12 | this.hooks = { 13 | 'before:deploy:createDeploymentArtifacts': this.createDeploymentArtifacts.bind(this), 14 | 'aws:info:displayStackOutputs': this.printSummary.bind(this), 15 | }; 16 | } 17 | 18 | createDeploymentArtifacts() { 19 | const baseResources = this.serverless.service.provider.compiledCloudFormationTemplate; 20 | 21 | const filename = path.resolve(__dirname, 'resources.yml'); 22 | const content = fs.readFileSync(filename, 'utf-8'); 23 | const resources = yaml.safeLoad(content, { 24 | filename: filename 25 | }); 26 | 27 | this.prepareResources(resources); 28 | return _.merge(baseResources, resources); 29 | } 30 | 31 | printSummary() { 32 | const cloudTemplate = this.serverless; 33 | 34 | const awsInfo = _.find(this.serverless.pluginManager.getPlugins(), (plugin) => { 35 | return plugin.constructor.name === 'AwsInfo'; 36 | }); 37 | 38 | if (!awsInfo || !awsInfo.gatheredData) { 39 | return; 40 | } 41 | 42 | const outputs = awsInfo.gatheredData.outputs; 43 | const apiDistributionDomain = _.find(outputs, (output) => { 44 | return output.OutputKey === 'ApiDistribution'; 45 | }); 46 | 47 | if (!apiDistributionDomain || !apiDistributionDomain.OutputValue) { 48 | return ; 49 | } 50 | 51 | const cnameDomain = this.getConfig('domain', '-'); 52 | 53 | this.serverless.cli.consoleLog(chalk.yellow('CloudFront domain name')); 54 | this.serverless.cli.consoleLog(` ${apiDistributionDomain.OutputValue} (CNAME: ${cnameDomain})`); 55 | } 56 | 57 | prepareResources(resources) { 58 | const distributionConfig = resources.Resources.ApiDistribution.Properties.DistributionConfig; 59 | 60 | this.prepareLogging(distributionConfig); 61 | this.prepareDomain(distributionConfig); 62 | this.preparePriceClass(distributionConfig); 63 | this.prepareOrigins(distributionConfig); 64 | this.prepareCookies(distributionConfig); 65 | this.prepareHeaders(distributionConfig); 66 | this.prepareQueryString(distributionConfig); 67 | this.prepareComment(distributionConfig); 68 | this.prepareCertificate(distributionConfig); 69 | this.prepareWaf(distributionConfig); 70 | this.prepareCompress(distributionConfig); 71 | this.prepareMinimumProtocolVersion(distributionConfig); 72 | } 73 | 74 | prepareLogging(distributionConfig) { 75 | const loggingBucket = this.getConfig('logging.bucket', null); 76 | 77 | if (loggingBucket !== null) { 78 | distributionConfig.Logging.Bucket = loggingBucket; 79 | distributionConfig.Logging.Prefix = this.getConfig('logging.prefix', ''); 80 | 81 | } else { 82 | delete distributionConfig.Logging; 83 | } 84 | } 85 | 86 | prepareDomain(distributionConfig) { 87 | const domain = this.getConfig('domain', null); 88 | 89 | if (domain !== null) { 90 | distributionConfig.Aliases = Array.isArray(domain) ? domain : [ domain ]; 91 | } else { 92 | delete distributionConfig.Aliases; 93 | } 94 | } 95 | 96 | preparePriceClass(distributionConfig) { 97 | const priceClass = this.getConfig('priceClass', 'PriceClass_All'); 98 | distributionConfig.PriceClass = priceClass; 99 | } 100 | 101 | prepareOrigins(distributionConfig) { 102 | distributionConfig.Origins[0].OriginPath = `/${this.options.stage}`; 103 | } 104 | 105 | prepareCookies(distributionConfig) { 106 | const forwardCookies = this.getConfig('cookies', 'all'); 107 | distributionConfig.DefaultCacheBehavior.ForwardedValues.Cookies.Forward = Array.isArray(forwardCookies) ? 'whitelist' : forwardCookies; 108 | if (Array.isArray(forwardCookies)) { 109 | distributionConfig.DefaultCacheBehavior.ForwardedValues.Cookies.WhitelistedNames = forwardCookies; 110 | } 111 | } 112 | 113 | prepareHeaders(distributionConfig) { 114 | const forwardHeaders = this.getConfig('headers', 'none'); 115 | 116 | if (Array.isArray(forwardHeaders)) { 117 | distributionConfig.DefaultCacheBehavior.ForwardedValues.Headers = forwardHeaders; 118 | } else { 119 | distributionConfig.DefaultCacheBehavior.ForwardedValues.Headers = forwardHeaders === 'none' ? [] : ['*']; 120 | } 121 | } 122 | 123 | prepareQueryString(distributionConfig) { 124 | const forwardQueryString = this.getConfig('querystring', 'all'); 125 | 126 | if (Array.isArray(forwardQueryString)) { 127 | distributionConfig.DefaultCacheBehavior.ForwardedValues.QueryString = true; 128 | distributionConfig.DefaultCacheBehavior.ForwardedValues.QueryStringCacheKeys = forwardQueryString; 129 | } else { 130 | distributionConfig.DefaultCacheBehavior.ForwardedValues.QueryString = forwardQueryString === 'all' ? true : false; 131 | } 132 | } 133 | 134 | prepareComment(distributionConfig) { 135 | const name = this.serverless.getProvider('aws').naming.getApiGatewayName(); 136 | distributionConfig.Comment = `Serverless Managed ${name}`; 137 | } 138 | 139 | prepareCertificate(distributionConfig) { 140 | const certificate = this.getConfig('certificate', null); 141 | 142 | if (certificate !== null) { 143 | distributionConfig.ViewerCertificate.AcmCertificateArn = certificate; 144 | } else { 145 | delete distributionConfig.ViewerCertificate; 146 | } 147 | } 148 | 149 | prepareWaf(distributionConfig) { 150 | const waf = this.getConfig('waf', null); 151 | 152 | if (waf !== null) { 153 | distributionConfig.WebACLId = waf; 154 | } else { 155 | delete distributionConfig.WebACLId; 156 | } 157 | } 158 | 159 | prepareCompress(distributionConfig) { 160 | distributionConfig.DefaultCacheBehavior.Compress = (this.getConfig('compress', false) === true) ? true : false; 161 | } 162 | 163 | prepareMinimumProtocolVersion(distributionConfig) { 164 | const minimumProtocolVersion = this.getConfig('minimumProtocolVersion', undefined); 165 | 166 | if (minimumProtocolVersion) { 167 | distributionConfig.ViewerCertificate.MinimumProtocolVersion = minimumProtocolVersion; 168 | } 169 | } 170 | 171 | getConfig(field, defaultValue) { 172 | return _.get(this.serverless, `service.custom.apiCloudFront.${field}`, defaultValue) 173 | } 174 | } 175 | 176 | module.exports = ServerlessApiCloudFrontPlugin; 177 | --------------------------------------------------------------------------------