nuxt.jsで構築したSPAアプリをS3とCloudFrontで公開。AWS CodeBuildで自動ビルドしてみた。ログはSentryで。

この記事はWEBアプリケーションをNuxt.js実装し、CodeBuildでビルドしS3にアップロード、S3ではホスティングせずにCloudFront経由で読み込めるようにした実装を記録しています。データの受け渡し元APIはRailsにて実装していますが、そちらに関してはこの記事では言及しません。

## 環境

- APIサーバー

Ruby on Rails 5.2.3(APIモード)
Ruby 2.6.3

- SPAフレームワーク

Nuxt.js + Typescript

- lint

ESLint + Prettier

- ビルド

AWS CodeBuild

- ホスティング

S3 + CloudFront

- ログ

Sentry

## アプリケーション本体実装（Nuxt.js）

Homebrewは入っているとして、nodebrewのインストール

```
brew update
brew install nodebrew
nodebrew setup
```

~/.bash_profileに下記を追加

``` 
export PATH=$HOME/.nodebrew/current/bin:$PATH
```

yarnをinstall

```
nodebrew install-binary stable
nodebrew use stable
nodebrew list
brew install yarn --ignore-dependencies
yarn -v
```

nuxt.jsのアプリを作成(プロジェクト名は[sample_nuxt_app])
いくつか質問されます。

```
$ yarn create-nuxt-app sample_nuxt_app
create-nuxt-app v2.8.0
✨  Generating Nuxt.js project in path_to_sample_nuxt_app
? Project name sample_nuxt_app
? Project description 説明文を入力
? Author name 著者を入力
? Choose the package manager Yarn
? Choose UI framework None（vuefyやbootstrapなど色々選べるが今回はデザインあるのでNoneを選択）
? Choose custom server framework None (Recommended)
? Choose Nuxt.js modules Axios
? Choose linting tools ESLintとPrettier
? Choose test framework Jest
? Choose rendering mode Single Page App（サーバーレンダリングも選べるが今回はSPA）
```

**AxiosとLint, PrettierはSpaceKeyを押して選ばないと入らない。エンターキーだとスルーされる。ややこしい！**

```
$ cd sample_nuxt_app
$ yarn dev
```

で一旦動く。

↓
typescript化
まず、nuxtのバージョンをあげたい。
package.jsonのなかみを新しくする。

```
$ ncu
```

ncuがはいってなければ

```
$ yarn global add npm-check-updates
```

はいったら

```
$ ncu
Checking path_to/sample_nuxt_app/package.json
[====================] 20/20 100%

 @nuxtjs/axios            ^5.3.6  →    ^5.5.4 
 nuxt                     ^2.0.0  →    ^2.8.1 
 @nuxtjs/eslint-config    ^0.0.1  →    ^1.0.1 
 @nuxtjs/eslint-module    ^0.0.1  →    ^0.2.1 
 babel-eslint            ^10.0.1  →   ^10.0.2 
 eslint                  ^5.15.1  →    ^6.0.1 
 eslint-plugin-import   >=2.16.0  →  >=2.18.0 
 eslint-plugin-jest     >=22.3.0  →  >=22.7.2 
 eslint-plugin-node      >=8.0.1  →   >=9.1.0 
 eslint-plugin-nuxt      >=0.4.2  →   >=0.4.3 
 eslint-plugin-promise   >=4.0.1  →   >=4.2.1 
 eslint-plugin-vue        ^5.2.2  →    ^5.2.3 
 babel-jest              ^24.1.0  →   ^24.8.0 
 jest                    ^24.1.0  →   ^24.8.0 
 vue-jest                 ^3.0.3  →    ^3.0.4 
 nodemon                 ^1.18.9  →   ^1.19.1 

Run ncu -u to upgrade package.json
```

こうなるので問題なければ

```
ncu -u
```

書き換わったら、一回全部消して入れ直す。

