├── .gitignore ├── .node-version ├── .prettierignore ├── .travis.yml ├── LICENSE.md ├── README.md ├── assets ├── logo.svg └── screenshot.gif ├── ava.config.js ├── cli.js ├── lib ├── aws.js ├── cache-control.js ├── cli │ ├── deploy-command.js │ ├── distribute-command.js │ └── init-command.js ├── configuration.js ├── configure.js ├── deploy.js ├── deploy │ ├── build-website.js │ ├── configure-bucket-as-website.js │ ├── create-bucket.js │ ├── expire-cache.js │ ├── load-configuration.js │ └── synchronize-website.js ├── diff.js ├── distribute.js ├── distribute │ ├── create-certificate.js │ ├── create-distribution.js │ ├── deploy-distribution.js │ ├── load-configuration.js │ └── verify-certificate.js ├── error.js ├── routing-rules.js └── utilities.js ├── package-lock.json ├── package.json └── test └── diff.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 12.2.0 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | README.md 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | before_install: 2 | - npm install -g yarn 3 | language: node_js 4 | node_js: 5 | - 8 6 | - 10 7 | - 12 8 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Brandon Weiss 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |
3 | Discharge 4 |
5 |
6 |

7 | 8 | > A simple, easy way to deploy static websites to Amazon S3 9 | 10 | [![](https://badgen.net/travis/brandonweiss/discharge?icon=travis)](https://www.travis-ci.com/brandonweiss/discharge) 11 | [![](https://badgen.net/npm/v/@static/discharge?icon=npm)](https://www.npmjs.com/package/@static/discharge) 12 | ![](https://badgen.net/npm/node/@static/discharge) 13 | [![](https://badgen.net/david/dep/brandonweiss/discharge)](https://david-dm.org/brandonweiss/discharge) 14 | ![](https://badgen.net/badge/documentation/lit/purple) 15 | [![](https://badgen.net/badge//AWESOME?icon=awesome&color=494368)](https://github.com/sindresorhus/awesome-nodejs#command-line-apps) 16 | 17 | ![screenshot](assets/screenshot.gif) 18 | 19 | ## Features 20 | 21 | * Very little understanding of AWS required 22 | * Interactive UI for configuring deployment 23 | * Step-by-step list of what’s happening 24 | * Support for clean URLs (no `.html` extensions) 25 | * Support for subdomains 26 | * Use an AWS Profile (named credentials) to authenticate with AWS 27 | * CDN (CloudFront) and HTTPS/TLS support 28 | 29 | ## Installation 30 | 31 | Install it globally: 32 | 33 | ``` 34 | $ npm install --global @static/discharge 35 | ``` 36 | 37 | Or add it to your application’s `package.json`: 38 | 39 | ``` 40 | $ npm install --save-dev @static/discharge 41 | ``` 42 | 43 | ## Usage 44 | 45 | ### Authentication 46 | 47 | #### Credentials in file 48 | 49 | [Configuring AWS credentials][aws-credentials-file] can be a bit confusing. After getting your Access Key ID and Secret Access Key from AWS, you should store them in a file at `~/.aws/credentials`. It should look something like this: 50 | 51 | 52 | ``` 53 | [default] 54 | aws_access_key_id=AKIAIOSFODNN7EXAMPLE 55 | aws_secret_access_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY 56 | ``` 57 | 58 | Replace the example keys with your own. 59 | 60 | #### Credentials in environment 61 | 62 | Alternatively, if you prefer environment variables or you are running Discharge in an automated environment like a continuous integration/deployment server you can omit the `aws_profile` configuration option explained later and set environment variables instead. 63 | 64 | ``` 65 | export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE 66 | export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY 67 | ``` 68 | 69 | Replace the example keys with your own. 70 | 71 | ### Configure 72 | 73 | Configuration is done via a `.discharge.json` file located at the root of your application. You can run `discharge init` to get an interactive UI that will help you generate the configuration file, or you can write it yourself from scratch. It will look something like this: 74 | 75 | ```json 76 | { 77 | "domain": "anti-pattern.com", 78 | "build_command": "bundle exec middleman build", 79 | "upload_directory": "build", 80 | "index_key": "index.html", 81 | "error_key": "404.html", 82 | "cache": 3600, 83 | "aws_profile": "website-deployment", 84 | "aws_region": "us-west-1", 85 | "cdn": true, 86 | "dns_configured": false 87 | } 88 | ``` 89 | 90 | Those are most of the configuration options but a complete list is next. 91 | 92 | #### Configuration options 93 | 94 | There are no defaults—all configuration options are explicit and must be provided unless marked as optional. 95 | 96 | **domain** `String` 97 | 98 | The domain name of your website. This will be used as the name of the S3 bucket your website will be uploaded to. 99 | 100 | **build_command** `String` 101 | 102 | The command that will be executed in the shell to build your static website. 103 | 104 | **upload_directory** `String` 105 | 106 | The name of the directory that the `build_command` generated with the static files in it. This is the directory that will be uploaded to S3. 107 | 108 | **index_key** `String` 109 | 110 | The key of the document to respond with at the root of the website. `index.html` is almost certainly what you want to use. For example, if `https://example.com` is requested, `https://example.com/index.html` will be returned. 111 | 112 | **error_key** `String` 113 | 114 | The key of the document to respond with if the website endpoint responds with a 404 Not Found. For example, `404.html` is pretty common. 115 | 116 | **cache** `Number` (optional when `cache_control` is set) 117 | 118 | The number of seconds a browser should cache the files of your website for. This is a simplified version of the HTTP `Cache-Control` header. If you set it to `0` the `Cache-Control` will be set to `"no-cache, no-store, must-revalidate"`. If you set it to a positive number, say, `3600`, the `Cache-Control` will be set to `"public, max-age=3600"`. 119 | 120 | Be careful about setting too high a cache length. If you do, when a browser caches it, if you then update the content, that browser will not get the updated content unless the user specifically hard-refreshes the page. 121 | 122 | When `cdn` is enabled, the `s-maxage` directive is included and set to a very high number (one month). It is recommended you set `cache` to a very low number (e.g five minutes). The CDN will use the `s-maxage` directive and the browser will use the `max-age` directive. This works because when you deploy the CDN’s cache will be automatically expired. For more information see the `distribute` command. 123 | 124 | If you need finer-grained control over the `Cache-Control` header, use the `cache_control` configuration option. 125 | 126 | **cache_control** `String` (optional) 127 | 128 | A `Cache-Control` directive as described in the [HTTP documentation](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control). This is for more advanced, finer-grained control of caching. If you don’t need that, use the `cache` configuration option. 129 | 130 | The `s-maxage` directive added to `cache` when `cdn` is enabled is not added here—you have to do it yourself. Caveat emptor. 131 | 132 | **redirects** `Array` (optional) 133 | 134 | * **prefix_match** `String` 135 | 136 | The URL path prefix to match on. The redirects are matched in order, so if you have two paths with similar parts, like `some/page` and `some`, make sure you put the more specific path first. 137 | 138 | * **destination** `String` 139 | 140 | The path to redirect to if the `prefix_match` matches. 141 | 142 | AWS does not allow the `prefix_match` and `destination` to start with a forward slash (`/some/page`). You can include them in the configuration for your convenience, but the forward slashes will be invisibly removed when configuring the bucket. 143 | 144 | If you need finer-grained control over the routing rules, use the `routing_rules` configuration option. 145 | 146 | **routing_rules** `Array` (optional) 147 | 148 | If the `redirects` configuration is not enough, you can declare more complex routing rules. There are some [horrible AWS docs][routing-rules-docs] that explain the available options and here’s an example of the syntax from the [AWS JavaScript docs][JavaScript-docs]. 149 | 150 | ```javascript 151 | [ 152 | { 153 | Redirect: { /* required */ 154 | HostName: "STRING", 155 | HttpRedirectCode: "STRING", 156 | Protocol: "http" || "https", 157 | ReplaceKeyPrefixWith: "STRING", 158 | ReplaceKeyWith: "STRING" 159 | }, 160 | Condition: { 161 | HttpErrorCodeReturnedEquals: "STRING", 162 | KeyPrefixEquals: "STRING" 163 | } 164 | }, 165 | /* more items */ 166 | ] 167 | ``` 168 | 169 | The unusual property casing is intentional—the entire configuration will be passed directly through in the HTTP request. 170 | 171 | **cdn**: `Boolean` 172 | 173 | Set this to `true` if you want to use a CDN and HTTPS/TLS. Setting up the CDN does not happen automatically when deploying. After deploying, run `discharge distribute` to set up the CDN. Once the CDN is set up, future deploys will expire the CDN’s cache. 174 | 175 | For more information see the `cache` configuration or the `distribute` command. 176 | 177 | **aws_profile** `String` (optional) 178 | 179 | The AWS profile you’ve specified in a credentials file at `~/.aws/credentials`. 180 | 181 | If you only have one set of credentials then specify “default”. 182 | 183 | If you want to create a new AWS user with specific permissions/policies for deployment, you can add another profile in the credentials file and specify the custom profile you’ve added. 184 | 185 | If you prefer environment variables or you are running Discharge in an automated environment like a continuous integration/deployment server you can omit this configuration option. 186 | 187 | **aws_region** `String` 188 | 189 | The [Amazon S3 region][s3-region] you want to create your website (bucket) in. 190 | 191 | **dns_configured** `Boolean` 192 | 193 | If you run `discharge init` this will be set to `false` automatically. Then when you run `discharge deploy` it will show the record you need to add to your DNS configuration. The deploy command will then automatically set this value to `true`, assuming you have properly created the DNS record. 194 | 195 | ### Deploy 196 | 197 | After you’ve finished configuring you can run `discharge deploy` to deploy. Deploying is a series of steps that are idempotent—that is, they are safe to run over and over again, and if you haven’t changed anything, then the outcome should always be the same. 198 | 199 | If you change your website configuration (`cache`, `redirects`, etc.) it will be updated. If you change your website content, a diff will be done to figure out what needs to change. New files will be added, changed files will be updated, and deleted files will be removed. The synchronization is one way—that is, if you remove a file from S3 it will just be re-uploaded the next time you deploy. 200 | 201 | #### Clean URLs 202 | 203 | Clean URLs are when the `.html` extensions are dropped from URLs for aesthetic or functional reasons. The `.html` extensions are now commonly considered superfluous. If you have a file named `/projects.html` it’s now understood and generally preferred that the URL `domain.com/projects` would serve that file. 204 | 205 | When you deploy, two copies of each HTML file will be uploaded: one with the `.html` extension and one without. So a file `some-page.html` will be uploaded as `some-page.html` and as `some-page`, which will allow it to be served from `https://example.com/some-page.html`, with the extension, or from `https://example.com/some-page`, without the extension. You are free to use whichever URL style you prefer! 206 | 207 | ### Distribute 208 | 209 | After you’ve finished deploying you can run `discharge distribute` to distribute your website via a CDN (content delivery network). The command will create a TLS certificate, ensure it’s verified, create a distribution, and ensure it’s deployed. Almost no configuration necessary[1]. This step is completely optional, but if you have a high-traffic website it’s highly recommended, and if you want to secure your website with HTTPS/TLS then you have to do it[2]. 210 | 211 | A CDN is a caching layer. It can significantly speed up requests for users located geographically farther from where your website is deployed, and sometimes even for users nearby it. In brief, the way a CDN works is you point your DNS to the CDN. When a request comes in, the CDN relays the request to your origin (in this case S3) then takes the response and caches it according to the `Cache-Control` header in the response. Future requests will only hit the CDN and not your origin, until either the CDN’s cache expires or it’s expired early. 212 | 213 | The `Cache-Control` header can specify two different cache lengths, one for the CDN and one for the browser. Because static sites are… static, the only times they change are when deployed, so it’s safe to set a very high cache length for the CDN, a low cache length for the browser, and then expire the CDN’s cache early when deploying. 214 | 215 | [1]: CDNs can be configured in _a lot_ of different, complex ways. The goal was to abstract away all of that—choose sane defaults and require no configuration. I think this will work for the vast majority of people, but if there’s a specific reason you need more flexibility let me know, and if it’s widely-needed we can add it. 216 | 217 | [2]: While CDNs can be configured without TLS, given that TLS certificates are free and we want the entire web to be encrypted, I can’t see any reason to support not using TLS. 218 | 219 | #### .io domains 220 | 221 | Verifying the TLS certificate is done via email. AWS will look up the contact information in the WHOIS database for your domain and then send a verification email to the following email addresses: 222 | 223 | * Domain registrant 224 | * Technical contact 225 | * Administrative contact 226 | * administrator@domain.tld 227 | * hostmaster@domain.tld 228 | * postmaster@domain.tld 229 | * webmaster@domain.tld 230 | * admin@domain.tld 231 | 232 | Inexplicably, the .io domain registrar is the only registrar that does not return contact information from the WHOIS database. That means you _have_ to have one of the five common system email addresses set up on a .io domain or you will not receive the TLS certificate verification email. 233 | 234 | ### Subdomains 235 | 236 | You can use any domain, subdomain, or combination you like. You just need to configure your DNS appropriately. 237 | 238 | If you want to use a naked domain (`domain.com`), because S3 and CloudFront expose a special URL rather than an IP address, your DNS provider will need to support ALIAS records; not all do. 239 | 240 | If you want to use a subdomain like `www.domain.com` or `blog.domain.com`, create a CNAME record for it. The TLS/HTTPS certificate is created for the root domain and all subdomains via a wildcard. 241 | 242 | If you want to use both a naked domain and a subdomain, create an ALIAS and a CNAME record. 243 | 244 | If you want to use only a naked domain or a subdomain, but redirect one to the other (like redirect `www.domain.com` to `domain.com`), then the easiest way to do that is to add a redirect at the DNS-level. It’s not technically a part of the DNS specification so not all DNS providers have it, but the vast majority do. If yours does not, you can either switch to a DNS provider that does or [manually create an S3 bucket that does the redirect][s3-redirect] and create an ALIAS or CNAME record pointing to it. 245 | 246 | ## Contributing 247 | 248 | Bug reports and pull requests are welcome on GitHub at [https://github.com/brandonweiss/discharge][github-discharge]. 249 | 250 | ## License 251 | 252 | The package is available as open source under the terms of the [MIT License][MIT-license]. 253 | 254 | [aws-credentials-file]: http://docs.aws.amazon.com/cli/latest/userguide/cli-config-files.html 255 | 256 | [routing-rules-docs]: http://docs.aws.amazon.com/AmazonS3/latest/dev/how-to-page-redirect.html#advanced-conditional-redirects 257 | [JavaScript-docs]: http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#putBucketWebsite-property 258 | [s3-region]: http://docs.aws.amazon.com/general/latest/gr/rande.html#s3_region 259 | [s3-redirect]: https://aws.amazon.com/premiumsupport/knowledge-center/redirect-domain-route-53/ 260 | [github-discharge]: https://github.com/brandonweiss/discharge 261 | [MIT-license]: http://opensource.org/licenses/MIT 262 | -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /assets/screenshot.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brandonweiss/discharge/cea552621c3561252394cdb5b37352022cea8f4d/assets/screenshot.gif -------------------------------------------------------------------------------- /ava.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | babel: true, 3 | } 4 | -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const meow = require("meow") 4 | const updateNotifier = require("update-notifier") 5 | const KnownError = require("./lib/error") 6 | const initCommand = require("./lib/cli/init-command") 7 | const deployCommand = require("./lib/cli/deploy-command") 8 | const distributeCommand = require("./lib/cli/distribute-command") 9 | const ConfigurationPath = ".discharge.json" 10 | 11 | const cli = meow(` 12 | Usage 13 | $ discharge init 14 | $ discharge deploy 15 | $ discharge distribute 16 | `) 17 | 18 | updateNotifier({ pkg: cli.pkg }).notify() 19 | 20 | let command = cli.input[0] 21 | 22 | switch (command) { 23 | case undefined: return cli.showHelp() 24 | case "init": return initCommand(ConfigurationPath).catch(KnownError.catch) 25 | case "deploy": return deployCommand(ConfigurationPath).catch(KnownError.catch) 26 | case "distribute": return distributeCommand(ConfigurationPath).catch(KnownError.catch) 27 | } 28 | -------------------------------------------------------------------------------- /lib/aws.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs") 2 | const AWS = require("aws-sdk") 3 | const KnownError = require("./error").error 4 | const untildify = require("./utilities").untildify 5 | 6 | // prettier-ignore 7 | const regions = [ 8 | { name: "US East (N. Virginia)", key: "us-east-1", endpoint: "s3-website-us-east-1.amazonaws.com" }, 9 | { name: "US East (Ohio)", key: "us-east-2", endpoint: "s3-website.us-east-2.amazonaws.com" }, 10 | { name: "US West (N. California)", key: "us-west-1", endpoint: "s3-website-us-west-1.amazonaws.com" }, 11 | { name: "US West (Oregon)", key: "us-west-2", endpoint: "s3-website-us-west-2.amazonaws.com" }, 12 | { name: "Canada (Central)", key: "ca-central-1", endpoint: "s3-website.ca-central-1.amazonaws.com" }, 13 | { name: "Asia Pacific (Mumbai)", key: "ap-south-1", endpoint: "s3-website.ap-south-1.amazonaws.com" }, 14 | { name: "Asia Pacific (Tokyo)", key: "ap-northeast-1", endpoint: "s3-website-ap-northeast-1.amazonaws.com" }, 15 | { name: "Asia Pacific (Seoul)", key: "ap-northeast-2", endpoint: "s3-website.ap-northeast-2.amazonaws.com" }, 16 | { name: "Asia Pacific (Singapore)", key: "ap-southeast-1", endpoint: "s3-website-ap-southeast-1.amazonaws.com" }, 17 | { name: "Asia Pacific (Sydney)", key: "ap-southeast-2", endpoint: "s3-website-ap-southeast-2.amazonaws.com" }, 18 | { name: "EU (Frankfurt)", key: "eu-central-1", endpoint: "s3-website.eu-central-1.amazonaws.com" }, 19 | { name: "EU (Ireland)", key: "eu-west-1", endpoint: "s3-website-eu-west-1.amazonaws.com" }, 20 | { name: "EU (London)", key: "eu-west-2", endpoint: "s3-website.eu-west-2.amazonaws.com" }, 21 | { name: "EU (Stockholm)", key: "eu-north-1", endpoint: "s3-website.eu-north-1.amazonaws.com" }, 22 | { name: "South America (São Paulo)", key: "sa-east-1", endpoint: "s3-website-sa-east-1.amazonaws.com" }, 23 | ] 24 | 25 | module.exports.findEndpointByRegionKey = (regionKey) => { 26 | return regions.find((region) => { 27 | return region.key === regionKey 28 | }).endpoint 29 | } 30 | 31 | module.exports.regionsGroupedByPrefix = () => { 32 | return regions.reduce((object, region) => { 33 | let regionKeyPrefix = region.key.split("-")[0] 34 | object[regionKeyPrefix] = object[regionKeyPrefix] || [] 35 | 36 | object[regionKeyPrefix].push({ name: region.name, key: region.key }) 37 | 38 | return object 39 | }, []) 40 | } 41 | 42 | module.exports.credentials = (profileKey = null) => { 43 | if (profileKey) { 44 | return credentialsFromProfile(profileKey) 45 | } else { 46 | return credentialsFromEnvironment() 47 | } 48 | } 49 | 50 | const credentialsFromProfile = (profileKey) => { 51 | let credentialsFilePath = untildify("~/.aws/credentials") 52 | 53 | if (!fs.existsSync(credentialsFilePath)) { 54 | throw new KnownError(`An AWS credentials file does not exist at ${credentialsFilePath}`) 55 | } 56 | 57 | let credentials = new AWS.SharedIniFileCredentials({ 58 | filename: credentialsFilePath, 59 | profile: profileKey, 60 | }) 61 | 62 | if (!credentials.accessKeyId) { 63 | throw new KnownError( 64 | `An AWS profile named ${profileKey} does not exist in ${credentialsFilePath}`, 65 | ) 66 | } 67 | 68 | return credentials 69 | } 70 | 71 | const credentialsFromEnvironment = () => { 72 | let credentials = new AWS.EnvironmentCredentials("AWS") 73 | 74 | if (!credentials.accessKeyId) { 75 | throw new KnownError( 76 | "Environment variables AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are not set", 77 | ) 78 | } 79 | 80 | return credentials 81 | } 82 | 83 | // Wraps an AWS endpoint instance so that you don’t always have to chain `.promise()` onto every function 84 | const iPromiseYou = (object) => { 85 | return new Proxy(object, { 86 | get(target, propertyKey, _receiver) { 87 | let property = target[propertyKey] 88 | 89 | if (typeof property === "function") { 90 | return function(...args) { 91 | let result = property.apply(this, args) 92 | 93 | if (result instanceof AWS.Request) { 94 | return result.promise() 95 | } else { 96 | return result 97 | } 98 | } 99 | } else { 100 | return property 101 | } 102 | }, 103 | }) 104 | } 105 | 106 | module.exports.s3 = (options) => { 107 | let s3 = new AWS.S3(options) 108 | return iPromiseYou(s3) 109 | } 110 | 111 | module.exports.acm = (options) => { 112 | let acm = new AWS.ACM(options) 113 | return iPromiseYou(acm) 114 | } 115 | 116 | module.exports.cloudFront = (options) => { 117 | let cloudFront = new AWS.CloudFront(options) 118 | return iPromiseYou(cloudFront) 119 | } 120 | -------------------------------------------------------------------------------- /lib/cache-control.js: -------------------------------------------------------------------------------- 1 | const oneMonthInSeconds = 2592000 2 | 3 | module.exports.build = (cacheInSeconds, cacheControl, cdn) => { 4 | if (cacheControl) { 5 | return cacheControl 6 | } 7 | 8 | if (!Number.isInteger(cacheInSeconds)) { 9 | return null 10 | } 11 | 12 | if (cacheInSeconds === 0) { 13 | return "no-cache, no-store, must-revalidate" 14 | } 15 | 16 | let parts = ["public", `max-age=${cacheInSeconds}`] 17 | 18 | if (cdn) { 19 | parts.push(`s-maxage=${oneMonthInSeconds}`) 20 | } 21 | 22 | return parts.join(", ") 23 | } 24 | -------------------------------------------------------------------------------- /lib/cli/deploy-command.js: -------------------------------------------------------------------------------- 1 | const deploy = require("../deploy") 2 | const AWS = require("../aws") 3 | const logSymbols = require("log-symbols") 4 | const configuration = require("../configuration") 5 | 6 | module.exports = async (configurationPath) => { 7 | let context = await deploy.run({ 8 | configurationPath: configurationPath, 9 | }) 10 | 11 | let dnsNotConfigured = !context.config.dns_configured 12 | let domain = context.config.domain 13 | let url = `http://${domain}` 14 | let endpoint = AWS.findEndpointByRegionKey(context.config.aws_region) 15 | 16 | if (dnsNotConfigured) { 17 | url = `${url}.${endpoint}` 18 | } 19 | 20 | console.log(`\n${logSymbols.success}`, `Website deployed! You can see it at ${url}`) 21 | 22 | if (dnsNotConfigured) { 23 | if (!context.config.cdn) { 24 | console.log(`\n${logSymbols.info}`, `Make sure you configure your DNS—add an ALIAS or CNAME from your domain to \`${domain}.${endpoint}\``) 25 | console.log(`${logSymbols.info}`, "This reminder won’t show again.") 26 | 27 | configuration.update(configurationPath, { dns_configured: true }) 28 | } else { 29 | console.log(`\n${logSymbols.info}`, "Run `discharge distribute` to configure the CDN") 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /lib/cli/distribute-command.js: -------------------------------------------------------------------------------- 1 | const distribute = require("../distribute") 2 | const logSymbols = require("log-symbols") 3 | const configuration = require("../configuration") 4 | 5 | module.exports = async (configurationPath) => { 6 | let context = await distribute.run({ 7 | configurationPath: configurationPath, 8 | }) 9 | 10 | let dnsNotConfigured = !context.config.dns_configured 11 | let cdnNotEnabled = !context.config.cdn 12 | let cdnDomain = context.cdnDomain 13 | let domain = context.config.domain 14 | let url = `https://${dnsNotConfigured ? cdnDomain : domain}` 15 | 16 | console.log(`\n${logSymbols.success}`, `Distribution deployed! You can see it at ${url}`) 17 | 18 | if (dnsNotConfigured || cdnNotEnabled) { 19 | console.log(`\n${logSymbols.info}`, `Make sure you configure your DNS—add an ALIAS or CNAME from your domain to \`${cdnDomain}\``) 20 | console.log(`${logSymbols.info}`, "This reminder won’t show again.") 21 | 22 | configuration.update(configurationPath, { 23 | cdn: true, 24 | dns_configured: true, 25 | }) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /lib/cli/init-command.js: -------------------------------------------------------------------------------- 1 | const logSymbols = require("log-symbols") 2 | const configure = require("../configure") 3 | const configuration = require("../configuration") 4 | 5 | module.exports = async (configurationPath) => { 6 | let data = await configure() 7 | 8 | configuration.write(configurationPath, data) 9 | 10 | console.log(`\n${logSymbols.success}`, `Configuration written to \`${configurationPath}\`!`) 11 | console.log(`${logSymbols.info}`, "Run `discharge deploy` to deploy your website.") 12 | } 13 | -------------------------------------------------------------------------------- /lib/configuration.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs") 2 | const KnownError = require("./error").error 3 | const logSymbols = require("log-symbols") 4 | 5 | const read = (configurationPath) => { 6 | if (!fs.existsSync(configurationPath)) { 7 | throw new KnownError("No configuration file, run `discharge init` first") 8 | } 9 | 10 | let file = fs.readFileSync(configurationPath) 11 | 12 | try { 13 | let configuration = JSON.parse(file) 14 | 15 | if (configuration.hasOwnProperty("trailing_slashes")) { 16 | delete configuration["trailing_slashes"] 17 | write(configurationPath, configuration) 18 | 19 | console.log( 20 | `\n${logSymbols.warning}`, 21 | "Removing unused `trailing_slashes` configuration option", 22 | "\n", 23 | ) 24 | } 25 | 26 | return configuration 27 | } catch (error) { 28 | throw new KnownError("Configuration file cannot be parsed—ensure the JSON is valid") 29 | } 30 | } 31 | 32 | module.exports.read = read 33 | 34 | const write = (configurationPath, configuration) => { 35 | let json = JSON.stringify(configuration, null, 2) 36 | return fs.writeFileSync(configurationPath, `${json}\n`) 37 | } 38 | 39 | module.exports.write = write 40 | 41 | module.exports.update = (configurationPath, updatedConfiguration) => { 42 | let configuration = read(configurationPath) 43 | configuration = Object.assign(configuration, updatedConfiguration) 44 | 45 | return write(configurationPath, configuration) 46 | } 47 | -------------------------------------------------------------------------------- /lib/configure.js: -------------------------------------------------------------------------------- 1 | const AWS = require("./aws") 2 | const inquirer = require("inquirer") 3 | const intersperse = require("./utilities").intersperse 4 | const flatten = require("./utilities").flatten 5 | 6 | let choices = Object.values(AWS.regionsGroupedByPrefix()).map((regionGroup) => { 7 | return regionGroup.map((region) => { 8 | return { name: region.name, value: region.key } 9 | }) 10 | }) 11 | 12 | let choicesWithSeparators = flatten(intersperse(choices, new inquirer.Separator())) 13 | 14 | let redirectsPrompt = async (array) => { 15 | let answers = await inquirer.prompt([ 16 | { 17 | name: "prefix_match", 18 | message: "Prefix to match on?", 19 | type: "input", 20 | }, 21 | { 22 | name: "destination", 23 | message: "Destination to redirect to?", 24 | type: "input", 25 | }, 26 | { 27 | name: "again", 28 | message: "Do you want to setup another redirect?", 29 | default: true, 30 | type: "confirm", 31 | }, 32 | ]) 33 | 34 | array.push({ prefix_match: answers.prefix_match, destination: answers.destination }) 35 | 36 | if (answers.again) { 37 | return redirectsPrompt(array) 38 | } else { 39 | return array 40 | } 41 | } 42 | 43 | module.exports = async () => { 44 | let configuration = {} 45 | let answers 46 | 47 | answers = await inquirer.prompt([ 48 | { 49 | name: "domain", 50 | message: "Domain name:", 51 | default: "example.com", 52 | type: "input", 53 | }, 54 | { 55 | name: "build_command", 56 | message: "Build command", 57 | default: "npm run build", 58 | type: "input", 59 | }, 60 | { 61 | name: "upload_directory", 62 | message: "Directory to upload", 63 | default: "build", 64 | type: "input", 65 | }, 66 | { 67 | name: "index_key", 68 | message: "Index key", 69 | default: "index.html", 70 | type: "input", 71 | }, 72 | { 73 | name: "error_key", 74 | message: "Error key", 75 | default: "404.html", 76 | type: "input", 77 | }, 78 | { 79 | name: "cache", 80 | message: "Number of seconds to cache pages for?", 81 | default: 3600, 82 | type: "number", 83 | }, 84 | { 85 | name: "redirects", 86 | message: "Do you want to setup redirects?", 87 | default: false, 88 | type: "confirm", 89 | }, 90 | ]) 91 | 92 | configuration = Object.assign(configuration, answers) 93 | 94 | if (configuration.redirects) { 95 | configuration.redirects = await redirectsPrompt([]) 96 | } else { 97 | delete configuration.redirects 98 | } 99 | 100 | answers = await inquirer.prompt([ 101 | { 102 | name: "aws_profile", 103 | message: "AWS credentials profile", 104 | default: "default", 105 | type: "input", 106 | }, 107 | { 108 | name: "aws_region", 109 | message: "AWS region", 110 | type: "list", 111 | choices: choicesWithSeparators, 112 | pageSize: choicesWithSeparators.length, 113 | }, 114 | { 115 | name: "cdn", 116 | message: "Use a CDN for performance and HTTPS/TLS for security?", 117 | default: true, 118 | type: "confirm", 119 | }, 120 | { 121 | name: "dns_configured", 122 | message: "Is your DNS configured?", 123 | default: false, 124 | type: "confirm", 125 | }, 126 | ]) 127 | 128 | configuration = Object.assign(configuration, answers) 129 | 130 | return configuration 131 | } 132 | -------------------------------------------------------------------------------- /lib/deploy.js: -------------------------------------------------------------------------------- 1 | const Listr = require("listr") 2 | const loadConfigurationTask = require("./deploy/load-configuration") 3 | const buildWebsiteTask = require("./deploy/build-website") 4 | const createBucketTask = require("./deploy/create-bucket") 5 | const configureBucketAsWebsiteTask = require("./deploy/configure-bucket-as-website") 6 | const synchronizeWebsiteTask = require("./deploy/synchronize-website") 7 | const expireCacheTask = require("./deploy/expire-cache") 8 | 9 | module.exports = new Listr([ 10 | loadConfigurationTask, 11 | buildWebsiteTask, 12 | createBucketTask, 13 | configureBucketAsWebsiteTask, 14 | synchronizeWebsiteTask, 15 | expireCacheTask, 16 | ]) 17 | -------------------------------------------------------------------------------- /lib/deploy/build-website.js: -------------------------------------------------------------------------------- 1 | const childProcess = require("child_process") 2 | 3 | module.exports = { 4 | title: "Build website", 5 | task: (context) => childProcess.execSync(context.config.build_command), 6 | } 7 | -------------------------------------------------------------------------------- /lib/deploy/configure-bucket-as-website.js: -------------------------------------------------------------------------------- 1 | const RoutingRules = require("../routing-rules") 2 | 3 | module.exports = { 4 | title: "Configure bucket as website", 5 | task: (context) => { 6 | let indexKey = context.config.index_key 7 | let errorKey = context.config.error_key 8 | 9 | let params = { 10 | Bucket: context.config.domain, 11 | WebsiteConfiguration: { 12 | ErrorDocument: { 13 | Key: errorKey, 14 | }, 15 | IndexDocument: { 16 | Suffix: indexKey, 17 | }, 18 | }, 19 | } 20 | 21 | if (context.config.redirects || context.config.routing_rules) { 22 | let routingRules = RoutingRules(context.config.redirects, context.config.routing_rules) 23 | params.WebsiteConfiguration.RoutingRules = routingRules 24 | } 25 | 26 | return context.s3.putBucketWebsite(params) 27 | }, 28 | } 29 | -------------------------------------------------------------------------------- /lib/deploy/create-bucket.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | title: "Create bucket", 3 | skip: async (context) => { 4 | let bucketExists = false 5 | 6 | try { 7 | bucketExists = await context.s3.headBucket({ Bucket: context.config.domain }) 8 | } catch(error) {} 9 | 10 | if (bucketExists) { 11 | return "Bucket already exists" 12 | } else { 13 | return false 14 | } 15 | }, 16 | task: (context) => { 17 | return context.s3.createBucket({ 18 | ACL: "public-read", 19 | Bucket: context.config.domain, 20 | }) 21 | }, 22 | } 23 | -------------------------------------------------------------------------------- /lib/deploy/expire-cache.js: -------------------------------------------------------------------------------- 1 | const delay = require("../utilities").delay 2 | const suppressConnectionErrors = require("../utilities").suppressConnectionErrors 3 | 4 | const isInvalidationCompleted = async (context, distributionId, invalidationId) => { 5 | let data = await context.cloudFront.getInvalidation({ 6 | DistributionId: distributionId, 7 | Id: invalidationId, 8 | }) 9 | 10 | let status = data.Invalidation.Status 11 | 12 | return status === "Completed" 13 | } 14 | 15 | module.exports = { 16 | title: "Expire cache", 17 | enabled: (context) => context.config && context.config.cdn, 18 | skip: async (context) => { 19 | let domain = context.config.domain 20 | 21 | let data = await context.cloudFront.listDistributions({ MaxItems: "100" }) 22 | let distributions = data.DistributionList.Items 23 | let distribution = distributions.find((distribution) => distribution.Aliases.Items.includes(domain)) 24 | 25 | if (distribution) { 26 | context.distributionId = distribution.Id 27 | return false 28 | } else { 29 | return "Distribution not set up yet" 30 | } 31 | }, 32 | task: async (context, task) => { 33 | let distributionId = context.distributionId 34 | let domain = context.config.domain 35 | let currentTime = new Date().toISOString() 36 | 37 | let data = await context.cloudFront.createInvalidation({ 38 | DistributionId: distributionId, 39 | InvalidationBatch: { 40 | CallerReference: `${domain.replace(/\W/g, "_")}_${currentTime}`, 41 | Paths: { 42 | Items: ["/*"], 43 | Quantity: 1, 44 | }, 45 | }, 46 | }) 47 | 48 | let invalidationId = data.Invalidation.Id 49 | 50 | let invalidationIsCompleted = false 51 | 52 | while (!invalidationIsCompleted) { 53 | await delay(5000) 54 | task.output = "This can take a few minutes" 55 | 56 | await suppressConnectionErrors(async () => { 57 | invalidationIsCompleted = await isInvalidationCompleted(context, distributionId, invalidationId) 58 | }) 59 | } 60 | }, 61 | } 62 | -------------------------------------------------------------------------------- /lib/deploy/load-configuration.js: -------------------------------------------------------------------------------- 1 | const configuration = require("../configuration") 2 | const AWS = require("../aws") 3 | 4 | module.exports = { 5 | title: "Load configuration", 6 | task: (context) => { 7 | let configurationPath = context.configurationPath 8 | let config = configuration.read(configurationPath) 9 | let credentials = AWS.credentials(config.aws_profile) 10 | let region = config.aws_region 11 | 12 | context.s3 = AWS.s3({ 13 | credentials: credentials, 14 | region: region, 15 | }) 16 | 17 | context.cloudFront = AWS.cloudFront({ 18 | credentials: credentials, 19 | }) 20 | 21 | context.config = config 22 | }, 23 | } 24 | -------------------------------------------------------------------------------- /lib/deploy/synchronize-website.js: -------------------------------------------------------------------------------- 1 | const glob = require("glob") 2 | const fs = require("fs") 3 | const CacheControl = require("../cache-control") 4 | const mime = require("mime") 5 | const crypto = require("crypto") 6 | const diff = require("../diff") 7 | const flatMap = require("lodash.flatmap") 8 | 9 | module.exports = { 10 | title: "Synchronize website", 11 | task: async (context, task) => { 12 | let domain = context.config.domain 13 | let uploadDirectory = context.config.upload_directory 14 | 15 | let paths = glob.sync("**/*", { 16 | cwd: uploadDirectory, 17 | nodir: true, 18 | }) 19 | 20 | let cacheControl = CacheControl.build( 21 | context.config.cache, 22 | context.config.cache_control, 23 | context.config.cdn, 24 | ) 25 | 26 | let source = flatMap(paths, (path) => { 27 | let fullPath = `${uploadDirectory}/${path}` 28 | let content = fs.readFileSync(fullPath) 29 | let md5Hash = `"${crypto 30 | .createHash("md5") 31 | .update(content) 32 | .digest("hex")}"` 33 | 34 | let files = [ 35 | { 36 | path: path, 37 | key: path, 38 | md5Hash: md5Hash, 39 | }, 40 | ] 41 | 42 | if (path.endsWith(".html")) { 43 | files.push({ 44 | path: path, 45 | key: path.replace(/\.html$/, ""), 46 | md5Hash: md5Hash, 47 | }) 48 | } 49 | 50 | return files 51 | }) 52 | 53 | let response = await context.s3.listObjectsV2({ Bucket: domain }) 54 | let target = response.Contents.map((object) => { 55 | return { 56 | key: object.Key, 57 | md5Hash: object.ETag, 58 | } 59 | }) 60 | 61 | let changes = diff({ 62 | source: source, 63 | target: target, 64 | locationProperty: "key", 65 | contentsHashProperty: "md5Hash", 66 | }) 67 | 68 | for (let change of changes.add) { 69 | task.output = `Adding ${change.path} as ${change.key}` 70 | let fullPath = `${uploadDirectory}/${change.path}` 71 | 72 | await context.s3.putObject({ 73 | Bucket: domain, 74 | Body: fs.readFileSync(fullPath), 75 | Key: change.key, 76 | ACL: "public-read", 77 | CacheControl: cacheControl, 78 | ContentType: mime.getType(fullPath), 79 | }) 80 | } 81 | 82 | for (let change of changes.update) { 83 | task.output = `Updating ${change.path} as ${change.key}` 84 | let fullPath = `${uploadDirectory}/${change.path}` 85 | 86 | await context.s3.putObject({ 87 | Bucket: domain, 88 | Body: fs.readFileSync(fullPath), 89 | Key: change.key, 90 | ACL: "public-read", 91 | CacheControl: cacheControl, 92 | ContentType: mime.getType(fullPath), 93 | }) 94 | } 95 | 96 | for (let change of changes.remove) { 97 | task.output = `Removing ${change.key}` 98 | 99 | await context.s3.deleteObject({ 100 | Bucket: domain, 101 | Key: change.key, 102 | }) 103 | } 104 | }, 105 | } 106 | -------------------------------------------------------------------------------- /lib/diff.js: -------------------------------------------------------------------------------- 1 | const intersectionBy = require("lodash.intersectionby") 2 | const differenceBy = require("lodash.differenceby") 3 | const intersectionWith = require("lodash.intersectionwith") 4 | 5 | module.exports = ({ source = [], target = [], locationProperty, contentsHashProperty }) => { 6 | let filesInBothSourceAndTarget = intersectionBy(source, target, locationProperty) 7 | let filesInSourceButNotTarget = differenceBy(source, target, locationProperty) 8 | let filesInTargetButNotSource = differenceBy(target, source, locationProperty) 9 | 10 | let filesInBothSourceAndTargetWithDifferentHashes = intersectionWith(source, target, (a, b) => { 11 | let locationsMatch = a[locationProperty] === b[locationProperty] 12 | let hashesDoNotMatch = a[contentsHashProperty] !== b[contentsHashProperty] 13 | 14 | return locationsMatch && hashesDoNotMatch 15 | }) 16 | 17 | let filesInBothSourceAndTargetWithIdenticalHashes = differenceBy( 18 | filesInBothSourceAndTarget, 19 | filesInBothSourceAndTargetWithDifferentHashes, 20 | locationProperty, 21 | ) 22 | 23 | return { 24 | add: filesInSourceButNotTarget, 25 | remove: filesInTargetButNotSource, 26 | update: filesInBothSourceAndTargetWithDifferentHashes, 27 | ignore: filesInBothSourceAndTargetWithIdenticalHashes, 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /lib/distribute.js: -------------------------------------------------------------------------------- 1 | const Listr = require("listr") 2 | const loadConfigurationTask = require("./distribute/load-configuration") 3 | const createCertificateTask = require("./distribute/create-certificate") 4 | const verifyCertificateTask = require("./distribute/verify-certificate") 5 | const createDistributionTask = require("./distribute/create-distribution") 6 | const deployDistributionTask = require("./distribute/deploy-distribution") 7 | 8 | module.exports = new Listr([ 9 | loadConfigurationTask, 10 | createCertificateTask, 11 | verifyCertificateTask, 12 | createDistributionTask, 13 | deployDistributionTask, 14 | ]) 15 | -------------------------------------------------------------------------------- /lib/distribute/create-certificate.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | title: "Create certificate", 3 | skip: async (context) => { 4 | let domain = context.config.domain 5 | 6 | let data = await context.acm.listCertificates({ MaxItems: 100 }) 7 | let certificates = data.CertificateSummaryList 8 | let certificate = certificates.find((certificate) => certificate.DomainName === domain) 9 | 10 | if (certificate) { 11 | context.certificateARN = certificate.CertificateArn 12 | return "Certificate already exists" 13 | } else { 14 | return false 15 | } 16 | }, 17 | task: async (context) => { 18 | let domain = context.config.domain 19 | 20 | let certificate = await context.acm.requestCertificate({ 21 | DomainName: domain, 22 | IdempotencyToken: domain.replace(/\W/g, "_"), 23 | SubjectAlternativeNames: [`*.${domain}`], 24 | }) 25 | 26 | context.certificateARN = certificate.CertificateArn 27 | }, 28 | } 29 | -------------------------------------------------------------------------------- /lib/distribute/create-distribution.js: -------------------------------------------------------------------------------- 1 | const AWS = require("../aws") 2 | 3 | module.exports = { 4 | title: "Create distribution", 5 | skip: async (context) => { 6 | let domain = context.config.domain 7 | 8 | let data = await context.cloudFront.listDistributions({ MaxItems: "100" }) 9 | let distributions = data.DistributionList.Items 10 | let distribution = distributions.find((distribution) => distribution.Aliases.Items.includes(domain)) 11 | 12 | if (distribution) { 13 | return "Distribution already exists" 14 | } else { 15 | return false 16 | } 17 | }, 18 | task: async (context) => { 19 | let domain = context.config.domain 20 | let endpoint = AWS.findEndpointByRegionKey(context.config.aws_region) 21 | let origin = `${domain}.${endpoint}` 22 | let certificateARN = context.certificateARN 23 | 24 | await context.cloudFront.createDistribution({ 25 | DistributionConfig: { 26 | Aliases: { 27 | Items: [ 28 | domain, 29 | `www.${domain}`, 30 | ], 31 | Quantity: 2, 32 | }, 33 | CallerReference: new Date().toISOString(), 34 | Comment: context.config.domain, 35 | DefaultCacheBehavior: { 36 | AllowedMethods: { 37 | Items: [ 38 | "GET", 39 | "HEAD", 40 | ], 41 | Quantity: 2, 42 | }, 43 | Compress: true, 44 | ForwardedValues: { 45 | Cookies: { 46 | Forward: "none", 47 | }, 48 | QueryString: false, 49 | }, 50 | MinTTL: 0, 51 | TargetOriginId: domain, 52 | TrustedSigners: { 53 | Enabled: false, 54 | Quantity: 0, 55 | }, 56 | ViewerProtocolPolicy: "redirect-to-https", 57 | }, 58 | Enabled: true, 59 | Origins: { 60 | Items: [{ 61 | CustomOriginConfig: { 62 | HTTPPort: 80, 63 | HTTPSPort: 443, 64 | OriginProtocolPolicy: "http-only", 65 | }, 66 | DomainName: origin, 67 | Id: domain, 68 | }], 69 | Quantity: 1, 70 | }, 71 | PriceClass: "PriceClass_100", 72 | ViewerCertificate: { 73 | ACMCertificateArn: certificateARN, 74 | CloudFrontDefaultCertificate: false, 75 | MinimumProtocolVersion: "TLSv1", 76 | SSLSupportMethod: "sni-only", 77 | }, 78 | }, 79 | }) 80 | }, 81 | } 82 | -------------------------------------------------------------------------------- /lib/distribute/deploy-distribution.js: -------------------------------------------------------------------------------- 1 | const delay = require("../utilities").delay 2 | const suppressConnectionErrors = require("../utilities").suppressConnectionErrors 3 | 4 | const findDistributionByDomain = async (context, domain) => { 5 | let data = await context.cloudFront.listDistributions({ MaxItems: "100" }) 6 | let distributions = data.DistributionList.Items 7 | return distributions.find((distribution) => distribution.Aliases.Items.includes(domain)) 8 | } 9 | 10 | const findDistributionById = async (context, id) => { 11 | let data = await context.cloudFront.getDistribution({ Id: id }) 12 | return data.Distribution 13 | } 14 | 15 | const isDistributionDeployed = (distribution) => distribution.Status === "Deployed" 16 | 17 | module.exports = { 18 | title: "Deploy distribution", 19 | task: async (context, task) => { 20 | let domain = context.config.domain 21 | 22 | let distribution = await findDistributionByDomain(context, domain) 23 | context.cdnDomain = distribution.DomainName 24 | if (isDistributionDeployed(distribution)) { return } 25 | 26 | let distributionIsDeployed = false 27 | 28 | while (!distributionIsDeployed) { 29 | await delay(5000) 30 | task.output = "This can take a while (~5–15 minutes)" 31 | 32 | await suppressConnectionErrors(async () => { 33 | distribution = await findDistributionById(context, distribution.Id) 34 | distributionIsDeployed = isDistributionDeployed(distribution) 35 | }) 36 | } 37 | }, 38 | } 39 | -------------------------------------------------------------------------------- /lib/distribute/load-configuration.js: -------------------------------------------------------------------------------- 1 | const configuration = require("../configuration") 2 | const AWS = require("../aws") 3 | 4 | module.exports = { 5 | title: "Load configuration", 6 | task: (context) => { 7 | let configurationPath = context.configurationPath 8 | let config = configuration.read(configurationPath) 9 | let credentials = AWS.credentials(config.aws_profile) 10 | 11 | context.acm = AWS.acm({ 12 | credentials: credentials, 13 | region: "us-east-1", 14 | }) 15 | 16 | context.cloudFront = AWS.cloudFront({ 17 | credentials: credentials, 18 | }) 19 | 20 | context.config = config 21 | }, 22 | } 23 | -------------------------------------------------------------------------------- /lib/distribute/verify-certificate.js: -------------------------------------------------------------------------------- 1 | const delay = require("../utilities").delay 2 | const suppressConnectionErrors = require("../utilities").suppressConnectionErrors 3 | 4 | const isCertificateVerified = async (context, certificateARN) => { 5 | let data = await context.acm.describeCertificate({ CertificateArn: certificateARN }) 6 | let status = data.Certificate.Status 7 | 8 | return status === "ISSUED" 9 | } 10 | 11 | module.exports = { 12 | title: "Verify certificate", 13 | task: async (context, task) => { 14 | let certificateARN = context.certificateARN 15 | 16 | if (await isCertificateVerified(context, certificateARN)) { return } 17 | 18 | let certificateIsVerified = false 19 | 20 | while (!certificateIsVerified) { 21 | task.output = "A verification email has been sent to an email address associated with your domain" 22 | await delay(5000) 23 | 24 | await suppressConnectionErrors(async () => { 25 | certificateIsVerified = await isCertificateVerified(context, certificateARN) 26 | }) 27 | } 28 | }, 29 | } 30 | -------------------------------------------------------------------------------- /lib/error.js: -------------------------------------------------------------------------------- 1 | class KnownError extends Error { 2 | constructor(...args) { 3 | super(...args) 4 | Error.captureStackTrace(this, KnownError) 5 | } 6 | } 7 | 8 | module.exports.error = KnownError 9 | 10 | module.exports.catch = (error) => { 11 | let errorIsKnown = error instanceof KnownError 12 | 13 | if (!errorIsKnown) { 14 | throw error 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /lib/routing-rules.js: -------------------------------------------------------------------------------- 1 | const URL = require("url") 2 | 3 | const removeRootForwardSlash = (path) => { 4 | return path.replace(/^\//, "") 5 | } 6 | 7 | module.exports = (redirects, routingRules) => { 8 | if (routingRules) { 9 | return routingRules 10 | } 11 | 12 | return redirects.map((redirect) => { 13 | let redirectOptions = { 14 | HttpRedirectCode: "301", 15 | } 16 | 17 | let url = URL.parse(redirect.destination) 18 | 19 | if (url.protocol && url.hostname) { 20 | redirectOptions.Protocol = url.protocol.replace(":", "") 21 | redirectOptions.HostName = url.hostname 22 | } 23 | 24 | redirectOptions.ReplaceKeyWith = removeRootForwardSlash(url.path) 25 | 26 | return { 27 | Condition: { 28 | KeyPrefixEquals: removeRootForwardSlash(redirect.prefix_match), 29 | }, 30 | Redirect: redirectOptions, 31 | } 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /lib/utilities.js: -------------------------------------------------------------------------------- 1 | const path = require("path") 2 | 3 | module.exports.intersperse = (array, separator) => { 4 | return array.reduce((newArray, item, index) => { 5 | newArray.push(item) 6 | 7 | if (index !== array.length - 1) { 8 | newArray.push(separator) 9 | } 10 | 11 | return newArray 12 | }, []) 13 | } 14 | 15 | module.exports.flatten = (arrayOfArrays) => { 16 | return [].concat(...arrayOfArrays) 17 | } 18 | 19 | module.exports.delay = (duration) => { 20 | return new Promise((resolve) => setTimeout(resolve, duration)) 21 | } 22 | 23 | module.exports.suppressConnectionErrors = (callback) => { 24 | try { 25 | return callback() 26 | } catch (error) { 27 | if (error.message.startsWith("connect")) { 28 | throw error 29 | } 30 | } 31 | } 32 | 33 | module.exports.untildify = (string) => { 34 | if (string.substr(0, 1) === "~") { 35 | string = process.env.HOME + string.substr(1) 36 | } 37 | 38 | return path.resolve(string) 39 | } 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@static/discharge", 3 | "version": "2.0.1", 4 | "description": "A simple, easy way to deploy static websites to Amazon S3", 5 | "author": { 6 | "name": "Brandon Weiss", 7 | "email": "brandon@anti-pattern.com" 8 | }, 9 | "bin": "cli.js", 10 | "engines": { 11 | "node": ">= 8.0.0" 12 | }, 13 | "keywords": [ 14 | "aws", 15 | "aws-s3", 16 | "deploy", 17 | "deployment", 18 | "static", 19 | "static-site", 20 | "s3" 21 | ], 22 | "license": "MIT", 23 | "repository": "https://github.com/brandonweiss/discharge", 24 | "scripts": { 25 | "test": "ava" 26 | }, 27 | "devDependencies": { 28 | "@ava/babel": "^1.0.1", 29 | "ava": "^3.12.0", 30 | "husky": "^4.2.5", 31 | "np": "^6.4.0", 32 | "prettier": "^2.0.5", 33 | "pretty-quick": "^3.0.0" 34 | }, 35 | "dependencies": { 36 | "aws-sdk": "^2.474.0", 37 | "glob": "^7.1.6", 38 | "inquirer": "^7.3.3", 39 | "listr": "^0.14.3", 40 | "lodash.differenceby": "^4.8.0", 41 | "lodash.flatmap": "^4.5.0", 42 | "lodash.intersectionby": "^4.7.0", 43 | "lodash.intersectionwith": "^4.4.0", 44 | "log-symbols": "^4.0.0", 45 | "meow": "^7.1.0", 46 | "mime": "^2.4.6", 47 | "update-notifier": "^4.1.1" 48 | }, 49 | "husky": { 50 | "hooks": { 51 | "pre-commit": "pretty-quick --staged" 52 | } 53 | }, 54 | "prettier": { 55 | "arrowParens": "always", 56 | "printWidth": 100, 57 | "semi": false, 58 | "trailingComma": "all" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /test/diff.js: -------------------------------------------------------------------------------- 1 | import test from "ava" 2 | import diff from "../lib/diff" 3 | 4 | test("a file is in the source but not the target", (t) => { 5 | let a = { path: "foo/bar", md5: "abc123" } 6 | 7 | let source = [a] 8 | let target = [] 9 | 10 | let changes = diff({ source, target, locationProperty: "path", contentsHashProperty: "md5" }) 11 | 12 | t.deepEqual(changes, { 13 | add: [a], 14 | remove: [], 15 | update: [], 16 | ignore: [], 17 | }) 18 | }) 19 | 20 | test("a file is in the target but not the source", (t) => { 21 | let a = { path: "foo/bar", md5: "abc123" } 22 | 23 | let source = [] 24 | let target = [a] 25 | 26 | let changes = diff({ source, target, locationProperty: "path", contentsHashProperty: "md5" }) 27 | 28 | t.deepEqual(changes, { 29 | add: [], 30 | remove: [a], 31 | update: [], 32 | ignore: [], 33 | }) 34 | }) 35 | 36 | test("a file is in both the target and the source but the hashes are different", (t) => { 37 | let aSource = { path: "foo/bar", md5: "abc123" } 38 | let aTarget = { path: "foo/bar", md5: "def456" } 39 | 40 | let source = [aSource] 41 | let target = [aTarget] 42 | 43 | let changes = diff({ source, target, locationProperty: "path", contentsHashProperty: "md5" }) 44 | 45 | t.deepEqual(changes, { 46 | add: [], 47 | remove: [], 48 | update: [aSource], 49 | ignore: [], 50 | }) 51 | }) 52 | 53 | test("a file is in both the target and the source and the hashes are the same", (t) => { 54 | let aSource = { path: "foo/bar", md5: "abc123" } 55 | let aTarget = { path: "foo/bar", md5: "abc123" } 56 | 57 | let source = [aSource] 58 | let target = [aTarget] 59 | 60 | let changes = diff({ source, target, locationProperty: "path", contentsHashProperty: "md5" }) 61 | 62 | t.deepEqual(changes, { 63 | add: [], 64 | remove: [], 65 | update: [], 66 | ignore: [aSource], 67 | }) 68 | }) 69 | 70 | test("a combination of files in different states", (t) => { 71 | let a = { path: "only/in/source" } 72 | let b = { path: "only/in/target" } 73 | let cSource = { path: "in/both/but/changed", md5: "abc123" } 74 | let cTarget = { path: "in/both/but/changed", md5: "def456" } 75 | let dSource = { path: "in/both/and/same", md5: "abc123" } 76 | let dTarget = { path: "in/both/and/same", md5: "abc123" } 77 | 78 | let source = [a, cSource, dSource] 79 | let target = [b, cTarget, dTarget] 80 | 81 | let changes = diff({ source, target, locationProperty: "path", contentsHashProperty: "md5" }) 82 | 83 | t.deepEqual(changes, { 84 | add: [a], 85 | remove: [b], 86 | update: [cSource], 87 | ignore: [dSource], 88 | }) 89 | }) 90 | --------------------------------------------------------------------------------