CloudFront Functions を TypeScript で書いて、ビルド&デプロイを CI で自動化する

# はじめに

環境は以下

- Next.js
- CloudFront+S3

`pages/index.tsx`と`pages/new.tsx`を持つ Next.js を静的ビルドすると、ビルド結果は`/index.html`と`/new/index.html`が生成される
（next.config.js に`trailingSlash: true`を設定している場合）

URL の`/`にアクセスした時は、CloudFront で Default Root Object に index.html を指定しておけば、`https://.../index.html`を返してくれるので普通に表示されるが、`/new`にアクセスすると CloudFront は 403 エラーを返す
この場合、`/new`のアクセスに対して、`https://.../new/index.html`を返すようにしてあげれば、問題なくリソースを表示することができるようになる

これは CloudFront の Edge Location において、`/new`という URL を`/new/index.html`に書き換えるスクリプトを実行してあげることで解決ができる

これは調べるとよく紹介されているやり方で、AWS の Edge 関数のコード例にも記載されている
https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/example-function-add-index.html

※ この 403 エラー問題の解決方法でいうと URL 書き換え以外に、「CloudFront ではなく、別の CDN サービスを使う」や「S3 の Static Website Hosting を設定する」など別の解決方法もある

今回実現したいことは以下になる

- CloudFront Functions で URL の書き換えを行う
- Function は GitHub でコード管理したい
- Function は TypeScript で書きたい
- CI で Function をデプロイしたい

## CloudFront Functions について

参考（AWS ブログ）:
https://aws.amazon.com/jp/blogs/news/introducing-cloudfront-functions-run-your-code-at-the-edge-with-low-latency-at-any-scale/

![スクリーンショット 2021-10-21 22.45.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/168197/b30ce4cd-f86a-84e1-60f3-6dc2e12f1385.png)
画像は上記の AWS ブログから引用

CloudFront Functions は、Lambda@Edge よりクライアントに近い Edge Location でスクリプトを実行できるので、より速いレスポンスを期待できる

具体的には、以下のことが実現できる

1. Edge Location に index.html がキャッシュされる
1. Edge Location 上の CloudFront Functions で URL を`/new`から`/new/index.html`に書き換える
1. キャッシュにヒットし、index.html がクライアントにレスポンスされる

上記の AWS ブログ内に CloudFront Functions と Lambda@Edge のスペック比較表があるが、Functions の制限は厳しめ
また、他の CDN サービスと比較しても Functions の制限はかなりきつい（Netlify と Vercel は AWS Lambda だけど）
そして、言語サポートが JavaScript（ECMAScript 5.1 準拠）なので、普通に実装すると開発体験がかなり悪いので、TypeScript で実装して tsc や webpack（ts-loader）などのトランスパイラー環境を構築する必要が出てくると思う

# CloudFront Functions 用の npm 管理プロジェクトを作成

```sh
mkdir functions # ディレクトリ名は適当
cd functions
npm init # package.jsonを作成
```

とりあえず以下みたいな感じで、依存モジュールは後で入れる

```json:package.json
{
  "name": "function-add-index",
  "private": true,
  "scripts": {
    "build": "tsc"
  }
}
```

# TypeScript の設定

```sh
npm i -D typescript
./node_modules/.bin/tsc --init # tsconfig.jsonを作成
```

tsconfig.json で重要なのは、`target`オプションになる
Function は JavaScript（ECMAScript 5.1 準拠）のみで動く

```json:tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs",
    "lib": ["es2015"],
    "outDir": "./dist",
    ...
  },
  "include": ["src/**/*.ts"]
}
```

`lib`は後述の`endsWith`や`includes`を使うために必要
`module`は指定しなくても良い（`target`が`es5`であればデフォルトで`commonjs`になる）

# Function の実装

型定義があるので、インストール

```sh
npm i -D @types/aws-cloudfront-function
```

以下は[こちらのサンプルコード](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/example-function-add-index.html)を TS 化したもの

```ts:src/index.ts
function handler(event: AWSCloudFrontFunction.Event): AWSCloudFrontFunction.Request {
  const { request } = event

  if (request.uri.endsWith('/')) {
    request.uri += 'index.html'
  } else if (!request.uri.includes('.')) {
    request.uri += '/index.html'
  }

  return request
}
```

# CI（CircleCI 2.1）の実装

Function のデプロイ周りを全部 aws-cli 使って書くと、以下のような感じになるかなと思う
（そのうちこの辺の CircleCI Orb ができたら、置き換える前提で）

処理の流れは

1. `aws cloudfront list-functions`で`function-add-index`が存在するか判断
   - 存在しない場合は、新規作成（`aws cloudfront create-function`）
   - 存在する場合は、更新（`aws cloudfront update-function`）
1. ETag を取得（`aws cloudfront describe-function`）
1. 公開する（`aws cloudfront publish-function`）

```yml
version: 2.1

orbs:
  node: circleci/node@4.7.0
  aws-cli: circleci/aws-cli@2.0.3

jobs:
  deploy:
    executor: node/default
    steps:
      - checkout
      - node/install-packages:
          app-dir: ./functions
      - run:
          name: Build
          working_directory: ./functions
          command: npm run build
      - aws-cli/setup
      - run:
          name: Deploy
          working_directory: ./functions
          command: |
            FN_NAME="function-add-index"
            FN_LIST=$(aws cloudfront list-functions | jq --arg fn_name $FN_NAME '.FunctionList.Items[] | select(.Name==$fn_name)')
            if [ -z "$FN_LIST" ]; then
              aws cloudfront create-function \
                --name $FN_NAME \
                --function-config Comment="Add index.html string to url",Runtime="cloudfront-js-1.0" \
                --function-code fileb://dist/index.js
            else
              ETAG_CURRENT=$(aws cloudfront describe-function --name $FN_NAME | jq -r '.ETag')
              aws cloudfront update-function \
                --name $FN_NAME \
                --function-config Comment="Add index.html string to url",Runtime="cloudfront-js-1.0" \
                --function-code fileb://dist/index.js \
                --if-match $ETAG_CURRENT
            fi
            ETAG_PUBLISH=$(aws cloudfront describe-function --name $FN_NAME | jq -r '.ETag')
            aws cloudfront publish-function \
              --name $FN_NAME \
              --if-match $ETAG_PUBLISH
```

上記は Function を作成・更新した後にすぐ Publish してるので、本当は公開前にテストした方が良い（一応`aws cloudfront test-function`ってのもある）

## 必要な IAM ポリシー

`aws-cli/setup`に設定する IAM ユーザーのポリシーは以下をアタッチする

```json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "VisualEditor0",
      "Effect": "Allow",
      "Action": [
        "cloudfront:DescribeFunction",
        "cloudfront:ListFunctions",
        "cloudfront:PublishFunction",
        "cloudfront:UpdateFunction",
        "cloudfront:CreateFunction"
      ],
      "Resource": "*"
    }
  ]
}
```