```bash
rm -rf node_modules yarn.lock
yarn install
```

## typescript本体

```
yarn add typescript ts-node @nuxt/typescript
```

### 設定ファイル作成・編集

**nuxt.config.js → nuxt.config.ts**

もともとがこれで↓

```nuxt.config.js
export default {
  ...中身
}
```

こうする↓

```nuxt.config.ts
import NuxtConfiguration from '@nuxt/config'

const nuxtConfig: NuxtConfiguration = {
  ...中身
}
export default nuxtConfig
```

+その中身にこれを追加

```
build: {
    extend(config, ctx) {
      if (ctx.isDev && ctx.isClient) {
        if (!config.module) return  // undefinedの場合、pushせずにreturnするように追加
        config.module.rules.push({
          enforce: 'pre',
          test: /\.(js|vue)$/,
          loader: 'eslint-loader',
          exclude: /(node_modules)/
        })
      }
    }
  }
```

**tsconfig.json**
ECMAバージョン周りでエラーが出る場合があるので
{target: es2015, module esNext} とした 

```tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "types": [
      "@types/node",
      "@nuxt/vue-app"
    ],
    "paths": { "@/*": [ "./*" ], "~/*": [ "./*" ] },
    "target": "es2015",
    "strict": true,
    "module": "esNext",
    "moduleResolution": "node",
    "experimentalDecorators": true
  }
}
```

- parserOptions
ESLintについてのところで記載

- rules（コーディングルールになるので自由にどうぞ）

```
rules: {
    'no-console': process.env.NODE_ENV === 'production' ? 2 : 0,
    'space-before-function-paren': [2, {
      'anonymous': 'always',
      'named': 'always',
      'asyncArrow': 'always'
    }],
    'comma-dangle': ['error', 'only-multiline'],
}
```

vueのクラスコンポーネントで書くように変更
nuxtで拡張したものを使わず本家の方で問題ない

```bash
$ yarn add vue-class-component vue-property-decorator vuex-class
```

それぞれモジュールをtypescriptで記述
- index.vue
scriptの箇所を修正

```html
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator'
import Logo from '~/components/Logo.vue'
@Component({
  components: {
    Logo
  }
})
export default class Index extends Vue {}
</script>
```

- logo.vue
script を追記

```html
<script lang="ts">
import { Vue } from 'vue-property-decorator'
export default class Logo extends Vue {}
</script>
```

