Nuxt.jsをAWS Lambdaで動かす。【TypeScript編】

# 概要

vue.jsのサーバサイドレンダリングで行う[Nuxt.js](https://ja.nuxtjs.org)をAWS Lambda上で動かしてみます。あくまでも試験的に試すことが目的の記事です。もし，本番サービスでの導入する場合には慎重な議論をお願いします。

なお、この記事は以前私が書いた[Nuxt.jsをAWS Lambda上で動かす．サーバレス・サーバサイドレンダリング](https://qiita.com/hiroyky/items/ff955af81df67c30b852)をTypeScriptでも実現できるようにしたものです。JavaScriptで書きたい人はリンク先を参照して頂きたいと思います。なお、内容自体はこの記事単体で完結します。

# 構成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73358/db485ca9-f538-85aa-09c9-119db83f8e0c.png)

- AWS Lambdaでnuxt.jsを可動させる
- API Gateway経由でWebブラウザ等からアクセスする．

# 前提

- yarn, npx, nodeといったnuxt.jsを開発する環境がある。
- TypeScript, JavaScriptを知っている。
- AWSアカウントを取得しaws-cliがインストール済みで実行できる状態である。
- node.jsとexpressでWebサーバを作ったことがある。
- AWS LambdaやAPI Gateway, CloudFormation, S3などAWSのサービスを使ったことがある。

# 手順

## 1. nuxtプロジェクト作成
### 1.1 初期化
npxコマンドを使ってnuxtプロジェクトを立ち上げます．

- UIフレームワークにVuetifyを指定していますが必須ではありません．
- パッケージマネージャはyarnを選択していますがnpmでも動くと思います(未検証)

# 手順

## 1. nuxtプロジェクト作成
### 1.1 初期化
npxコマンドを使ってnuxtプロジェクトを立ち上げます。2020年8月8日現在、言語選択でTypeScriptを選択すればTypeScriptで実装できるように設定してくれるようです。便利になりましたね。また、[この時点でexpressの導入はできなくなった](https://github.com/nuxt/create-nuxt-app/issues/563)ようです。

- 言語選択ではTypeScriptを選択します。
- UIフレームワークにVuetifyを指定していますが必須ではありません。
- パッケージマネージャはyarnを選択していますがnpmでも動くと思います(未検証)。
- 必要に応じて適宜変更してください。

```shell
$ yarn create nuxt-app nuxt_lambda_ts_sample
? Project name: nuxt_lambda_ts_sample
? Programming language: TypeScript
? Package manager: Yarn
? UI framework: Vuetify.js
? Nuxt.js modules: (Press <space> to select, <a> to toggle all, <i> to invert selection)
? Linting tools: ESLint
? Testing framework: Jest
? Rendering mode: Universal (SSR / SSG)
? Deployment target: Server (Node.js hosting)
? Development tools: jsconfig.json (Recommended for VS Code if you're not using typescript)
```

## 1.2 nuxt.jsを起動

初期化後,下記コマンドでローカル環境にnuxt.jsが起動します。

```
$ cd nuxt_lambda_ts_sample
$ yarn run dev

ℹ Listening on: http://localhost:3000/
```

## 1.3 expressを導入し、Lambdaで動くようにする。
必要なパッケージをインストールします。

```shell
$ yarn add -D aws-lambda aws-serverless-express express @types/aws-lambda @types/aws-serverless-express
```

AWS Lambdaのエンドポイントとなるプログラム実装します。ここでExpressを使います。``./server/lambda.ts``を作成します。

ポイントは以下2つです。

- Lambdaのエントリポイントであるhandler関数を作成し， [awsServerlessExpres](https://github.com/awslabs/aws-serverless-express)を動かしている．
- ミドルウェアとしてURLを置き換える処理(customDomainAdaptorMiddleware)を追加している．

```typescript:server/lambda.ts
import http from 'http'
import { APIGatewayEvent, Context } from 'aws-lambda'
import awsServerlessExpress from 'aws-serverless-express'
import express from 'express'
import { Nuxt, Builder } from 'nuxt'
import config from '../nuxt.config'
import { customDomainAdaptorMiddleware } from './middleware'

const app = express()

async function initApp () {
  const nuxt = new Nuxt(config)

  if (config.dev) {
    const builder = new Builder(nuxt)
    await builder.build()
  } else {
    await nuxt.ready()
  }

  app.use(customDomainAdaptorMiddleware)
  app.use(nuxt.render)

  return app
}

let server: http.Server
const binaryMimeTypes = [
  'application/javascript',
  'application/json',
  'application/octet-stream',
  'application/xml',
  'font/eot',
  'font/opentype',
  'font/otf',
  'image/jpeg',
  'image/png',
  'image/svg+xml',
  'text/comma-separated-values',
  'text/css',
  'text/html',
  'text/javascript',
  'text/plain',
  'text/text',
  'text/xml'
]

export function handler (event: APIGatewayEvent, context: Context) {
  initApp().then((app) => {
    if (server === undefined) {
      server = awsServerlessExpress.createServer(app, undefined, binaryMimeTypes)
    }
    awsServerlessExpress.proxy(server, event, context)
  })
}
```

上記のコードで導入したミドルウェアを記述します。これはAPI GatewaydでURL解決を良い感じにやるためです。

```typescript:server/middleware.ts
import express from 'express'

export const customDomainAdaptorMiddleware = (req: express.Request, _: express.Response, next: express.NextFunction) => {
  const apigatewayHeader = req.headers['x-apigateway-event']
  if (apigatewayHeader === undefined) {
    next()
    return
  }

  req.url = req.originalUrl = `/${req.url}`.replace('//', '/')

  next()
}
```

また``Nuxt``, ``Builder``の型定義が内容なので一時的な型定義を作成します。

```typescript:types/nuxt.d.ts
declare module 'nuxt' {
  const Nuxt: any
  const Builder: any
  export { Nuxt, Builder }
}
```

## 1.4 設定変更

### 1.4.1 nuxt.config.jsをnuxt.config.tsに

nuxt.config.jsをnuxt.config.tsに名前変更します。また、必須ではありませんが、下記のように型指定するとエディタの支援を受けられやすくなります。また、本番環境かそれ以外で分岐処理を先ほど記述したため、[dev](https://ja.nuxtjs.org/api/configuration-dev/)という項目で環境を定義します。

```nuxt.config.ts
+ import { NuxtConfig } from '@nuxt/types'

- export default {
+ const config: NuxtConfig {
+    dev: process.env.NODE_ENV !== 'production',

+ export default config
```

### 1.4.2 ビルド設定
Nuxt部分は標準で設定された``nuxt-ts``でビルドし、その後、Express, Lambdaの部分をwebpackでビルドします。

必要なパッケージを追加します。

```shell
yarn add -D webpack webpack-cli ts-loader
```

webpackの設定ファイルを下記のように記述します。

```javascript:webpack.config.js
const path = require('path')

module.exports = {
  mode: 'production',
  entry: {
    lambda: path.resolve(__dirname, './server/lambda.ts')
  },
  output: {
    path: path.resolve(__dirname, './.nuxt/dist'),
    filename: '[name].js',
    libraryTarget: 'commonjs'
  },
  target: 'node',
  externals: [
    'nuxt'
  ],
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: 'ts-loader',
        exclude: /node_modules/
      }
    ]
  },
  resolve: {
    extensions: ['.tsx', '.ts', '.js']
  }
}
```

### 1.4.3 package.jsonのｓcriptsにpostbuildを追加

Nuxtの通常ビルド完了後にExpressをWebpackでビルドするように``package.json``のscripts追記します。

```package.json
"scripts": {
・・・・
"postbuild": "webpack",  // 追記
・・・・
}
```

上記の設定により、Nuxtビルド後にｗebpackがLambdaのエンドポイントである``server/lambda.ts``を``./nuxt/dist/lambda.js``に出力します。これがAWS Lambdaのエンドポイントとなります。従ってこれがエンドポイントとなるようにインフラを設定します。

### tsconfig.jsonを改修

ビルド時にwebpackで

```
Error: TypeScript emitted no output
```

というエラーが出たので、``tsconfig.json``の``noEmit``を``false``にします。

```tsconfig.json
- "noEmit": true,
+ "noEmit": false,
```

### 1.4.4 vuetifyで型が無いためビルドエラー

UIフレームワークにvuetifyを使用していた場合、nuxt.config.tsでインポートしている``vuetify/es5/util/colors``が見つけられずにエラーになりました。

通常のvuetifyをインストールした上で``tsconfig.json``の``types``に追記することで解決できました。

```shell
$ yarn add -D vuetify
```

```tsconfig.json
"types": [
"vuetify",
・・・
]
```

### 1.4.5 ESLintの実行設定

``package.json``で定義されたESLintの実行コマンドが.tsファイルを対象外にしていたため対象にするように修正しました。

```package.json
"scripts": [
    "lint:js": "eslint --ext .js,.vue --ignore-path .gitignore .",
-    "lint": "yarn lint:js",
+    "lint": "eslint --ext .ts,.js,.vue --ignore-path .gitignore .",
]
```

## 1.5 デプロイ定義
CloudFormationでデプロイ定義を作成します。LambdaとAPI Gateway及びそれらに付随する権限設定を書いています。

```yaml:cloudformation.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: 'AWS::Serverless-2016-10-31'

Resources:
  NuxtServerLambda:
    Type: AWS::Lambda::Function
    Properties:
      Code: .
      Timeout: 10
      MemorySize: 512
      FunctionName: nuxt-lambda-ts-sampl
      Role: !GetAtt [ "NuxtServerLambdaRole", "Arn" ]
      Runtime: nodejs12.x
      Handler: .nuxt/dist/lambda.handler

  NuxtServerLambdaRole:
    Type: AWS::IAM::Role
    Properties:
      ManagedPolicyArns:
        - !Ref NuxtServerLambdaPolicy
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: "sts:AssumeRole"

  NuxtServerLambdaPolicy:
    Type: AWS::IAM::ManagedPolicy
    Properties:
      PolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Action:
              - "logs:CreateLogStream"
              - "logs:PutLogEvents"
              - "logs:CreateLogGroup"
            Resource: "arn:aws:logs:*:*:*"

  LogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub /aws/lambda/${NuxtServerLambda}
      RetentionInDays: 3

  ApiGateway:
    Type: AWS::Serverless::Api
    Properties:
      StageName: default
      DefinitionBody:
        swagger: "2.0"
        info:
          version: "1.0.0"
          title: "nuxt-lambda-ts-sampl-gateway"
        basePath: default
        x-amazon-apigateway-binary-media-types:
          - '*/*'
        paths:
          /:
            get:
              produces:
                - "application/json"
              responses:
                "200":
                  description: "200 response"
                  schema:
                    $ref: "#/definitions/Empty"
              x-amazon-apigateway-integration:
                uri: !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${NuxtServerLambda.Arn}/invocations"
                responses:
                  default:
                    statusCode: "200"
                passthroughBehavior: "when_no_match"
                httpMethod: "POST"
                contentHandling: "CONVERT_TO_TEXT"
                type: "aws_proxy"
          /{proxy+}:
            get:
              produces:
                - "application/json"
              responses:
                "200":
                  description: "200 response"
                  schema:
                    $ref: "#/definitions/Empty"
              x-amazon-apigateway-integration:
                uri: !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${NuxtServerLambda.Arn}/invocations"
                responses:
                  default:
                    statusCode: "200"
                passthroughBehavior: "when_no_match"
                httpMethod: "POST"
                contentHandling: "CONVERT_TO_TEXT"
                type: "aws_proxy"

  ApiPermission:
    Type: "AWS::Lambda::Permission"
    DependsOn:
      - ApiGateway
      - NuxtServerLambda
    Properties:
      Action: "lambda:InvokeFunction"
      FunctionName: !Ref NuxtServerLambda
      Principal: apigateway.amazonaws.com
```

## 1.6 デプロイ

yarnでビルド後, awsコマンドでデプロイしています。
STACK_NAME, S3_BUCKETは適切に変更してください。

AWS API GatewayにエンドポイントとなるURLが作成されていると思います。
その後、カスタムドメイン設定やRoute53などでドメインを割り当てればウェブサイトとして機能するかと思います。

```
$ STACK_NAME=nuxt-lambda-ts-sampl
$ S3_BUCKET=<デプロイするパッケージを置くS3バケット名>

$ yarn run build
$ aws cloudformation package --template-file cloudformation.yaml --s3-bucket $S3_BUCKET --output-template-file cloudformation_dist.yaml
$ aws cloudformation deploy --template-file cloudformation_dist.yaml --stack-name $STACK_NAME --capabilities CAPABILITY_IAM
```

# 全体
個人開発でこれと同様の変更を加えました。ここには記載していない細かい変更も含まれていますので、ぜひご参考にしてください。

- https://github.com/hiroyky/nikki_frontend/pull/1/files

# 参考にした文献
- https://qiita.com/hiroyky/items/ff955af81df67c30b852
- https://github.com/tonyfromundefined/nuxt-serverless
- https://github.com/nuxt/nuxt.js/issues/7651