├── .babelrc ├── .d.ts ├── .eslintrc ├── .github └── stale.yml ├── .gitignore ├── .prettierrc ├── .yarnignore ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── deploy-term.gif ├── examples ├── aws │ └── README.md └── github │ └── README.md ├── lerna.json ├── package.json ├── packages ├── aws-cloudfront │ ├── package.json │ ├── src │ │ ├── component.ts │ │ └── lib │ │ │ ├── addLambdaAtEdgeToCacheBehavior.ts │ │ │ ├── createInvalidation.ts │ │ │ ├── createOriginAccessIdentity.ts │ │ │ ├── getCacheBehavior.ts │ │ │ ├── getDefaultCacheBehavior.ts │ │ │ ├── getForwardedValues.ts │ │ │ ├── getOriginConfig.ts │ │ │ ├── grantCloudFrontBucketAccess.ts │ │ │ ├── index.ts │ │ │ └── parseInputOrigins.ts │ ├── tsconfig.build.json │ ├── types.d.ts │ └── yarn.lock ├── aws-component │ ├── package.json │ ├── src │ │ ├── component.ts │ │ └── utils.ts │ ├── tsconfig.build.json │ ├── types.d.ts │ └── yarn.lock ├── aws-domain │ ├── package.json │ ├── src │ │ ├── component.ts │ │ └── utils.ts │ ├── tsconfig.build.json │ ├── types.d.ts │ └── yarn.lock ├── aws-iam-role │ ├── package.json │ ├── src │ │ ├── component.ts │ │ └── utils.ts │ ├── tsconfig.build.json │ └── types.d.ts ├── aws-lambda-builder │ ├── package.json │ ├── src │ │ ├── compat.ts │ │ ├── index.ts │ │ ├── lib │ │ │ ├── createServerlessConfig.ts │ │ │ ├── getAllFilesInDirectory.ts │ │ │ └── sortedRoutes.ts │ │ ├── request-handler.ts │ │ └── utils.ts │ ├── tsconfig.build.json │ ├── types.d.ts │ └── yarn.lock ├── aws-lambda │ ├── package.json │ ├── src │ │ ├── component.ts │ │ └── utils.ts │ ├── tsconfig.build.json │ ├── types.d.ts │ └── yarn.lock ├── aws-s3 │ ├── package.json │ ├── src │ │ ├── component.ts │ │ └── lib │ │ │ ├── getPublicAssetCacheControl.ts │ │ │ ├── s3.ts │ │ │ ├── syncStageStateDirectory.ts │ │ │ ├── uploadStaticAssets.ts │ │ │ └── utils.ts │ ├── tsconfig.build.json │ ├── types.d.ts │ └── yarn.lock ├── cli │ ├── bin │ │ └── next-deploy │ ├── package.json │ ├── src │ │ ├── config.ts │ │ ├── context.ts │ │ ├── deploy.ts │ │ ├── index.ts │ │ └── utils.ts │ ├── tsconfig.build.json │ ├── types.d.ts │ └── yarn.lock └── github │ ├── package.json │ ├── src │ ├── builder.ts │ └── component.ts │ ├── tsconfig.build.json │ ├── types.d.ts │ └── yarn.lock ├── tsconfig.json └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", { "targets": { "node": "current" } }], 4 | "@babel/preset-typescript" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@serverless/core' { 2 | import { Credentials } from 'aws-sdk'; 3 | export class Component { 4 | save(): void; 5 | state: any; 6 | context: { 7 | resourceId(): string; 8 | status(message: string): void; 9 | debug(message: string): void; 10 | log(message: string): void; 11 | credentials: { 12 | aws: Credentials; 13 | }; 14 | instance: { 15 | debugMode: boolean; 16 | metrics: any; 17 | }; 18 | }; 19 | } 20 | 21 | export const utils: Utils; 22 | 23 | type Utils = { 24 | dirExists(path: string): boolean; 25 | fileExists(path: string): boolean; 26 | hashFile(path: string): string; 27 | isArchivePath(path: string): boolean; 28 | sleep(time: number): void; 29 | readFileIfExists(path: string): Promise; 30 | randomId(): string; 31 | readFile(path: string): Promise; 32 | writeFile(contextStatePath: string, state: Record): Promise; 33 | }; 34 | } 35 | 36 | declare module 's3-stream-upload' { 37 | import { S3 } from 'aws-sdk'; 38 | 39 | export default function (s3: S3, options: UploadStreamOptions): NodeJS.WritableStream; 40 | } 41 | 42 | declare module 'prettyoutput' { 43 | export default function (data: any, options?: any, indent?: number): string; 44 | } 45 | 46 | type UploadStreamOptions = { 47 | Bucket?: string; 48 | Key?: string; 49 | ContentType?: string; 50 | CacheControl?: string; 51 | }; 52 | 53 | type BaseDeploymentOptions = { 54 | engine?: 'aws' | 'github'; 55 | debug?: boolean; 56 | onPreDeploy?: () => Promise; 57 | onPostDeploy?: () => Promise; 58 | onShutdown?: () => Promise; 59 | build?: BuildOptions; 60 | nextConfigDir?: string; 61 | domain?: string | string[]; 62 | stage?: Stage; 63 | }; 64 | 65 | type BuildOptions = { 66 | cwd?: string; 67 | cmd: string; 68 | args: string[]; 69 | }; 70 | 71 | type Stage = { 72 | bucketName: string; 73 | name: string; 74 | versioned?: boolean; 75 | }; 76 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["@typescript-eslint"], 5 | "extends": [ 6 | "plugin:@typescript-eslint/recommended", // Uses the recommended rules from the @typescript-eslint/eslint-plugin 7 | "prettier/@typescript-eslint", // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier 8 | "plugin:prettier/recommended" // Enables eslint-plugin-prettier and eslint-config-prettier. This will display prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array. 9 | ], 10 | "env": { 11 | "browser": true, 12 | "es6": true 13 | }, 14 | "globals": { 15 | "Atomics": "readonly", 16 | "SharedArrayBuffer": "readonly" 17 | }, 18 | "parserOptions": { 19 | "ecmaFeatures": { 20 | "jsx": true 21 | }, 22 | "ecmaVersion": 2020, 23 | "sourceType": "module" 24 | }, 25 | "rules": { 26 | "@typescript-eslint/no-var-requires": 0 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 30 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: wontfix 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. 15 | # Comment to post when closing a stale issue. Set to `false` to disable 16 | closeComment: true 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode 3 | dist 4 | yarn-error.log 5 | *.tgz 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 100, 4 | "endOfLine": "auto" 5 | } 6 | -------------------------------------------------------------------------------- /.yarnignore: -------------------------------------------------------------------------------- 1 | .github 2 | .vscode 3 | lerna.json 4 | tsconfig.json 5 | .babelrc.json 6 | CODE_OF_CONDUCT.md 7 | .prettierrc 8 | .eslintrc.json 9 | .yarnignore 10 | *.tgz 11 | src 12 | tsconfig.build.json 13 | examples 14 | *.gif -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | [INSERT CONTACT METHOD]. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Nidratech Ltd. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /deploy-term.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lone-cloud/next-deploy/20e2b46626b196be8c88885c13c9e8a6daedf4e2/deploy-term.gif -------------------------------------------------------------------------------- /examples/aws/README.md: -------------------------------------------------------------------------------- 1 | An example of a fully functional Next.js deployment to AWS can be found [here](https://github.com/nidratech/next-deploy-aws-demo). 2 | 3 | You can see it in action on AWS [here](https://d1glu8cqlkaas6.cloudfront.net). 4 | 5 | The key part is its `next.config.js` configuration: 6 | 7 | ```javascript 8 | module.exports = { 9 | engine: 'aws', 10 | debug: true, 11 | bucketName: 'next-deploy-demo-bucket', 12 | description: { 13 | requestLambda: 'Next deploy demo request lambda.', 14 | }, 15 | name: { 16 | requestLambda: 'request-handler-demo', 17 | }, 18 | stage: { 19 | name: 'demo', 20 | versioned: true, 21 | bucketName: 'next-deploy-demo-environments', 22 | }, 23 | }; 24 | ``` 25 | -------------------------------------------------------------------------------- /examples/github/README.md: -------------------------------------------------------------------------------- 1 | Deployments to [GitHub Pages](#https://pages.github.com/) work strictly for static sites, but still possess most of the advantages of using Next.js such as automatic page pre-loading. 2 | 3 | An up-to-date Static-Site Generation implementation of https://www.nidratech.com and its deployment using Next Deploy can be found at: https://github.com/nidratech/nidratech.com 4 | 5 | The key part is its `next.config.js` configuration: 6 | 7 | ```javascript 8 | module.exports = { 9 | engine: 'github', 10 | debug: true, 11 | domain: 'www.nidratech.com', 12 | }; 13 | ``` 14 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "independent", 3 | "npmClient": "yarn", 4 | "packages": ["packages/*"] 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-deploy", 3 | "version": "1.3.1", 4 | "description": "Effortless deployment for Next.js apps 🚀", 5 | "author": "Nidratech Ltd. ", 6 | "keywords": [ 7 | "next", 8 | "deploy", 9 | "nextjs", 10 | "serverless", 11 | "aws", 12 | "github", 13 | "lambda", 14 | "lambda@edge", 15 | "cloudfront", 16 | "gh-pages" 17 | ], 18 | "scripts": { 19 | "dev": "lerna run --parallel build:watch", 20 | "build": "lerna run build", 21 | "clean": "lerna run clean", 22 | "prepack": "yarn build", 23 | "postinstall": "cd packages/aws-component && npx yarn --no-lockfile && cd ../aws-domain && npx yarn --no-lockfile && cd ../aws-lambda && npx yarn --no-lockfile && cd ../aws-lambda-builder && npx yarn --no-lockfile && cd ../cli && npx yarn --no-lockfile" 24 | }, 25 | "bin": { 26 | "next-deploy": "./packages/cli/bin/next-deploy" 27 | }, 28 | "repository": { 29 | "type": "git", 30 | "url": "https://github.com/lone-cloud/next-deploy" 31 | }, 32 | "license": "MIT", 33 | "bugs": { 34 | "url": "https://github.com/lone-cloud/next-deploy/issues" 35 | }, 36 | "homepage": "https://github.com/lone-cloud/next-deploy#readme", 37 | "dependencies": { 38 | "@serverless/core": "^1.1.2", 39 | "@zeit/node-file-trace": "^0.8.2", 40 | "ansi-escapes": "^4.3.1", 41 | "archiver": "^5.1.0", 42 | "aws-sdk": "^2.813.0", 43 | "chalk": "^4.1.0", 44 | "execa": "^5.0.0", 45 | "figures": "^3.2.0", 46 | "fs-extra": "^9.0.1", 47 | "gh-pages": "^3.1.0", 48 | "globby": "^11.0.1", 49 | "klaw": "^3.0.0", 50 | "klaw-sync": "^6.0.0", 51 | "mime-types": "^2.1.27", 52 | "minimist": "^1.2.5", 53 | "next": "^10.0.3", 54 | "path-to-regexp": "^6.2.0", 55 | "prettyoutput": "^1.2.0", 56 | "ramda": "^0.27.1", 57 | "regex-parser": "^2.2.11", 58 | "s3-stream-upload": "^2.0.2", 59 | "strip-ansi": "^6.0.0" 60 | }, 61 | "devDependencies": { 62 | "@babel/plugin-proposal-class-properties": "^7.12.1", 63 | "@babel/preset-env": "^7.12.11", 64 | "@babel/preset-typescript": "^7.12.7", 65 | "@types/archiver": "^5.1.0", 66 | "@types/aws-lambda": "^8.10.66", 67 | "@types/execa": "^2.0.0", 68 | "@types/fs-extra": "^9.0.5", 69 | "@types/gh-pages": "^3.0.0", 70 | "@types/klaw": "^3.0.1", 71 | "@types/klaw-sync": "^6.0.0", 72 | "@types/mime-types": "^2.1.0", 73 | "@types/node": "^14.14.14", 74 | "@types/path-to-regexp": "^1.7.0", 75 | "@types/ramda": "^0.27.34", 76 | "@types/react": "^17.0.0", 77 | "@types/react-dom": "^17.0.0", 78 | "@types/strip-ansi": "^5.2.1", 79 | "@types/webpack": "^4.41.25", 80 | "@typescript-eslint/eslint-plugin": "^4.10.0", 81 | "@typescript-eslint/parser": "^4.10.0", 82 | "eslint": "^7.15.0", 83 | "eslint-config-prettier": "^7.0.0", 84 | "eslint-plugin-prettier": "^3.3.0", 85 | "husky": "^4.3.6", 86 | "lerna": "^3.22.1", 87 | "lint-staged": "^10.5.3", 88 | "prettier": "^2.2.1", 89 | "react": "^17.0.1", 90 | "react-dom": "^17.0.1", 91 | "typescript": "^4.1.3" 92 | }, 93 | "husky": { 94 | "hooks": { 95 | "pre-commit": "lint-staged" 96 | } 97 | }, 98 | "lint-staged": { 99 | "*.{js,ts,md,yml}": "prettier --write" 100 | }, 101 | "resolutions": { 102 | "node-fetch": "^2.6.1" 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /packages/aws-cloudfront/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@next-deploy/aws-cloudfront", 3 | "version": "4.2.0", 4 | "license": "MIT", 5 | "main": "dist/component.js", 6 | "types": "dist/component.d.ts", 7 | "scripts": { 8 | "build": "yarn clean && yarn compile", 9 | "build:watch": "yarn compile -w", 10 | "clean": "rm -rf ./dist", 11 | "compile": "tsc -p tsconfig.build.json" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/aws-cloudfront/src/component.ts: -------------------------------------------------------------------------------- 1 | import { CloudFront as AwsCloudFront, S3, Credentials } from 'aws-sdk'; 2 | import { equals } from 'ramda'; 3 | import { Component } from '@serverless/core'; 4 | 5 | import { 6 | createInvalidation, 7 | createCloudFrontDistribution, 8 | updateCloudFrontDistribution, 9 | deleteCloudFrontDistribution, 10 | } from './lib'; 11 | import { CloudFrontInputs } from '../types'; 12 | 13 | class CloudFront extends Component { 14 | static createInvalidation({ 15 | credentials, 16 | distributionId, 17 | paths, 18 | }: { 19 | credentials: Credentials; 20 | distributionId: string; 21 | paths?: string[]; 22 | }): Promise { 23 | return createInvalidation({ credentials, distributionId, paths }); 24 | } 25 | 26 | async default(inputs: CloudFrontInputs): Promise> { 27 | this.context.status('Deploying'); 28 | 29 | inputs.region = inputs.region || 'us-east-1'; 30 | inputs.enabled = inputs.enabled === false ? false : true; 31 | inputs.comment = 32 | inputs.comment === null || inputs.comment === undefined ? '' : String(inputs.comment); 33 | inputs.priceClass = ['PriceClass_All', 'PriceClass_200', 'PriceClass_100'].includes( 34 | inputs.priceClass || '' 35 | ) 36 | ? inputs.priceClass 37 | : 'PriceClass_All'; 38 | 39 | this.context.debug( 40 | `Starting deployment of CloudFront distribution to the ${inputs.region} region.` 41 | ); 42 | 43 | const cf = new AwsCloudFront({ 44 | credentials: this.context.credentials.aws, 45 | region: inputs.region, 46 | }); 47 | 48 | const s3 = new S3({ 49 | credentials: this.context.credentials.aws, 50 | region: inputs.region, 51 | }); 52 | 53 | this.state.id = inputs.distributionId || this.state.id; 54 | 55 | if (this.state.id) { 56 | if ( 57 | !equals(this.state.origins, inputs.origins) || 58 | !equals(this.state.defaults, inputs.defaults) || 59 | !equals(this.state.enabled, inputs.enabled) || 60 | !equals(this.state.comment, inputs.comment) || 61 | !equals(this.state.priceClass, inputs.priceClass) 62 | ) { 63 | this.context.debug(`Updating CloudFront distribution of ID ${this.state.id}.`); 64 | this.state = await updateCloudFrontDistribution(cf, s3, this.state.id, inputs); 65 | } 66 | } else { 67 | this.context.debug(`Creating CloudFront distribution in the ${inputs.region} region.`); 68 | this.state = await createCloudFrontDistribution(cf, s3, inputs); 69 | } 70 | 71 | this.state.region = inputs.region; 72 | this.state.enabled = inputs.enabled; 73 | this.state.comment = inputs.comment; 74 | this.state.priceClass = inputs.priceClass; 75 | this.state.origins = inputs.origins; 76 | this.state.defaults = inputs.defaults; 77 | 78 | await this.save(); 79 | 80 | this.context.debug(`CloudFront deployed successfully with URL: ${this.state.url}.`); 81 | 82 | return this.state; 83 | } 84 | 85 | async remove(): Promise { 86 | this.context.status('Removing'); 87 | 88 | if (!this.state.id) { 89 | return; 90 | } 91 | 92 | const cf = new AwsCloudFront({ 93 | credentials: this.context.credentials.aws, 94 | region: this.state.region, 95 | }); 96 | 97 | this.context.debug( 98 | `Removing CloudFront distribution of ID ${this.state.id}. It could take a while.` 99 | ); 100 | 101 | try { 102 | await deleteCloudFrontDistribution(cf, this.state.id, this.context.debug); 103 | } catch (error) { 104 | this.context.debug(error.message); 105 | } 106 | 107 | this.state = {}; 108 | await this.save(); 109 | 110 | this.context.debug('CloudFront distribution was successfully removed.'); 111 | } 112 | } 113 | 114 | export default CloudFront; 115 | -------------------------------------------------------------------------------- /packages/aws-cloudfront/src/lib/addLambdaAtEdgeToCacheBehavior.ts: -------------------------------------------------------------------------------- 1 | import { LambdaAtEdgeConfig, LambdaAtEdge } from '../../types'; 2 | 3 | const validLambdaTriggers = [ 4 | 'viewer-request', 5 | 'origin-request', 6 | 'origin-response', 7 | 'viewer-response', 8 | ]; 9 | 10 | const triggersAllowedBody = ['viewer-request', 'origin-request']; 11 | 12 | const makeCacheItem = (eventType: string, lambdaConfig: string | LambdaAtEdgeConfig) => { 13 | let arn, includeBody; 14 | 15 | if (typeof lambdaConfig === 'string') { 16 | arn = lambdaConfig; 17 | includeBody = triggersAllowedBody.includes(eventType); 18 | } else { 19 | ({ arn, includeBody } = lambdaConfig); 20 | if (includeBody && !triggersAllowedBody.includes(eventType)) { 21 | throw new Error(`"includeBody" not allowed for ${eventType} lambda triggers.`); 22 | } 23 | } 24 | 25 | return { 26 | EventType: eventType, 27 | LambdaFunctionARN: arn, 28 | IncludeBody: includeBody, 29 | }; 30 | }; 31 | 32 | // adds lambda@edge to cache behavior passed 33 | const addLambdaAtEdgeToCacheBehavior = (cacheBehavior: any, lambdaAtEdge: LambdaAtEdge = {}) => { 34 | Object.keys(lambdaAtEdge).forEach((eventType) => { 35 | if (!validLambdaTriggers.includes(eventType)) { 36 | throw new Error( 37 | `"${eventType}" is not a valid lambda trigger. See https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-cloudfront-trigger-events.html for valid event types.` 38 | ); 39 | } 40 | 41 | const haveValidConfig = 42 | //@ts-ignore 43 | typeof lambdaAtEdge[eventType] === 'string' || lambdaAtEdge[eventType]?.arn; 44 | 45 | if (haveValidConfig) { 46 | cacheBehavior.LambdaFunctionAssociations.Items.push( 47 | makeCacheItem(eventType, lambdaAtEdge[eventType]) 48 | ); 49 | 50 | cacheBehavior.LambdaFunctionAssociations.Quantity = 51 | cacheBehavior.LambdaFunctionAssociations.Quantity + 1; 52 | } 53 | }); 54 | }; 55 | 56 | export default addLambdaAtEdgeToCacheBehavior; 57 | -------------------------------------------------------------------------------- /packages/aws-cloudfront/src/lib/createInvalidation.ts: -------------------------------------------------------------------------------- 1 | import { CloudFront, Credentials } from 'aws-sdk'; 2 | 3 | export const ALL_FILES_PATH = '/*'; 4 | 5 | const createInvalidation = ({ 6 | credentials, 7 | distributionId, 8 | paths = [ALL_FILES_PATH], 9 | }: { 10 | credentials: Credentials; 11 | distributionId: string; 12 | paths?: string[]; 13 | }): Promise => { 14 | const cloudFront = new CloudFront({ credentials }); 15 | const callerReference = new Date().getTime().toString(); 16 | 17 | return cloudFront 18 | .createInvalidation({ 19 | DistributionId: distributionId, 20 | InvalidationBatch: { 21 | CallerReference: callerReference, 22 | Paths: { 23 | Quantity: paths.length, 24 | Items: paths, 25 | }, 26 | }, 27 | }) 28 | .promise(); 29 | }; 30 | 31 | export default createInvalidation; 32 | -------------------------------------------------------------------------------- /packages/aws-cloudfront/src/lib/createOriginAccessIdentity.ts: -------------------------------------------------------------------------------- 1 | import { CloudFront } from 'aws-sdk'; 2 | 3 | const createOriginAccessIdentity = async (cf: CloudFront) => { 4 | const { CloudFrontOriginAccessIdentity } = await cf 5 | .createCloudFrontOriginAccessIdentity({ 6 | CloudFrontOriginAccessIdentityConfig: { 7 | CallerReference: 'next-deploy-managed-cloudfront-access-identity', 8 | Comment: 'CloudFront Origin Access Identity created to allow serving private S3 content', 9 | }, 10 | }) 11 | .promise(); 12 | 13 | return { 14 | originAccessIdentityId: CloudFrontOriginAccessIdentity?.Id, 15 | s3CanonicalUserId: CloudFrontOriginAccessIdentity?.S3CanonicalUserId, 16 | }; 17 | }; 18 | 19 | export default createOriginAccessIdentity; 20 | -------------------------------------------------------------------------------- /packages/aws-cloudfront/src/lib/getCacheBehavior.ts: -------------------------------------------------------------------------------- 1 | import getForwardedValues from './getForwardedValues'; 2 | import { PathPatternConfig } from '../../types'; 3 | 4 | const getCacheBehavior = ( 5 | pathPattern: string, 6 | pathPatternConfig: PathPatternConfig, 7 | originId: string 8 | ) => { 9 | const { 10 | allowedHttpMethods = ['GET', 'HEAD'], 11 | ttl, 12 | forward, 13 | compress = true, 14 | smoothStreaming = false, 15 | viewerProtocolPolicy = 'https-only', 16 | fieldLevelEncryptionId = '', 17 | } = pathPatternConfig; 18 | 19 | return { 20 | ForwardedValues: getForwardedValues(forward, { 21 | cookies: 'all', 22 | queryString: true, 23 | }), 24 | MinTTL: ttl, 25 | PathPattern: pathPattern, 26 | TargetOriginId: originId, 27 | TrustedSigners: { 28 | Enabled: false, 29 | Quantity: 0, 30 | }, 31 | ViewerProtocolPolicy: viewerProtocolPolicy, 32 | AllowedMethods: { 33 | Quantity: allowedHttpMethods.length, 34 | Items: allowedHttpMethods, 35 | CachedMethods: { 36 | Items: ['GET', 'HEAD'], 37 | Quantity: 2, 38 | }, 39 | }, 40 | Compress: compress, 41 | SmoothStreaming: smoothStreaming, 42 | DefaultTTL: ttl, 43 | MaxTTL: ttl, 44 | FieldLevelEncryptionId: fieldLevelEncryptionId, 45 | LambdaFunctionAssociations: { 46 | Quantity: 0, 47 | Items: [], 48 | }, 49 | }; 50 | }; 51 | 52 | export default getCacheBehavior; 53 | -------------------------------------------------------------------------------- /packages/aws-cloudfront/src/lib/getDefaultCacheBehavior.ts: -------------------------------------------------------------------------------- 1 | import { CloudFront } from 'aws-sdk'; 2 | 3 | import addLambdaAtEdgeToCacheBehavior from './addLambdaAtEdgeToCacheBehavior'; 4 | import getForwardedValues from './getForwardedValues'; 5 | import { PathPatternConfig } from '../../types'; 6 | 7 | const getDefaultCacheBehavior = (originId: string, defaults: PathPatternConfig = {}) => { 8 | const { 9 | allowedHttpMethods = ['HEAD', 'GET'], 10 | forward = {}, 11 | ttl = 86400, 12 | compress = false, 13 | smoothStreaming = false, 14 | viewerProtocolPolicy = 'redirect-to-https', 15 | fieldLevelEncryptionId = '', 16 | } = defaults; 17 | 18 | const defaultCacheBehavior = { 19 | TargetOriginId: originId, 20 | ForwardedValues: getForwardedValues(forward || {}), 21 | TrustedSigners: { 22 | Enabled: false, 23 | Quantity: 0, 24 | Items: [], 25 | }, 26 | ViewerProtocolPolicy: viewerProtocolPolicy, 27 | MinTTL: 0, 28 | AllowedMethods: { 29 | Quantity: allowedHttpMethods.length, 30 | Items: allowedHttpMethods, 31 | CachedMethods: { 32 | Quantity: 2, 33 | Items: ['HEAD', 'GET'], 34 | }, 35 | }, 36 | SmoothStreaming: smoothStreaming, 37 | DefaultTTL: ttl, 38 | MaxTTL: 31536000, 39 | Compress: compress, 40 | LambdaFunctionAssociations: { 41 | Quantity: 0, 42 | Items: [], 43 | }, 44 | FieldLevelEncryptionId: fieldLevelEncryptionId, 45 | }; 46 | 47 | addLambdaAtEdgeToCacheBehavior(defaultCacheBehavior, defaults['lambda@edge']); 48 | 49 | return (defaultCacheBehavior as any) as CloudFront.Types.CacheBehavior; 50 | }; 51 | 52 | export default getDefaultCacheBehavior; 53 | -------------------------------------------------------------------------------- /packages/aws-cloudfront/src/lib/getForwardedValues.ts: -------------------------------------------------------------------------------- 1 | import { CloudFront } from 'aws-sdk'; 2 | 3 | import { Forward } from '../../types'; 4 | 5 | const forwardDefaults = { 6 | cookies: 'none', 7 | queryString: false, 8 | }; 9 | 10 | /** 11 | * @param config User-defined config 12 | * @param defaults Default framework values (default cache behavior and custom cache behavior have different default values) 13 | * @returns Object 14 | */ 15 | export default function getForwardedValues(config: Forward = {}, defaults?: Forward) { 16 | const defaultValues = { ...forwardDefaults, ...defaults }; 17 | const { 18 | cookies, 19 | queryString = defaultValues.queryString, 20 | headers, 21 | queryStringCacheKeys, 22 | } = config; 23 | 24 | // Cookies 25 | const forwardCookies: CloudFront.CookiePreference = { 26 | Forward: defaultValues.cookies as string, 27 | }; 28 | 29 | if (typeof cookies === 'string') { 30 | forwardCookies.Forward = cookies; 31 | } else if (Array.isArray(cookies)) { 32 | forwardCookies.Forward = 'whitelist'; 33 | forwardCookies.WhitelistedNames = { 34 | Quantity: cookies.length, 35 | Items: cookies, 36 | }; 37 | } 38 | 39 | // Headers 40 | const forwardHeaders: CloudFront.Headers = { 41 | Quantity: 0, 42 | Items: [], 43 | }; 44 | 45 | if (typeof headers === 'string' && headers === 'all') { 46 | forwardHeaders.Quantity = 1; 47 | forwardHeaders.Items = ['*']; 48 | } else if (Array.isArray(headers)) { 49 | forwardHeaders.Quantity = headers.length; 50 | forwardHeaders.Items = headers; 51 | } 52 | 53 | // QueryStringCacheKeys 54 | const forwardQueryKeys: CloudFront.QueryStringCacheKeys = { 55 | Quantity: 0, 56 | Items: [], 57 | }; 58 | 59 | if (Array.isArray(queryStringCacheKeys)) { 60 | forwardQueryKeys.Quantity = queryStringCacheKeys.length; 61 | forwardQueryKeys.Items = queryStringCacheKeys; 62 | } 63 | 64 | return { 65 | QueryString: queryString, 66 | Cookies: forwardCookies, 67 | Headers: forwardHeaders, 68 | QueryStringCacheKeys: forwardQueryKeys, 69 | }; 70 | } 71 | -------------------------------------------------------------------------------- /packages/aws-cloudfront/src/lib/getOriginConfig.ts: -------------------------------------------------------------------------------- 1 | import { CloudFront } from 'aws-sdk'; 2 | import url from 'url'; 3 | 4 | import { Origin } from '../../types'; 5 | 6 | const getOriginConfig = (origin: string | Origin, { originAccessIdentityId = '' }) => { 7 | const originUrl = typeof origin === 'string' ? origin : origin.url; 8 | const protocolPolicy = typeof origin === 'string' ? null : origin.protocolPolicy; 9 | 10 | const { hostname, pathname } = url.parse(originUrl); 11 | 12 | const originConfig: CloudFront.Origin = { 13 | Id: `${hostname}${pathname}`.replace(/\/$/, ''), 14 | DomainName: hostname as string, 15 | CustomHeaders: { 16 | Quantity: 0, 17 | Items: [], 18 | }, 19 | OriginPath: pathname === '/' ? '' : (pathname as string), 20 | }; 21 | 22 | if (originUrl.includes('s3') && !originUrl.includes('s3-website')) { 23 | const bucketName = (hostname as string).split('.')[0]; 24 | 25 | originConfig.Id = pathname === '/' ? bucketName : `${bucketName}${pathname}`; 26 | originConfig.DomainName = hostname as string; 27 | originConfig.S3OriginConfig = { 28 | OriginAccessIdentity: originAccessIdentityId 29 | ? `origin-access-identity/cloudfront/${originAccessIdentityId}` 30 | : '', 31 | }; 32 | } else { 33 | originConfig.CustomOriginConfig = { 34 | HTTPPort: 80, 35 | HTTPSPort: 443, 36 | OriginProtocolPolicy: protocolPolicy || 'https-only', 37 | OriginSslProtocols: { 38 | Quantity: 1, 39 | Items: ['TLSv1.2'], 40 | }, 41 | OriginReadTimeout: 30, 42 | OriginKeepaliveTimeout: 5, 43 | }; 44 | } 45 | 46 | return originConfig; 47 | }; 48 | 49 | export default getOriginConfig; 50 | -------------------------------------------------------------------------------- /packages/aws-cloudfront/src/lib/grantCloudFrontBucketAccess.ts: -------------------------------------------------------------------------------- 1 | import { S3 } from 'aws-sdk'; 2 | 3 | const grantCloudFrontBucketAccess = (s3: S3, bucketName: string, s3CanonicalUserId: string) => { 4 | const policy = ` 5 | { 6 | "Version":"2012-10-17", 7 | "Id":"PolicyForCloudFrontPrivateContent", 8 | "Statement":[ 9 | { 10 | "Sid":" Grant a CloudFront Origin Identity access to support private content", 11 | "Effect":"Allow", 12 | "Principal":{"CanonicalUser":"${s3CanonicalUserId}"}, 13 | "Action":"s3:GetObject", 14 | "Resource":"arn:aws:s3:::${bucketName}/*" 15 | } 16 | ] 17 | } 18 | `; 19 | 20 | return s3 21 | .putBucketPolicy({ 22 | Bucket: bucketName, 23 | Policy: policy.replace(/(\r\n|\n|\r|\t)/gm, ''), 24 | }) 25 | .promise(); 26 | }; 27 | 28 | export default grantCloudFrontBucketAccess; 29 | -------------------------------------------------------------------------------- /packages/aws-cloudfront/src/lib/index.ts: -------------------------------------------------------------------------------- 1 | import { CloudFront, S3 } from 'aws-sdk'; 2 | 3 | import parseInputOrigins from './parseInputOrigins'; 4 | import getDefaultCacheBehavior from './getDefaultCacheBehavior'; 5 | import createOriginAccessIdentity from './createOriginAccessIdentity'; 6 | import grantCloudFrontBucketAccess from './grantCloudFrontBucketAccess'; 7 | import { CloudFrontInputs, Origin } from '../../types'; 8 | 9 | export { default as createInvalidation } from './createInvalidation'; 10 | 11 | const servePrivateContentEnabled = (inputs: CloudFrontInputs) => 12 | inputs?.origins?.some((origin: string | Origin) => origin && (origin as Origin).private === true); 13 | const unique = (value: string, index: number, self: string[]) => self.indexOf(value) === index; 14 | const updateBucketsPolicies = async ( 15 | s3: S3, 16 | origins: CloudFront.Origins, 17 | s3CanonicalUserId: string 18 | ) => { 19 | // update bucket policies with cloudfront access 20 | const bucketNames = origins.Items.filter((origin) => origin.S3OriginConfig) 21 | .map( 22 | (origin) => 23 | // remove path from the bucket name if origin had pathname 24 | origin.Id.split('/')[0] 25 | ) 26 | .filter(unique); 27 | 28 | return Promise.all( 29 | bucketNames.map((bucketName: string) => 30 | grantCloudFrontBucketAccess(s3, bucketName, s3CanonicalUserId) 31 | ) 32 | ); 33 | }; 34 | 35 | export const createCloudFrontDistribution = async ( 36 | cf: CloudFront, 37 | s3: S3, 38 | inputs: CloudFrontInputs 39 | ): Promise<{ 40 | id?: string; 41 | arn?: string; 42 | url?: string; 43 | }> => { 44 | let originAccessIdentityId; 45 | let s3CanonicalUserId; 46 | 47 | if (servePrivateContentEnabled(inputs)) { 48 | ({ originAccessIdentityId, s3CanonicalUserId } = await createOriginAccessIdentity(cf)); 49 | } 50 | 51 | const { Origins, CacheBehaviors } = parseInputOrigins(inputs.origins, { 52 | originAccessIdentityId, 53 | }); 54 | 55 | if (s3CanonicalUserId) { 56 | await updateBucketsPolicies(s3, Origins, s3CanonicalUserId); 57 | } 58 | 59 | const createDistributionRequest: CloudFront.Types.CreateDistributionRequest = { 60 | DistributionConfig: { 61 | CallerReference: String(Date.now()), 62 | Comment: inputs.comment as string, 63 | Aliases: { 64 | Quantity: 0, 65 | Items: [], 66 | }, 67 | Origins, 68 | PriceClass: inputs.priceClass, 69 | Enabled: inputs.enabled as boolean, 70 | HttpVersion: 'http2', 71 | DefaultCacheBehavior: getDefaultCacheBehavior(Origins.Items[0].Id, inputs.defaults), 72 | }, 73 | }; 74 | 75 | if (CacheBehaviors) { 76 | createDistributionRequest.DistributionConfig.CacheBehaviors = CacheBehaviors; 77 | } 78 | 79 | const res = await cf.createDistribution(createDistributionRequest).promise(); 80 | 81 | return { 82 | id: res?.Distribution?.Id, 83 | arn: res?.Distribution?.ARN, 84 | url: `https://${res?.Distribution?.DomainName}`, 85 | }; 86 | }; 87 | 88 | export const updateCloudFrontDistribution = async ( 89 | cf: CloudFront, 90 | s3: S3, 91 | distributionId: string, 92 | inputs: CloudFrontInputs 93 | ): Promise<{ 94 | id?: string; 95 | arn?: string; 96 | url?: string; 97 | }> => { 98 | const distributionConfigResponse = await cf 99 | .getDistributionConfig({ Id: distributionId }) 100 | .promise(); 101 | 102 | if (!distributionConfigResponse.DistributionConfig) { 103 | throw new Error('Could not get a distribution config'); 104 | } 105 | 106 | let s3CanonicalUserId; 107 | let originAccessIdentityId; 108 | 109 | if (servePrivateContentEnabled(inputs)) { 110 | // presumably it's ok to call create origin access identity again 111 | // aws api returns cached copy of what was previously created 112 | // https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/CloudFront.html#createCloudFrontOriginAccessIdentity-property 113 | ({ originAccessIdentityId, s3CanonicalUserId } = await createOriginAccessIdentity(cf)); 114 | } 115 | 116 | const { Origins, CacheBehaviors } = parseInputOrigins(inputs.origins, { 117 | originAccessIdentityId, 118 | }); 119 | 120 | if (s3CanonicalUserId) { 121 | await updateBucketsPolicies(s3, Origins, s3CanonicalUserId); 122 | } 123 | 124 | const updateDistributionRequest: CloudFront.Types.UpdateDistributionRequest = { 125 | Id: distributionId, 126 | IfMatch: distributionConfigResponse.ETag, 127 | DistributionConfig: { 128 | ...distributionConfigResponse.DistributionConfig, 129 | PriceClass: inputs.priceClass, 130 | Enabled: inputs.enabled as boolean, 131 | Comment: inputs.comment as string, 132 | DefaultCacheBehavior: getDefaultCacheBehavior(Origins.Items[0].Id, inputs.defaults), 133 | Origins, 134 | }, 135 | }; 136 | 137 | const origins = updateDistributionRequest.DistributionConfig.Origins; 138 | const existingOriginIds = origins.Items.map((origin) => origin.Id); 139 | 140 | Origins.Items.forEach((inputOrigin) => { 141 | const originIndex = existingOriginIds.indexOf(inputOrigin.Id); 142 | 143 | if (originIndex > -1) { 144 | // replace origin with new input configuration 145 | origins.Items.splice(originIndex, 1, inputOrigin); 146 | } else { 147 | origins.Items.push(inputOrigin); 148 | origins.Quantity += 1; 149 | } 150 | }); 151 | 152 | if (CacheBehaviors) { 153 | updateDistributionRequest.DistributionConfig.CacheBehaviors = CacheBehaviors; 154 | } 155 | 156 | const res = await cf.updateDistribution(updateDistributionRequest).promise(); 157 | 158 | return { 159 | id: res?.Distribution?.Id, 160 | arn: res?.Distribution?.ARN, 161 | url: `https://${res?.Distribution?.DomainName}`, 162 | }; 163 | }; 164 | 165 | const disableCloudFrontDistribution = async ( 166 | cf: CloudFront, 167 | distributionId: string, 168 | debug: (message: string) => void 169 | ) => { 170 | const distributionConfigResponse = await cf 171 | .getDistributionConfig({ Id: distributionId }) 172 | .promise(); 173 | 174 | if (!distributionConfigResponse.DistributionConfig) { 175 | throw new Error('Could not get a distribution config'); 176 | } 177 | 178 | const updateDistributionRequest: CloudFront.Types.UpdateDistributionRequest = { 179 | Id: distributionId, 180 | IfMatch: distributionConfigResponse.ETag, 181 | DistributionConfig: { 182 | ...distributionConfigResponse.DistributionConfig, 183 | Enabled: false, 184 | }, 185 | }; 186 | 187 | const res = await cf.updateDistribution(updateDistributionRequest).promise(); 188 | 189 | debug('Waiting for the CloudFront distribution changes to be deployed.'); 190 | await cf.waitFor('distributionDeployed', { Id: distributionId }).promise(); 191 | 192 | return res; 193 | }; 194 | 195 | export const deleteCloudFrontDistribution = async ( 196 | cf: CloudFront, 197 | distributionId: string, 198 | debug: (message: string) => void 199 | ): Promise => { 200 | try { 201 | const res = await cf.getDistributionConfig({ Id: distributionId }).promise(); 202 | 203 | const params = { Id: distributionId, IfMatch: res.ETag }; 204 | await cf.deleteDistribution(params).promise(); 205 | } catch (e) { 206 | if (e.code === 'DistributionNotDisabled') { 207 | await disableCloudFrontDistribution(cf, distributionId, debug); 208 | } else { 209 | throw e; 210 | } 211 | } 212 | }; 213 | -------------------------------------------------------------------------------- /packages/aws-cloudfront/src/lib/parseInputOrigins.ts: -------------------------------------------------------------------------------- 1 | import { CloudFront } from 'aws-sdk'; 2 | 3 | import getOriginConfig from './getOriginConfig'; 4 | import getCacheBehavior from './getCacheBehavior'; 5 | import addLambdaAtEdgeToCacheBehavior from './addLambdaAtEdgeToCacheBehavior'; 6 | 7 | import { Origin } from '../../types'; 8 | 9 | const parseInputOrigins = (origins: string[] | Origin[], options: any) => { 10 | const distributionOrigins = { 11 | Quantity: 0, 12 | Items: [], 13 | }; 14 | 15 | const distributionCacheBehaviors = { 16 | Quantity: 0, 17 | Items: [], 18 | }; 19 | 20 | for (const origin of origins) { 21 | const originConfig = getOriginConfig(origin, options); 22 | 23 | distributionOrigins.Quantity = distributionOrigins.Quantity + 1; 24 | distributionOrigins.Items.push(originConfig as never); 25 | 26 | if (typeof origin === 'object') { 27 | // add any cache behaviors 28 | for (const pathPattern in origin.pathPatterns) { 29 | const pathPatternConfig = origin.pathPatterns[pathPattern]; 30 | const cacheBehavior = getCacheBehavior(pathPattern, pathPatternConfig, originConfig.Id); 31 | 32 | addLambdaAtEdgeToCacheBehavior(cacheBehavior, pathPatternConfig['lambda@edge']); 33 | 34 | distributionCacheBehaviors.Quantity = distributionCacheBehaviors.Quantity + 1; 35 | distributionCacheBehaviors.Items.push(cacheBehavior as never); 36 | } 37 | } 38 | } 39 | 40 | return { 41 | Origins: distributionOrigins as CloudFront.Origins, 42 | CacheBehaviors: distributionCacheBehaviors, 43 | }; 44 | }; 45 | 46 | export default parseInputOrigins; 47 | -------------------------------------------------------------------------------- /packages/aws-cloudfront/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "sourceMap": false, 5 | "removeComments": true, 6 | "outDir": "dist" 7 | }, 8 | "include": ["./src/", "../../.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/aws-cloudfront/types.d.ts: -------------------------------------------------------------------------------- 1 | export type CloudFrontInputs = { 2 | distributionId?: string; 3 | region?: string; 4 | enabled?: boolean; 5 | comment?: string; 6 | origins: string[] | Origin[]; 7 | defaults?: PathPatternConfig; 8 | priceClass?: 'PriceClass_All' | 'PriceClass_200' | 'PriceClass_100'; 9 | }; 10 | 11 | type PathPatternConfig = { 12 | allowedHttpMethods?: string[]; 13 | ttl?: number; 14 | compress?: boolean; 15 | smoothStreaming?: boolean; 16 | viewerProtocolPolicy?: string; 17 | fieldLevelEncryptionId?: string; 18 | forward?: Forward; 19 | viewerCertificate?: ViewerCertificate; 20 | 'lambda@edge'?: LambdaAtEdge; 21 | }; 22 | 23 | type ViewerCertificate = { 24 | ACMCertificateArn: string; 25 | SSLSupportMethod: string; 26 | minimumProtocolVersion: string; 27 | }; 28 | 29 | type LambdaAtEdge = { 30 | [type: string]: string | LambdaAtEdgeConfig; 31 | }; 32 | 33 | type LambdaAtEdgeConfig = { 34 | arn: string; 35 | includeBody: boolean; 36 | }; 37 | 38 | type Origin = { 39 | url: string; 40 | private?: boolean; 41 | pathPatterns?: Record; 42 | protocolPolicy?: string; 43 | }; 44 | 45 | type Forward = { 46 | cookies?: string | string[]; 47 | queryString?: boolean; 48 | headers?: string[]; 49 | queryStringCacheKeys?: string[]; 50 | }; 51 | -------------------------------------------------------------------------------- /packages/aws-cloudfront/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | -------------------------------------------------------------------------------- /packages/aws-component/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@next-deploy/aws-component", 3 | "version": "4.2.0", 4 | "license": "MIT", 5 | "main": "dist/component.js", 6 | "types": "dist/component.d.ts", 7 | "scripts": { 8 | "build": "yarn clean && yarn compile", 9 | "build:watch": "yarn compile -w", 10 | "clean": "rm -rf ./dist", 11 | "compile": "tsc -p tsconfig.build.json" 12 | }, 13 | "dependencies": { 14 | "@next-deploy/aws-cloudfront": "link:../aws-cloudfront", 15 | "@next-deploy/aws-domain": "link:../aws-domain", 16 | "@next-deploy/aws-lambda": "link:../aws-lambda", 17 | "@next-deploy/aws-lambda-builder": "link:../aws-lambda-builder", 18 | "@next-deploy/aws-s3": "link:../aws-s3" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/aws-component/src/component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@serverless/core'; 2 | import { readJSON } from 'fs-extra'; 3 | import { resolve, join } from 'path'; 4 | 5 | import Builder from '@next-deploy/aws-lambda-builder'; 6 | import AwsS3 from '@next-deploy/aws-s3'; 7 | import AwsCloudFront from '@next-deploy/aws-cloudfront'; 8 | import AwsDomain from '@next-deploy/aws-domain'; 9 | import { SubDomain } from '@next-deploy/aws-domain/types'; 10 | import AwsLambda from '@next-deploy/aws-lambda'; 11 | import { AwsLambdaInputs } from '@next-deploy/aws-lambda/types'; 12 | import { OriginRequestHandlerManifest as BuildManifest } from '@next-deploy/aws-lambda-builder/types'; 13 | import { Origin } from '@next-deploy/aws-cloudfront/types'; 14 | import { getDomains, load } from './utils'; 15 | import { DeploymentResult, AwsComponentInputs, LambdaType } from '../types'; 16 | 17 | export const BUILD_DIR = '.next-deploy-build'; 18 | export const REQUEST_LAMBDA_CODE_DIR = `${BUILD_DIR}/request-lambda`; 19 | 20 | class Aws extends Component { 21 | async default(inputs: AwsComponentInputs = {}): Promise { 22 | await this.build(inputs); 23 | return this.deploy(inputs); 24 | } 25 | 26 | readRequestLambdaBuildManifest(nextConfigPath: string): Promise { 27 | return readJSON(join(nextConfigPath, `${REQUEST_LAMBDA_CODE_DIR}/manifest.json`)); 28 | } 29 | 30 | validatePathPatterns(pathPatterns: string[], buildManifest: BuildManifest): void { 31 | const stillToMatch = new Set(pathPatterns); 32 | 33 | if (stillToMatch.size !== pathPatterns.length) { 34 | throw Error('Duplicate path declared in cloudfront configuration'); 35 | } 36 | 37 | // there wont be pages for these paths for this so we can remove them 38 | stillToMatch.delete('api/*'); 39 | stillToMatch.delete('static/*'); 40 | stillToMatch.delete('_next/static/*'); 41 | 42 | // check for other api like paths 43 | for (const path of stillToMatch) { 44 | if (/^(\/?api\/.*|\/?api)$/.test(path)) { 45 | throw Error(`Setting custom cache behaviour for api/ route "${path}" is not supported`); 46 | } 47 | } 48 | 49 | // setup containers for the paths we're going to be matching against 50 | 51 | // for dynamic routes 52 | const manifestRegex: RegExp[] = []; 53 | // for static routes 54 | const manifestPaths = new Set(); 55 | 56 | // extract paths to validate against from build manifest 57 | const ssrDynamic = buildManifest.pages.ssr.dynamic || {}; 58 | const ssrNonDynamic = buildManifest.pages.ssr.nonDynamic || {}; 59 | const htmlDynamic = buildManifest.pages.html.dynamic || {}; 60 | const htmlNonDynamic = buildManifest.pages.html.nonDynamic || {}; 61 | 62 | // dynamic paths to check. We use their regex to match against our input yaml 63 | Object.entries({ 64 | ...ssrDynamic, 65 | ...htmlDynamic, 66 | }).map(([, { regex }]) => { 67 | manifestRegex.push(new RegExp(regex)); 68 | }); 69 | 70 | // static paths to check 71 | Object.entries({ 72 | ...ssrNonDynamic, 73 | ...htmlNonDynamic, 74 | }).map(([path]) => { 75 | manifestPaths.add(path); 76 | }); 77 | 78 | // first we check if the path patterns match any of the dynamic page regex. 79 | // paths with stars (*) shouldn't cause any issues because the regex will treat these 80 | // as characters. 81 | manifestRegex.forEach((re) => { 82 | for (const path of stillToMatch) { 83 | if (re.test(path)) { 84 | stillToMatch.delete(path); 85 | } 86 | } 87 | }); 88 | 89 | // now we check the remaining unmatched paths against the non dynamic paths 90 | // and use the path as regex so that we are testing * 91 | for (const pathToMatch of stillToMatch) { 92 | for (const path of manifestPaths) { 93 | if (new RegExp(pathToMatch).test(path as string)) { 94 | stillToMatch.delete(pathToMatch); 95 | } 96 | } 97 | } 98 | 99 | if (stillToMatch.size > 0) { 100 | throw Error( 101 | `CloudFront input failed validation. Could not find next.js pages for "${[ 102 | ...stillToMatch, 103 | ]}"` 104 | ); 105 | } 106 | } 107 | 108 | async build({ build, nextConfigDir }: AwsComponentInputs = {}): Promise { 109 | this.context.status('Building'); 110 | 111 | const nextConfigPath = nextConfigDir ? resolve(nextConfigDir) : process.cwd(); 112 | const builder = new Builder(nextConfigPath, join(nextConfigPath, BUILD_DIR), { 113 | cmd: build?.cmd || 'node_modules/.bin/next', 114 | cwd: build?.cwd ? resolve(build.cwd) : nextConfigPath, 115 | args: build?.args || ['build'], 116 | }); 117 | 118 | await builder.build(this.context.instance.debugMode ? this.context.debug : undefined); 119 | } 120 | 121 | async deploy({ 122 | nextConfigDir, 123 | nextStaticDir, 124 | bucketRegion, 125 | bucketName, 126 | cloudfront: cloudfrontInput, 127 | policy, 128 | publicDirectoryCache, 129 | domain, 130 | domainType, 131 | stage, 132 | ...inputs 133 | }: AwsComponentInputs = {}): Promise { 134 | this.context.status('Deploying'); 135 | 136 | const nextConfigPath = nextConfigDir ? resolve(nextConfigDir) : process.cwd(); 137 | const nextStaticPath = nextStaticDir ? resolve(nextStaticDir) : nextConfigPath; 138 | const { 139 | defaults: cloudFrontDefaultsInputs, 140 | origins: cloudFrontOriginsInputs, 141 | priceClass: cloudFrontPriceClassInputs, 142 | ...cloudFrontOtherInputs 143 | } = cloudfrontInput || {}; 144 | const cloudFrontDefaults = cloudFrontDefaultsInputs || {}; 145 | const calculatedBucketRegion = bucketRegion || 'us-east-1'; 146 | const stageBucket = await load('@next-deploy/aws-s3', this, 'StageStateStorage'); 147 | const stageStateBucketName = 148 | (typeof stage !== 'boolean' && stage?.bucketName) || 'next-deploy-environments'; 149 | const isSyncStateVersioned = typeof stage !== 'boolean' && stage?.versioned; 150 | const stageName = stage?.name || 'local'; 151 | const canSyncStageState = stageName !== 'local'; 152 | 153 | if (canSyncStageState) { 154 | await stageBucket.default({ 155 | accelerated: true, 156 | name: stageStateBucketName, 157 | region: calculatedBucketRegion, 158 | }); 159 | 160 | await AwsS3.syncStageStateDirectory({ 161 | name: stageName, 162 | bucketName: stageStateBucketName, 163 | versioned: isSyncStateVersioned, 164 | nextConfigDir: nextConfigPath, 165 | credentials: this.context.credentials.aws, 166 | }); 167 | } 168 | 169 | const [bucket, cloudfront, requestEdgeLambda, defaultBuildManifest] = await Promise.all([ 170 | load('@next-deploy/aws-s3', this, 'StaticStorage'), 171 | load('@next-deploy/aws-cloudfront', this), 172 | load('@next-deploy/aws-lambda', this, 'RequestEdgeLambda'), 173 | this.readRequestLambdaBuildManifest(nextConfigPath), 174 | ]); 175 | const bucketOutputs = await bucket.default({ 176 | accelerated: true, 177 | name: bucketName, 178 | region: calculatedBucketRegion, 179 | }); 180 | const bucketUrl = `http://${bucketOutputs.name}.s3.${calculatedBucketRegion}.amazonaws.com`; 181 | 182 | await AwsS3.uploadStaticAssets({ 183 | bucketName: bucketOutputs.name, 184 | nextConfigDir: nextConfigPath, 185 | nextStaticDir: nextStaticPath, 186 | credentials: this.context.credentials.aws, 187 | publicDirectoryCache: publicDirectoryCache, 188 | }); 189 | 190 | // if the origin is relative path then prepend the bucketUrl 191 | // e.g. /path => http://bucket.s3.aws.com/path 192 | const expandRelativeUrls = (origin: string | Origin): string | Origin => { 193 | const originUrl = typeof origin === 'string' ? origin : origin.url; 194 | const fullOriginUrl = originUrl.charAt(0) === '/' ? `${bucketUrl}${originUrl}` : originUrl; 195 | 196 | if (typeof origin === 'string') { 197 | return fullOriginUrl; 198 | } else { 199 | return { 200 | ...origin, 201 | url: fullOriginUrl, 202 | }; 203 | } 204 | }; 205 | 206 | // parse origins from inputs 207 | let inputOrigins: any = []; 208 | if (cloudFrontOriginsInputs) { 209 | const origins = cloudFrontOriginsInputs as string[]; 210 | inputOrigins = origins.map(expandRelativeUrls); 211 | } 212 | 213 | const cloudFrontOrigins = [ 214 | { 215 | url: bucketUrl, 216 | private: true, 217 | pathPatterns: { 218 | '_next/static/*': { 219 | ttl: 86400, 220 | forward: { 221 | headers: 'none', 222 | cookies: 'none', 223 | queryString: false, 224 | }, 225 | }, 226 | 'static/*': { 227 | ttl: 86400, 228 | forward: { 229 | headers: 'none', 230 | cookies: 'none', 231 | queryString: false, 232 | }, 233 | }, 234 | }, 235 | }, 236 | ...inputOrigins, 237 | ]; 238 | const getLambdaInputValue = ( 239 | inputKey: 'memory' | 'timeout' | 'name' | 'runtime' | 'description', 240 | lambdaType: LambdaType, 241 | defaultValue: string | number | undefined 242 | ): string | number | undefined => { 243 | const inputValue = inputs[inputKey]; 244 | if (typeof inputValue === 'string' || typeof inputValue === 'number') { 245 | return inputValue; 246 | } 247 | 248 | if (!inputValue) { 249 | return defaultValue; 250 | } 251 | 252 | return inputValue[lambdaType] || defaultValue; 253 | }; 254 | 255 | const defaultEdgeLambdaInput: Partial = { 256 | handler: 'index.handler', 257 | code: join(nextConfigPath, REQUEST_LAMBDA_CODE_DIR), 258 | role: { 259 | service: ['lambda.amazonaws.com', 'edgelambda.amazonaws.com'], 260 | policy: { 261 | arn: policy || 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole', 262 | }, 263 | }, 264 | memory: getLambdaInputValue('memory', 'requestLambda', 512) as number, 265 | timeout: getLambdaInputValue('timeout', 'requestLambda', 10) as number, 266 | runtime: getLambdaInputValue('runtime', 'requestLambda', 'nodejs12.x') as string, 267 | name: getLambdaInputValue('name', 'requestLambda', undefined) as string, 268 | description: getLambdaInputValue( 269 | 'description', 270 | 'requestLambda', 271 | 'Request handler for the Next CloudFront distribution.' 272 | ) as string, 273 | }; 274 | 275 | const requestEdgeLambdaOutputs = await requestEdgeLambda.default(defaultEdgeLambdaInput); 276 | const requestEdgeLambdaPublishOutputs = await requestEdgeLambda.publishVersion(); 277 | 278 | // validate that the custom config paths match generated paths in the manifest 279 | this.validatePathPatterns(Object.keys(cloudFrontOtherInputs), defaultBuildManifest); 280 | 281 | // add any custom cloudfront configuration - this includes overrides for _next, static and api 282 | Object.entries(cloudFrontOtherInputs).map(([path, config]) => { 283 | const edgeConfig = { 284 | ...(config['lambda@edge'] || {}), 285 | }; 286 | 287 | if (!['static/*', '_next/*'].includes(path)) { 288 | // for everything but static/* and _next/* we want to ensure that they are pointing at our lambda 289 | edgeConfig[ 290 | 'origin-request' 291 | ] = `${requestEdgeLambdaOutputs.arn}:${requestEdgeLambdaPublishOutputs.version}`; 292 | } 293 | 294 | cloudFrontOrigins[0].pathPatterns[path] = { 295 | // spread the existing value if there is one 296 | ...cloudFrontOrigins[0].pathPatterns[path], 297 | // spread custom config 298 | ...config, 299 | 'lambda@edge': { 300 | // spread the provided value 301 | ...(cloudFrontOrigins[0].pathPatterns[path] && 302 | cloudFrontOrigins[0].pathPatterns[path]['lambda@edge']), 303 | // then overrides 304 | ...edgeConfig, 305 | }, 306 | }; 307 | }); 308 | 309 | cloudFrontOrigins[0].pathPatterns['_next/data/*'] = { 310 | ttl: 0, 311 | allowedHttpMethods: ['HEAD', 'GET'], 312 | 'lambda@edge': { 313 | 'origin-request': `${requestEdgeLambdaOutputs.arn}:${requestEdgeLambdaPublishOutputs.version}`, 314 | }, 315 | }; 316 | 317 | const defaultLambdaAtEdgeConfig = { 318 | ...(cloudFrontDefaults['lambda@edge'] || {}), 319 | }; 320 | 321 | const cloudFrontOutputs = await cloudfront.default({ 322 | defaults: { 323 | ttl: 0, 324 | ...cloudFrontDefaults, 325 | forward: { 326 | cookies: 'all', 327 | queryString: true, 328 | ...cloudFrontDefaults.forward, 329 | }, 330 | allowedHttpMethods: ['HEAD', 'DELETE', 'POST', 'GET', 'OPTIONS', 'PUT', 'PATCH'], 331 | 'lambda@edge': { 332 | ...defaultLambdaAtEdgeConfig, 333 | 'origin-request': `${requestEdgeLambdaOutputs.arn}:${requestEdgeLambdaPublishOutputs.version}`, 334 | }, 335 | compress: true, 336 | }, 337 | origins: cloudFrontOrigins, 338 | ...(cloudFrontPriceClassInputs && { 339 | priceClass: cloudFrontPriceClassInputs, 340 | }), 341 | }); 342 | 343 | let appUrl = cloudFrontOutputs.url; 344 | 345 | await AwsCloudFront.createInvalidation({ 346 | distributionId: cloudFrontOutputs.id, 347 | credentials: this.context.credentials.aws, 348 | }); 349 | 350 | const { domain: calculatedDomain, subdomain } = getDomains(domain); 351 | 352 | if (calculatedDomain && subdomain) { 353 | const domainComponent = await load('@next-deploy/aws-domain', this); 354 | const domainOutputs = await domainComponent.default({ 355 | privateZone: false, 356 | domain: calculatedDomain, 357 | subdomains: { 358 | [subdomain]: cloudFrontOutputs as SubDomain, 359 | }, 360 | domainType: domainType || 'both', 361 | defaultCloudfrontInputs: cloudFrontDefaults, 362 | }); 363 | appUrl = domainOutputs.domains[0]; 364 | } 365 | 366 | if (canSyncStageState) { 367 | await AwsS3.syncStageStateDirectory({ 368 | name: stageName, 369 | bucketName: stageStateBucketName, 370 | versioned: isSyncStateVersioned, 371 | nextConfigDir: nextConfigPath, 372 | credentials: this.context.credentials.aws, 373 | syncTo: true, 374 | }); 375 | } 376 | 377 | return { 378 | appUrl, 379 | bucketName: bucketOutputs.name, 380 | }; 381 | } 382 | 383 | async remove(): Promise { 384 | const [bucket, cloudfront, domain] = await Promise.all([ 385 | load('@next-deploy/aws-s3', this, 'StaticStorage'), 386 | load('@next-deploy/aws-cloudfront', this), 387 | load('@next-deploy/aws-domain', this), 388 | ]); 389 | 390 | await Promise.all([bucket.remove(), cloudfront.remove(), domain.remove()]); 391 | 392 | this.context.log( 393 | 'You will need to manually delete your deployed lambda functions as it may take a while (hours) for them to detech from CloudFront.' 394 | ); 395 | } 396 | } 397 | 398 | export default Aws; 399 | -------------------------------------------------------------------------------- /packages/aws-component/src/utils.ts: -------------------------------------------------------------------------------- 1 | export const getDomains = ( 2 | domains: string | string[] | undefined 3 | ): { domain?: string; subdomain?: string } => { 4 | if (typeof domains === 'string') { 5 | return { domain: domains, subdomain: 'www' }; 6 | } 7 | 8 | if (domains instanceof Array && domains.length) { 9 | return { 10 | domain: domains.length > 1 ? domains[1] : domains[0], 11 | subdomain: domains.length > 1 && domains[0] ? domains[0] : 'www', 12 | }; 13 | } 14 | 15 | return { domain: undefined, subdomain: undefined }; 16 | }; 17 | 18 | export const load = async (path: string, that: any, name?: string): Promise => { 19 | const EngineComponent = await import(path); 20 | const component = new EngineComponent.default( 21 | `${that.id}.${name || EngineComponent.default.name}`, 22 | that.context.instance 23 | ); 24 | await component.init(); 25 | 26 | component.context.log = () => ({}); 27 | component.context.status = () => ({}); 28 | component.context.output = () => ({}); 29 | 30 | return component; 31 | }; 32 | -------------------------------------------------------------------------------- /packages/aws-component/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "sourceMap": false, 5 | "removeComments": true, 6 | "outDir": "dist" 7 | }, 8 | "include": ["./src/", "../../.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/aws-component/types.d.ts: -------------------------------------------------------------------------------- 1 | import { PublicDirectoryCache } from '@next-deploy/aws-s3/types'; 2 | import { CloudFrontInputs } from '@next-deploy/aws-cloudfront/types'; 3 | import { DomainType } from '@next-deploy/aws-domain/types'; 4 | 5 | type AwsComponentInputs = BaseDeploymentOptions & { 6 | nextStaticDir?: string; 7 | bucketName?: string; 8 | bucketRegion?: string; 9 | publicDirectoryCache?: PublicDirectoryCache; 10 | memory?: number | { [key in LambdaType]: number }; 11 | timeout?: number | { [key in LambdaType]: number }; 12 | name?: string | { [key in LambdaType]: string }; 13 | runtime?: string | { [key in LambdaType]: string }; 14 | description?: string | { [key in LambdaType]: string }; 15 | policy?: string; 16 | domainType?: DomainType; 17 | cloudfront?: CloudFrontInputs; 18 | }; 19 | 20 | type LambdaType = 'requestLambda' | 'responseLambda'; 21 | 22 | type DeploymentResult = { 23 | appUrl: string; 24 | bucketName: string; 25 | }; 26 | -------------------------------------------------------------------------------- /packages/aws-component/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@next-deploy/aws-cloudfront@link:../aws-cloudfront": 6 | version "0.0.0" 7 | uid "" 8 | 9 | "@next-deploy/aws-domain@link:../aws-domain": 10 | version "0.0.0" 11 | uid "" 12 | 13 | "@next-deploy/aws-lambda-builder@link:../aws-lambda-builder": 14 | version "0.0.0" 15 | uid "" 16 | 17 | "@next-deploy/aws-lambda@link:../aws-lambda": 18 | version "0.0.0" 19 | uid "" 20 | 21 | "@next-deploy/aws-s3@link:../aws-s3": 22 | version "0.0.0" 23 | uid "" 24 | -------------------------------------------------------------------------------- /packages/aws-domain/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@next-deploy/aws-domain", 3 | "version": "4.2.0", 4 | "license": "MIT", 5 | "main": "dist/component.js", 6 | "types": "dist/component.d.ts", 7 | "scripts": { 8 | "build": "yarn clean && yarn compile", 9 | "build:watch": "yarn compile -w", 10 | "clean": "rm -rf ./dist", 11 | "compile": "tsc -p tsconfig.build.json" 12 | }, 13 | "dependencies": { 14 | "@next-deploy/aws-cloudfront": "link:../aws-cloudfront" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/aws-domain/src/component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@serverless/core'; 2 | 3 | import { 4 | getClients, 5 | prepareSubdomains, 6 | getDomainHostedZoneId, 7 | describeCertificateByArn, 8 | getCertificateArnByDomain, 9 | createCertificate, 10 | validateCertificate, 11 | configureDnsForCloudFrontDistribution, 12 | removeCloudFrontDomainDnsRecords, 13 | addDomainToCloudfrontDistribution, 14 | removeDomainFromCloudFrontDistribution, 15 | } from './utils'; 16 | import { AwsDomainInputs } from '../types'; 17 | 18 | class Domain extends Component { 19 | async default(inputs: AwsDomainInputs): Promise<{ domains: string[] }> { 20 | this.context.status('Deploying'); 21 | 22 | this.context.debug(`Starting Domain component deployment.`); 23 | 24 | this.context.debug(`Validating inputs.`); 25 | 26 | inputs.region = inputs.region || 'us-east-1'; 27 | inputs.privateZone = inputs.privateZone || false; 28 | inputs.domainType = inputs.domainType || 'both'; 29 | inputs.defaultCloudfrontInputs = inputs.defaultCloudfrontInputs || {}; 30 | 31 | if (!inputs.domain) { 32 | throw Error(`"domain" is a required input.`); 33 | } 34 | 35 | // TODO: Check if domain has changed. 36 | // On domain change, call remove for all previous state. 37 | 38 | // Get AWS SDK Clients 39 | const clients = getClients(this.context.credentials.aws, inputs.region); 40 | 41 | this.context.debug(`Formatting domains and identifying cloud services being used.`); 42 | const subdomains = prepareSubdomains(inputs); 43 | this.state.region = inputs.region; 44 | this.state.privateZone = inputs.privateZone; 45 | this.state.domain = inputs.domain; 46 | this.state.subdomains = subdomains; 47 | 48 | await this.save(); 49 | 50 | this.context.debug(`Getting the Hosted Zone ID for the domain ${inputs.domain}.`); 51 | const domainHostedZoneId = await getDomainHostedZoneId( 52 | clients.route53, 53 | inputs.domain, 54 | inputs.privateZone 55 | ); 56 | 57 | this.context.debug( 58 | `Searching for an AWS ACM Certificate based on the domain: ${inputs.domain}.` 59 | ); 60 | 61 | let certificateArn: string | null | undefined = await getCertificateArnByDomain( 62 | clients.acm, 63 | inputs.domain 64 | ); 65 | 66 | if (!certificateArn) { 67 | this.context.debug( 68 | `No existing AWS ACM Certificates found for the domain: ${inputs.domain}.` 69 | ); 70 | this.context.debug(`Creating a new AWS ACM Certificate for the domain: ${inputs.domain}.`); 71 | certificateArn = await createCertificate(clients.acm, inputs.domain); 72 | } 73 | 74 | if (!certificateArn) { 75 | throw Error(`Failed getting a certificateArn`); 76 | } 77 | 78 | this.context.debug(`Checking the status of AWS ACM Certificate.`); 79 | const certificate = await describeCertificateByArn(clients.acm, certificateArn); 80 | 81 | if (!certificate || !certificate.CertificateArn) { 82 | throw Error(`Failed getting a certificate for certificateArn = ${certificateArn}`); 83 | } 84 | 85 | if (certificate.Status === 'PENDING_VALIDATION') { 86 | this.context.debug(`AWS ACM Certificate Validation Status is "PENDING_VALIDATION".`); 87 | this.context.debug(`Validating AWS ACM Certificate via Route53 "DNS" method.`); 88 | await validateCertificate( 89 | clients.acm, 90 | clients.route53, 91 | certificate, 92 | inputs.domain, 93 | domainHostedZoneId 94 | ); 95 | this.context.log( 96 | 'Your AWS ACM Certificate has been created and is being validated via DNS. This could take up to 30 minutes since it depends on DNS propagation. Continuing deployment, but you may have to wait for DNS propagation.' 97 | ); 98 | } 99 | 100 | if (certificate.Status !== 'ISSUED' && certificate.Status !== 'PENDING_VALIDATION') { 101 | // TODO: Should we auto-create a new one in this scenario? 102 | throw new Error( 103 | `Your AWS ACM Certificate for the domain "${inputs.domain}" has an unsupported status of: "${certificate.Status}". Please remove it manually and deploy again.` 104 | ); 105 | } 106 | 107 | // Setting up domains for different services 108 | for (const subdomain of subdomains) { 109 | if (subdomain.type === 'awsS3Website') { 110 | throw new Error(`Unsupported subdomain type ${subdomain.type}`); 111 | } else if (subdomain.type === 'awsApiGateway') { 112 | throw new Error(`Unsupported subdomain type ${subdomain.type}`); 113 | } else if (subdomain.type === 'awsCloudFront') { 114 | this.context.debug( 115 | `Adding ${subdomain.domain} domain to CloudFront distribution with URL "${subdomain.url}"` 116 | ); 117 | await addDomainToCloudfrontDistribution( 118 | clients.cf, 119 | subdomain, 120 | certificate.CertificateArn, 121 | inputs.domainType, 122 | inputs.defaultCloudfrontInputs 123 | ); 124 | 125 | this.context.debug(`Configuring DNS for distribution "${subdomain.url}".`); 126 | await configureDnsForCloudFrontDistribution( 127 | clients.route53, 128 | subdomain, 129 | domainHostedZoneId, 130 | subdomain.url.replace('https://', ''), 131 | inputs.domainType 132 | ); 133 | } else if (subdomain.type === 'awsAppSync') { 134 | throw new Error(`Unsupported subdomain type ${subdomain.type}`); 135 | } 136 | } 137 | 138 | const outputs = { domains: [] as string[] }; 139 | let hasRoot = false; 140 | outputs.domains = subdomains.map((subdomain) => { 141 | if (subdomain.domain.startsWith('www')) { 142 | hasRoot = true; 143 | } 144 | return `https://${subdomain.domain}`; 145 | }); 146 | 147 | if (hasRoot && inputs.domainType !== 'www') { 148 | outputs.domains.unshift(`https://${inputs.domain.replace('www.', '')}`); 149 | } 150 | 151 | return outputs; 152 | } 153 | 154 | async remove(): Promise { 155 | this.context.status('Deploying'); 156 | 157 | if (!this.state.domain) { 158 | return; 159 | } 160 | 161 | this.context.debug(`Starting Domain component removal.`); 162 | 163 | // Get AWS SDK Clients 164 | const clients = getClients(this.context.credentials.aws, this.state.region); 165 | 166 | this.context.debug(`Getting the Hosted Zone ID for the domain ${this.state.domain}.`); 167 | const domainHostedZoneId = await getDomainHostedZoneId( 168 | clients.route53, 169 | this.state.domain, 170 | this.state.privateZone 171 | ); 172 | 173 | for (const subdomain in this.state.subdomains) { 174 | const domainState = this.state.subdomains[subdomain]; 175 | if (domainState.type === 'awsS3Website') { 176 | this.context.debug(`Unsupported subdomain type ${domainState.type}`); 177 | } else if (domainState.type === 'awsApiGateway') { 178 | this.context.debug(`Unsupported subdomain type ${domainState.type}`); 179 | } else if (domainState.type === 'awsCloudFront') { 180 | try { 181 | this.context.debug(`Removing domain ${domainState.domain} from CloudFront.`); 182 | await removeDomainFromCloudFrontDistribution(clients.cf, domainState); 183 | } catch (error) { 184 | this.context.debug(error.message); 185 | } 186 | 187 | this.context.debug(`Removing CloudFront DNS records for domain ${domainState.domain}`); 188 | 189 | await removeCloudFrontDomainDnsRecords( 190 | clients.route53, 191 | domainState.domain, 192 | domainHostedZoneId, 193 | domainState.url.replace('https://', '') 194 | ); 195 | } else if (domainState.type === 'awsAppSync') { 196 | this.context.debug(`Unsupported subdomain type ${domainState.type}`); 197 | } 198 | } 199 | 200 | this.state = {}; 201 | await this.save(); 202 | } 203 | } 204 | 205 | export default Domain; 206 | -------------------------------------------------------------------------------- /packages/aws-domain/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { Route53, ACM, CloudFront, Credentials } from 'aws-sdk'; 2 | import { utils } from '@serverless/core'; 3 | 4 | import { PathPatternConfig } from '@next-deploy/aws-cloudfront/types'; 5 | import { AwsDomainInputs, SubDomain, DomainType } from '../types'; 6 | 7 | const DEFAULT_MINIMUM_PROTOCOL_VERSION = 'TLSv1.2_2019'; 8 | const HOSTED_ZONE_ID = 'Z2FDTNDATAQYW2'; // this is a constant that you can get from here https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-route53-aliastarget.html 9 | 10 | /** 11 | * Get Clients 12 | * - Gets AWS SDK clients to use within this Component 13 | */ 14 | export const getClients = ( 15 | credentials: Credentials, 16 | region = 'us-east-1' 17 | ): { route53: Route53; acm: ACM; cf: CloudFront } => { 18 | const route53 = new Route53({ 19 | credentials, 20 | region, 21 | }); 22 | 23 | const acm = new ACM({ 24 | credentials, 25 | region: 'us-east-1', // ACM must be in us-east-1 26 | }); 27 | 28 | const cf = new CloudFront({ 29 | credentials, 30 | region, 31 | }); 32 | 33 | return { 34 | route53, 35 | acm, 36 | cf, 37 | }; 38 | }; 39 | 40 | /** 41 | * Prepare Domains 42 | * - Formats component domains & identifies cloud services they're using. 43 | */ 44 | export const prepareSubdomains = (inputs: AwsDomainInputs): SubDomain[] => { 45 | const subdomains = []; 46 | 47 | for (const subdomain in inputs.subdomains || {}) { 48 | const domainObj: Partial = {}; 49 | 50 | domainObj.domain = `${subdomain}.${inputs.domain}`; 51 | 52 | if (inputs.subdomains[subdomain].url.includes('cloudfront')) { 53 | domainObj.distributionId = inputs.subdomains[subdomain].id; 54 | domainObj.url = inputs.subdomains[subdomain].url; 55 | domainObj.type = 'awsCloudFront'; 56 | } 57 | 58 | subdomains.push(domainObj); 59 | } 60 | 61 | return subdomains as SubDomain[]; 62 | }; 63 | 64 | /** 65 | * Get Domain Hosted Zone ID 66 | * - Every Domain on Route53 always has a Hosted Zone w/ 2 Record Sets. 67 | * - These Record Sets are: "Name Servers (NS)" & "Start of Authority (SOA)" 68 | * - These don't need to be created and SHOULD NOT be modified. 69 | */ 70 | export const getDomainHostedZoneId = async ( 71 | route53: Route53, 72 | domain: string, 73 | privateZone: boolean 74 | ): Promise => { 75 | const hostedZonesRes = await route53.listHostedZonesByName().promise(); 76 | 77 | const hostedZone = hostedZonesRes.HostedZones.find( 78 | // Name has a period at the end, so we're using includes rather than equals 79 | (zone) => zone?.Config?.PrivateZone === privateZone && zone.Name.includes(domain) 80 | ); 81 | 82 | if (!hostedZone) { 83 | throw Error( 84 | `Domain ${domain} was not found in your AWS account. Please purchase it from Route53 first then try again.` 85 | ); 86 | } 87 | 88 | return hostedZone.Id.replace('/hostedzone/', ''); // hosted zone id is always prefixed with this :( 89 | }; 90 | 91 | /** 92 | * Describe Certificate By Arn 93 | * - Describe an AWS ACM Certificate by its ARN 94 | */ 95 | export const describeCertificateByArn = async ( 96 | acm: ACM, 97 | certificateArn: string 98 | ): Promise => { 99 | const certificate = await acm.describeCertificate({ CertificateArn: certificateArn }).promise(); 100 | return certificate && certificate.Certificate ? certificate.Certificate : null; 101 | }; 102 | 103 | /** 104 | * Get Certificate Arn By Domain 105 | * - Gets an AWS ACM Certificate by a specified domain or return null 106 | */ 107 | export const getCertificateArnByDomain = async ( 108 | acm: ACM, 109 | domain: string 110 | ): Promise => { 111 | const listRes = await acm.listCertificates().promise(); 112 | 113 | if (!listRes.CertificateSummaryList) { 114 | throw new Error('Could not get a list of certificates'); 115 | } 116 | 117 | for (const certificate of listRes.CertificateSummaryList) { 118 | if (certificate.DomainName === domain && certificate.CertificateArn) { 119 | if (domain.startsWith('www.')) { 120 | const nakedDomain = domain.replace('wwww.', ''); 121 | // check whether certificate support naked domain 122 | const certDetail = await describeCertificateByArn(acm, certificate.CertificateArn); 123 | 124 | if (!certDetail?.DomainValidationOptions) { 125 | throw new Error('Could not get a domain validation options'); 126 | } 127 | 128 | const nakedDomainCert = certDetail.DomainValidationOptions.find( 129 | ({ DomainName }) => DomainName === nakedDomain 130 | ); 131 | 132 | if (!nakedDomainCert) { 133 | continue; 134 | } 135 | } 136 | 137 | return certificate.CertificateArn; 138 | } 139 | } 140 | 141 | return null; 142 | }; 143 | 144 | /** 145 | * Create Certificate 146 | * - Creates an AWS ACM Certificate for the specified domain 147 | */ 148 | export const createCertificate = async (acm: ACM, domain: string): Promise => { 149 | const wildcardSubDomain = `*.${domain}`; 150 | 151 | const params = { 152 | DomainName: domain, 153 | SubjectAlternativeNames: [domain, wildcardSubDomain], 154 | ValidationMethod: 'DNS', 155 | }; 156 | 157 | const res = await acm.requestCertificate(params).promise(); 158 | 159 | return res.CertificateArn; 160 | }; 161 | 162 | /** 163 | * Validate Certificate 164 | * - Validate an AWS ACM Certificate via the "DNS" method 165 | */ 166 | export const validateCertificate = async ( 167 | acm: ACM, 168 | route53: Route53, 169 | certificate: ACM.CertificateDetail, 170 | domain: string, 171 | domainHostedZoneId: string 172 | ): Promise => { 173 | let readinessCheckCount = 16; 174 | let statusCheckCount = 16; 175 | 176 | /** 177 | * Check Readiness 178 | * - Newly Created AWS ACM Certificates may not yet have the info needed to validate it 179 | * - Specifically, the "ResourceRecord" object in the Domain Validation Options 180 | * - Ensure this exists. 181 | */ 182 | const checkReadiness = async function (): Promise { 183 | if (readinessCheckCount < 1) { 184 | throw new Error( 185 | 'Your newly created AWS ACM Certificate is taking a while to initialize. Please try running this component again in a few minutes.' 186 | ); 187 | } 188 | 189 | const cert = await describeCertificateByArn(acm, certificate.CertificateArn as string); 190 | 191 | if (!cert?.DomainValidationOptions) { 192 | throw new Error(`Could not get a certificate by ${certificate.CertificateArn}`); 193 | } 194 | 195 | // Find root domain validation option resource record 196 | cert.DomainValidationOptions.forEach((option) => { 197 | if (domain === option.DomainName && option.ResourceRecord) { 198 | return option.ResourceRecord; 199 | } 200 | }); 201 | 202 | readinessCheckCount--; 203 | await utils.sleep(5000); 204 | 205 | return await checkReadiness(); 206 | }; 207 | 208 | const validationResourceRecord = await checkReadiness(); 209 | 210 | const checkRecordsParams = { 211 | HostedZoneId: domainHostedZoneId, 212 | MaxItems: '10', 213 | StartRecordName: validationResourceRecord.Name, 214 | }; 215 | 216 | // Check if the validation resource record sets already exist. 217 | // This might be the case if the user is trying to deploy multiple times while validation is occurring. 218 | const existingRecords = await route53.listResourceRecordSets(checkRecordsParams).promise(); 219 | 220 | if (!existingRecords.ResourceRecordSets.length) { 221 | // Create CNAME record for DNS validation check 222 | // NOTE: It can take 30 minutes or longer for DNS propagation so validation can complete, just continue on and don't wait for this... 223 | const recordParams = { 224 | HostedZoneId: domainHostedZoneId, 225 | ChangeBatch: { 226 | Changes: [ 227 | { 228 | Action: 'UPSERT', 229 | ResourceRecordSet: { 230 | Name: validationResourceRecord.Name, 231 | Type: validationResourceRecord.Type, 232 | TTL: 300, 233 | ResourceRecords: [ 234 | { 235 | Value: validationResourceRecord.Value, 236 | }, 237 | ], 238 | }, 239 | }, 240 | ], 241 | }, 242 | }; 243 | 244 | await route53.changeResourceRecordSets(recordParams).promise(); 245 | } 246 | 247 | /** 248 | * Check Validated Status 249 | * - Newly Validated AWS ACM Certificates may not yet show up as valid 250 | * - This gives them some time to update their status. 251 | */ 252 | const checkStatus = async function (): Promise { 253 | if (statusCheckCount < 1) { 254 | throw new Error( 255 | 'Your newly validated AWS ACM Certificate is taking a while to register as valid. Please try running this component again in a few minutes.' 256 | ); 257 | } 258 | 259 | const cert = await describeCertificateByArn(acm, certificate.CertificateArn as string); 260 | 261 | if (cert?.Status !== 'ISSUED') { 262 | statusCheckCount--; 263 | await utils.sleep(10000); 264 | return await checkStatus(); 265 | } 266 | }; 267 | 268 | await checkStatus(); 269 | }; 270 | 271 | /** 272 | * Configure DNS records for a distribution domain 273 | */ 274 | export const configureDnsForCloudFrontDistribution = async ( 275 | route53: Route53, 276 | subdomain: SubDomain, 277 | domainHostedZoneId: string, 278 | distributionUrl: string, 279 | domainType: DomainType 280 | ): Promise => { 281 | const dnsRecordParams = { 282 | HostedZoneId: domainHostedZoneId, 283 | ChangeBatch: { 284 | Changes: [], 285 | }, 286 | }; 287 | 288 | // don't create www records for apex mode 289 | if (!subdomain.domain.startsWith('www.') || domainType !== 'apex') { 290 | dnsRecordParams.ChangeBatch.Changes.push({ 291 | Action: 'UPSERT', 292 | ResourceRecordSet: { 293 | Name: subdomain.domain, 294 | Type: 'A', 295 | AliasTarget: { 296 | HostedZoneId: HOSTED_ZONE_ID, 297 | DNSName: distributionUrl, 298 | EvaluateTargetHealth: false, 299 | }, 300 | }, 301 | } as never); 302 | } 303 | 304 | // don't create apex records for www mode 305 | if (subdomain.domain.startsWith('www.') && domainType !== 'www') { 306 | dnsRecordParams.ChangeBatch.Changes.push({ 307 | Action: 'UPSERT', 308 | ResourceRecordSet: { 309 | Name: subdomain.domain.replace('www.', ''), 310 | Type: 'A', 311 | AliasTarget: { 312 | HostedZoneId: HOSTED_ZONE_ID, 313 | DNSName: distributionUrl, 314 | EvaluateTargetHealth: false, 315 | }, 316 | }, 317 | } as never); 318 | } 319 | 320 | return route53.changeResourceRecordSets(dnsRecordParams).promise(); 321 | }; 322 | 323 | /** 324 | * Remove AWS CloudFront Website DNS Records 325 | */ 326 | export const removeCloudFrontDomainDnsRecords = async ( 327 | route53: Route53, 328 | domain: string, 329 | domainHostedZoneId: string, 330 | distributionUrl: string 331 | ): Promise => { 332 | const params = { 333 | HostedZoneId: domainHostedZoneId, 334 | ChangeBatch: { 335 | Changes: [ 336 | { 337 | Action: 'DELETE', 338 | ResourceRecordSet: { 339 | Name: domain, 340 | Type: 'A', 341 | AliasTarget: { 342 | HostedZoneId: HOSTED_ZONE_ID, 343 | DNSName: distributionUrl, 344 | EvaluateTargetHealth: false, 345 | }, 346 | }, 347 | }, 348 | ], 349 | }, 350 | }; 351 | 352 | // TODO: should the CNAME records be removed too? 353 | 354 | try { 355 | await route53.changeResourceRecordSets(params).promise(); 356 | } catch (e) { 357 | if (e.code !== 'InvalidChangeBatch') { 358 | throw e; 359 | } 360 | } 361 | }; 362 | 363 | export const addDomainToCloudfrontDistribution = async ( 364 | cf: CloudFront, 365 | subdomain: SubDomain, 366 | certificateArn: string, 367 | domainType: DomainType, 368 | { viewerCertificate }: PathPatternConfig 369 | ): Promise<{ 370 | id?: string; 371 | arn?: string; 372 | url?: string; 373 | }> => { 374 | const distributionConfigResponse = await cf 375 | .getDistributionConfig({ Id: subdomain.distributionId }) 376 | .promise(); 377 | 378 | if (!distributionConfigResponse.DistributionConfig) { 379 | throw new Error('Could not get a distribution config'); 380 | } 381 | 382 | const updateDistributionRequest = { 383 | IfMatch: distributionConfigResponse.ETag, 384 | Id: subdomain.distributionId, 385 | DistributionConfig: { 386 | ...distributionConfigResponse.DistributionConfig, 387 | Aliases: { 388 | Quantity: 1, 389 | Items: [subdomain.domain], 390 | }, 391 | ViewerCertificate: { 392 | ACMCertificateArn: viewerCertificate?.ACMCertificateArn || certificateArn, 393 | SSLSupportMethod: viewerCertificate?.SSLSupportMethod || 'sni-only', 394 | MinimumProtocolVersion: 395 | viewerCertificate?.minimumProtocolVersion || DEFAULT_MINIMUM_PROTOCOL_VERSION, 396 | Certificate: certificateArn, 397 | CertificateSource: 'acm', 398 | }, 399 | }, 400 | }; 401 | 402 | if (subdomain.domain.startsWith('www.')) { 403 | if (domainType === 'apex') { 404 | updateDistributionRequest.DistributionConfig.Aliases.Items = [ 405 | `${subdomain.domain.replace('www.', '')}`, 406 | ]; 407 | } else if (domainType !== 'www') { 408 | updateDistributionRequest.DistributionConfig.Aliases.Quantity = 2; 409 | updateDistributionRequest.DistributionConfig.Aliases.Items.push( 410 | `${subdomain.domain.replace('www.', '')}` 411 | ); 412 | } 413 | } 414 | 415 | const res = await cf.updateDistribution(updateDistributionRequest).promise(); 416 | 417 | return { 418 | id: res?.Distribution?.Id, 419 | arn: res?.Distribution?.ARN, 420 | url: res?.Distribution?.DomainName, 421 | }; 422 | }; 423 | 424 | export const removeDomainFromCloudFrontDistribution = async ( 425 | cf: CloudFront, 426 | subdomain: SubDomain 427 | ): Promise<{ 428 | id?: string; 429 | arn?: string; 430 | url?: string; 431 | }> => { 432 | const distributionConfigResponse = await cf 433 | .getDistributionConfig({ Id: subdomain.distributionId }) 434 | .promise(); 435 | 436 | if (!distributionConfigResponse.DistributionConfig) { 437 | throw new Error('Could not get a distribution config'); 438 | } 439 | 440 | const updateDistributionRequest = { 441 | Id: subdomain.distributionId, 442 | IfMatch: distributionConfigResponse.ETag, 443 | DistributionConfig: { 444 | ...distributionConfigResponse.DistributionConfig, 445 | Aliases: { 446 | Quantity: 0, 447 | Items: [], 448 | }, 449 | ViewerCertificate: { 450 | SSLSupportMethod: 'sni-only', 451 | MinimumProtocolVersion: DEFAULT_MINIMUM_PROTOCOL_VERSION, 452 | }, 453 | }, 454 | }; 455 | 456 | const res = await cf.updateDistribution(updateDistributionRequest).promise(); 457 | 458 | return { 459 | id: res?.Distribution?.Id, 460 | arn: res?.Distribution?.ARN, 461 | url: res?.Distribution?.DomainName, 462 | }; 463 | }; 464 | -------------------------------------------------------------------------------- /packages/aws-domain/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "sourceMap": false, 5 | "removeComments": true, 6 | "outDir": "dist" 7 | }, 8 | "include": ["./src/", "../../.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/aws-domain/types.d.ts: -------------------------------------------------------------------------------- 1 | import { PathPatternConfig } from '@next-deploy/aws-cloudfront/types'; 2 | 3 | type AwsDomainInputs = { 4 | domain: string; 5 | region?: string; 6 | privateZone?: boolean; 7 | domainType?: DomainType; 8 | defaultCloudfrontInputs?: PathPatternConfig; 9 | subdomains: Record; 10 | }; 11 | 12 | type SubDomain = { 13 | id: string; 14 | domain: string; 15 | distributionId: string; 16 | url: string; 17 | type: SubDomainType; 18 | }; 19 | 20 | type SubDomainType = 'awsCloudFront' | 'awsS3Website' | 'awsApiGateway' | 'awsAppSync'; 21 | type DomainType = 'www' | 'apex' | 'both'; 22 | -------------------------------------------------------------------------------- /packages/aws-domain/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@next-deploy/aws-cloudfront@link:../aws-cloudfront": 6 | version "0.0.0" 7 | uid "" 8 | -------------------------------------------------------------------------------- /packages/aws-iam-role/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@next-deploy/aws-iam-role", 3 | "version": "4.2.0", 4 | "license": "MIT", 5 | "main": "dist/component.js", 6 | "types": "dist/component.d.ts", 7 | "scripts": { 8 | "build": "yarn clean && yarn compile", 9 | "build:watch": "yarn compile -w", 10 | "clean": "rm -rf ./dist", 11 | "compile": "tsc -p tsconfig.build.json" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/aws-iam-role/src/component.ts: -------------------------------------------------------------------------------- 1 | import { equals, mergeDeepRight } from 'ramda'; 2 | import { IAM } from 'aws-sdk'; 3 | import { Component } from '@serverless/core'; 4 | 5 | import { Role, Policy } from '../types'; 6 | import { 7 | createRole, 8 | deleteRole, 9 | getRole, 10 | addRolePolicy, 11 | removeRolePolicy, 12 | updateAssumeRolePolicy, 13 | inputsChanged, 14 | } from './utils'; 15 | 16 | const defaults: Role = { 17 | service: 'lambda.amazonaws.com', 18 | policy: { 19 | arn: 'arn:aws:iam::aws:policy/AdministratorAccess', 20 | }, 21 | region: 'us-east-1', 22 | }; 23 | 24 | class IamRole extends Component { 25 | async default( 26 | inputs: Role = {} 27 | ): Promise<{ 28 | name: string; 29 | arn: string; 30 | service: string | string[]; 31 | policy: Policy; 32 | }> { 33 | inputs = mergeDeepRight(defaults, inputs) as Role; 34 | const iam = new IAM({ region: inputs.region, credentials: this.context.credentials.aws }); 35 | 36 | this.context.status('Deploying'); 37 | 38 | inputs.name = this.state.name || this.context.resourceId(); 39 | 40 | this.context.debug(`Syncing role ${inputs.name} in region ${inputs.region}.`); 41 | const prevRole = await getRole({ iam, name: inputs.name as string }); 42 | 43 | if (!prevRole) { 44 | this.context.debug(`Creating role ${inputs.name}.`); 45 | this.context.status('Creating'); 46 | inputs.arn = await createRole({ 47 | iam, 48 | name: inputs.name as string, 49 | service: inputs.service as string | string[], 50 | policy: inputs.policy as Policy, 51 | }); 52 | } else { 53 | inputs.arn = prevRole.arn; 54 | 55 | if (inputsChanged(prevRole as Role, inputs as Role)) { 56 | this.context.status(`Updating`); 57 | if (prevRole.service !== inputs.service) { 58 | this.context.debug(`Updating service for role ${inputs.name}.`); 59 | await updateAssumeRolePolicy({ 60 | iam, 61 | name: inputs.name as string, 62 | service: inputs.service as string | string[], 63 | }); 64 | } 65 | if (!equals(prevRole.policy, inputs.policy)) { 66 | this.context.debug(`Updating policy for role ${inputs.name}.`); 67 | await removeRolePolicy({ 68 | iam, 69 | name: inputs.name as string, 70 | policy: inputs.policy as Policy, 71 | }); 72 | await addRolePolicy({ 73 | iam, 74 | name: inputs.name as string, 75 | policy: inputs.policy as Policy, 76 | }); 77 | } 78 | } 79 | } 80 | 81 | // todo we probably don't need this logic now that 82 | // we auto generate unconfigurable names 83 | if (this.state.name && this.state.name !== inputs.name) { 84 | this.context.status(`Replacing`); 85 | this.context.debug(`Deleting/Replacing role ${inputs.name}.`); 86 | await deleteRole({ iam, name: this.state.name, policy: inputs.policy as Policy }); 87 | } 88 | 89 | this.state.name = inputs.name; 90 | this.state.arn = inputs.arn; 91 | this.state.service = inputs.service; 92 | this.state.policy = inputs.policy; 93 | this.state.region = inputs.region; 94 | 95 | await this.save(); 96 | 97 | this.context.debug(`Saved state for role ${inputs.name}.`); 98 | 99 | const outputs = { 100 | name: inputs.name as string, 101 | arn: inputs.arn as string, 102 | service: inputs.service as string | string[], 103 | policy: inputs.policy as Policy, 104 | }; 105 | 106 | this.context.debug(`Role ${inputs.name} was successfully deployed to region ${inputs.region}.`); 107 | this.context.debug(`Deployed role arn is ${inputs.arn}.`); 108 | 109 | return outputs; 110 | } 111 | 112 | async remove(): Promise { 118 | this.context.status('Removing'); 119 | 120 | if (!this.state.name) { 121 | this.context.debug('Aborting removal. Role name not found in state.'); 122 | return; 123 | } 124 | 125 | const iam = new IAM({ 126 | region: this.state.region, 127 | credentials: this.context.credentials.aws, 128 | }); 129 | 130 | this.context.debug(`Removing role ${this.state.name} from region ${this.state.region}.`); 131 | await deleteRole({ iam, ...this.state }); 132 | this.context.debug( 133 | `Role ${this.state.name} successfully removed from region ${this.state.region}.` 134 | ); 135 | 136 | const outputs = { 137 | name: this.state.name, 138 | arn: this.state.arn, 139 | service: this.state.service, 140 | policy: this.state.policy, 141 | }; 142 | 143 | this.state = {}; 144 | await this.save(); 145 | 146 | return outputs; 147 | } 148 | } 149 | 150 | export default IamRole; 151 | -------------------------------------------------------------------------------- /packages/aws-iam-role/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { utils } from '@serverless/core'; 2 | import { IAM } from 'aws-sdk'; 3 | import { equals, isEmpty, has, not, pick, type } from 'ramda'; 4 | 5 | import { Policy, Role } from '../types'; 6 | 7 | export const addRolePolicy = async ({ 8 | iam, 9 | name, 10 | policy, 11 | }: { 12 | iam: IAM; 13 | name: string; 14 | policy: Policy; 15 | }): Promise => { 16 | if (has('arn', policy)) { 17 | await iam 18 | .attachRolePolicy({ 19 | RoleName: name, 20 | PolicyArn: policy.arn, 21 | }) 22 | .promise(); 23 | } else if (!isEmpty(policy)) { 24 | await iam 25 | .putRolePolicy({ 26 | RoleName: name, 27 | PolicyName: `${name}-policy`, 28 | PolicyDocument: JSON.stringify(policy), 29 | }) 30 | .promise(); 31 | } 32 | 33 | return utils.sleep(15000); 34 | }; 35 | 36 | export const removeRolePolicy = async ({ 37 | iam, 38 | name, 39 | policy, 40 | }: { 41 | iam: IAM; 42 | name: string; 43 | policy: Policy; 44 | }): Promise => { 45 | if (has('arn', policy)) { 46 | await iam 47 | .detachRolePolicy({ 48 | RoleName: name, 49 | PolicyArn: policy.arn, 50 | }) 51 | .promise(); 52 | } else if (!isEmpty(policy)) { 53 | await iam 54 | .deleteRolePolicy({ 55 | RoleName: name, 56 | PolicyName: `${name}-policy`, 57 | }) 58 | .promise(); 59 | } 60 | }; 61 | 62 | export const createRole = async ({ 63 | iam, 64 | name, 65 | service, 66 | policy, 67 | }: { 68 | iam: IAM; 69 | name: string; 70 | service: string | string[]; 71 | policy: Policy; 72 | }): Promise => { 73 | const assumeRolePolicyDocument = { 74 | Version: '2012-10-17', 75 | Statement: { 76 | Effect: 'Allow', 77 | Principal: { 78 | Service: service, 79 | }, 80 | Action: 'sts:AssumeRole', 81 | }, 82 | }; 83 | const roleRes = await iam 84 | .createRole({ 85 | RoleName: name, 86 | Path: '/', 87 | AssumeRolePolicyDocument: JSON.stringify(assumeRolePolicyDocument), 88 | }) 89 | .promise(); 90 | 91 | await addRolePolicy({ 92 | iam, 93 | name, 94 | policy, 95 | }); 96 | 97 | return roleRes.Role.Arn; 98 | }; 99 | 100 | export const deleteRole = async ({ 101 | iam, 102 | name, 103 | policy, 104 | }: { 105 | iam: IAM; 106 | name: string; 107 | policy: Policy; 108 | }): Promise => { 109 | try { 110 | await removeRolePolicy({ 111 | iam, 112 | name, 113 | policy, 114 | }); 115 | await iam 116 | .deleteRole({ 117 | RoleName: name, 118 | }) 119 | .promise(); 120 | } catch (error) { 121 | if (error.message !== `Policy ${policy.arn} was not found.` && error.code !== 'NoSuchEntity') { 122 | throw error; 123 | } 124 | } 125 | }; 126 | 127 | export const getRole = async ({ 128 | iam, 129 | name, 130 | }: { 131 | iam: IAM; 132 | name: string; 133 | }): Promise> => { 134 | try { 135 | const res = await iam.getRole({ RoleName: name }).promise(); 136 | // todo add policy 137 | return { 138 | name: res.Role.RoleName, 139 | arn: res.Role.Arn, 140 | service: JSON.parse(decodeURIComponent(res.Role.AssumeRolePolicyDocument as string)) 141 | .Statement[0].Principal.Service, 142 | }; 143 | } catch (e) { 144 | if (e.message.includes('cannot be found')) { 145 | return null; 146 | } 147 | throw e; 148 | } 149 | }; 150 | 151 | export const updateAssumeRolePolicy = async ({ 152 | iam, 153 | name, 154 | service, 155 | }: { 156 | iam: IAM; 157 | name: string; 158 | service: string | string[]; 159 | }): Promise => { 160 | const assumeRolePolicyDocument = { 161 | Version: '2012-10-17', 162 | Statement: { 163 | Effect: 'Allow', 164 | Principal: { 165 | Service: service, 166 | }, 167 | Action: 'sts:AssumeRole', 168 | }, 169 | }; 170 | await iam 171 | .updateAssumeRolePolicy({ 172 | RoleName: name, 173 | PolicyDocument: JSON.stringify(assumeRolePolicyDocument), 174 | }) 175 | .promise(); 176 | }; 177 | 178 | export const inputsChanged = (prevRole: Role, role: Role): boolean => { 179 | // todo add name and policy 180 | const inputs = pick(['service'], role); 181 | const prevInputs = pick(['service'], prevRole); 182 | 183 | if (type(inputs.service) === 'Array') { 184 | //@ts-ignore 185 | inputs?.service?.sort(); 186 | } 187 | if (type(prevInputs.service) === 'Array') { 188 | //@ts-ignore 189 | prevInputs?.service?.sort(); 190 | } 191 | 192 | return not(equals(inputs, prevInputs)); 193 | }; 194 | -------------------------------------------------------------------------------- /packages/aws-iam-role/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "sourceMap": false, 5 | "removeComments": true, 6 | "outDir": "dist" 7 | }, 8 | "include": ["./src/", "../../.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/aws-iam-role/types.d.ts: -------------------------------------------------------------------------------- 1 | export type Role = { 2 | name?: string; 3 | region?: string; 4 | policy?: Policy; 5 | service?: string | string[]; 6 | arn?: string; 7 | }; 8 | 9 | type Policy = { 10 | arn: string; 11 | }; 12 | -------------------------------------------------------------------------------- /packages/aws-lambda-builder/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@next-deploy/aws-lambda-builder", 3 | "version": "4.2.0", 4 | "license": "MIT", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "build": "yarn clean && yarn compile", 9 | "build:watch": "yarn compile -w", 10 | "clean": "rm -rf ./dist", 11 | "compile": "tsc -p tsconfig.build.json" 12 | }, 13 | "devDependencies": { 14 | "@next-deploy/aws-lambda-builder": "link:../aws-lambda-builder" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/aws-lambda-builder/src/compat.ts: -------------------------------------------------------------------------------- 1 | import Stream from 'stream'; 2 | import zlib from 'zlib'; 3 | import { CloudFrontResultResponse, CloudFrontHeaders, CloudFrontRequest } from 'aws-lambda'; 4 | import { IncomingMessage, OutgoingHttpHeaders } from 'http'; 5 | 6 | import { PrivateServerResponse } from '../types'; 7 | 8 | const readOnlyCloudFrontHeaders: { [key: string]: boolean } = { 9 | 'accept-encoding': true, 10 | 'content-length': true, 11 | 'if-modified-since': true, 12 | 'if-none-match': true, 13 | 'if-range': true, 14 | 'if-unmodified-since': true, 15 | 'transfer-encoding': true, 16 | via: true, 17 | }; 18 | 19 | const HttpStatusCodes: Record = { 20 | 202: 'Accepted', 21 | 502: 'Bad Gateway', 22 | 400: 'Bad Request', 23 | 409: 'Conflict', 24 | 100: 'Continue', 25 | 201: 'Created', 26 | 417: 'Expectation Failed', 27 | 424: 'Failed Dependency', 28 | 403: 'Forbidden', 29 | 504: 'Gateway Timeout', 30 | 410: 'Gone', 31 | 505: 'HTTP Version Not Supported', 32 | 418: "I'm a teapot", 33 | 419: 'Insufficient Space on Resource', 34 | 507: 'Insufficient Storage', 35 | 500: 'Server Error', 36 | 411: 'Length Required', 37 | 423: 'Locked', 38 | 420: 'Method Failure', 39 | 405: 'Method Not Allowed', 40 | 301: 'Moved Permanently', 41 | 302: 'Moved Temporarily', 42 | 207: 'Multi-Status', 43 | 300: 'Multiple Choices', 44 | 511: 'Network Authentication Required', 45 | 204: 'No Content', 46 | 203: 'Non Authoritative Information', 47 | 406: 'Not Acceptable', 48 | 404: 'Not Found', 49 | 501: 'Not Implemented', 50 | 304: 'Not Modified', 51 | 200: 'OK', 52 | 206: 'Partial Content', 53 | 402: 'Payment Required', 54 | 308: 'Permanent Redirect', 55 | 412: 'Precondition Failed', 56 | 428: 'Precondition Required', 57 | 102: 'Processing', 58 | 407: 'Proxy Authentication Required', 59 | 431: 'Request Header Fields Too Large', 60 | 408: 'Request Timeout', 61 | 413: 'Request Entity Too Large', 62 | 414: 'Request-URI Too Long', 63 | 416: 'Requested Range Not Satisfiable', 64 | 205: 'Reset Content', 65 | 303: 'See Other', 66 | 503: 'Service Unavailable', 67 | 101: 'Switching Protocols', 68 | 307: 'Temporary Redirect', 69 | 429: 'Too Many Requests', 70 | 401: 'Unauthorized', 71 | 422: 'Unprocessable Entity', 72 | 415: 'Unsupported Media Type', 73 | 305: 'Use Proxy', 74 | }; 75 | 76 | const toCloudFrontHeaders = (headers: OutgoingHttpHeaders) => { 77 | const result: { [key: string]: any } = {}; 78 | 79 | Object.keys(headers).forEach((headerName) => { 80 | const lowerCaseHeaderName = headerName.toLowerCase(); 81 | const headerValue = headers[headerName]; 82 | 83 | if (readOnlyCloudFrontHeaders[lowerCaseHeaderName]) { 84 | return; 85 | } 86 | 87 | result[lowerCaseHeaderName] = []; 88 | 89 | if (headerValue instanceof Array) { 90 | headerValue.forEach((val) => { 91 | result[lowerCaseHeaderName].push({ 92 | key: headerName, 93 | value: val.toString(), 94 | }); 95 | }); 96 | } else { 97 | result[lowerCaseHeaderName].push({ 98 | key: headerName, 99 | value: (headerValue as string).toString(), 100 | }); 101 | } 102 | }); 103 | 104 | return result; 105 | }; 106 | 107 | const isGzipSupported = (headers: CloudFrontHeaders) => { 108 | let gz = false; 109 | const ae = headers['accept-encoding']; 110 | 111 | if (ae) { 112 | for (let i = 0; i < ae.length; i++) { 113 | const { value } = ae[i]; 114 | const bits = value.split(',').map((x) => x.split(';')[0].trim()); 115 | if (bits.indexOf('gzip') !== -1) { 116 | gz = true; 117 | } 118 | } 119 | } 120 | 121 | return gz; 122 | }; 123 | 124 | const handler = ({ 125 | request, 126 | }: { 127 | request: CloudFrontRequest; 128 | }): { 129 | responsePromise: Promise; 130 | req: IncomingMessage; 131 | res: PrivateServerResponse; 132 | } => { 133 | const response = { 134 | headers: {}, 135 | } as CloudFrontResultResponse; 136 | 137 | const newStream = new Stream.Readable(); 138 | const req = Object.assign(newStream, IncomingMessage.prototype); 139 | req.url = request.uri; 140 | req.method = request.method; 141 | req.rawHeaders = []; 142 | req.headers = {}; 143 | // @ts-ignore 144 | req.connection = {}; 145 | 146 | if (request.querystring) { 147 | req.url = `${req.url}?${request.querystring}`; 148 | } 149 | 150 | const headers = request.headers || {}; 151 | 152 | for (const lowercaseKey of Object.keys(headers)) { 153 | const headerKeyValPairs = headers[lowercaseKey]; 154 | 155 | headerKeyValPairs.forEach((keyVal) => { 156 | req.rawHeaders.push(keyVal.key as string); 157 | req.rawHeaders.push(keyVal.value); 158 | }); 159 | 160 | req.headers[lowercaseKey] = headerKeyValPairs[0].value; 161 | } 162 | 163 | // @ts-ignore 164 | req.getHeader = (name: string) => req.headers[name.toLowerCase()]; 165 | 166 | // @ts-ignore 167 | req.getHeaders = () => req.headers; 168 | 169 | if (request.body && request.body.data) { 170 | req.push(request.body.data, request.body.encoding ? 'base64' : undefined); 171 | } 172 | 173 | req.push(null); 174 | 175 | const res = new Stream() as PrivateServerResponse; 176 | res.finished = false; 177 | 178 | Object.defineProperty(res, 'statusCode', { 179 | get() { 180 | return response.status; 181 | }, 182 | set(statusCode) { 183 | response.status = statusCode; 184 | response.statusDescription = HttpStatusCodes[statusCode]; 185 | }, 186 | }); 187 | 188 | res.headers = {}; 189 | //@ts-ignore 190 | res.writeHead = (status, headers: OutgoingHttpHeaders) => { 191 | response.status = status; 192 | 193 | if (headers) { 194 | res.headers = Object.assign(res.headers, headers); 195 | } 196 | }; 197 | res.write = (chunk: any) => { 198 | if (!response.body) { 199 | // @ts-ignore 200 | response.body = Buffer.from(''); 201 | } 202 | 203 | // @ts-ignore 204 | response.body = Buffer.concat([ 205 | // @ts-ignore 206 | response.body, 207 | Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk), 208 | ]); 209 | 210 | return true; 211 | }; 212 | 213 | const gz = isGzipSupported(headers); 214 | 215 | const responsePromise = new Promise((resolve) => { 216 | res.end = (text: any) => { 217 | if (res.finished === true) { 218 | return; 219 | } 220 | 221 | res.finished = true; 222 | 223 | if (text) res.write(text); 224 | 225 | if (!res.statusCode) { 226 | res.statusCode = 200; 227 | } 228 | 229 | if (response.body) { 230 | response.bodyEncoding = 'base64'; 231 | response.body = gz 232 | ? zlib.gzipSync(response.body).toString('base64') 233 | : Buffer.from(response.body).toString('base64'); 234 | } 235 | 236 | response.headers = toCloudFrontHeaders(res.headers); 237 | 238 | if (gz) { 239 | response.headers['content-encoding'] = [{ key: 'Content-Encoding', value: 'gzip' }]; 240 | } 241 | 242 | resolve(response); 243 | }; 244 | }); 245 | 246 | res.setHeader = (name, value) => { 247 | res.headers[name.toLowerCase()] = value as string; 248 | }; 249 | res.removeHeader = (name) => { 250 | delete res.headers[name.toLowerCase()]; 251 | }; 252 | res.getHeader = (name) => res.headers[name.toLowerCase()]; 253 | res.getHeaders = () => res.headers; 254 | res.hasHeader = (name) => !!res.getHeader(name); 255 | 256 | return { 257 | req, 258 | res, 259 | responsePromise, 260 | }; 261 | }; 262 | 263 | export default handler; 264 | -------------------------------------------------------------------------------- /packages/aws-lambda-builder/src/index.ts: -------------------------------------------------------------------------------- 1 | import { nodeFileTrace, NodeFileTraceReasons } from '@zeit/node-file-trace'; 2 | import execa from 'execa'; 3 | import { 4 | emptyDir, 5 | pathExists, 6 | readJSON, 7 | copy, 8 | writeJson, 9 | remove, 10 | readdir, 11 | readFile, 12 | } from 'fs-extra'; 13 | import { join, resolve, sep, extname, relative, basename } from 'path'; 14 | 15 | import getAllFiles from './lib/getAllFilesInDirectory'; 16 | import { getSortedRoutes } from './lib/sortedRoutes'; 17 | import { OriginRequestHandlerManifest } from '../types'; 18 | import createServerlessConfig from './lib/createServerlessConfig'; 19 | import { 20 | expressifyDynamicRoute, 21 | normalizeNodeModules, 22 | isDynamicRoute, 23 | pathToRegexStr, 24 | } from './utils'; 25 | 26 | export const REQUEST_LAMBDA_CODE_DIR = 'request-lambda'; 27 | 28 | const defaultBuildOptions = { 29 | args: [], 30 | cwd: process.cwd(), 31 | cmd: './node_modules/.bin/next', 32 | }; 33 | 34 | class Builder { 35 | nextConfigDir: string; 36 | dotNextDir: string; 37 | serverlessDir: string; 38 | outputDir: string; 39 | buildOptions: BuildOptions = defaultBuildOptions; 40 | 41 | constructor(nextConfigDir: string, outputDir: string, buildOptions?: BuildOptions) { 42 | this.nextConfigDir = resolve(nextConfigDir); 43 | this.dotNextDir = join(this.nextConfigDir, '.next'); 44 | this.serverlessDir = join(this.dotNextDir, 'serverless'); 45 | this.outputDir = outputDir; 46 | 47 | if (buildOptions) { 48 | this.buildOptions = buildOptions; 49 | } 50 | } 51 | 52 | async readPublicFiles(): Promise { 53 | const dirExists = await pathExists(join(this.nextConfigDir, 'public')); 54 | if (dirExists) { 55 | return getAllFiles(join(this.nextConfigDir, 'public')) 56 | .map((e) => e.replace(this.nextConfigDir, '')) 57 | .map((e) => e.split(sep).slice(2).join('/')); 58 | } else { 59 | return []; 60 | } 61 | } 62 | 63 | async readPagesManifest(): Promise<{ [key: string]: string }> { 64 | const path = join(this.serverlessDir, 'pages-manifest.json'); 65 | const hasServerlessPageManifest = await pathExists(path); 66 | 67 | if (!hasServerlessPageManifest) { 68 | return Promise.reject( 69 | "pages-manifest not found. Check if `next.config.js` target is set to 'serverless'" 70 | ); 71 | } 72 | 73 | const pagesManifest = await readJSON(path); 74 | const pagesManifestWithoutDynamicRoutes = Object.keys(pagesManifest).reduce( 75 | (acc: { [key: string]: string }, route: string) => { 76 | if (isDynamicRoute(route)) { 77 | return acc; 78 | } 79 | 80 | acc[route] = pagesManifest[route]; 81 | return acc; 82 | }, 83 | {} 84 | ); 85 | 86 | const dynamicRoutedPages = Object.keys(pagesManifest).filter(isDynamicRoute); 87 | const sortedDynamicRoutedPages = getSortedRoutes(dynamicRoutedPages); 88 | const sortedPagesManifest = pagesManifestWithoutDynamicRoutes; 89 | 90 | sortedDynamicRoutedPages.forEach( 91 | (route) => (sortedPagesManifest[route] = pagesManifest[route]) 92 | ); 93 | 94 | return sortedPagesManifest; 95 | } 96 | 97 | copyLambdaHandlerDependencies( 98 | fileList: string[], 99 | reasons: NodeFileTraceReasons, 100 | handlerDirectory: string 101 | ): Promise[] { 102 | return ( 103 | fileList 104 | // exclude "initial" files from lambda artifact. These are just the pages themselves which are copied over separately 105 | .filter( 106 | (file) => file !== 'package.json' && (!reasons[file] || reasons[file].type !== 'initial') 107 | ) 108 | .map((filePath: string) => { 109 | const resolvedFilePath = resolve(filePath); 110 | const dst = normalizeNodeModules(relative(this.serverlessDir, resolvedFilePath)); 111 | return copy(resolvedFilePath, join(this.outputDir, handlerDirectory, dst)); 112 | }) 113 | ); 114 | } 115 | 116 | async buildRequestLambda(buildManifest: OriginRequestHandlerManifest): Promise { 117 | const ignoreAppAndDocumentPages = (page: string): boolean => { 118 | const pageBasename = basename(page); 119 | return pageBasename !== '_app.js' && pageBasename !== '_document.js'; 120 | }; 121 | 122 | const allServerProcessablePages = [ 123 | ...Object.values(buildManifest.pages.ssr.nonDynamic), 124 | ...Object.values(buildManifest.pages.ssr.dynamic).map((entry) => entry.file), 125 | ...Object.values(buildManifest.pages.apis.nonDynamic), 126 | ...Object.values(buildManifest.pages.apis.dynamic).map((entry) => entry.file), 127 | ].filter(ignoreAppAndDocumentPages); 128 | 129 | const ssrPages = Object.values(allServerProcessablePages).map((pageFile) => 130 | join(this.serverlessDir, pageFile) 131 | ); 132 | 133 | const { fileList, reasons } = await nodeFileTrace(ssrPages, { 134 | base: process.cwd(), 135 | }); 136 | 137 | const copyTraces = this.copyLambdaHandlerDependencies( 138 | fileList, 139 | reasons, 140 | REQUEST_LAMBDA_CODE_DIR 141 | ); 142 | 143 | return Promise.all([ 144 | ...copyTraces, 145 | copy( 146 | require.resolve('@next-deploy/aws-lambda-builder/dist/request-handler.js'), 147 | join(this.outputDir, REQUEST_LAMBDA_CODE_DIR, 'index.js') 148 | ), 149 | copy( 150 | require.resolve('@next-deploy/aws-lambda-builder/dist/compat.js'), 151 | join(this.outputDir, REQUEST_LAMBDA_CODE_DIR, 'compat.js') 152 | ), 153 | writeJson(join(this.outputDir, REQUEST_LAMBDA_CODE_DIR, 'manifest.json'), buildManifest), 154 | copy( 155 | join(this.serverlessDir, 'pages'), 156 | join(this.outputDir, REQUEST_LAMBDA_CODE_DIR, 'pages'), 157 | { 158 | filter: (file: string) => extname(file) !== '.html' && extname(file) !== '.json', 159 | } 160 | ), 161 | copy( 162 | join(this.dotNextDir, 'prerender-manifest.json'), 163 | join(this.outputDir, REQUEST_LAMBDA_CODE_DIR, 'prerender-manifest.json') 164 | ), 165 | ]); 166 | } 167 | 168 | async prepareBuildManifest(): Promise { 169 | const pagesManifest = await this.readPagesManifest(); 170 | const buildId = await readFile(join(this.dotNextDir, 'BUILD_ID'), 'utf-8'); 171 | const defaultBuildManifest: OriginRequestHandlerManifest = { 172 | buildId, 173 | pages: { 174 | ssr: { 175 | dynamic: {}, 176 | nonDynamic: {}, 177 | }, 178 | html: { 179 | dynamic: {}, 180 | nonDynamic: {}, 181 | }, 182 | apis: { 183 | dynamic: {}, 184 | nonDynamic: {}, 185 | }, 186 | }, 187 | publicFiles: {}, 188 | }; 189 | 190 | const ssrPages = defaultBuildManifest.pages.ssr; 191 | const htmlPages = defaultBuildManifest.pages.html; 192 | const apiPages = defaultBuildManifest.pages.apis; 193 | 194 | const isHtmlPage = (path: string): boolean => path.endsWith('.html'); 195 | const isApiPage = (path: string): boolean => path.startsWith('pages/api'); 196 | 197 | Object.entries(pagesManifest).forEach(([route, pageFile]) => { 198 | const dynamicRoute = isDynamicRoute(route); 199 | const expressRoute = dynamicRoute ? expressifyDynamicRoute(route) : null; 200 | 201 | if (isHtmlPage(pageFile)) { 202 | if (dynamicRoute) { 203 | const route = expressRoute as string; 204 | 205 | htmlPages.dynamic[route] = { 206 | file: pageFile, 207 | regex: pathToRegexStr(route), 208 | }; 209 | } else { 210 | htmlPages.nonDynamic[route] = pageFile; 211 | } 212 | } else if (isApiPage(pageFile)) { 213 | if (dynamicRoute) { 214 | const route = expressRoute as string; 215 | apiPages.dynamic[route] = { 216 | file: pageFile, 217 | regex: pathToRegexStr(route), 218 | }; 219 | } else { 220 | apiPages.nonDynamic[route] = pageFile; 221 | } 222 | } else if (dynamicRoute) { 223 | const route = expressRoute as string; 224 | ssrPages.dynamic[route] = { 225 | file: pageFile, 226 | regex: pathToRegexStr(route), 227 | }; 228 | } else { 229 | ssrPages.nonDynamic[route] = pageFile; 230 | } 231 | }); 232 | 233 | const publicFiles = await this.readPublicFiles(); 234 | 235 | publicFiles.forEach((pf) => (defaultBuildManifest.publicFiles[`/${pf}`] = pf)); 236 | 237 | return defaultBuildManifest; 238 | } 239 | 240 | async cleanupDotNext(): Promise { 241 | const exists = await pathExists(this.dotNextDir); 242 | 243 | if (exists) { 244 | const fileItems = await readdir(this.dotNextDir); 245 | 246 | await Promise.all( 247 | fileItems 248 | .filter( 249 | (fileItem) => fileItem !== 'cache' // avoid deleting the cache folder as that would lead to slow builds! 250 | ) 251 | .map((fileItem) => remove(join(this.dotNextDir, fileItem))) 252 | ); 253 | } 254 | } 255 | 256 | async build(debug?: (message: string) => void): Promise { 257 | const { cmd, args, cwd } = { ...defaultBuildOptions, ...this.buildOptions }; 258 | 259 | await this.cleanupDotNext(); 260 | 261 | await emptyDir(join(this.outputDir, REQUEST_LAMBDA_CODE_DIR)); 262 | 263 | const { restoreUserConfig } = await createServerlessConfig(cwd, join(this.nextConfigDir)); 264 | 265 | try { 266 | if (debug) { 267 | const { stdout: nextVersion } = await execa(cmd, ['--version'], { 268 | cwd, 269 | }); 270 | 271 | debug(`Starting a new build with ${nextVersion}`); 272 | 273 | console.log(); 274 | } 275 | 276 | const subprocess = execa(cmd, args, { 277 | cwd, 278 | env: { 279 | NODE_OPTIONS: '--max_old_space_size=3000', 280 | }, 281 | }); 282 | 283 | if (debug && subprocess.stdout) { 284 | subprocess.stdout.pipe(process.stdout); 285 | } 286 | 287 | await subprocess; 288 | } finally { 289 | await restoreUserConfig(); 290 | } 291 | 292 | const defaultBuildManifest = await this.prepareBuildManifest(); 293 | 294 | await this.buildRequestLambda(defaultBuildManifest); 295 | } 296 | } 297 | 298 | export default Builder; 299 | -------------------------------------------------------------------------------- /packages/aws-lambda-builder/src/lib/createServerlessConfig.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import path from 'path'; 3 | 4 | import { CreateServerlessConfigResult } from '../../types'; 5 | 6 | function getCustomData(importName: string, target: string): string { 7 | return ` 8 | module.exports = function(...args) { 9 | let original = require('./${importName}'); 10 | const finalConfig = {}; 11 | const target = { target: '${target}' }; 12 | if (typeof original === 'function' && original.constructor.name === 'AsyncFunction') { 13 | // AsyncFunctions will become promises 14 | original = original(...args); 15 | } 16 | if (original instanceof Promise) { 17 | // Special case for promises, as it's currently not supported 18 | // and will just error later on 19 | return original 20 | .then((originalConfig) => Object.assign(finalConfig, originalConfig)) 21 | .then((config) => Object.assign(config, target)); 22 | } else if (typeof original === 'function') { 23 | Object.assign(finalConfig, original(...args)); 24 | } else if (typeof original === 'object') { 25 | Object.assign(finalConfig, original); 26 | } 27 | Object.assign(finalConfig, target); 28 | return finalConfig; 29 | } 30 | `.trim(); 31 | } 32 | 33 | function getDefaultData(target: string): string { 34 | return `module.exports = { target: '${target}' };`; 35 | } 36 | 37 | export default async function createServerlessConfig( 38 | workPath: string, 39 | entryPath: string 40 | ): Promise { 41 | const target = 'experimental-serverless-trace'; 42 | 43 | const primaryConfigPath = path.join(entryPath, 'next.config.js'); 44 | const secondaryConfigPath = path.join(workPath, 'next.config.js'); 45 | const backupConfigName = `next.config.original.${Date.now()}.js`; 46 | 47 | const hasPrimaryConfig = fs.existsSync(primaryConfigPath); 48 | const hasSecondaryConfig = fs.existsSync(secondaryConfigPath); 49 | 50 | let configPath: string; 51 | let backupConfigPath: string; 52 | 53 | if (hasPrimaryConfig) { 54 | // Prefer primary path 55 | configPath = primaryConfigPath; 56 | backupConfigPath = path.join(entryPath, backupConfigName); 57 | } else if (hasSecondaryConfig) { 58 | // Work with secondary path (some monorepo setups) 59 | configPath = secondaryConfigPath; 60 | backupConfigPath = path.join(workPath, backupConfigName); 61 | } else { 62 | // Default to primary path for creation 63 | configPath = primaryConfigPath; 64 | backupConfigPath = path.join(entryPath, backupConfigName); 65 | } 66 | 67 | const configPathExists = fs.existsSync(configPath); 68 | 69 | if (configPathExists) { 70 | await fs.rename(configPath, backupConfigPath); 71 | await fs.writeFile(configPath, getCustomData(backupConfigName, target)); 72 | } else { 73 | await fs.writeFile(configPath, getDefaultData(target)); 74 | } 75 | 76 | return { 77 | restoreUserConfig: async (): Promise => { 78 | const needToRestoreUserConfig = configPathExists; 79 | await fs.remove(configPath); 80 | 81 | if (needToRestoreUserConfig) { 82 | await fs.rename(backupConfigPath, configPath); 83 | } 84 | }, 85 | }; 86 | } 87 | -------------------------------------------------------------------------------- /packages/aws-lambda-builder/src/lib/getAllFilesInDirectory.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | const getAllFilesRecursively = (dirPath: string, arrayOfFiles: string[]): string[] => { 5 | const files = fs.readdirSync(dirPath); 6 | 7 | files.forEach((file) => { 8 | if (fs.statSync(dirPath + path.sep + file).isDirectory()) { 9 | arrayOfFiles = getAllFilesRecursively(path.join(dirPath, file), arrayOfFiles); 10 | } else { 11 | arrayOfFiles.push(path.join(dirPath, file)); 12 | } 13 | }); 14 | 15 | return arrayOfFiles; 16 | }; 17 | 18 | const getAllFiles = (dirPath: string): string[] => getAllFilesRecursively(dirPath, []); 19 | 20 | export default getAllFiles; 21 | -------------------------------------------------------------------------------- /packages/aws-lambda-builder/src/lib/sortedRoutes.ts: -------------------------------------------------------------------------------- 1 | // copied as is from https://github.com/zeit/next.js/blob/canary/packages/next/next-server/lib/router/utils/sorted-routes.ts 2 | 3 | class UrlNode { 4 | placeholder: boolean = true; 5 | children: Map = new Map(); 6 | slugName: string | null = null; 7 | restSlugName: string | null = null; 8 | optionalRestSlugName: string | null = null; 9 | 10 | insert(urlPath: string): void { 11 | this._insert(urlPath.split('/').filter(Boolean), [], false); 12 | } 13 | 14 | smoosh(): string[] { 15 | return this._smoosh(); 16 | } 17 | 18 | private _smoosh(prefix: string = '/'): string[] { 19 | const childrenPaths = [...this.children.keys()].sort(); 20 | if (this.slugName !== null) { 21 | childrenPaths.splice(childrenPaths.indexOf('[]'), 1); 22 | } 23 | if (this.restSlugName !== null) { 24 | childrenPaths.splice(childrenPaths.indexOf('[...]'), 1); 25 | } 26 | if (this.optionalRestSlugName !== null) { 27 | childrenPaths.splice(childrenPaths.indexOf('[[...]]'), 1); 28 | } 29 | 30 | const routes = childrenPaths 31 | .map((c) => this.children.get(c)!._smoosh(`${prefix}${c}/`)) 32 | .reduce((prev, curr) => [...prev, ...curr], []); 33 | 34 | if (this.slugName !== null) { 35 | routes.push(...this.children.get('[]')!._smoosh(`${prefix}[${this.slugName}]/`)); 36 | } 37 | 38 | if (!this.placeholder) { 39 | const r = prefix === '/' ? '/' : prefix.slice(0, -1); 40 | if (this.optionalRestSlugName != null) { 41 | throw new Error( 42 | `You cannot define a route with the same specificity as a optional catch-all route ("${r}" and "${r}[[...${this.optionalRestSlugName}]]").` 43 | ); 44 | } 45 | 46 | routes.unshift(r); 47 | } 48 | 49 | if (this.restSlugName !== null) { 50 | routes.push(...this.children.get('[...]')!._smoosh(`${prefix}[...${this.restSlugName}]/`)); 51 | } 52 | 53 | if (this.optionalRestSlugName !== null) { 54 | routes.push( 55 | ...this.children.get('[[...]]')!._smoosh(`${prefix}[[...${this.optionalRestSlugName}]]/`) 56 | ); 57 | } 58 | 59 | return routes; 60 | } 61 | 62 | private _insert(urlPaths: string[], slugNames: string[], isCatchAll: boolean): void { 63 | if (urlPaths.length === 0) { 64 | this.placeholder = false; 65 | return; 66 | } 67 | 68 | if (isCatchAll) { 69 | throw new Error(`Catch-all must be the last part of the URL.`); 70 | } 71 | 72 | // The next segment in the urlPaths list 73 | let nextSegment = urlPaths[0]; 74 | 75 | // Check if the segment matches `[something]` 76 | if (nextSegment.startsWith('[') && nextSegment.endsWith(']')) { 77 | // Strip `[` and `]`, leaving only `something` 78 | let segmentName = nextSegment.slice(1, -1); 79 | 80 | let isOptional = false; 81 | if (segmentName.startsWith('[') && segmentName.endsWith(']')) { 82 | // Strip optional `[` and `]`, leaving only `something` 83 | segmentName = segmentName.slice(1, -1); 84 | isOptional = true; 85 | } 86 | 87 | if (segmentName.startsWith('...')) { 88 | // Strip `...`, leaving only `something` 89 | segmentName = segmentName.substring(3); 90 | isCatchAll = true; 91 | } 92 | 93 | if (segmentName.startsWith('[') || segmentName.endsWith(']')) { 94 | throw new Error( 95 | `Segment names may not start or end with extra brackets ('${segmentName}').` 96 | ); 97 | } 98 | 99 | if (segmentName.startsWith('.')) { 100 | throw new Error(`Segment names may not start with erroneous periods ('${segmentName}').`); 101 | } 102 | 103 | function handleSlug(previousSlug: string | null, nextSlug: string) { 104 | if (previousSlug !== null) { 105 | // If the specific segment already has a slug but the slug is not `something` 106 | // This prevents collisions like: 107 | // pages/[post]/index.js 108 | // pages/[id]/index.js 109 | // Because currently multiple dynamic params on the same segment level are not supported 110 | if (previousSlug !== nextSlug) { 111 | // TODO: This error seems to be confusing for users, needs an err.sh link, the description can be based on above comment. 112 | throw new Error( 113 | `You cannot use different slug names for the same dynamic path ('${previousSlug}' !== '${nextSlug}').` 114 | ); 115 | } 116 | } 117 | 118 | slugNames.forEach((slug) => { 119 | if (slug === nextSlug) { 120 | throw new Error( 121 | `You cannot have the same slug name "${nextSlug}" repeat within a single dynamic path` 122 | ); 123 | } 124 | 125 | if (slug.replace(/\W/g, '') === nextSegment.replace(/\W/g, '')) { 126 | throw new Error( 127 | `You cannot have the slug names "${slug}" and "${nextSlug}" differ only by non-word symbols within a single dynamic path` 128 | ); 129 | } 130 | }); 131 | 132 | slugNames.push(nextSlug); 133 | } 134 | 135 | if (isCatchAll) { 136 | if (isOptional) { 137 | if (this.restSlugName != null) { 138 | throw new Error( 139 | `You cannot use both an required and optional catch-all route at the same level ("[...${this.restSlugName}]" and "${urlPaths[0]}" ).` 140 | ); 141 | } 142 | 143 | handleSlug(this.optionalRestSlugName, segmentName); 144 | // slugName is kept as it can only be one particular slugName 145 | this.optionalRestSlugName = segmentName; 146 | // nextSegment is overwritten to [[...]] so that it can later be sorted specifically 147 | nextSegment = '[[...]]'; 148 | } else { 149 | if (this.optionalRestSlugName != null) { 150 | throw new Error( 151 | `You cannot use both an optional and required catch-all route at the same level ("[[...${this.optionalRestSlugName}]]" and "${urlPaths[0]}").` 152 | ); 153 | } 154 | 155 | handleSlug(this.restSlugName, segmentName); 156 | // slugName is kept as it can only be one particular slugName 157 | this.restSlugName = segmentName; 158 | // nextSegment is overwritten to [...] so that it can later be sorted specifically 159 | nextSegment = '[...]'; 160 | } 161 | } else { 162 | if (isOptional) { 163 | throw new Error(`Optional route parameters are not yet supported ("${urlPaths[0]}").`); 164 | } 165 | handleSlug(this.slugName, segmentName); 166 | // slugName is kept as it can only be one particular slugName 167 | this.slugName = segmentName; 168 | // nextSegment is overwritten to [] so that it can later be sorted specifically 169 | nextSegment = '[]'; 170 | } 171 | } 172 | 173 | // If this UrlNode doesn't have the nextSegment yet we create a new child UrlNode 174 | if (!this.children.has(nextSegment)) { 175 | this.children.set(nextSegment, new UrlNode()); 176 | } 177 | 178 | this.children.get(nextSegment)!._insert(urlPaths.slice(1), slugNames, isCatchAll); 179 | } 180 | } 181 | 182 | export function getSortedRoutes(normalizedPages: string[]): string[] { 183 | // First the UrlNode is created, and every UrlNode can have only 1 dynamic segment 184 | // Eg you can't have pages/[post]/abc.js and pages/[hello]/something-else.js 185 | // Only 1 dynamic segment per nesting level 186 | 187 | // So in the case that is test/integration/dynamic-routing it'll be this: 188 | // pages/[post]/comments.js 189 | // pages/blog/[post]/comment/[id].js 190 | // Both are fine because `pages/[post]` and `pages/blog` are on the same level 191 | // So in this case `UrlNode` created here has `this.slugName === 'post'` 192 | // And since your PR passed through `slugName` as an array basically it'd including it in too many possibilities 193 | // Instead what has to be passed through is the upwards path's dynamic names 194 | const root = new UrlNode(); 195 | 196 | // Here the `root` gets injected multiple paths, and insert will break them up into sublevels 197 | normalizedPages.forEach((pagePath) => root.insert(pagePath)); 198 | // Smoosh will then sort those sublevels up to the point where you get the correct route definition priority 199 | return root.smoosh(); 200 | } 201 | -------------------------------------------------------------------------------- /packages/aws-lambda-builder/src/request-handler.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import PrerenderManifest from './prerender-manifest.json'; 3 | // @ts-ignore 4 | import Manifest from './manifest.json'; 5 | 6 | import lambdaAtEdgeCompat from './compat'; 7 | import { 8 | CloudFrontRequest, 9 | CloudFrontS3Origin, 10 | CloudFrontOrigin, 11 | CloudFrontResultResponse, 12 | } from 'aws-lambda'; 13 | import { PrerenderManifest as PrerenderManifestType } from 'next/dist/build/index'; 14 | 15 | import { OriginRequestEvent, OriginRequestHandlerManifest } from '../types'; 16 | 17 | const addS3HostHeader = (req: CloudFrontRequest, s3DomainName: string): void => { 18 | req.headers['host'] = [{ key: 'host', value: s3DomainName }]; 19 | }; 20 | const isDataRequest = (uri: string): boolean => uri.startsWith('/_next/data'); 21 | const isApiRequest = (uri: string): boolean => uri.startsWith('/api'); 22 | const getUriPageName = (uri: string): string => (uri === '/' ? '/index' : uri); 23 | const getUriKey = (uri: string): string => (uri === '/index' ? '/' : uri); 24 | const normalizeS3OriginDomain = (s3Origin: CloudFrontS3Origin): string => { 25 | if (s3Origin.region === 'us-east-1') { 26 | return s3Origin.domainName; 27 | } 28 | 29 | if (!s3Origin.domainName.includes(s3Origin.region)) { 30 | const regionalEndpoint = s3Origin.domainName.replace( 31 | 's3.amazonaws.com', 32 | `s3.${s3Origin.region}.amazonaws.com` 33 | ); 34 | return regionalEndpoint; 35 | } 36 | 37 | return s3Origin.domainName; 38 | }; 39 | 40 | const router = (manifest: OriginRequestHandlerManifest): ((uri: string) => string | null) => { 41 | const { 42 | pages: { ssr, html, apis }, 43 | } = manifest; 44 | const allDynamicRoutes = { ...ssr.dynamic, ...html.dynamic, ...apis.dynamic }; 45 | 46 | return (uri: string): string | null => { 47 | const normalizedUri = isDataRequest(uri) 48 | ? uri.replace(`/_next/data/${manifest.buildId}`, '').replace('.json', '') 49 | : uri; 50 | 51 | if (ssr.nonDynamic[normalizedUri]) { 52 | return ssr.nonDynamic[normalizedUri]; 53 | } else if (apis.nonDynamic[normalizedUri]) { 54 | return apis.nonDynamic[normalizedUri]; 55 | } 56 | 57 | for (const route in allDynamicRoutes) { 58 | const { file, regex } = allDynamicRoutes[route]; 59 | 60 | const re = new RegExp(regex, 'i'); 61 | const pathMatchesRoute = re.test(normalizedUri); 62 | 63 | if (pathMatchesRoute) { 64 | return file; 65 | } 66 | } 67 | 68 | if (isApiRequest(uri)) { 69 | return null; 70 | } else if (html.nonDynamic['/404'] !== undefined) { 71 | return 'pages/404.html'; 72 | } 73 | 74 | return 'pages/_error.js'; 75 | }; 76 | }; 77 | 78 | export const handler = async ( 79 | event: OriginRequestEvent 80 | ): Promise => { 81 | const { request } = event.Records[0].cf; 82 | const uriKey = getUriKey(request.uri); 83 | const manifest: OriginRequestHandlerManifest = Manifest; 84 | const prerenderManifest: PrerenderManifestType = PrerenderManifest; 85 | const { pages, publicFiles } = manifest; 86 | const isStaticPage = pages.html.nonDynamic[uriKey]; 87 | const isPublicFile = publicFiles[uriKey]; 88 | const isPrerenderedPage = prerenderManifest.routes[request.uri]; // prerendered pages are also static pages like "pages.html" above, but are defined in the prerender-manifest 89 | const origin = request.origin as CloudFrontOrigin; 90 | const s3Origin = origin.s3 as CloudFrontS3Origin; 91 | const isHTMLPage = isStaticPage || isPrerenderedPage; 92 | const normalizedS3DomainName = normalizeS3OriginDomain(s3Origin); 93 | 94 | s3Origin.domainName = normalizedS3DomainName; 95 | 96 | if (isHTMLPage || isPublicFile) { 97 | s3Origin.path = isHTMLPage ? '/static-pages' : '/public'; 98 | 99 | addS3HostHeader(request, normalizedS3DomainName); 100 | 101 | if (isHTMLPage) { 102 | request.uri = `${getUriPageName(uriKey)}.html`; 103 | } 104 | 105 | return request; 106 | } 107 | 108 | const pagePath = router(manifest)(uriKey); 109 | 110 | if (!pagePath) { 111 | return { 112 | status: '404', 113 | }; 114 | } 115 | 116 | if (pagePath.endsWith('.html')) { 117 | s3Origin.path = '/static-pages'; 118 | request.uri = pagePath.replace('pages', ''); 119 | addS3HostHeader(request, normalizedS3DomainName); 120 | 121 | return request; 122 | } 123 | 124 | const page = require(`./${pagePath}`); 125 | const { req, res, responsePromise } = lambdaAtEdgeCompat(event.Records[0].cf); 126 | 127 | if (isApiRequest(uriKey)) { 128 | page.default(req, res); 129 | } else if (isDataRequest(uriKey)) { 130 | const { renderOpts } = await page.renderReqToHTML(req, res, 'passthrough'); 131 | 132 | res.setHeader('Content-Type', 'application/json'); 133 | res.end(JSON.stringify(renderOpts.pageData)); 134 | } else { 135 | page.render(req, res); 136 | } 137 | 138 | return responsePromise; 139 | }; 140 | -------------------------------------------------------------------------------- /packages/aws-lambda-builder/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { pathToRegexp } from 'path-to-regexp'; 2 | 3 | /** 4 | * Dynamic routes: /[param]/ -> /:param 5 | * Catch all routes: /[...param]/ -> /:param+ 6 | * Optional catch all routes: /[...param]/ -> /:param* 7 | * 8 | * @param dynamicRoute - route to expressify. 9 | */ 10 | export const expressifyDynamicRoute = (dynamicRoute: string): string => { 11 | // replace any catch all group first 12 | let expressified = dynamicRoute.replace(/\[\.\.\.(.*)]$/, ':$1+'); 13 | 14 | // replace other dynamic route groups 15 | expressified = expressified.replace(/\[(.*?)]/g, ':$1'); 16 | 17 | // check if this is actually an optional catch all group 18 | if (expressified.includes('/::')) { 19 | expressified = expressified.replace('/::', '/:').replace(/\+$/, '*'); 20 | } 21 | 22 | return expressified; 23 | }; 24 | export const normalizeNodeModules = (path: string): string => 25 | path.substring(path.indexOf('node_modules')); 26 | // Identify /[param]/ in route string 27 | export const isDynamicRoute = (route: string): boolean => /\/\[[^\/]+?\](?=\/|$)/.test(route); 28 | export const pathToRegexStr = (path: string): string => 29 | pathToRegexp(path) 30 | .toString() 31 | .replace(/\/(.*)\/\i/, '$1'); 32 | -------------------------------------------------------------------------------- /packages/aws-lambda-builder/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "sourceMap": false, 5 | "removeComments": true, 6 | "outDir": "dist" 7 | }, 8 | "include": ["./src/", "../../.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/aws-lambda-builder/types.d.ts: -------------------------------------------------------------------------------- 1 | import { CloudFrontRequest } from 'aws-lambda'; 2 | import { ServerResponse, OutgoingHttpHeaders } from 'http'; 3 | 4 | export class PrivateServerResponse extends ServerResponse { 5 | headers: OutgoingHttpHeaders; 6 | } 7 | 8 | type DynamicPageKeyValue = { 9 | [key: string]: { 10 | file: string; 11 | regex: string; 12 | }; 13 | }; 14 | 15 | type OriginRequestHandlerManifest = { 16 | buildId: string; 17 | pages: { 18 | ssr: { 19 | dynamic: DynamicPageKeyValue; 20 | nonDynamic: { 21 | [key: string]: string; 22 | }; 23 | }; 24 | html: { 25 | dynamic: DynamicPageKeyValue; 26 | nonDynamic: { 27 | [path: string]: string; 28 | }; 29 | }; 30 | apis: { 31 | dynamic: DynamicPageKeyValue; 32 | nonDynamic: { 33 | [key: string]: string; 34 | }; 35 | }; 36 | }; 37 | publicFiles: { 38 | [key: string]: string; 39 | }; 40 | }; 41 | 42 | type OriginRequestEvent = { 43 | Records: [{ cf: { request: CloudFrontRequest } }]; 44 | }; 45 | 46 | type CreateServerlessConfigResult = { 47 | restoreUserConfig: () => Promise; 48 | }; 49 | -------------------------------------------------------------------------------- /packages/aws-lambda-builder/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@next-deploy/aws-lambda-builder@link:.": 6 | version "0.0.0" 7 | uid "" 8 | -------------------------------------------------------------------------------- /packages/aws-lambda/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@next-deploy/aws-lambda", 3 | "version": "4.2.0", 4 | "license": "MIT", 5 | "main": "dist/component.js", 6 | "types": "dist/component.d.ts", 7 | "scripts": { 8 | "build": "yarn clean && yarn compile", 9 | "build:watch": "yarn compile -w", 10 | "clean": "rm -rf ./dist", 11 | "compile": "tsc -p tsconfig.build.json" 12 | }, 13 | "dependencies": { 14 | "@next-deploy/aws-iam-role": "link:../aws-iam-role" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/aws-lambda/src/component.ts: -------------------------------------------------------------------------------- 1 | import { Lambda } from 'aws-sdk'; 2 | import { Component, utils } from '@serverless/core'; 3 | import { mergeDeepRight, pick } from 'ramda'; 4 | 5 | import { 6 | createLambda, 7 | updateLambdaCode, 8 | updateLambdaConfig, 9 | getLambda, 10 | deleteLambda, 11 | configChanged, 12 | pack, 13 | load, 14 | } from './utils'; 15 | import AwsIamRole from '@next-deploy/aws-iam-role'; 16 | import { AwsLambdaInputs } from '../types'; 17 | 18 | const outputsList = [ 19 | 'name', 20 | 'hash', 21 | 'description', 22 | 'memory', 23 | 'timeout', 24 | 'code', 25 | 'bucket', 26 | 'shims', 27 | 'handler', 28 | 'runtime', 29 | 'env', 30 | 'role', 31 | 'arn', 32 | 'region', 33 | ]; 34 | 35 | const SUPPORTED_RUNTIMES = ['nodejs10.x', 'nodejs12.x']; 36 | const LATEST_RUNTIME = SUPPORTED_RUNTIMES[1]; 37 | 38 | const defaults: Partial = { 39 | description: 'AWS Lambda Component', 40 | memory: 512, 41 | timeout: 10, 42 | code: process.cwd(), 43 | bucket: undefined, 44 | shims: [], 45 | handler: 'handler.hello', 46 | runtime: LATEST_RUNTIME, 47 | env: {}, 48 | region: 'us-east-1', 49 | }; 50 | 51 | class LambdaComponent extends Component { 52 | async default(inputs: Partial = {}) { 53 | this.context.status('Deploying'); 54 | 55 | const config = mergeDeepRight(defaults, inputs) as AwsLambdaInputs; 56 | 57 | config.name = inputs.name || (this.state.name as string) || this.context.resourceId(); 58 | 59 | this.context.debug( 60 | `Starting deployment of lambda ${config.name} to the ${config.region} region.` 61 | ); 62 | 63 | const lambda = new Lambda({ 64 | region: config.region, 65 | credentials: this.context.credentials.aws, 66 | }); 67 | 68 | const awsIamRole = await load('@next-deploy/aws-iam-role', this); 69 | const outputsAwsIamRole = await awsIamRole.default(config.role); 70 | config.role = { arn: outputsAwsIamRole.arn }; 71 | 72 | this.context.status('Packaging'); 73 | this.context.debug(`Packaging lambda code from ${config.code}.`); 74 | config.zipPath = (await pack(config.code, config.shims)) as string; 75 | 76 | config.hash = await utils.hashFile(config.zipPath as string); 77 | 78 | const prevLambda = await getLambda({ lambda, ...config }); 79 | 80 | if (!prevLambda) { 81 | this.context.status('Creating'); 82 | this.context.debug(`Creating lambda ${config.name} in the ${config.region} region.`); 83 | 84 | //@ts-ignore 85 | const createResult = await createLambda({ lambda, ...config }); 86 | config.arn = createResult.arn; 87 | config.hash = createResult.hash; 88 | } else { 89 | config.arn = prevLambda.arn; 90 | 91 | if (configChanged(prevLambda, config)) { 92 | if (prevLambda.hash !== config.hash) { 93 | this.context.status(`Uploading code`); 94 | this.context.debug(`Uploading ${config.name} lambda code.`); 95 | await updateLambdaCode({ lambda, ...config }); 96 | } 97 | 98 | this.context.status(`Updating`); 99 | this.context.debug(`Updating ${config.name} lambda config.`); 100 | 101 | const updateResult = await updateLambdaConfig({ lambda, ...config }); 102 | config.hash = updateResult.hash; 103 | } 104 | } 105 | 106 | // todo we probably don't need this logic now that we auto generate names 107 | if (this.state.name && this.state.name !== config.name) { 108 | this.context.status(`Replacing`); 109 | await deleteLambda({ lambda, name: this.state.name as string }); 110 | } 111 | 112 | this.context.debug( 113 | `Successfully deployed lambda ${config.name} in the ${config.region} region.` 114 | ); 115 | 116 | const outputs = pick(outputsList, config); 117 | 118 | this.state = outputs; 119 | await this.save(); 120 | 121 | return outputs; 122 | } 123 | 124 | async publishVersion(): Promise<{ version: string | undefined }> { 125 | const { name, region, hash } = this.state; 126 | 127 | const lambda = new Lambda({ 128 | region: region as string, 129 | credentials: this.context.credentials.aws, 130 | }); 131 | 132 | const { Version } = await lambda 133 | .publishVersion({ 134 | FunctionName: name as string, 135 | CodeSha256: hash as string, 136 | }) 137 | .promise(); 138 | 139 | return { version: Version }; 140 | } 141 | 142 | async remove() { 143 | this.context.status(`Removing`); 144 | 145 | if (!this.state.name) { 146 | this.context.debug(`Aborting removal. Function name not found in state.`); 147 | return; 148 | } 149 | 150 | const { name, region } = this.state; 151 | 152 | const lambda = new Lambda({ 153 | region: region as string, 154 | credentials: this.context.credentials.aws, 155 | }); 156 | 157 | const awsIamRole = await load('@next-deploy/aws-iam-role', this); 158 | 159 | await awsIamRole.remove(); 160 | 161 | this.context.debug(`Removing lambda ${name} from the ${region} region.`); 162 | await deleteLambda({ lambda, name: name as string }); 163 | this.context.debug(`Successfully removed lambda ${name} from the ${region} region.`); 164 | 165 | const outputs = pick(outputsList, this.state); 166 | 167 | this.state = {}; 168 | await this.save(); 169 | 170 | return outputs; 171 | } 172 | } 173 | 174 | export default LambdaComponent; 175 | -------------------------------------------------------------------------------- /packages/aws-lambda/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { tmpdir } from 'os'; 2 | import path from 'path'; 3 | import archiver, { Format } from 'archiver'; 4 | import globby from 'globby'; 5 | import { contains, isNil, last, split, equals, not, pick } from 'ramda'; 6 | import { readFile, createReadStream, createWriteStream } from 'fs-extra'; 7 | import { utils } from '@serverless/core'; 8 | import { Lambda } from 'aws-sdk'; 9 | 10 | import { AwsLambdaInputs } from '../types'; 11 | 12 | const VALID_FORMATS = ['zip', 'tar']; 13 | const isValidFormat = (format: Format) => contains(format, VALID_FORMATS); 14 | 15 | const packDir = async ( 16 | inputDirPath: string, 17 | outputFilePath: string, 18 | include: string[] = [], 19 | exclude: string[] = [], 20 | prefix?: string 21 | ) => { 22 | const format = last(split('.', outputFilePath)) as Format; 23 | 24 | if (!isValidFormat(format)) { 25 | throw new Error('Please provide a valid format. Either a "zip" or a "tar".'); 26 | } 27 | 28 | const patterns = ['**/*']; 29 | 30 | if (!isNil(exclude)) { 31 | exclude.forEach((excludedItem) => patterns.push(`!${excludedItem}`)); 32 | } 33 | 34 | const files = (await globby(patterns, { cwd: inputDirPath, dot: true })) 35 | .sort() // we must sort to ensure correct hash 36 | .map((file) => ({ 37 | input: path.join(inputDirPath, file), 38 | output: prefix ? path.join(prefix, file) : file, 39 | })); 40 | 41 | return new Promise((resolve, reject) => { 42 | const output = createWriteStream(outputFilePath); 43 | const archive = archiver(format, { 44 | zlib: { level: 9 }, 45 | }); 46 | 47 | output.on('open', () => { 48 | archive.pipe(output); 49 | 50 | // we must set the date to ensure correct hash 51 | files.forEach((file) => 52 | archive.append(createReadStream(file.input), { 53 | name: file.output, 54 | date: new Date(0), 55 | }) 56 | ); 57 | 58 | if (!isNil(include)) { 59 | include.forEach((file) => { 60 | const stream = createReadStream(file); 61 | archive.append(stream, { 62 | name: path.basename(file), 63 | date: new Date(0), 64 | }); 65 | }); 66 | } 67 | 68 | archive.finalize(); 69 | }); 70 | 71 | archive.on('error', (err) => reject(err)); 72 | output.on('close', () => resolve(outputFilePath)); 73 | }); 74 | }; 75 | 76 | export const createLambda = async ({ 77 | lambda, 78 | name, 79 | handler, 80 | memory, 81 | timeout, 82 | runtime, 83 | env, 84 | description, 85 | zipPath, 86 | bucket, 87 | role, 88 | }: AwsLambdaInputs): Promise<{ arn?: string; hash?: string }> => { 89 | const params: Lambda.Types.CreateFunctionRequest = { 90 | FunctionName: name, 91 | Code: {}, 92 | Description: description, 93 | Handler: handler, 94 | MemorySize: memory, 95 | Publish: true, 96 | Role: role.arn as string, 97 | Runtime: runtime, 98 | Timeout: timeout, 99 | Environment: { 100 | Variables: env, 101 | }, 102 | }; 103 | 104 | if (bucket) { 105 | params.Code.S3Bucket = bucket; 106 | params.Code.S3Key = path.basename(zipPath); 107 | } else { 108 | params.Code.ZipFile = await readFile(zipPath); 109 | } 110 | 111 | const res = await (lambda as Lambda).createFunction(params).promise(); 112 | 113 | return { arn: res.FunctionArn, hash: res.CodeSha256 }; 114 | }; 115 | 116 | export const updateLambdaConfig = async ({ 117 | lambda, 118 | name, 119 | handler, 120 | memory, 121 | timeout, 122 | runtime, 123 | env, 124 | description, 125 | role, 126 | }: AwsLambdaInputs): Promise<{ arn?: string; hash?: string }> => { 127 | const functionConfigParams: Lambda.Types.UpdateFunctionConfigurationRequest = { 128 | FunctionName: name, 129 | Description: description, 130 | Handler: handler, 131 | MemorySize: memory, 132 | Role: role.arn, 133 | Runtime: runtime, 134 | Timeout: timeout, 135 | Environment: { 136 | Variables: env, 137 | }, 138 | }; 139 | 140 | const res = await (lambda as Lambda).updateFunctionConfiguration(functionConfigParams).promise(); 141 | 142 | return { arn: res.FunctionArn, hash: res.CodeSha256 }; 143 | }; 144 | 145 | export const updateLambdaCode = async ({ 146 | lambda, 147 | name, 148 | zipPath, 149 | bucket, 150 | }: { 151 | lambda: Lambda; 152 | name: string; 153 | zipPath: string; 154 | bucket: string; 155 | }): Promise => { 156 | const functionCodeParams: Lambda.Types.UpdateFunctionCodeRequest = { 157 | FunctionName: name, 158 | Publish: true, 159 | }; 160 | 161 | if (bucket) { 162 | functionCodeParams.S3Bucket = bucket; 163 | functionCodeParams.S3Key = path.basename(zipPath); 164 | } else { 165 | functionCodeParams.ZipFile = await readFile(zipPath); 166 | } 167 | const res = await lambda.updateFunctionCode(functionCodeParams).promise(); 168 | 169 | return res.FunctionArn; 170 | }; 171 | 172 | export const getLambda = async ({ 173 | lambda, 174 | name, 175 | }: { 176 | lambda: Lambda; 177 | name: string; 178 | }): Promise | null> => { 179 | try { 180 | const res = await lambda 181 | .getFunctionConfiguration({ 182 | FunctionName: name, 183 | }) 184 | .promise(); 185 | 186 | return { 187 | name: res.FunctionName, 188 | description: res.Description, 189 | timeout: res.Timeout, 190 | runtime: res.Runtime, 191 | role: { 192 | arn: res.Role as string, 193 | }, 194 | handler: res.Handler, 195 | memory: res.MemorySize, 196 | hash: res.CodeSha256, 197 | env: res.Environment ? res.Environment.Variables : {}, 198 | arn: res.FunctionArn, 199 | }; 200 | } catch (e) { 201 | if (e.code === 'ResourceNotFoundException') { 202 | return null; 203 | } 204 | throw e; 205 | } 206 | }; 207 | 208 | export const deleteLambda = async ({ 209 | lambda, 210 | name, 211 | }: { 212 | lambda: Lambda; 213 | name: string; 214 | }): Promise => { 215 | try { 216 | const params = { FunctionName: name }; 217 | await lambda.deleteFunction(params).promise(); 218 | } catch (error) { 219 | if (error.code !== 'ResourceNotFoundException') { 220 | throw error; 221 | } 222 | } 223 | }; 224 | 225 | export const getPolicy = ({ 226 | name, 227 | region, 228 | accountId, 229 | }: { 230 | name: string; 231 | region: string; 232 | accountId: string; 233 | }): Record => { 234 | return { 235 | Version: '2012-10-17', 236 | Statement: [ 237 | { 238 | Action: ['logs:CreateLogStream'], 239 | Resource: [`arn:aws:logs:${region}:${accountId}:log-group:/aws/lambda/${name}:*`], 240 | Effect: 'Allow', 241 | }, 242 | { 243 | Action: ['logs:PutLogEvents'], 244 | Resource: [`arn:aws:logs:${region}:${accountId}:log-group:/aws/lambda/${name}:*:*`], 245 | Effect: 'Allow', 246 | }, 247 | ], 248 | }; 249 | }; 250 | 251 | export const configChanged = ( 252 | prevLambda: Record, 253 | lambda: Record 254 | ): boolean => { 255 | const keys = ['description', 'runtime', 'role', 'handler', 'memory', 'timeout', 'env', 'hash']; 256 | const inputs = pick(keys, lambda) as AwsLambdaInputs; 257 | inputs.role = { arn: inputs.role.arn }; // remove other inputs.role component outputs 258 | const prevInputs = pick(keys, prevLambda); 259 | 260 | return not(equals(inputs, prevInputs)); 261 | }; 262 | 263 | export const pack = async (code: string, shims = [], packDeps = true): Promise => { 264 | if (utils.isArchivePath(code)) { 265 | return path.resolve(code); 266 | } 267 | 268 | let exclude: string[] = []; 269 | 270 | if (!packDeps) { 271 | exclude = ['node_modules/**']; 272 | } 273 | 274 | const outputFilePath = path.join(tmpdir(), `${Math.random().toString(36).substring(6)}.zip`); 275 | 276 | return packDir(code, outputFilePath, shims, exclude); 277 | }; 278 | 279 | // TODO: remove me, this is a duplicate of aws-component's load 280 | export const load = async (path: string, that: any, name?: string): Promise => { 281 | const EngineComponent = await import(path); 282 | const component = new EngineComponent.default( 283 | `${that.id}.${name || EngineComponent.default.name}`, 284 | that.context.instance 285 | ); 286 | await component.init(); 287 | 288 | component.context.log = () => ({}); 289 | component.context.status = () => ({}); 290 | component.context.output = () => ({}); 291 | 292 | return component; 293 | }; 294 | -------------------------------------------------------------------------------- /packages/aws-lambda/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "sourceMap": false, 5 | "removeComments": true, 6 | "outDir": "dist" 7 | }, 8 | "include": ["./src/", "../../.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/aws-lambda/types.d.ts: -------------------------------------------------------------------------------- 1 | import { Lambda } from 'aws-sdk'; 2 | import { Role } from '@next-deploy/aws-iam-role/types'; 3 | 4 | export type AwsLambdaInputs = { 5 | name: string; 6 | description: string; 7 | memory: number; 8 | timeout: number; 9 | code: string; 10 | bucket: any; 11 | shims: never[]; 12 | handler: string; 13 | runtime: string; 14 | env: Record; 15 | region: string; 16 | role: Role; 17 | arn?: string; 18 | zipPath: string; 19 | hash?: string; 20 | lambda?: Lambda; 21 | }; 22 | -------------------------------------------------------------------------------- /packages/aws-lambda/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@next-deploy/aws-s3@link:../aws-s3": 6 | version "0.0.0" 7 | uid "" 8 | -------------------------------------------------------------------------------- /packages/aws-s3/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@next-deploy/aws-s3", 3 | "version": "4.2.0", 4 | "license": "MIT", 5 | "main": "dist/component.js", 6 | "types": "dist/component.d.ts", 7 | "scripts": { 8 | "build": "yarn clean && yarn compile", 9 | "build:watch": "yarn compile -w", 10 | "clean": "rm -rf ./dist", 11 | "compile": "tsc -p tsconfig.build.json" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/aws-s3/src/component.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { S3 } from 'aws-sdk'; 3 | import { mergeDeepRight } from 'ramda'; 4 | import { Component, utils } from '@serverless/core'; 5 | 6 | import { UploadStaticAssetsOptions, SyncStageStateDirectoryOptions, AwsS3Inputs } from '../types'; 7 | import { 8 | getClients, 9 | clearBucket, 10 | accelerateBucket, 11 | deleteBucket, 12 | uploadDir, 13 | packAndUploadDir, 14 | uploadFile, 15 | ensureBucket, 16 | configureCors, 17 | } from './lib/utils'; 18 | import uploadStaticAssets from './lib/uploadStaticAssets'; 19 | import syncStageStateDirectory from './lib/syncStageStateDirectory'; 20 | 21 | const defaults = { 22 | name: undefined, 23 | accelerated: true, 24 | region: 'us-east-1', 25 | }; 26 | 27 | class AwsS3 extends Component { 28 | static uploadStaticAssets( 29 | options: UploadStaticAssetsOptions 30 | ): Promise { 31 | return uploadStaticAssets(options); 32 | } 33 | 34 | static syncStageStateDirectory( 35 | options: SyncStageStateDirectoryOptions 36 | ): Promise { 37 | return syncStageStateDirectory(options); 38 | } 39 | 40 | async default(inputs: AwsS3Inputs = {}): Promise { 41 | const config = mergeDeepRight(defaults, inputs); 42 | 43 | this.context.status(`Deploying`); 44 | 45 | config.name = inputs.name || this.state.name || this.context.resourceId(); 46 | 47 | this.context.debug(`Deploying bucket ${config.name} in region ${config.region}.`); 48 | 49 | const clients = getClients(this.context.credentials.aws, config.region); 50 | await ensureBucket(clients.regular, config.name as string, this.context.debug); 51 | 52 | // todo we probably don't need this logic now that we auto generate names 53 | if (config.accelerated) { 54 | if (config?.name?.includes('.')) { 55 | throw new Error('Accelerated buckets must be DNS-compliant and must NOT contain periods'); 56 | } 57 | 58 | this.context.debug( 59 | `Setting acceleration to "${config.accelerated}" for bucket ${config.name}.` 60 | ); 61 | await accelerateBucket(clients.regular, config.name as string, config.accelerated); 62 | } 63 | 64 | if (config.cors) { 65 | this.context.debug(`Setting cors for bucket ${config.name}.`); 66 | await configureCors(clients.regular, config.name as string, config.cors); 67 | } 68 | 69 | // todo we probably don't need this logic now that we auto generate names 70 | const nameChanged = this.state.name && this.state.name !== config.name; 71 | if (nameChanged) { 72 | await this.remove(); 73 | } 74 | 75 | this.state.name = config.name; 76 | this.state.region = config.region; 77 | this.state.accelerated = config.accelerated; 78 | this.state.url = `https://${config.name}.s3.amazonaws.com`; 79 | await this.save(); 80 | 81 | this.context.debug( 82 | `Bucket ${config.name} was successfully deployed to the ${config.region} region.` 83 | ); 84 | 85 | return this.state; 86 | } 87 | 88 | async remove(): Promise<{ name: string; region: string; accelerated: boolean } | undefined> { 89 | this.context.status(`Removing`); 90 | 91 | if (!this.state.name) { 92 | this.context.debug(`Aborting S3 removal - bucket name not found in state.`); 93 | return; 94 | } 95 | 96 | const clients = getClients(this.context.credentials.aws, this.state.region); 97 | 98 | this.context.debug(`Clearing bucket ${this.state.name} contents.`); 99 | 100 | await clearBucket( 101 | this.state.accelerated ? clients.accelerated : clients.regular, 102 | this.state.name 103 | ); 104 | 105 | this.context.debug(`Deleting bucket ${this.state.name} from region ${this.state.region}.`); 106 | 107 | await deleteBucket(clients.regular, this.state.name); 108 | 109 | this.context.debug( 110 | `Bucket ${this.state.name} was successfully deleted from region ${this.state.region}.` 111 | ); 112 | 113 | const outputs = { 114 | name: this.state.name, 115 | region: this.state.region, 116 | accelerated: this.state.accelerated, 117 | }; 118 | 119 | this.state = {}; 120 | await this.save(); 121 | 122 | return outputs; 123 | } 124 | 125 | async upload(inputs: AwsS3Inputs = {}): Promise { 126 | this.context.status('Uploading'); 127 | 128 | const name = this.state.name || inputs.name; 129 | const region = this.state.region || inputs.region || defaults.region; 130 | 131 | if (!name) { 132 | throw Error('Unable to upload. Bucket name not found in state.'); 133 | } 134 | 135 | this.context.debug(`Starting upload to bucket ${name} in region ${region}`); 136 | 137 | const clients = getClients(this.context.credentials.aws, region); 138 | 139 | if (inputs.dir && (await utils.dirExists(inputs.dir))) { 140 | if (inputs.zip) { 141 | this.context.debug(`Packing and uploading directory ${inputs.dir} to bucket ${name}`); 142 | // pack & upload using multipart uploads 143 | const defaultKey = Math.random().toString(36).substring(6); 144 | 145 | await packAndUploadDir({ 146 | s3: this.state.accelerated ? clients.accelerated : clients.regular, 147 | bucketName: name, 148 | dirPath: inputs.dir, 149 | key: inputs.key || `${defaultKey}.zip`, 150 | cacheControl: inputs.cacheControl, 151 | }); 152 | } else { 153 | this.context.debug(`Uploading directory ${inputs.dir} to bucket ${name}`); 154 | // upload directory contents 155 | await uploadDir( 156 | this.state.accelerated ? clients.accelerated : clients.regular, 157 | name, 158 | inputs.dir, 159 | inputs.cacheControl, 160 | { keyPrefix: inputs.keyPrefix } 161 | ); 162 | } 163 | } else if (inputs.file && (await utils.fileExists(inputs.file))) { 164 | // upload a single file using multipart uploads 165 | this.context.debug(`Uploading file ${inputs.file} to bucket ${name}`); 166 | 167 | await uploadFile({ 168 | s3: this.state.accelerated ? clients.accelerated : clients.regular, 169 | bucketName: name, 170 | filePath: inputs.file, 171 | key: inputs.key || path.basename(inputs.file), 172 | cacheControl: inputs.cacheControl, 173 | }); 174 | 175 | this.context.debug( 176 | `File ${inputs.file} uploaded with key ${inputs.key || path.basename(inputs.file)}` 177 | ); 178 | } 179 | } 180 | } 181 | 182 | export default AwsS3; 183 | -------------------------------------------------------------------------------- /packages/aws-s3/src/lib/getPublicAssetCacheControl.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import regexParser from 'regex-parser'; 3 | 4 | import { PublicDirectoryCache } from '../../types'; 5 | 6 | export const DEFAULT_PUBLIC_DIR_CACHE_CONTROL = 'public, max-age=31536000, must-revalidate'; 7 | export const DEFAULT_PUBLIC_DIR_CACHE_REGEX = /\.(gif|jpe?g|jp2|tiff|png|webp|bmp|svg|ico)$/i; 8 | 9 | /** 10 | * If options is not present, or is explicitly set to true, returns a default Cache-Control configuration for image types. 11 | * If options is explicitly set to false, it returns undefined. 12 | * If assigned an options object, it uses whichever value is defined there, falling back to the default if one is not present. 13 | */ 14 | const getPublicAssetCacheControl = ( 15 | filePath: string, 16 | options?: PublicDirectoryCache 17 | ): string | undefined => { 18 | if (options === false) { 19 | return undefined; 20 | } 21 | 22 | let value: string = DEFAULT_PUBLIC_DIR_CACHE_CONTROL; 23 | let test: RegExp = DEFAULT_PUBLIC_DIR_CACHE_REGEX; 24 | 25 | if (typeof options === 'object') { 26 | if (options.value) { 27 | value = options.value; 28 | } 29 | 30 | if (options.test) { 31 | test = regexParser(options.test); 32 | } 33 | } 34 | 35 | if (test.test(path.basename(filePath))) { 36 | return value; 37 | } 38 | 39 | return undefined; 40 | }; 41 | 42 | export default getPublicAssetCacheControl; 43 | -------------------------------------------------------------------------------- /packages/aws-s3/src/lib/s3.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from 'fs-extra'; 2 | import { S3 } from 'aws-sdk'; 3 | 4 | import { 5 | S3ClientFactoryOptions, 6 | S3Client, 7 | UploadFileOptions, 8 | ListFileOptions, 9 | SetVersioningOptions, 10 | } from '../../types'; 11 | import { getMimeType } from './utils'; 12 | 13 | export default async ({ bucketName, credentials }: S3ClientFactoryOptions): Promise => { 14 | let s3 = new S3({ ...credentials }); 15 | 16 | try { 17 | const { Status } = await s3 18 | .getBucketAccelerateConfiguration({ 19 | Bucket: bucketName, 20 | }) 21 | .promise(); 22 | 23 | if (Status === 'Enabled') { 24 | s3 = new S3({ ...credentials, useAccelerateEndpoint: true }); 25 | } 26 | } catch (err) { 27 | console.warn( 28 | `Checking for bucket acceleration failed, falling back to non-accelerated S3 client. Err: ${err.message}` 29 | ); 30 | } 31 | 32 | return { 33 | uploadFile: async ({ 34 | filePath, 35 | cacheControl, 36 | s3Key, 37 | }: UploadFileOptions): Promise => { 38 | const fileBody = await readFile(filePath); 39 | 40 | return s3 41 | .upload({ 42 | Bucket: bucketName, 43 | Key: s3Key || filePath, 44 | Body: fileBody, 45 | ContentType: getMimeType(filePath), 46 | CacheControl: cacheControl || undefined, 47 | }) 48 | .promise(); 49 | }, 50 | listFiles: async ({ s3Key }: ListFileOptions) => { 51 | return s3 52 | .listObjectsV2({ 53 | Bucket: bucketName, 54 | Prefix: s3Key, 55 | }) 56 | .promise(); 57 | }, 58 | downloadFile: async ({ s3Key }: ListFileOptions) => { 59 | return s3 60 | .getObject({ 61 | Bucket: bucketName, 62 | Key: s3Key, 63 | }) 64 | .promise(); 65 | }, 66 | setVersioning: async ({ versioned }: SetVersioningOptions) => { 67 | return s3 68 | .putBucketVersioning({ 69 | Bucket: bucketName, 70 | VersioningConfiguration: { 71 | Status: versioned ? 'Enabled' : 'Suspended', 72 | }, 73 | }) 74 | .promise(); 75 | }, 76 | }; 77 | }; 78 | -------------------------------------------------------------------------------- /packages/aws-s3/src/lib/syncStageStateDirectory.ts: -------------------------------------------------------------------------------- 1 | import { S3 } from 'aws-sdk'; 2 | import path from 'path'; 3 | import { writeFile } from 'fs-extra'; 4 | 5 | import S3ClientFactory from './s3'; 6 | import { SyncStageStateDirectoryOptions } from '../../types'; 7 | import { pathToPosix, readDirectoryFiles, filterOutDirectories } from './utils'; 8 | 9 | const STATE_ROOT = '.next-deploy'; 10 | 11 | const syncStageStateDirectory = async ({ 12 | name: stage, 13 | bucketName, 14 | credentials, 15 | nextConfigDir, 16 | syncTo, 17 | versioned, 18 | }: SyncStageStateDirectoryOptions): Promise => { 19 | const s3 = await S3ClientFactory({ 20 | bucketName, 21 | credentials, 22 | }); 23 | const stateRootDirectory = path.join(nextConfigDir, STATE_ROOT); 24 | 25 | if (syncTo) { 26 | const stateRootDirectoryFiles = await readDirectoryFiles(path.join(stateRootDirectory, stage)); 27 | 28 | const buildStateRootDirectoryFilesUploads = stateRootDirectoryFiles 29 | .filter(filterOutDirectories) 30 | .map(async (fileItem) => 31 | s3.uploadFile({ 32 | s3Key: pathToPosix(path.relative(path.resolve(nextConfigDir), fileItem.path)).replace( 33 | `${STATE_ROOT}/`, 34 | '' 35 | ), 36 | filePath: fileItem.path, 37 | }) 38 | ); 39 | 40 | return Promise.all([...buildStateRootDirectoryFilesUploads]); 41 | } else { 42 | const files: { name: string; data: S3.GetObjectOutput }[] = []; 43 | 44 | if (versioned !== undefined) { 45 | await s3.setVersioning({ versioned }); 46 | } 47 | 48 | const bucketFiles = await s3.listFiles({ 49 | s3Key: stage, 50 | }); 51 | 52 | for (const file of bucketFiles.Contents || []) { 53 | if (file.Key) { 54 | const fileData = await s3.downloadFile({ s3Key: file.Key }); 55 | files.push({ name: path.join(STATE_ROOT, file.Key), data: fileData }); 56 | } 57 | } 58 | 59 | for (const { name, data } of files || []) { 60 | if (data.Body) { 61 | await writeFile(name, data.Body.toString()); 62 | } 63 | } 64 | } 65 | }; 66 | 67 | export default syncStageStateDirectory; 68 | -------------------------------------------------------------------------------- /packages/aws-s3/src/lib/uploadStaticAssets.ts: -------------------------------------------------------------------------------- 1 | import { S3 } from 'aws-sdk'; 2 | import path from 'path'; 3 | import { readJSON, pathExists } from 'fs-extra'; 4 | import { PrerenderManifest } from 'next/dist/build/index'; 5 | 6 | import S3ClientFactory from './s3'; 7 | import { pathToPosix, readDirectoryFiles, filterOutDirectories } from './utils'; 8 | import getPublicAssetCacheControl from './getPublicAssetCacheControl'; 9 | import { UploadStaticAssetsOptions, PublicDirectoryCache } from '../../types'; 10 | 11 | export const SERVER_CACHE_CONTROL_HEADER = 'public, max-age=0, s-maxage=2678400, must-revalidate'; 12 | export const IMMUTABLE_CACHE_CONTROL_HEADER = 'public, max-age=31536000, immutable'; 13 | 14 | const uploadStaticAssets = async ({ 15 | bucketName, 16 | nextConfigDir, 17 | nextStaticDir = nextConfigDir, 18 | publicDirectoryCache, 19 | credentials, 20 | }: UploadStaticAssetsOptions): Promise => { 21 | const s3 = await S3ClientFactory({ 22 | bucketName, 23 | credentials, 24 | }); 25 | 26 | const dotNextDirectory = path.join(nextConfigDir, '.next'); 27 | const buildStaticFiles = await readDirectoryFiles(path.join(dotNextDirectory, 'static')); 28 | 29 | const buildStaticFileUploads = buildStaticFiles 30 | .filter(filterOutDirectories) 31 | .map(async (fileItem) => { 32 | const s3Key = pathToPosix( 33 | path.relative(path.resolve(nextConfigDir), fileItem.path).replace(/^.next/, '_next') 34 | ); 35 | 36 | return s3.uploadFile({ 37 | s3Key, 38 | filePath: fileItem.path, 39 | cacheControl: IMMUTABLE_CACHE_CONTROL_HEADER, 40 | }); 41 | }); 42 | 43 | const pagesManifest = await readJSON( 44 | path.join(dotNextDirectory, 'serverless/pages-manifest.json') 45 | ); 46 | 47 | const htmlPageUploads = Object.values(pagesManifest) 48 | .filter((pageFile) => (pageFile as string).endsWith('.html')) 49 | .map((relativePageFilePath) => { 50 | const pageFilePath = pathToPosix( 51 | path.join(dotNextDirectory, `serverless/${relativePageFilePath}`) 52 | ); 53 | 54 | return s3.uploadFile({ 55 | s3Key: `static-pages/${(relativePageFilePath as string).replace(/^pages\//, '')}`, 56 | filePath: pageFilePath, 57 | cacheControl: SERVER_CACHE_CONTROL_HEADER, 58 | }); 59 | }); 60 | 61 | const prerenderManifest: PrerenderManifest = await readJSON( 62 | path.join(dotNextDirectory, 'prerender-manifest.json') 63 | ); 64 | 65 | const prerenderManifestJSONPropFileUploads = Object.keys(prerenderManifest.routes).map((key) => { 66 | const pageFilePath = pathToPosix( 67 | path.join( 68 | dotNextDirectory, 69 | `serverless/pages/${key.endsWith('/') ? `${key}index.json` : `${key}.json`}` 70 | ) 71 | ); 72 | 73 | return s3.uploadFile({ 74 | s3Key: prerenderManifest.routes[key].dataRoute.slice(1), 75 | filePath: pageFilePath, 76 | }); 77 | }); 78 | 79 | const prerenderManifestHTMLPageUploads = Object.keys(prerenderManifest.routes).map((key) => { 80 | const relativePageFilePath = key.endsWith('/') 81 | ? path.posix.join(key, 'index.html') 82 | : `${key}.html`; 83 | 84 | const pageFilePath = pathToPosix( 85 | path.join(dotNextDirectory, `serverless/pages/${relativePageFilePath}`) 86 | ); 87 | 88 | return s3.uploadFile({ 89 | s3Key: path.posix.join('static-pages', relativePageFilePath), 90 | filePath: pageFilePath, 91 | cacheControl: SERVER_CACHE_CONTROL_HEADER, 92 | }); 93 | }); 94 | 95 | const uploadPublicOrStaticDirectory = async ( 96 | directory: 'public' | 'static', 97 | publicDirectoryCache?: PublicDirectoryCache 98 | ): Promise[]> => { 99 | const directoryPath = path.join(nextStaticDir, directory); 100 | if (!(await pathExists(directoryPath))) { 101 | return Promise.resolve([]); 102 | } 103 | 104 | const files = await readDirectoryFiles(directoryPath); 105 | 106 | return files.filter(filterOutDirectories).map((fileItem) => 107 | s3.uploadFile({ 108 | filePath: fileItem.path, 109 | s3Key: pathToPosix(path.relative(path.resolve(nextStaticDir), fileItem.path)), 110 | cacheControl: getPublicAssetCacheControl(fileItem.path, publicDirectoryCache), 111 | }) 112 | ); 113 | }; 114 | 115 | const publicDirUploads = await uploadPublicOrStaticDirectory('public', publicDirectoryCache); 116 | const staticDirUploads = await uploadPublicOrStaticDirectory('static', publicDirectoryCache); 117 | 118 | const allUploads = [ 119 | ...buildStaticFileUploads, // .next/static 120 | ...htmlPageUploads, // prerendered HTML pages 121 | ...prerenderManifestJSONPropFileUploads, // SSG JSON files 122 | ...prerenderManifestHTMLPageUploads, // SSG HTML files 123 | ...publicDirUploads, // app public dir 124 | ...staticDirUploads, // app static dir 125 | ]; 126 | 127 | return Promise.all(allUploads); 128 | }; 129 | 130 | export default uploadStaticAssets; 131 | -------------------------------------------------------------------------------- /packages/aws-s3/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { Credentials, S3 } from 'aws-sdk'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | import klawSync from 'klaw-sync'; 5 | import klaw, { Item } from 'klaw'; 6 | import mime from 'mime-types'; 7 | import UploadStream from 's3-stream-upload'; 8 | import { isEmpty } from 'ramda'; 9 | import { createReadStream } from 'fs-extra'; 10 | import archiver from 'archiver'; 11 | import { utils } from '@serverless/core'; 12 | 13 | import {} from '../../types'; 14 | 15 | export const getClients = ( 16 | credentials: Credentials, 17 | region: string 18 | ): { 19 | regular: S3; 20 | accelerated: S3; 21 | } => { 22 | const params = { 23 | region, 24 | credentials, 25 | }; 26 | 27 | // we need two S3 clients because creating/deleting buckets 28 | // is not available with the acceleration feature. 29 | return { 30 | regular: new S3(params), 31 | accelerated: new S3({ ...params, endpoint: `s3-accelerate.amazonaws.com` }), 32 | }; 33 | }; 34 | 35 | const bucketCreation = async (s3: S3, Bucket: S3.BucketName): Promise => { 36 | try { 37 | await s3.headBucket({ Bucket }).promise(); 38 | } catch (e) { 39 | if (e.code === 'NotFound' || e.code === 'NoSuchBucket') { 40 | await utils.sleep(2000); 41 | return bucketCreation(s3, Bucket); 42 | } 43 | throw new Error(e); 44 | } 45 | }; 46 | 47 | export const ensureBucket = async ( 48 | s3: S3, 49 | name: string, 50 | debug: (message: string) => void 51 | ): Promise => { 52 | try { 53 | debug(`Checking if bucket ${name} exists.`); 54 | await s3.headBucket({ Bucket: name }).promise(); 55 | } catch (e) { 56 | if (e.code === 'NotFound') { 57 | debug(`Bucket ${name} does not exist. Creating...`); 58 | await s3.createBucket({ Bucket: name }).promise(); 59 | // there's a race condition when using acceleration 60 | // so we need to sleep for a couple seconds. See this issue: 61 | // https://github.com/serverless/components/issues/428 62 | debug(`Bucket ${name} created. Confirming it's ready...`); 63 | await bucketCreation(s3, name); 64 | debug(`Bucket ${name} creation confirmed.`); 65 | } else if (e.code === 'Forbidden' && e.message === null) { 66 | throw Error(`Forbidden: Invalid credentials or this AWS S3 bucket name may already be taken`); 67 | } else if (e.code === 'Forbidden') { 68 | throw Error(`Bucket name "${name}" is already taken.`); 69 | } else { 70 | throw e; 71 | } 72 | } 73 | }; 74 | 75 | export const uploadDir = async ( 76 | s3: S3, 77 | bucketName: string, 78 | dirPath: string, 79 | cacheControl?: S3.CacheControl, 80 | options?: { keyPrefix?: string } 81 | ): Promise => { 82 | const items: ReadonlyArray = await new Promise((resolve, reject) => { 83 | try { 84 | resolve(klawSync(dirPath)); 85 | } catch (error) { 86 | reject(error); 87 | } 88 | }); 89 | 90 | const uploadItems: Promise[] = []; 91 | items.forEach((item) => { 92 | if (item.stats.isDirectory()) { 93 | return; 94 | } 95 | 96 | let key = path.relative(dirPath, item.path); 97 | 98 | if (options?.keyPrefix) { 99 | key = path.posix.join(options.keyPrefix, key); 100 | } 101 | 102 | // convert backslashes to forward slashes on windows 103 | if (path.sep === '\\') { 104 | key = key.replace(/\\/g, '/'); 105 | } 106 | 107 | const itemParams = { 108 | Bucket: bucketName, 109 | Key: key, 110 | Body: fs.readFileSync(item.path), 111 | ContentType: mime.lookup(path.basename(item.path)) || 'application/octet-stream', 112 | CacheControl: cacheControl, 113 | }; 114 | 115 | uploadItems.push(s3.upload(itemParams).promise()); 116 | }); 117 | 118 | await Promise.all(uploadItems); 119 | }; 120 | 121 | export const packAndUploadDir = async ({ 122 | s3, 123 | bucketName, 124 | dirPath, 125 | key, 126 | append = [], 127 | cacheControl, 128 | }: { 129 | s3: S3; 130 | bucketName: string; 131 | dirPath: string; 132 | key: string; 133 | append?: string[]; 134 | cacheControl?: S3.CacheControl; 135 | }): Promise => { 136 | const ignore = (await utils.readFileIfExists(path.join(dirPath, '.slsignore'))) || []; 137 | return new Promise((resolve, reject) => { 138 | const archive = archiver('zip', { 139 | zlib: { level: 9 }, 140 | }); 141 | 142 | if (!isEmpty(append)) { 143 | append.forEach((file) => { 144 | const fileStream = createReadStream(file); 145 | archive.append(fileStream, { name: path.basename(file) }); 146 | }); 147 | } 148 | 149 | archive.glob( 150 | '**/*', 151 | { 152 | cwd: dirPath, 153 | ignore, 154 | }, 155 | {} 156 | ); 157 | 158 | archive 159 | .pipe( 160 | UploadStream(s3, { 161 | Bucket: bucketName, 162 | Key: key, 163 | CacheControl: cacheControl, 164 | }) 165 | ) 166 | .on('error', (err: Error) => reject(err)) 167 | .on('finish', () => resolve()); 168 | 169 | archive.finalize(); 170 | }); 171 | }; 172 | 173 | export const uploadFile = async ({ 174 | s3, 175 | bucketName, 176 | filePath, 177 | key, 178 | cacheControl, 179 | }: { 180 | s3: S3; 181 | bucketName: string; 182 | filePath: string; 183 | key: string; 184 | cacheControl?: S3.CacheControl; 185 | }): Promise => { 186 | return new Promise((resolve, reject) => { 187 | fs.createReadStream(filePath) 188 | .pipe( 189 | UploadStream(s3, { 190 | Bucket: bucketName, 191 | Key: key, 192 | ContentType: mime.lookup(filePath) || 'application/octet-stream', 193 | CacheControl: cacheControl, 194 | }) 195 | ) 196 | .on('error', (err: Error) => reject(err)) 197 | .on('finish', () => resolve()); 198 | }); 199 | }; 200 | 201 | /* 202 | * Delete Website Bucket 203 | */ 204 | export const clearBucket = async (s3: S3, bucketName: string): Promise => { 205 | try { 206 | const data = await s3.listObjects({ Bucket: bucketName }).promise(); 207 | 208 | const items = data.Contents || []; 209 | const promises = []; 210 | 211 | for (let i = 0; i < items.length; i += 1) { 212 | const deleteParams = { Bucket: bucketName, Key: items[i].Key as string }; 213 | const delObj = s3.deleteObject(deleteParams).promise(); 214 | promises.push(delObj); 215 | } 216 | 217 | await Promise.all(promises); 218 | } catch (error) { 219 | if (error.code !== 'NoSuchBucket') { 220 | throw error; 221 | } 222 | } 223 | }; 224 | 225 | export const accelerateBucket = async ( 226 | s3: S3, 227 | bucketName: string, 228 | accelerated: boolean 229 | ): Promise => { 230 | try { 231 | await s3 232 | .putBucketAccelerateConfiguration({ 233 | AccelerateConfiguration: { 234 | Status: accelerated ? 'Enabled' : 'Suspended', 235 | }, 236 | Bucket: bucketName, 237 | }) 238 | .promise(); 239 | } catch (e) { 240 | if (e.code === 'NoSuchBucket') { 241 | await utils.sleep(2000); 242 | return accelerateBucket(s3, bucketName, accelerated); 243 | } 244 | throw e; 245 | } 246 | }; 247 | 248 | export const deleteBucket = async (s3: S3, bucketName: string): Promise => { 249 | try { 250 | await s3.deleteBucket({ Bucket: bucketName }).promise(); 251 | } catch (error) { 252 | if (error.code !== 'NoSuchBucket') { 253 | throw error; 254 | } 255 | } 256 | }; 257 | 258 | export const configureCors = async ( 259 | s3: S3, 260 | bucketName: string, 261 | config: S3.CORSConfiguration 262 | ): Promise => { 263 | const params = { Bucket: bucketName, CORSConfiguration: config }; 264 | try { 265 | await s3.putBucketCors(params).promise(); 266 | } catch (e) { 267 | if (e.code === 'NoSuchBucket') { 268 | await utils.sleep(2000); 269 | return configureCors(s3, bucketName, config); 270 | } 271 | throw e; 272 | } 273 | }; 274 | 275 | export const pathToPosix = (path: string): string => path.replace(/\\/g, '/'); 276 | 277 | export const readDirectoryFiles = (directory: string): Promise> => { 278 | const items: Item[] = []; 279 | return new Promise((resolve, reject) => { 280 | klaw(directory.trim()) 281 | .on('data', (item) => items.push(item)) 282 | .on('end', () => { 283 | resolve(items); 284 | }) 285 | .on('error', reject); 286 | }); 287 | }; 288 | 289 | export const filterOutDirectories = (fileItem: Item): boolean => !fileItem.stats.isDirectory(); 290 | 291 | export const getMimeType = (filePath: string): string => 292 | mime.lookup(path.basename(filePath)) || 'application/octet-stream'; 293 | -------------------------------------------------------------------------------- /packages/aws-s3/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "sourceMap": false, 5 | "removeComments": true, 6 | "outDir": "dist" 7 | }, 8 | "include": ["./src/", "../../.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/aws-s3/types.d.ts: -------------------------------------------------------------------------------- 1 | import { Credentials, S3 } from 'aws-sdk'; 2 | 3 | type AwsS3Inputs = { 4 | name?: string; 5 | region?: string; 6 | file?: string; 7 | dir?: string; 8 | key?: string; 9 | zip?: string; 10 | accelerated?: boolean; 11 | cacheControl?: S3.CacheControl; 12 | keyPrefix?: string; 13 | cors?: S3.CORSConfiguration; 14 | }; 15 | 16 | type PublicDirectoryCache = 17 | | boolean 18 | | { 19 | test?: string; 20 | value?: string; 21 | }; 22 | 23 | type SyncStageStateDirectoryOptions = Stage & { 24 | nextConfigDir: string; 25 | credentials: Credentials; 26 | syncTo?: boolean; 27 | }; 28 | 29 | type UploadStaticAssetsOptions = { 30 | bucketName: string; 31 | nextConfigDir: string; 32 | nextStaticDir?: string; 33 | credentials: Credentials; 34 | publicDirectoryCache?: PublicDirectoryCache; 35 | }; 36 | 37 | type S3ClientFactoryOptions = { 38 | bucketName: string; 39 | credentials: Credentials; 40 | }; 41 | 42 | type UploadFileOptions = { 43 | filePath: string; 44 | cacheControl?: string; 45 | s3Key?: string; 46 | }; 47 | 48 | type DownloadFileOptions = { 49 | s3Key: string; 50 | }; 51 | 52 | type ListFileOptions = { 53 | s3Key: string; 54 | }; 55 | 56 | type SetVersioningOptions = { 57 | versioned?: boolean; 58 | }; 59 | 60 | type S3Client = { 61 | uploadFile: (options: UploadFileOptions) => Promise; 62 | listFiles: (options: ListFileOptions) => Promise; 63 | downloadFile: (options: DownloadFileOptions) => Promise; 64 | setVersioning: (options: SetVersioningOptions) => Promise; 65 | }; 66 | -------------------------------------------------------------------------------- /packages/aws-s3/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | -------------------------------------------------------------------------------- /packages/cli/bin/next-deploy: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('../dist/index.js'); 3 | -------------------------------------------------------------------------------- /packages/cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@next-deploy/cli", 3 | "version": "4.2.0", 4 | "license": "MIT", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "build": "yarn clean && yarn compile", 9 | "build:watch": "yarn compile -w", 10 | "clean": "rm -rf ./dist", 11 | "compile": "tsc -p tsconfig.build.json" 12 | }, 13 | "dependencies": { 14 | "@next-deploy/aws-component": "link:../aws-component", 15 | "@next-deploy/github": "link:../github" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/cli/src/config.ts: -------------------------------------------------------------------------------- 1 | export const SUPPORTED_ENGINES = [ 2 | { 3 | type: 'aws', 4 | component: '@next-deploy/aws-component', 5 | }, 6 | { 7 | type: 'github', 8 | component: '@next-deploy/github', 9 | }, 10 | ]; 11 | export const DEFAULT_ENGINE = 'aws'; 12 | export const DEPLOY_CONFIG_NAME = 'next-deploy.config.js'; 13 | export const METHOD_NAME_MAP = [ 14 | { name: 'init' }, 15 | { name: 'default', action: 'Deploying', actionNoun: 'Deployment' }, 16 | { name: 'build', action: 'Building', actionNoun: 'Build' }, 17 | { name: 'deploy', action: 'Deploying', actionNoun: 'Deployment' }, 18 | { name: 'remove', action: 'Removing', actionNoun: 'Removal' }, 19 | ]; 20 | export const STATE_ROOT = '.next-deploy'; 21 | -------------------------------------------------------------------------------- /packages/cli/src/context.ts: -------------------------------------------------------------------------------- 1 | import os from 'os'; 2 | import chalk from 'chalk'; 3 | import ansiEscapes from 'ansi-escapes'; 4 | import stripAnsi from 'strip-ansi'; 5 | import figures from 'figures'; 6 | import path from 'path'; 7 | import prettyoutput from 'prettyoutput'; 8 | import { utils } from '@serverless/core'; 9 | 10 | import { ContextMetrics, ContextConfig } from '../types'; 11 | 12 | const { red, green, blue, dim: grey } = chalk; 13 | const STATE_ROOT = '.next-deploy'; 14 | 15 | class Context { 16 | root: string; 17 | stateRoot: string; 18 | debugMode: boolean; 19 | state: Record; 20 | credentials: Record; 21 | id: string; 22 | outputs: Record; 23 | metrics: ContextMetrics; 24 | 25 | constructor({ root, stateRoot, credentials, debug, entity, message }: ContextConfig) { 26 | this.root = path.resolve(root) || process.cwd(); 27 | this.stateRoot = stateRoot ? path.resolve(stateRoot) : path.join(this.root, STATE_ROOT); 28 | 29 | this.credentials = credentials || {}; 30 | this.debugMode = debug || false; 31 | this.state = { id: utils.randomId() }; 32 | this.id = this.state.id as string; 33 | this.outputs = {}; 34 | 35 | this.metrics = { 36 | entity: entity || 'Component', 37 | lastDebugTime: undefined, 38 | useTimer: true, 39 | seconds: 0, 40 | status: { 41 | running: false, 42 | message: message || 'Running', 43 | loadingDots: '', 44 | loadingDotCount: 0, 45 | }, 46 | }; 47 | 48 | // Hide cursor always, to keep it clean 49 | process.stdout.write(ansiEscapes.cursorHide); 50 | 51 | // Event Handler: Control + C 52 | process.on('SIGINT', () => { 53 | if (this.isStatusEngineActive()) { 54 | return this.statusEngineStop('cancel'); 55 | } 56 | process.exit(1); 57 | }); 58 | 59 | // Count seconds 60 | setInterval(() => { 61 | this.metrics.seconds++; 62 | }, 1000); 63 | } 64 | 65 | async init(): Promise { 66 | const contextStatePath = path.join(this.stateRoot, `_.json`); 67 | 68 | if (await utils.fileExists(contextStatePath)) { 69 | this.state = await utils.readFile(contextStatePath); 70 | } else { 71 | await utils.writeFile(contextStatePath, this.state); 72 | } 73 | 74 | this.id = this.state.id as string; 75 | 76 | await this.setCredentials(); 77 | } 78 | 79 | resourceId(): string { 80 | return `${this.id}-${utils.randomId()}`; 81 | } 82 | 83 | async readState(id: string): Promise { 84 | const stateFilePath = path.join(this.stateRoot, `${id}.json`); 85 | 86 | if (await utils.fileExists(stateFilePath)) { 87 | return utils.readFile(stateFilePath); 88 | } 89 | return {}; 90 | } 91 | 92 | async writeState(id: string, state: Record): Promise { 93 | const stateFilePath = path.join(this.stateRoot, `${id}.json`); 94 | await utils.writeFile(stateFilePath, state); 95 | return state; 96 | } 97 | 98 | async setCredentials(): Promise> { 99 | // Load env vars 100 | const envVars = process.env; 101 | 102 | // Known Provider Environment Variables and their SDK configuration properties 103 | const providers: Record = {}; 104 | 105 | providers.aws = {}; 106 | providers.aws.AWS_ACCESS_KEY_ID = 'accessKeyId'; 107 | providers.aws.AWS_SECRET_ACCESS_KEY = 'secretAccessKey'; 108 | providers.aws.AWS_REGION = 'region'; 109 | 110 | const credentials: Record = {}; 111 | 112 | for (const provider in providers) { 113 | const providerEnvVars = providers[provider]; 114 | for (const providerEnvVar in providerEnvVars) { 115 | if (!envVars.hasOwnProperty(providerEnvVar)) { 116 | continue; 117 | } 118 | 119 | if (!credentials[provider]) { 120 | credentials[provider] = {}; 121 | } 122 | 123 | credentials[provider][providerEnvVars[providerEnvVar]] = envVars[providerEnvVar]; 124 | } 125 | } 126 | 127 | this.credentials = credentials; 128 | 129 | return credentials; 130 | } 131 | 132 | close(reason: string, error?: Error): void { 133 | // Skip if not active 134 | process.stdout.write(ansiEscapes.cursorShow); 135 | if (!this.isStatusEngineActive()) { 136 | console.log(); 137 | error && console.error(error); 138 | process.exit(0); 139 | } 140 | 141 | return this.statusEngineStop(reason, error); 142 | } 143 | 144 | getRelativeVerticalCursorPosition(contentString: string): number { 145 | const base = 1; 146 | const terminalWidth = process.stdout.columns; 147 | const contentWidth = stripAnsi(contentString).length; 148 | const nudges = Math.ceil(Number(contentWidth) / Number(terminalWidth)); 149 | return base + nudges; 150 | } 151 | 152 | async statusEngine(): Promise { 153 | this.renderStatus(); 154 | 155 | await utils.sleep(100); 156 | 157 | if (this.isStatusEngineActive()) { 158 | return this.statusEngine(); 159 | } 160 | } 161 | 162 | isStatusEngineActive(): boolean { 163 | return this.metrics.status.running; 164 | } 165 | 166 | statusEngineStart(): Promise { 167 | if (this.debugMode) { 168 | this.log(); 169 | } 170 | this.metrics.status.running = true; 171 | // Start Status engine 172 | return this.statusEngine(); 173 | } 174 | 175 | statusEngineStop(reason: string, error?: Error): void { 176 | this.metrics.status.running = false; 177 | let message = ''; 178 | 179 | if (reason === 'error' && error) { 180 | message = red(`❌ ${error.stack || error.message}`); 181 | } else if (reason === 'cancel') { 182 | message = red('Cancelled ❌'); 183 | } else if (reason === 'done') { 184 | message = green('Done ✔'); 185 | } 186 | 187 | // Clear any existing content 188 | process.stdout.write(ansiEscapes.cursorLeft); 189 | process.stdout.write(ansiEscapes.eraseDown); 190 | 191 | // Write content 192 | this.log(); 193 | let content = ''; 194 | 195 | if (this.metrics.useTimer) { 196 | content += `${grey(`${this.metrics.seconds}s`)}`; 197 | content += ` ${blue(figures.pointerSmall)}`; 198 | } 199 | content += ` ${message}`; 200 | process.stdout.write(content); 201 | 202 | // Put cursor to starting position for next view 203 | console.log(os.EOL); 204 | process.stdout.write(ansiEscapes.cursorLeft); 205 | process.stdout.write(ansiEscapes.cursorShow); 206 | 207 | if (reason === 'error') { 208 | process.exit(1); 209 | } else { 210 | process.exit(0); 211 | } 212 | } 213 | 214 | renderStatus(status?: string, entity?: string): void { 215 | // Start Status engine, if it isn't running yet 216 | if (!this.isStatusEngineActive()) { 217 | this.statusEngineStart(); 218 | } 219 | 220 | // Set global status 221 | if (status) { 222 | this.metrics.status.message = status; 223 | } 224 | 225 | // Set global status 226 | if (entity) { 227 | this.metrics.entity = entity; 228 | } 229 | 230 | // Loading dots 231 | if (this.metrics.status.loadingDotCount === 0) { 232 | this.metrics.status.loadingDots = `.`; 233 | } else if (this.metrics.status.loadingDotCount === 2) { 234 | this.metrics.status.loadingDots = `..`; 235 | } else if (this.metrics.status.loadingDotCount === 4) { 236 | this.metrics.status.loadingDots = `...`; 237 | } else if (this.metrics.status.loadingDotCount === 6) { 238 | this.metrics.status.loadingDots = ''; 239 | } 240 | this.metrics.status.loadingDotCount++; 241 | if (this.metrics.status.loadingDotCount > 8) { 242 | this.metrics.status.loadingDotCount = 0; 243 | } 244 | 245 | // Clear any existing content 246 | process.stdout.write(ansiEscapes.eraseDown); 247 | 248 | // Write content 249 | console.log(); 250 | let content = ''; 251 | if (this.metrics.useTimer) { 252 | content += `${grey(this.metrics.seconds + 's')}`; 253 | content += ` ${blue(figures.pointerSmall)}`; 254 | } 255 | 256 | content += ` ${grey(this.metrics.status.message)}`; 257 | content += `${blue(this.metrics.status.loadingDots)}`; 258 | process.stdout.write(content); 259 | console.log(); 260 | 261 | // Get cursor starting position according to terminal & content width 262 | const startingPosition = this.getRelativeVerticalCursorPosition(content); 263 | 264 | // Put cursor to starting position for next view 265 | process.stdout.write(ansiEscapes.cursorUp(startingPosition)); 266 | process.stdout.write(ansiEscapes.cursorLeft); 267 | } 268 | 269 | renderLog(msg?: string): void { 270 | if (!msg || msg == '') { 271 | console.log(); 272 | return; 273 | } 274 | 275 | // Clear any existing content 276 | process.stdout.write(ansiEscapes.eraseDown); 277 | console.log(); 278 | 279 | console.log(msg); 280 | 281 | // Put cursor to starting position for next view 282 | process.stdout.write(ansiEscapes.cursorLeft); 283 | } 284 | 285 | renderDebug(msg: string): void { 286 | if (!this.debugMode || !msg || msg == '') { 287 | return; 288 | } 289 | 290 | this.metrics.lastDebugTime = this.metrics.lastDebugTime || new Date().getTime(); 291 | 292 | const now = new Date().getTime(); 293 | const elapsedMs = now - this.metrics.lastDebugTime; 294 | const elapsedTimeSuffix = 295 | elapsedMs > 1000 296 | ? chalk.red(`(${Math.floor(elapsedMs / 1000)}s)`) 297 | : grey.bold(`(${elapsedMs}ms)`); 298 | 299 | this.metrics.lastDebugTime = now; 300 | 301 | // Clear any existing content 302 | process.stdout.write(ansiEscapes.eraseDown); 303 | 304 | console.log(`${blue.bold(`DEBUG ${figures.line}`)} ${chalk.white(msg)} ${elapsedTimeSuffix}`); 305 | 306 | // Put cursor to starting position for next view 307 | process.stdout.write(ansiEscapes.cursorLeft); 308 | } 309 | 310 | renderError(errorProp?: string | Error): void { 311 | let error: Error; 312 | 313 | // If no argument, skip 314 | if (!errorProp) { 315 | return; 316 | } 317 | 318 | if (typeof errorProp === 'string') { 319 | error = new Error(errorProp); 320 | } else { 321 | error = errorProp; 322 | } 323 | 324 | // Clear any existing content 325 | process.stdout.write(ansiEscapes.eraseDown); 326 | console.log(); 327 | 328 | // Write Error 329 | console.log(`${red('Error:')}`); 330 | 331 | console.log(` `, error); 332 | 333 | // Put cursor to starting position for next view 334 | process.stdout.write(ansiEscapes.cursorLeft); 335 | } 336 | 337 | renderOutputs(outputs: Record): void { 338 | if (typeof outputs !== 'object' || Object.keys(outputs).length === 0) { 339 | return; 340 | } 341 | // Clear any existing content 342 | process.stdout.write(ansiEscapes.eraseDown); 343 | console.log(); 344 | process.stdout.write(prettyoutput(outputs, {}, 2)); 345 | } 346 | 347 | // basic CLI utilities 348 | log(msg?: string): void { 349 | this.renderLog(msg); 350 | } 351 | 352 | debug(msg: string): void { 353 | this.renderDebug(msg); 354 | } 355 | 356 | status(status: string, entity: string): void { 357 | this.renderStatus(status, entity); 358 | } 359 | } 360 | 361 | export default Context; 362 | -------------------------------------------------------------------------------- /packages/cli/src/deploy.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import chalk from 'chalk'; 3 | 4 | import { DEFAULT_ENGINE, SUPPORTED_ENGINES, METHOD_NAME_MAP, STATE_ROOT } from './config'; 5 | import Context from './context'; 6 | import { createBaseConfig } from './utils'; 7 | 8 | const deploy = async (deployConfigPath: string, methodName = 'default'): Promise => { 9 | let options: BaseDeploymentOptions = {}; 10 | try { 11 | options = await import(deployConfigPath); 12 | } catch (e) {} 13 | 14 | const { 15 | debug = false, 16 | engine = DEFAULT_ENGINE, 17 | onPreDeploy, 18 | onPostDeploy, 19 | onShutdown, 20 | stage, 21 | } = options; 22 | const engineIndex = SUPPORTED_ENGINES.findIndex(({ type }) => type === engine); 23 | const isInit = methodName === 'init'; 24 | 25 | onShutdown && handleShutDown(onShutdown); 26 | 27 | if (engineIndex === -1) { 28 | console.error( 29 | `❌ ${chalk.red(`Unsupported engine:`)}: ${engine}\nPick one of: ${SUPPORTED_ENGINES.map( 30 | ({ type }) => type 31 | ).join(', ')}` 32 | ); 33 | 34 | process.exit(1); 35 | } 36 | 37 | const EngineComponent = await import(SUPPORTED_ENGINES[engineIndex].component); 38 | const method = METHOD_NAME_MAP.find(({ name }) => name === methodName); 39 | 40 | if (!method) { 41 | console.error( 42 | `❌ ${chalk.red(`Unsupported method:`)} ${methodName}\nPick one of: ${METHOD_NAME_MAP.map( 43 | ({ name }) => name 44 | ).join(', ')}` 45 | ); 46 | 47 | process.exit(1); 48 | } 49 | 50 | createBaseConfig(deployConfigPath, isInit); 51 | 52 | if (isInit) { 53 | process.exit(0); 54 | } 55 | 56 | const context = new Context({ 57 | root: process.cwd(), 58 | stateRoot: path.join(process.cwd(), STATE_ROOT, stage?.name || 'local'), 59 | debug, 60 | entity: engine.toUpperCase(), 61 | message: method.action, 62 | }); 63 | const component = new EngineComponent.default(undefined, context); 64 | 65 | try { 66 | context.log(`⚡ Starting ${method.actionNoun} ⚡`); 67 | 68 | onPreDeploy && (await onPreDeploy()); 69 | 70 | await component.init(); 71 | 72 | context.metrics.lastDebugTime = new Date().getTime(); 73 | context.statusEngineStart(); 74 | 75 | const outputs = await component[methodName](options); 76 | 77 | context.renderOutputs(outputs); 78 | 79 | onPostDeploy && (await onPostDeploy()); 80 | 81 | context.close('done'); 82 | process.exit(0); 83 | } catch (e) { 84 | context.close('error', e); 85 | process.exit(1); 86 | } 87 | }; 88 | 89 | function handleShutDown(onShutdown: () => Promise) { 90 | const doShutdown = async () => { 91 | await onShutdown(); 92 | process.exit(1); 93 | }; 94 | 95 | process.on('SIGINT', doShutdown); 96 | process.on('SIGQUIT', doShutdown); 97 | process.on('SIGTERM', doShutdown); 98 | } 99 | 100 | export default deploy; 101 | -------------------------------------------------------------------------------- /packages/cli/src/index.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import minimist from 'minimist'; 3 | 4 | import deploy from './deploy'; 5 | import { DEPLOY_CONFIG_NAME } from './config'; 6 | 7 | const deployConfigPath = path.join(process.cwd(), DEPLOY_CONFIG_NAME); 8 | const args = minimist(process.argv.slice(2)); 9 | const method = args._[0] || undefined; 10 | 11 | deploy(deployConfigPath, method); 12 | -------------------------------------------------------------------------------- /packages/cli/src/utils.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | 3 | import { DEPLOY_CONFIG_NAME } from './config'; 4 | 5 | export const createBaseConfig = (deployConfigPath: string, displayWarning?: boolean): void => { 6 | const configPathExists = fs.existsSync(deployConfigPath); 7 | 8 | if (displayWarning && configPathExists) { 9 | console.warn(`⚠️ The ${DEPLOY_CONFIG_NAME} configuration already exists.`); 10 | } 11 | 12 | // create a default next-deploy config if one doesn't exist yet 13 | if (!configPathExists) { 14 | fs.writeFileSync( 15 | deployConfigPath, 16 | `// for more configurable options see: https://github.com/nidratech/next-deploy#configuration-options 17 | module.exports = { 18 | engine: 'aws', 19 | debug: true, 20 | }; 21 | ` 22 | ); 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /packages/cli/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "sourceMap": false, 5 | "removeComments": true, 6 | "outDir": "dist" 7 | }, 8 | "include": ["./src/", "../../.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/cli/types.d.ts: -------------------------------------------------------------------------------- 1 | export type ContextConfig = { 2 | root: string; 3 | stateRoot: string; 4 | credentials?: Record; 5 | debug?: boolean; 6 | entity?: string; 7 | message?: string; 8 | }; 9 | 10 | type ContextMetrics = { 11 | entity: string; 12 | lastDebugTime?: number; 13 | useTimer: boolean; 14 | seconds: 0; 15 | status: MetricsStatus; 16 | }; 17 | 18 | type MetricsStatus = { 19 | running: boolean; 20 | message: string; 21 | loadingDots: string; 22 | loadingDotCount: number; 23 | }; 24 | -------------------------------------------------------------------------------- /packages/cli/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@next-deploy/aws-cloudfront@link:../aws-cloudfront": 6 | version "0.0.0" 7 | uid "" 8 | 9 | "@next-deploy/aws-component@link:../aws-component": 10 | version "0.0.0" 11 | uid "" 12 | 13 | "@next-deploy/aws-domain@link:../aws-domain": 14 | version "0.0.0" 15 | uid "" 16 | 17 | "@next-deploy/aws-lambda-builder@link:../aws-lambda-builder": 18 | version "0.0.0" 19 | uid "" 20 | 21 | "@next-deploy/aws-lambda@link:../aws-lambda": 22 | version "0.0.0" 23 | uid "" 24 | 25 | "@next-deploy/aws-s3@link:../aws-s3": 26 | version "0.0.0" 27 | uid "" 28 | 29 | "@next-deploy/github@link:../github": 30 | version "0.0.0" 31 | uid "" 32 | -------------------------------------------------------------------------------- /packages/github/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@next-deploy/github", 3 | "version": "4.2.0", 4 | "license": "MIT", 5 | "main": "dist/component.js", 6 | "types": "dist/component.d.ts", 7 | "scripts": { 8 | "build": "yarn clean && yarn compile", 9 | "build:watch": "yarn compile -w", 10 | "clean": "rm -rf ./dist", 11 | "compile": "tsc -p tsconfig.build.json" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/github/src/builder.ts: -------------------------------------------------------------------------------- 1 | import execa from 'execa'; 2 | import { writeFileSync } from 'fs'; 3 | 4 | const build = async ( 5 | { cmd, cwd, args }: BuildOptions, 6 | debug?: (message: string) => void 7 | ): Promise => { 8 | if (debug) { 9 | const { stdout: nextVersion } = await execa(cmd, ['--version'], { 10 | cwd, 11 | }); 12 | 13 | debug(`Starting a new build with ${nextVersion}`); 14 | 15 | console.log(); 16 | } 17 | 18 | // run the build 19 | const subprocess = execa(cmd, args, { 20 | cwd, 21 | env: { 22 | NODE_OPTIONS: '--max_old_space_size=3000', 23 | }, 24 | }); 25 | 26 | if (debug && subprocess.stdout) { 27 | subprocess.stdout.pipe(process.stdout); 28 | } 29 | 30 | await subprocess; 31 | 32 | // needed to be able for gh-pages to serve directories that start with _ 33 | writeFileSync('out/.nojekyll', ''); 34 | }; 35 | 36 | export default build; 37 | -------------------------------------------------------------------------------- /packages/github/src/component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@serverless/core'; 2 | import { resolve } from 'path'; 3 | import { publish } from 'gh-pages'; 4 | import { emptyDir } from 'fs-extra'; 5 | import { writeFileSync } from 'fs'; 6 | 7 | import builder from './builder'; 8 | import { GithubInputs, DeploymentResult } from '../types'; 9 | 10 | class GithubComponent extends Component { 11 | async default(inputs: GithubInputs = {}): Promise { 12 | await this.build(inputs); 13 | return this.deploy(inputs); 14 | } 15 | 16 | async build({ build, nextConfigDir, domain }: GithubInputs = {}): Promise { 17 | this.context.status('Building'); 18 | 19 | const nextConfigPath = nextConfigDir ? resolve(nextConfigDir) : process.cwd(); 20 | 21 | await builder( 22 | { 23 | cmd: build?.cmd || 'node_modules/.bin/next', 24 | cwd: build?.cwd ? resolve(build.cwd) : nextConfigPath, 25 | args: build?.args || ['build'], 26 | }, 27 | this.context.instance.debugMode ? this.context.debug : undefined 28 | ); 29 | 30 | if (domain?.length) { 31 | const computedDomain = getComputedDomain(domain); 32 | 33 | if (computedDomain) { 34 | writeFileSync('out/CNAME', computedDomain); 35 | } 36 | } 37 | } 38 | 39 | async deploy({ domain, publish: publishOptions }: GithubInputs = {}): Promise { 40 | this.context.status('Deploying'); 41 | 42 | const outputs: DeploymentResult = {}; 43 | 44 | if (domain?.length) { 45 | const computedDomain = getComputedDomain(domain); 46 | outputs.appUrl = `https://${computedDomain}`; 47 | } 48 | 49 | const publishPromise = new Promise((resolve, reject) => { 50 | publish( 51 | 'out', 52 | { message: 'Next Deployment Update', dotfiles: true, ...publishOptions }, 53 | (err) => { 54 | if (err) { 55 | return reject(err); 56 | } 57 | 58 | resolve(); 59 | } 60 | ); 61 | }); 62 | 63 | await publishPromise; 64 | 65 | return outputs; 66 | } 67 | 68 | async remove(): Promise { 69 | await emptyDir('out'); 70 | writeFileSync('out/empty', ''); 71 | 72 | const publishPromise = new Promise((resolve, reject) => { 73 | publish('out', { message: 'Next Deployment Removal', remove: '*' }, (err) => { 74 | if (err) { 75 | return reject(err); 76 | } 77 | 78 | resolve(); 79 | }); 80 | }); 81 | 82 | await publishPromise; 83 | } 84 | } 85 | 86 | function getComputedDomain(inputDomain: string | string[]): string | undefined { 87 | let domain; 88 | 89 | if (typeof inputDomain === 'string') { 90 | domain = inputDomain; 91 | } else if (inputDomain instanceof Array) { 92 | domain = inputDomain.join(); 93 | } 94 | 95 | return domain; 96 | } 97 | 98 | export default GithubComponent; 99 | -------------------------------------------------------------------------------- /packages/github/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "sourceMap": false, 5 | "removeComments": true, 6 | "outDir": "dist" 7 | }, 8 | "include": ["./src/", "../../.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/github/types.d.ts: -------------------------------------------------------------------------------- 1 | import { PublishOptions } from 'gh-pages'; 2 | 3 | export type GithubInputs = BaseDeploymentOptions & { 4 | publish?: PublishOptions; 5 | }; 6 | 7 | type DeploymentResult = { 8 | appUrl?: string; 9 | }; 10 | -------------------------------------------------------------------------------- /packages/github/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "resolveJsonModule": true, 4 | "esModuleInterop": true, 5 | "declaration": true, 6 | "target": "ES2018", 7 | "module": "CommonJS", 8 | "moduleResolution": "node", 9 | "sourceMap": true, 10 | "strict": true, 11 | "allowJs": true 12 | }, 13 | "exclude": ["node_modules"], 14 | "include": ["**/*.ts", ".d.ts"] 15 | } 16 | --------------------------------------------------------------------------------