ESLintについては[こちら参照](https://qiita.com/dora1998/items/932506fa995962d4dc63#eslint-%E3%81%A7-typescript-%E3%82%92%E6%89%B1%E3%81%86%E8%A8%AD%E5%AE%9A)

```bash
$ yarn add -D @typescript-eslint/parser @typescript-eslint/eslint-plugin
```

**.eslintrc.js**を修正
サイトにはこのように。

```eslintrc.js
module.exports = {
  root: true,
  env: {
    browser: true,
    node: true
  },
  parserOptions: {
    parser: '@typescript-eslint/parser',
    sourceType: 'module',
    project: './tsconfig.json',
    ecmaFeatures: { "legacyDecorators": true }
  },
  extends: [
    '@nuxtjs',
    'plugin:nuxt/recommended',
    'plugin:prettier/recommended',
    'prettier/vue',
    'prettier/@typescript-eslint'
  ],
  plugins: [
    'prettier',
    '@typescript-eslint'
  ],
  // add your custom rules here
  rules: {
    "no-unused-vars": "off",
    "@typescript-eslint/no-unused-vars": "error"
  }
}
```

他にもエラーあったのでこのようにした。

```eslintrc.js
module.exports = {
  root: true,
  env: {
    browser: true,
    node: true,
    commonjs: true,
    es6: true
  },
  parserOptions: {
    "parser": '@typescript-eslint/parser',
    "sourceType": "module",
    "project": './tsconfig.json',
    "ecmaVersion": 2018,
    "ecmaFeatures": {
      "legacyDecorators": true,
    },
  },
  extends: [
    '@nuxtjs',
    'plugin:nuxt/recommended',
    'plugin:prettier/recommended',
    'prettier/vue',
    // 'prettier',
  ],
  plugins: [
    'prettier',
    '@typescript-eslint'
  ],
  // add your custom rules here
  rules: {
    "no-unused-vars": "off",
    "@typescript-eslint/no-unused-vars": "error",
    'no-console': process.env.NODE_ENV === 'production' ? 2 : 0,
    'comma-dangle': ['error', 'only-multiline'],
  }
}
```  

## S3, CloudFrontの構築
[公式](https://ja.nuxtjs.org/faq/deployment-aws-s3-cloudfront/#s3-%E3%81%A8-cloudfront-%E3%82%92%E4%BD%BF%E7%94%A8%E3%81%97%E3%81%A6-aws-%E3%81%B8%E3%83%87%E3%83%97%E3%83%AD%E3%82%A4%E3%81%99%E3%82%8B%E3%81%AB%E3%81%AF-)に詳しく載ってる

### S3
バケットを作成し、アクセス権限はprivateにしておく。

### CloudFront
[こちら](https://johnlouros.com/blog/using-CloudFront-to-serve-an-SPA-from-S3)に従って作成
[他の参考サイト](https://tech.actindi.net/2018/10/19/083000)
**エラーページは大事（らしい）**


#### 1. S3 バケットを作成する（プライベートバケットでOK）
バケット名をとっとく`AWS_BUCKET_NAME="sample_bucket"`

#### 2. CloudFront distribution を作成する
distributionのIDを取得

`AWS_CLOUDFRONT="UPPERCASEDID"`

#### 3. セキュリティアクセスを設定する
IAMのポリシーを作成、そのポリシーをアタッチしたユーザーを作成。

```yaml
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": "s3:ListBucket",
            "Resource": "arn:aws:s3:::bucket_name/*"
        },
        {
            "Sid": "VisualEditor1",
            "Effect": "Allow",
            "Action": [
                "s3:PutObject",
                "s3:GetObjectAcl",
                "s3:GetObject",
                "s3:AbortMultipartUpload",
                "s3:DeleteObject",
                "s3:PutObjectAcl",
                "s3:ListMultipartUploadParts"
            ],
            "Resource": "arn:aws:s3:::bucket_name/*"
        },
        {
            "Sid": "VisualEditor2",
            "Effect": "Allow",
            "Action": [
                "cloudfront:UnknownOperation",
                "cloudfront:ListInvalidations",
                "cloudfront:GetInvalidation",
                "cloudfront:CreateInvalidation"
            ],
            "Resource": "*"
        }
    ]
}
```
AWSアクセスキーとシークレットをとっとく。
AWS_ACCESS_KEY_ID="key_id_value"
AWS_SECRET_ACCESS_KEY="secret_value"

## ローカルDeploy用のスクリプトを作成

**deploy.sh**

```bash
#!/bin/bash

export AWS_ACCESS_KEY_ID="key_id_value"
export AWS_SECRET_ACCESS_KEY="secret_value"
export AWS_BUCKET_NAME="sample_bucket"
export AWS_CLOUDFRONT="UPPERCASEDID"
export AWS_DEFAULT_REGION="ap-northeast-1"

# nvm（node version manager）を読み込み、node（.nvmrc 内のバージョン）をインストールし、npm パッケージをインストールします。
[ -s "$HOME/.nvm/nvm.sh" ] && source "$HOME/.nvm/nvm.sh" && nvm use
# まだインストールされていない場合は npm をインストールする
[ ! -d "node_modules" ] && yarn install

yarn run generate
gulp deploy
```

- 権限変更

```bash
chmod +x deploy.sh
```

- .gitignoreに追加

```bash
echo "
# Don't commit build files
node_modules
dist
.nuxt
.awspublish
deploy.sh
" >> .gitignore
```

- yarnでgulpをインストール

```bash
yarn add --save-dev gulp gulp-awspublish gulp-cloudfront-invalidate-aws-publish concurrent-transform
yarn global add gulp
yarn add gulp   (こっちもしないと使えなかった)
```

- gulpfile.js を作成（中身は上記nuxtの公式から。）
やってることとしては、
 - yarnをインストール
 - yarnでいろいろインストール
 - `gulp-awspublish`でS3にアップロード。
 - `gulp-cloudfront-invalidate-aws-publish`でCloudFrontのキャッシュを削除。

```gulpfile.js
var gulp = require('gulp');
var awspublish = require('gulp-awspublish');
var cloudfront = require('gulp-cloudfront-invalidate-aws-publish');
var parallelize = require('concurrent-transform');

// https://docs.aws.amazon.com/cli/latest/userguide/cli-environment.html

var config = {

  // 必須
  params: { 
    Bucket: process.env.AWS_BUCKET_NAME
  },
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
    signatureVersion: 'v3'
  },

  // 任意
  deleteOldVersions: false,                 // PRODUCTION で使用しない
  distribution: process.env.AWS_CLOUDFRONT, // CloudFront distribution ID
  region: process.env.AWS_DEFAULT_REGION,
  headers: { /*'Cache-Control': 'max-age=315360000, no-transform, public',*/ },

  // 適切なデフォルト値 - これらのファイル及びディレクトリは gitignore されている
  distDir: 'dist',
  indexRootPath: true,
  cacheFileName: '.awspublish',
  concurrentUploads: 10,
  wait: true,  // CloudFront のキャッシュ削除が完了するまでの時間（約30〜60秒）
}

gulp.task('deploy', function() {
  // S3 オプションを使用して新しい publisher を作成する
  // http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#constructor-property
  var publisher = awspublish.create(config);

  var g = gulp.src('./' + config.distDir + '/**');
    // publisher は、上記で指定した Content-Length、Content-Type、および他のヘッダーを追加する
    // 指定しない場合、はデフォルトで x-amz-acl が public-read に設定される
  g = g.pipe(parallelize(publisher.publish(config.headers), config.concurrentUploads))

  // CDN のキャッシュを削除する
  if (config.distribution) {
    console.log('Configured with CloudFront distribution');
    g = g.pipe(cloudfront(config));
  } else {
    console.log('No CloudFront distribution configured - skipping CDN invalidation');
  }

  // 削除したファイルを同期する
  if (config.deleteOldVersions) g = g.pipe(publisher.sync());
  // 連続したアップロードを高速化するためにキャッシュファイルを作成する
  g = g.pipe(publisher.cache());
  // アップロードの更新をコンソールに出力する
  g = g.pipe(awspublish.reporter());
  return g;
});
```

- スクリプトを実行（ローカルからデプロイ）

```
./deploy.sh
```

## CodeBuild構築

ここから先はAWS CodeBuildを使用してGithubをコミットフックとした自動デプロイの実装です。

CodeBuildの画面にアクセス。

- プロジェクト名、説明文

- バッジはかっこいいのでonにした。（アプリのREADME.mdにデプロイステータスのバッジをつけることができる）

- 送信元：ソースをgithubにし、Repoを選択。
ここではgithubのPersonal Access Tokenを使用しました。（登録すると、githubと連携してRepoを選択できるようになる。）

- ウェブフックイベント
これをOnにすると、PushやPRマージをフックにしてデプロイを自動化できる。ブランチ指定も可能
「これらの条件でビルドを開始」をクリックし開いたところで下記を設定
[イベントタイプ]（複数選択可）を`プッシュ`と`PULL_REQUEST_MERGED`に。
[HEAD_REF - オプショナル]に`^refs/heads/master$`と入力

- 環境
[マネージド型イメージ]を選択
[Ubuntu]
[Standard]
[aws/codebuild/standard:2.0]
[最新のもの]
の順に設定
オプションを開き、タイムアウトを10分程度に。（SPAでそんなに時間かからない？）
証明書はここでは省略。
VPCにはアクセスしないのでなし。
コンピューティングはいちばんしょぼいもので十分`3 GB メモリ、2 vCPU`
環境変数に下記を指定
`AWS_BUCKET_NAME`: バケット名
`AWS_CLOUDFRONT`: distributionのID
`API_URL_BROWSER`: CloudFrontで設定したDNS、なければCloudFrontで発行されてるURL（一旦）
`DEPLOY_ENV`: "S3" と記載

- ロール：先に作ってあればそれをセット。なければ新しいロール名を設定し、あとでポリシーをセットする。
使うポリシーはS3とCloudFrontの設定ができるPolicy（前述のポリシーを付与で良い）
（ASMなど使う場合はそのポリシーも付与する。）

- buildspec（ビルド仕様）
ビルドはリポジトリのルートに配置した`buildspec.yml`か、フォームに直接記述で指定できる。
git管理したいのでリポジトリに配置。
名前を変えることもできるがデフォルトでいいと思う。

- アーティファクトなし
- CloudWatchLog: 使う場合は設定

**設定は以上、masterにプッシュして動作確認！（できたかな？）**

## Sentry実装
ログマネージメントを行いたいので、Sentryを採用。
[こちら](https://qiita.com/tanakaworld/items/910d766361d398f43254)がとてもわかりやすくまとまってます。

- Sentryのサイトで新規登録＋プロジェクト新規作成。

言語、フレームワークを選択するような感じになってるけど選択しなくてもいい（やり方が出るだけ）＋nuxt.jsない。
プロジェクト名などを入力し、DSNを取得できればそれでいい。

- モジュールインストール

```bash
$ yarn add @nuxtjs/sentry
```

- 設定

```nuxt.config.ts
  // •••
  modules: [ '@nuxtjs/sentry' ],
  sentry: { dsn: 'https://aaaaaaaaaa@sentry.io/000000' }
```

なお、直書きじゃない場合、envを使うのだが、これが少し厄介。
例えば

```
  sentry: { dsn: process.env.SENTRY_DSN }
```
とかやって、CodeBuildの環境変数にセットしてもうまく読めない。
下記サイトを参考にしてみてください。

[[CodeBuild]buildspec.ymlでの環境変数指定方法あれこれまとめ](https://dev.classmethod.jp/cloud/codebuild-env/)
[Sentry で Nuxt.js のエラー検知 + 環境変数の扱いに関する Tips](https://qiita.com/tanakaworld/items/910d766361d398f43254#dsn-%E3%82%92%E7%92%B0%E5%A2%83%E5%A4%89%E6%95%B0%E3%81%A7%E8%A8%AD%E5%AE%9A%E3%81%99%E3%82%8B%E3%83%93%E3%83%AB%E3%83%89%E6%99%82%E5%AE%9F%E8%A1%8C%E6%99%82)

- ログを出してみる

typescriptでログを出すには、moduleインポートが必要。

```
import { captureException } from '@sentry/browser'
```
として

```
captureException(new Error('Sentryから始まるエラーマネジメント'))
```


以上となります！
Frontendの実装はほとんど初めてになりますので間違いなどあればご指摘いただけると幸いです！

## 参考にさせていただいた記事
[nuxt.js公式](https://ja.nuxtjs.org/guide/installation)
[nuxt.js公式(S3+CloudFront)](https://ja.nuxtjs.org/faq/deployment-aws-s3-cloudfront/)
[nuxt.js+Typescript](https://qiita.com/dora1998/items/932506fa995962d4dc63)
[S3+CloudFront+CodeBuild](https://tech.actindi.net/2018/10/19/083000)
[SentryとNuxt.js](https://qiita.com/tanakaworld/items/910d766361d398f43254)