Nuxt.js(SSR)をLambdaで配信する【個人開発】

## 概要
Qiitaのストックを整理するためのサービス「Mindexer(ミンデクサー)」のフロントエンドを
Vue.jsで構築したSPAのアプリケーションからNuxt.jsでSSRを行うように再構築しました。

LambdaでExpressサーバーを動かし、その上でNuxt.jsを動かしています。
この記事では、今回の開発で得られたNuxt.jsに関するTipsやインフラ構成などについて解説していきたいと思います。

なお、Vue.jsからの移行に関する解説は含みません。

## サービスについて
「Mindexer(ミンデクサー)」については、こちらの記事で解説しています。
バックエンドで利用している技術についても解説していますので、合わせてご覧いただけると嬉しいです。

[:link: AWS + Laravel + Vue.js でQiitaのストックを整理するサービスを作りました！【個人開発】](https://qiita.com/kobayashi-m42/items/14e137727ffda3bf79e7)
[:link: 個人開発のインフラをEC2からFargateに置き換えました！](https://qiita.com/kobayashi-m42/items/ebc399df258a69b8c76b)

## ソースコード
ソースコードは全てこちらで公開しています。

Nuxt.js : https://github.com/nekochans/qiita-stocker-frontend
Terraform : https://github.com/nekochans/qiita-stocker-terraform

## Nuxt.jsへの移行理由
以下のような理由です。

- SSRによってページの表示速度を向上させたかった為
- BFF層を導入したかった為（認証部分はサーバーサイドで行ったほうが安全）
- Nuxtのレイアウト機能を利用したかった為

## アーキテクチャ選定理由
Nuxt.jsを実行させる為のアーキテクチャ選定に悩みました。

選択肢としては、AWS FargateやGAE（Google App Engine）等があります。
最初は、GAE（Google App Engine）が簡単そうだなと思ったのですが、ドメインレジストラとしてRoute53、証明書にACMを利用している関係上、このあたりの連携が複雑そうだったので止めました。

最終的にAWS Lambda上でNuxt.jsを動作させるという選択をしました。

理由としては下記の通りです。

- アクセス数が相当増えない限り、ランニングコストが安い
- AWS上のサービスだけで完結出来る

AWS Lambda上でNuxt.jsを動作させる設定が少々複雑になったので、次項でそれを説明します。

## 技術要素
- Nuxt.js v2.10.0
- Vue.js + Vuex
- TypeScript 
- Serverless Framework

## インフラ構成
### AWS 構成図
![mindexer-nuxt.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/206741/7c031409-4d36-b35b-f7f5-2cb289b80bdb.png)

### フロントエンド
Lambda上でExpressサーバーを動かし、その上でNuxt.jsを動かしています。

まず、CloudFrontによって、S3とAPI Gatewayへのアクセスの振り分けを行います。
画像やJavaScriptファイルなどの静的リソースについてはS3から配信し、SSRやBFFへのアクセスはAPI Gateway経由でLambdaから配信しています。

### バックエンド
バックエンドのAPIは、ECS Fargateで配信しています。
ECS Fargateについては、こちらの記事で解説していますので、よろしければこちらをご覧ください。
[個人開発のインフラをEC2からFargateに置き換えました！](https://qiita.com/kobayashi-m42/items/ebc399df258a69b8c76b)

## インフラ構築

AWSのインフラ構成図に登場するリソースの構築は、少しややこしいのですが`Serverless Framework`と`Terraform`の2つによって構築しています。

- Serverless Framework
  - API Gateway
  - Lambda
- Terraform
  - CloudFront
  - S3

まず、AWSのリソース説明の前に、Lambdaで動いているExpressサーバーについて解説し、その後にAWSのリソースについてみていきたいと思います。

### Expressサーバー
Nuxt.jsの`universal`モードを選択し、SSRを行うためにExpressサーバーを用意しています。
Nuxt.jsは、Expressサーバーのミドルウェアとして使用しています。
これに加え、ExpressサーバーではBFFの役割を担っています。

#### Nuxt.jsのrender(SSR)
`nuxt.render()`を呼ぶことで、Express上でNuxt.jsを動かしています。

[API: nuxt.render(req, res) - NuxtJS](https://ja.nuxtjs.org/api/nuxt-render/)

#### BFF(Backends For Frontends)
主に以下のような役割を担います。

- 複数のWebAPIへのリクエスト処理を1つにまとめる
- SSR（サーバーサイドレンダリング）を行う（これはNuxtの仕組みでやってくれる）

普通にサーバーサイドのコードが書ける（Express等のコードが普通に動く）ので、認証周りの処理はここで書いたほうがシンプルに書けます。

また外部APIにアクセスする際に利用するクレデンシャル（APISecret等）もサーバーサイドの環境変数として隠蔽出来るので、セキュリティ的にも安全度が向上します。

今回、Qiitaアカウントを利用してログインを行う部分をこのBFF上で行うように改修しました。

補足となりますが、`serverMiddleware`を使用することで、 Nuxt.js内にExpressサーバーの処理を持たせることもできます。
[API: serverMiddleware プロパティ - NuxtJS](https://ja.nuxtjs.org/api/configuration-servermiddleware/)

### Serverless
ここまで、Express上のサーバーでNuxt.jsを動かしているという解説をしてきましたが、どのようにLambda上でExpressを動かしているのかを見ていきたいと思います。

### Serverless Framework
[Serverless Framework](https://serverless.com/)は、はServerless Applicationを構成管理デプロイするためのCLIツールです。 Serverless Frameworkを使うことで簡単にAWS Lmabdaへのデプロイを行うことができます。


Serverless Frameworkに関する解説は下記の記事が参考になりましたので、掲載させて頂きます。
[Serverless Frameworkの使い方まとめ](https://qiita.com/horike37/items/b295a91908fcfd4033a2)

今回のケースでは、以下のことを行います。

- LambdaとAPI Gatewayの作成
- Lamndaへのデプロイ

LambdaでNuxt.jsを配信するためには、Lambdaを呼び出すための入り口となるAPI Gatewayが必要となります。
API Gateway用意して、HTTPリクエストをLambdaに送ります。

API Gatewayの設定については、下記をご確認ください。
[Serverless Framework - AWS Lambda Events - API Gateway](https://serverless.com/framework/docs/providers/aws/events/apigateway/)

また、LambdaでNuxt.jsを配信するために、下記のライブラリを使用しています。

- aws-serverless-express
- serverless-plugin-warmup 

#### aws-serverless-express
AWS Labsによって提供されている[aws-serverless-express](https://github.com/awslabs/aws-serverless-express)を使用することで、Lambda上でExpressサーバーを動かすことができます。

`aws-serverless-express`は、API GatewayからのリクエストをNode.jsのHTTPリクエストに変換してくれるので、簡単にLambda上でExpressサーバーを動かすことができます。

#### serverless-plugin-warmup 
Lambdaのコールドスタート対策のために利用しています。
詳細については、下記の記事に詳しく記載されていますので、掲載させて頂きます。
[Serverless FrameworkでLambdaのコールドスタート対策を行う](https://qiita.com/keitakn/items/d1c524a97dc844cb02cc)

### S3、CloudFront
Serverless Frameworkで作成されるAWSリソース以外については、Terraformで管理します。
Vue.jsでSPA配信していた際も、インフラはバックエンド含めて全てTerraformで管理しています。
ここではTerraformには触れずに、S3、CloudFrontの設定内容について解説します。

Serverless Frameworkによって、LambdaとAPI Gatewayが作成されていることを前提とします。

#### S3
画像やJavaScriptファイルなどの静的リソースを配信するためのS3バケットを用意します。

アクセスポリシーを下記の通り設定し、CloudFrontからのアクセス(GetObject)のみを許可しています。

```
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity <CloudFrontのID>"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::<S3バケット>/*"
        }
    ]
}
```

また、S3バケットのオブジェクトは下記のディレクトリ構成とします。(S3へのデプロイ方法の解説については省略します。)

```
├── _nuxt  # Nuxt.jsのクライアントのリソース
└── assets # 画像などの静的リソース
```

`nuxt build`を実行すると、`.nuxt/dist/client`にクライアントのコードが生成されます。
このクライアントのソースコードは、CDNにアップロード可能なJavaScriptファイルになります。
CDNの設定は`nuxt.config.js`の`publicPath`で定義されます。デフォルトは`_nuxt`となっており、今回はそのまま利用するので、S3のディレクトリも上記のような構成としています。

[API: build プロパティ - NuxtJS](https://ja.nuxtjs.org/api/configuration-build/#publicpath)

#### CloudFront

インフラ構成の部分で、`CloudFrontによって、S3とAPI Gatewayへのアクセスの振り分けを行います。`と記載しましたが、どのような設定にしているか詳しく解説したいと思います。

##### Origins
S3とApiGatewayの両方を登録しています。

**S3**

|項目|　設定内容|
|:------------------|:------------------|
|Origin Domain Name|静的リソースを置いているS3バケットを指定|
|Origin Path|設定なし|
|Origin ID|任意のID|
|Restrict Bucket Access|No|
|Origin Custom Headers|設定なし|

`Restrict Bucket Access`をNoとしているのは、S3のバケットポリシーを登録しているからです。

**ApiGateway**

|項目|設定内容|
|:------------------|:------------------|
|Origin Domain Name|Severless Frameworkによって作成されたAPI GatewayのAPIエンドポイントを指定|
|Origin Path|/`stage`(例：/dev)|
|Origin ID|任意のID|
|Minimum Origin SSL Protocol|TLSv1.2|
|Origin Protocol Policy|HTTPS Only|
|Origin Response Timeout|任意の値|
|Origin Keep-alive Timeout|任意の値|
|HTTP Port|80|
|HTTPS Port|443|
|Origin Custom Headers|設定なし|

`Origin Path`はServerlessFrameworkの`serverless.yml`で設置しているstageの値になります。
stageを設定しなかった場合、デフォルトは`dev`です。

##### Behaviors
パスによるオリジンの振り分けの設定をします。

|precedence| Path Pattern | Origin |
|:------------------|:------------------|:------------------|
|0|_nuxt/*|静的リソースを置いているS3バケットを指定|
|1|assets/*|静的リソースを置いているS3バケットを指定|
|2|Default (*)|API Gatewayを指定|

設定の解説をすると、Nuxt.jsのクライアントのコードは`/_nuxt/*`にアクセスされます。クライアントのコードをS3にデプロイすることで、S3から配信を行います。
また、staticなファイル(faviconなど)もS3バケットに振り分けられるように設定を行っています。
それ以外のリクエストについては、API Gatewayへ振り分けています。

該当するソースコードはこちらです。
https://github.com/nekochans/qiita-stocker-terraform/blob/master/modules/aws/frontend/cloudfront.tf

## Nuxt.js Tips
### ディレクトリ構成
```
├── app    # Nuxt.jsのコード
├── server # Expressのコード
├── nuxt.config.ts
└── serverless.yml
```

- `app`にNuxt.jsのコード、`server`にExpressのコードを置き、ビルドすると下記のような結果になるように設定しています。
  - `/.nuxt`：Nuxt.jsのコード
  - `/dist`：Expressのコード

- 画像などの静的リソースは、`app/static/assets`に置いています。
CloudFrontで、S3とAPI Gatewayへリクエストの振り分けを行っていますが、このように設定しておくことでCloudFrontの設定が簡単になります。

### Nuxt.js TypeScript対応

Nuxt.js v2.10では、TypeScriptが公式によりサポートされています。
設定は下記をご確認ください。
[Nuxt TypeScript](https://typescript.nuxtjs.org/)

上記のドキュメント通りの設定を行いましたが、一部エラーとなってしまった箇所があったので記載したいと思います。

- `@nuxt/typescript-build`を`dependencies`に移動

CodeBuild上でビルドする際に、`@nuxt/typescript-build`が`devDependencies`に追加されているとエラーとなってしまいました。公式では、 `devDependencies` に追加するように記載されていますが、エラーを回避するために`dependencies`に移動しています。

- developmentモードでのビルドエラーを回避

devモードでは、`@nuxt/builder`によってBuildしています。その際に、Vuexのモジュールでエラーとなってしまったので、下記の対応をしています。

devモードの場合、`config.extensions`に`[ts]`を追加しています。

```typescript:server/core/nuxt.ts
import config from '../../nuxt.config'
const { Nuxt } = require('nuxt')

config.dev = !(process.env.NODE_ENV === 'production')

if (config.dev) {
  config.extensions = ['ts'] // 追加
}

export const nuxt = new Nuxt(config)

export default config
```

これを追加しなかった場合、`.nuxt/store.js`が下記の通りになり、tsファイルで定義したVuexのモジュールが認識されずにエラーとなりました。

```javascript:.nuxt/store.js
function resolveStoreModules (moduleData, filename) {
  moduleData = moduleData.default || moduleData
  // Remove store src + extension (./foo/index.js -> foo/index)
  const namespace = filename.replace(/\.(js|mjs)$/, '')   //tsが含まれない 
```

- `nuxt.ready()`を追加
LambdaでNuxt.jsを動かした際に、下記の設定を追加していなかったためエラーとなっていました。
原因は、Nuxt.js 2.5.x から、`new Nuxt()`をした後に、`nuxt.ready()`を呼ばないとサーバーがレンダリングしないようになっていたためでした。下記の対応をすることで、解消しました。

```typescript:server/app.ts
// 変更前
app.use(nuxt.render)

// 変更後
app.use(async (req, res, next) => {
  await nuxt.ready()
  nuxt.render(req, res, next)
})
```

### エラーページ
エラーページの表示には2つの方法をとっています。

#### Nuxt.jsのLayoutプロパティを利用

[ビュー - NuxtJS (エラーページ)](https://ja.nuxtjs.org/guide/views/#%E3%82%A8%E3%83%A9%E3%83%BC%E3%83%9A%E3%83%BC%E3%82%B8)

APIからエラーが返ってきた場合に表示しています。
エラーが発生した場合に、 `error()`関数を呼び出すことで、Layoutプロパティを利用したエラーページを表示しています。
[API: コンテキスト - NuxtJS (error (Function))](https://ja.nuxtjs.org/api/context/#-code-error-code-em-function-em-)

ページコンポーネント

```typescript:app/pages/stocks/all.vue
  async fetch({ store, error }: Context) {
    try {
      await store.dispatch('qiita/fetchUncategorizedStocks')
      await store.dispatch('qiita/saveDisplayCategoryId', 0)
    } catch (e) {
      error({
        statusCode: e.code,
        message: e.message
      })
    }
  }
```

ページコンポーネント以外

```typescript:app/components/pages/stocks/All.vue
  async onClickDestroyCategory(categoryId: number) {
    try {
      await this.destroyCategory(categoryId)
    } catch (error) {
      return this.$nuxt.error({
        statusCode: error.code,
        message: error.message
      })
    }
  }
```

#### Nuxt.js によって作成されるルーティングを拡張しエラーページに遷移

[API: router プロパティ - NuxtJS (extendRoutes)](https://ja.nuxtjs.org/api/configuration-router/#extendroutes)

BFFでエラーが発生した場合、`/error`に遷移し、エラーページを表示しています。

Nuxt.js によって作成されるルーティングを拡張し、エラーページを作成。
BFFでエラーが発生した場合は、`/error`にリダイレクトし、`app/pages/error.vue`で定義したエラーページを表示しています。

`nuxt.config.ts`で定義している、routerプロパティに`extendRoutes`を追加しルーティングを拡張しています。

```typescript:nuxt.config.ts
  router: {
    middleware: ['authCookieMiddleware', 'redirectMiddleware'],
    extendRoutes(routes: any, resolve) {
      routes.push({
        name: 'original_error',
        path: '/error',
        props: true,
        component: resolve(__dirname, 'app/pages/error.vue')
      })
    }
  },

```

### Middllwere

認証に関連する処理を行うために、Middllwereを利用しています。
[API: middleware プロパティ - NuxtJS](https://ja.nuxtjs.org/api/pages-middleware/)

- authCookieMiddleware.ts：Cookieに保持している認証情報をVuexのStoreに保存する
- redirectMiddleware.ts：ユーザの認証状態から、必要に応じてリダイレクトする

Middllwereは、`nuxt.config.ts`に下記の通り追加するだけで動作します。
ここで記載した順番で、Middllwereは動作します。(`authCookieMiddleware` -> `redirectMiddleware`)

```typescript:nuxt.config.ts
  router: {
    middleware: ['authCookieMiddleware', 'redirectMiddleware'],
  }
```

## あとがき
インフラ構築からNuxt.jsのことまで解説したので、内容が盛りだくさんな記事になってしまいました。
最後まで読んで頂きありがとうございます。少しでもお役に立てれば幸いです。

これからも、技術的に参考になるような情報を発信していきたいと思っていますので、よろしくお願いします。

Mindexerは無料で利用できますので、多くの人に使っていただけると嬉しいです😊
[:link: Mindexer | Qiitaのストックを整理するためのサービスです](https://www.mindexer.net/)

ソースコードもこちらで公開しています！
ここにはMindexerのソースコード以外にもサービスのコードを公開しています！
:link: https://github.com/nekochans