VueのSPAでの動的OGPをS3+Cloudfront+Lambda@Edgeの構成でcdkを使って実現したメモ

## 概要
VueでSPAを作ってS3に配置し、CloudFront経由で公開できるようにした。
この時、以下の問題が発生する。

* ブラウザで直接SPAが生成したURL(例:`http://oursite.com/user/id`)にアクセスすると404となる
   *   (参考:[Vue Router HTML5 History モード](https://router.vuejs.org/ja/guide/essentials/history-mode.html))
* SPAのページごとにヘッダを変えられない。
  * [OGP](https://www.e-webseisaku.com/column/marketing/3947/)を設定できない。
  * TwitterにURLをシェアしてもトップのOGPやタイトルしか表示されず残念
* OGP画像はTwitterなどのサイトからJSで読み込まれるため、[CORSの同一オリジンポリシー](https://developer.mozilla.org/ja/docs/Web/HTTP/CORS)により画像のリクエストはブロックされる
   * S3とCloudFrontにCORSの許可設定が必要

今回はこれを解決するための設定をCloudFront＋Lambda@Edgeを利用して行う。
設定にはAWS CDKを利用する。

[完了時点のソース](https://github.com/hibohiboo/aws-whitemap-client/tree/c288733c02adbc78ad47cea8dc444a5d83be2b4a)

## 概要図
* ユーザからのアクセスにはS3に置かれたSPAのリソースを返す
* OGPのアクセス(Twitterなど)にはOGP用のレスポンスを作成して返す

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/ed5c4f44-9d62-1a71-49e8-2fdf9cbd8a45.png)



## 検討

この構成にするまでに検討した事項について記載する

### 使用するCloudFrontイベント
CloudFrontイベントは以下の4つが存在する。
今回は、ユーザとTwitterbotの区別を`User-agent`を使って行う予定のため、ビューワーリクエストイベントに関数を紐づける。

イベント|タイミング|キャッシュがある場合|備考
--|--|--|--
ビューワーリクエスト|ビューワーからリクエストを受け取った時|実行する|
オリジンリクエスト|リクエストをオリジンに転送したとき|実行しない|特に設定しないとUser-agentが`Amazon CloudFront`に書き換えられた状態で関数に渡される
オリジンレスポンス|オリジンからのレスポンスを受け取った後|実行しない|レスポンス内のオブジェクトをキャッシュする前に関数が実行される。関数は、オリジンからエラーが返された場合でも実行されることに注意。
ビューワーレスポンス|リクエストされたファイルがビューワーに返される前|実行する|ビューワーリクエストイベントによってトリガーされた関数からレスポンスが生成された場合には実行しない

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/72dfc670-213e-743b-3a5e-7161033389fd.png)

図は[Lambda 関数をトリガーできる CloudFront イベント](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/lambda-cloudfront-trigger-events.html)から抜粋

### CloudFront Functions vs Lambda@Edge 
ビューワーリクエストに設定できる関数は`CloudFront Functions` と `Lambda@Edge`の2種類が存在する。
(参考: [CloudFront Functions と Lambda@Edge の選択](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/edge-functions.html))
今回はOGP用の文言を取得するのにHTTP通信を利用するため、最大実行時間が1msの`CloudFront Functions`は不適と考え`Lambda@Edge`を利用する。

特徴|CloudFront Functions|Lambda@Edge
--|--|--
プログラミング言語|JavaScript (ECMAScript 5.1 準拠)|Node.js と Python
関数コードと含まれるライブラリの最大サイズ|10 KB|1 MB
最大メモリ|2 MB|128MB
最大実行時間|1ミリ秒 [*](https://aws.amazon.com/jp/blogs/aws/introducing-cloudfront-functions-run-your-code-at-the-edge-with-low-latency-at-any-scale/)|5秒


### CDK CloudFrontのDistributeionについて
cloudfrontを行えるクラスが`Distribution`と`CloudFrontWebDistribution`の2種類あったため、検討する。

[@aws-cdk/aws-cloudfront module](https://docs.aws.amazon.com/cdk/api/latest/docs/aws-cloudfront-readme.html)を見ると、`Distribution`は`CloudFrontWebDistribution`を置き換えるもの。そのため、今回は`Distribution`を利用する。



## 設定
ここからは設定項目について記載する。

### 環境

* Windows 10 Home
* nodejs v14.17.5
* @aws/cdk 1.120.0

<details><summary>ローカルのフォルダ構造</summary><div>

```
+ client ... SPAのVueファイルが格納されたフォルダ
+ dist ... SPAのVueファイルのビルド成果物が格納されたフォルダ
- cdk
  - bin
    - index.ts ... CDKの起点ファイル
  - lib
    - cdk-stack.ts ... CDKStackファイル
  - dist ... lambda@Edgeのデプロイ用ソース。git管理外
    - ogp
      - index.js
  - src
    - ogp
      - index.ts ... lambda@Edgeのソース
  - build.js ... lambda@Edgeをトランスパイルするためのesbuildの呼び出しファイル
  - .env ... lambda@Edgeのトランスパイル時に定数を埋め込むための環境変数ファイル
  - package.json
  - cdk.json
  - tsconfig.json
```

</div></details>

**S3の構造**

```
- data
  + background-images ... OGP用の画像フォルダ
- whitemap ... SPA用のフォルダ
  - index.html
```

`https://ドメイン/whitemap/scene/:id`のパスで、各記事のページにアクセスするようにしている。

### ブラウザで直接SPAが生成したURLにアクセスすると404となる件の対応
カスタムレスポンスを利用する。

```diff:cdk/lib/cdk-stack.ts
    return new cf.Distribution(this, 'Distribution', {
      defaultRootObject: '/index.html',
      priceClass: cf.PriceClass.PRICE_CLASS_200,
      defaultBehavior: {
        origin,
        allowedMethods: cf.AllowedMethods.ALLOW_GET_HEAD,
        cachedMethods: cf.CachedMethods.CACHE_GET_HEAD,
        cachePolicy: myCachePolicy,
        viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS
      },
      errorResponses: [
+        {
+          httpStatus: 404,
+          responseHttpStatus: 200,
+          responsePagePath: "/whitemap/index.html",
+          ttl: core.Duration.seconds(0),
+        }
      ]
    })
```

<details><summary>cdk全文</summary><div>

```ts:cdk/lib/cdk-stack.ts
import * as core from '@aws-cdk/core'
import * as s3 from '@aws-cdk/aws-s3'
import * as cf from '@aws-cdk/aws-cloudfront'
import * as iam from '@aws-cdk/aws-iam'
import * as s3deploy from '@aws-cdk/aws-s3-deployment'
import { basePath } from '../../vite.config'
import * as lambda from "@aws-cdk/aws-lambda";
import * as origins from '@aws-cdk/aws-cloudfront-origins';
interface Props extends core.StackProps {
  bucketName: string
}

export class AWSWhiteMapClientStack extends core.Stack {
  constructor(scope: core.Construct, id: string, props: Props) {
    super(scope, id, props)
    // CloudFront オリジン用のS3バケットを作成
    const bucket = this.createS3(props.bucketName)
    // CloudFront で設定する オリジンアクセスアイデンティティ を作成
    const identity = this.createIdentity(bucket)
    // S3バケットポリシーで、CloudFrontのオリジンアクセスアイデンティティを許可
    this.createPolicy(bucket, identity)
    // lambda edge作成
    const f = this.createLambdaEdge()
    // CloudFrontディストリビューションを作成
    const distribution = this.createCloudFront(bucket, identity, f)
    // 指定したディレクトリをデプロイ
    this.deployS3(bucket, distribution, '../dist')

    // 確認用にCloudFrontのURLに整形して出力
    new core.CfnOutput(this, 'CFTopURL', {
      value: `https://${distribution.distributionDomainName}/`,
    })
  }

  private createS3(bucketName: string) {
    const bucket = new s3.Bucket(this, 'S3Bucket', {
      bucketName,
      accessControl: s3.BucketAccessControl.PRIVATE,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      removalPolicy: core.RemovalPolicy.DESTROY,
      cors: [{ allowedMethods: [s3.HttpMethods.GET], allowedOrigins: ['*'], allowedHeaders: ['*'] }]
    })
    return bucket
  }

  private createIdentity(bucket: s3.Bucket) {
    const identity = new cf.OriginAccessIdentity(this, 'OriginAccessIdentity', {

      comment: `${bucket.bucketName} access identity`,
    })
    return identity
  }

  private createPolicy(bucket: s3.Bucket, identity: cf.OriginAccessIdentity) {
    const myBucketPolicy = new iam.PolicyStatement({
      effect: iam.Effect.ALLOW,
      actions: ['s3:GetObject',
        "s3:ListBucket"],
      principals: [
        new iam.CanonicalUserPrincipal(
          identity.cloudFrontOriginAccessIdentityS3CanonicalUserId,
        ),
      ],
      resources: [bucket.bucketArn + '/*',
      bucket.bucketArn],
    })
    bucket.addToResourcePolicy(myBucketPolicy)
  }

  private createCloudFront(
    bucket: s3.Bucket,
    identity: cf.OriginAccessIdentity,
    f: cf.experimental.EdgeFunction
  ) {
    const defaultPolicyOption = {
      cachePolicyName: 'MyPolicy',
      comment: 'A default policy',
      defaultTtl: core.Duration.days(2),
      minTtl: core.Duration.seconds(0), // core.Duration.minutes(1),
      maxTtl: core.Duration.days(365), // core.Duration.days(10),
      cookieBehavior: cf.CacheCookieBehavior.all(),
      headerBehavior: cf.CacheHeaderBehavior.none(),
      queryStringBehavior: cf.CacheQueryStringBehavior.none(),
      enableAcceptEncodingGzip: true,
      enableAcceptEncodingBrotli: true,

    }
    const myCachePolicy = new cf.CachePolicy(this, 'myDefaultCachePolicy', defaultPolicyOption);
    const imgCachePolicy = new cf.CachePolicy(this, 'myImageCachePolicy', {
      headerBehavior: cf.CacheHeaderBehavior.allowList('Access-Control-Request-Headers', 'Access-Control-Request-Method', 'Origin'),
    });
    const origin = new origins.S3Origin(bucket, { originAccessIdentity: identity })
    return new cf.Distribution(this, 'Distribution', {
      defaultRootObject: '/index.html',

      priceClass: cf.PriceClass.PRICE_CLASS_200,
      defaultBehavior: {
        origin,
        allowedMethods: cf.AllowedMethods.ALLOW_GET_HEAD,
        cachedMethods: cf.CachedMethods.CACHE_GET_HEAD,
        cachePolicy: myCachePolicy,
        viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS
      },
      additionalBehaviors: {
        'whitemap/scene/*': {
          origin,
          viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
          edgeLambdas: [
            {
              eventType: cf.LambdaEdgeEventType.VIEWER_REQUEST,
              functionVersion: f.currentVersion,
              includeBody: true,
            },
          ],

        },
        'data': {
          origin,
          cachePolicy: imgCachePolicy,
          allowedMethods: cf.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
          viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS
        }
      },
      errorResponses: [
        {
          httpStatus: 404,
          responseHttpStatus: 200,
          responsePagePath: "/whitemap/index.html",
          ttl: core.Duration.seconds(0),
        },
        {
          httpStatus: 403,
          responseHttpStatus: 200,
          responsePagePath: "/index.html",
          ttl: core.Duration.seconds(0),
        }
      ]

    })
  }

  private deployS3(
    siteBucket: s3.Bucket,
    distribution: cf.Distribution,
    sourcePath: string,
  ) {
    // Deploy site contents to S3 bucket
    new s3deploy.BucketDeployment(this, 'DeployWithInvalidation', {
      sources: [s3deploy.Source.asset(sourcePath)],
      destinationBucket: siteBucket,
      distribution,
      distributionPaths: ['/*'],
      destinationKeyPrefix: basePath,
    })
  }

  private createLambdaEdge() {
    const f = new cf.experimental.EdgeFunction(this, "lambda-edge", {
      code: lambda.Code.fromAsset("dist/ogp"),
      handler: "index.handler",
      runtime: lambda.Runtime.NODEJS_14_X,
    });
    return f;
  }
}

```


</div></details>

### SPAのページごとにヘッダを変えられない。
Lambda@Edgeを利用する。

*  Botからのアクセスでない場合は通常のCloudFrontのフロー
*  Botからのアクセスの場合、FireStoreからデータを取得してOGP用のHTMLを作成する
  * FireStoreからの読込については認証の制限をかけていないパスなので、[Cloud Firestore REST API を使用](https://firebase.google.com/docs/firestore/use-rest-api?hl=ja)してHTTPSアクセスを行い、データを取得している。

**ハンドラ**

```ts:cdk/src/ogp/index.ts
export const handler: CloudFrontRequestHandler = async (event) => {
  const request = event.Records[0].cf.request;
  const userAgent = request.headers['user-agent'][0].value;
 
  // Botからのアクセスでない場合は通常のCloudFrontのフロー
  if (!bots.some((bot) => userAgent.includes(bot))) {
    return request;
  }

  // Botからのアクセスの場合、FireStoreからデータを取得してOGP用のHTMLを作成する
  const matches = /scene\/([^/]+)/.exec(request.uri)
  if (!matches) {
    return request;
  }
  const [, sceneId] = matches;
  const scene = await httpGet<FireStoreScene>(`https://${firestoreApiPath}/scenes/${sceneId}`);
  const sceneBgUrl = getBgUrl(scene);
  const url = sceneBgUrl ? sceneBgUrl : defaultBg
  const title = getTitle(scene);
  // Create OGP response
  const botResponse = {
    status: '200',
    headers: { 'content-type': [{ value: 'text/html;charset=UTF-8' }] },
    body: getHTML(title, url, DOMAIN + request.uri)
  };
  return botResponse;
};

```

**HTML作成処理**

```ts:cdk/src/ogp/index.ts

const getHTML = (title: string, ogImage: string, url: string) => {
  return `
<!doctype html>
<html lang="ja" prefix="og: http://ogp.me/ns#">
<head prefix="og: http://ogp.me/ns#">
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>${title}｜${SERVICE_NAME}</title>
  <meta name="description" content="${DESCRIPTION}" />
  <meta name="author" content="hibohiboo">
  <meta name="keywords" content="TRPG,白地図と足跡,紙芝居" />
  <meta property="og:type" content="article" />
  <meta property="og:locale" content="ja_JP" />
  <meta property="og:site_name" content="${SERVICE_NAME}">
  <meta property="og:title" content="${title}｜${SERVICE_NAME}" />
  <meta property="og:description" content="${DESCRIPTION}" />
  <meta property="og:image" content="${ogImage}" />
  <meta property="og:url" content="https://${url}" />
  <meta name="twitter:card" content="summary_large_image" />
  <meta name="twitter:site" content="@hibohiboo" />
  <meta name="twitter:creator" content="@hibohiboo" />
</head>
<body></body>
</html>
`;
};
```

<details><summary>lambda全文</summary><div>

```ts:cdk/src/ogp/index.ts
import * as https from 'https';
import type { CloudFrontRequestHandler } from 'aws-lambda';
import { FireStoreScene, SceneBg } from './types';

declare var DEFINE_DOMAIN: string;
declare var DEFINE_SERVICE_NAME: string;
declare var DEFINE_DESCRIPTION: string;
declare var DEFINE_FIREBASE_PROJECT_ID: string;
declare var DEFINE_DEFAULT_OGP_IMAGE_URL: string;

const DOMAIN = DEFINE_DOMAIN;
const SERVICE_NAME = DEFINE_SERVICE_NAME;
const DESCRIPTION = DEFINE_DESCRIPTION;
const projectId = DEFINE_FIREBASE_PROJECT_ID;
const defaultBg = DEFINE_DEFAULT_OGP_IMAGE_URL;
const firestoreApiPath = `firestore.googleapis.com/v1/projects/${projectId}/databases/(default)/documents`;

// OGP を返したい User-Agent をリストで定義しておく。
const bots = [
  'Twitterbot',
  'facebookexternalhit',
  'Slackbot-LinkExpanding'
];

const httpGet = <T>(url: string): Promise<T> => new Promise((resolve, reject) => {
  https.get(url, (res) => {
    const { statusCode } = res;
    const contentType = res.headers['content-type'];

    let error;
    if (statusCode !== 200) {
      error = new Error('Request Failed.\n' +
        `Status Code: ${statusCode}`);
    } else if (!contentType || !/^application\/json/.test(contentType)) {
      error = new Error('Invalid content-type.\n' +
        `Expected application/json but received ${contentType}`);
    }
    if (error) {
      console.error(error.message);
      res.resume();
      reject(error);
      return;
    }

    res.setEncoding('utf8');
    let rawData = '';
    res.on('data', (chunk) => { rawData += chunk; });
    res.on('end', () => {
      try {
        const parsedData = JSON.parse(rawData);
        resolve(parsedData)
      } catch (e) {
        console.error(e.message);
        reject(e);
      }
    });
  }).on('error', (e) => {
    console.error(`Got error: ${e.message}`);
    reject(e)
  });
})

const getBgUrl = (scene: FireStoreScene) => {
  const bg = scene.fields.bg as SceneBg
  if (!bg.mapValue) {
    return null;
  }
  return bg.mapValue.fields.url.stringValue
}
const getTitle = (scene: FireStoreScene) => {
  return scene.fields.title.stringValue;
}

const getHTML = (title: string, ogImage: string, url: string) => {
  return `
<!doctype html>
<html lang="ja" prefix="og: http://ogp.me/ns#">
<head prefix="og: http://ogp.me/ns#">
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>${title}｜${SERVICE_NAME}</title>
  <meta name="description" content="${DESCRIPTION}" />
  <meta name="author" content="hibohiboo">
  <meta name="keywords" content="TRPG,白地図と足跡,紙芝居" />
  <meta property="og:type" content="article" />
  <meta property="og:locale" content="ja_JP" />
  <meta property="og:site_name" content="${SERVICE_NAME}">
  <meta property="og:title" content="${title}｜${SERVICE_NAME}" />
  <meta property="og:description" content="${DESCRIPTION}" />
  <meta property="og:image" content="${ogImage}" />
  <meta property="og:url" content="https://${url}" />
  <meta name="twitter:card" content="summary_large_image" />
  <meta name="twitter:site" content="@hibohiboo" />
  <meta name="twitter:creator" content="@hibohiboo" />
</head>
<body></body>
</html>
`;
};

export const handler: CloudFrontRequestHandler = async (event) => {
  const request = event.Records[0].cf.request;
  const userAgent = request.headers['user-agent'][0].value;
  const isBotAccess = bots.some((bot) => userAgent.includes(bot));
  if (!isBotAccess) {
    return request;
  }
  const matches = /scene\/([^/]+)/.exec(request.uri)
  if (!matches) {
    return request;
  }
  const [, sceneId] = matches;
  const scene = await httpGet<FireStoreScene>(`https://${firestoreApiPath}/scenes/${sceneId}`);
  const sceneBgUrl = getBgUrl(scene);
  const url = sceneBgUrl ? sceneBgUrl : defaultBg
  const title = getTitle(scene);
  // Create OGP response
  const botResponse = {
    status: '200',
    headers: { 'content-type': [{ value: 'text/html;charset=UTF-8' }] },
    body: getHTML(title, url, DOMAIN + request.uri)
  };
  return botResponse;
};


```

```ts:cdk/src/ogp/types.ts

interface NullValue {
  nullValue: null
}
interface StringValue {
  stringValue: string
}

interface TimestampValue {
  mapValue: {
    fields: {
      seconds: StringValue
      nanoseconds: StringValue
    }
  }
}

export interface SceneBg {
  mapValue: {
    fields: {
      url: StringValue
    }
  }
}

export interface FireStoreScene {
  name: string,
  fields: {
    bg: SceneBg | NullValue
    title: StringValue
  }
}
```



</div></details>

ドメインなど、環境によって変化する部分はesbuildを使って環境変数を定数に置き換える処理を行っている。(参考:[define](https://esbuild.github.io/api/#define))

```ts:cdk/src/ogp/index.ts
declare var DEFINE_DOMAIN: string;
declare var DEFINE_SERVICE_NAME: string;
declare var DEFINE_DESCRIPTION: string;
declare var DEFINE_FIREBASE_PROJECT_ID: string;
declare var DEFINE_DEFAULT_OGP_IMAGE_URL: string;

const DOMAIN = DEFINE_DOMAIN;
const SERVICE_NAME = DEFINE_SERVICE_NAME;
const DESCRIPTION = DEFINE_DESCRIPTION;
const projectId = DEFINE_FIREBASE_PROJECT_ID;
const defaultBg = DEFINE_DEFAULT_OGP_IMAGE_URL;
```

```js:cdk/build.js
require('dotenv').config();

require('esbuild').build({
  entryPoints: ['src/ogp/index.ts'],
  bundle: true,
  platform: 'node',
  outfile: 'dist/ogp/index.js',
  define: {
    "DEFINE_DOMAIN": `'${process.env.ENV_DEFINE_DOMAIN}'`,
    "DEFINE_SERVICE_NAME": `'${process.env.ENV_DEFINE_SERVICE_NAME}'`,
    "DEFINE_DESCRIPTION": `'${process.env.ENV_DEFINE_DESCRIPTION}'`,
    "DEFINE_FIREBASE_PROJECT_ID": `'${process.env.ENV_DEFINE_FIREBASE_PROJECT_ID}'`,
    "DEFINE_DEFAULT_OGP_IMAGE_URL": `'${process.env.ENV_DEFINE_DEFAULT_OGP_IMAGE_URL}'`,
  },
  minify: false,
  sourcemap: false,
  target: ['node14.17'],
}).catch(() => process.exit(1))
```

```sh:cdk/.env
ENV_DEFINE_DOMAIN=hoge.cloudfront.net
ENV_DEFINE_SERVICE_NAME=サイト名
ENV_DEFINE_DESCRIPTION=説明
ENV_DEFINE_FIREBASE_PROJECT_ID=xxx
ENV_DEFINE_DEFAULT_OGP_IMAGE_URL=https://piyo/fuga.png
```

package.jsonのスクリプトで、デプロイする前にビルドが走るように設定している。(参考: [npm scripts](https://docs.npmjs.com/cli/v7/using-npm/scripts))

```diff:cdk/package.json

{
  "scripts": {
+    "predeploy": "node build.js",
+    "deploy": "cdk deploy --all"
  },
  "devDependencies": {
    "dotenv": "^10.0.0",
    "esbuild": "^0.12.24",
    "typescript": "~4.4.2"
  }
}
```
CDKではCloudFrontの`scene`以下のパスが来た時にLambda@Edgeを動かすようにする。

```diff:cdk/lib/cdk-stack.ts
    return new cf.Distribution(this, 'Distribution', {
      additionalBehaviors: {
        'whitemap/scene/*': {
          origin,
          viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
+          edgeLambdas: [
+            {
+              eventType: cf.LambdaEdgeEventType.VIEWER_REQUEST,
+              functionVersion: f.currentVersion,
+              includeBody: true,
+            },
          ],
        },
```

Lambda@Edgeの作成には `experimental.EdgeFunction`クラスを使用する。
これを使うと、US-East-1 (バージニア北部) リージョン (us-east-1) にStackを作成してLambdaを配置してくれる。

```ts:cdk/lib/cdk-stack.ts
    const f = new cf.experimental.EdgeFunction(this, "lambda-edge", {
      code: lambda.Code.fromAsset("dist/ogp"),
      handler: "index.handler",
      runtime: lambda.Runtime.NODEJS_14_X,
    });
```

デプロイの初回でエラーが出た。
新しくUS-East-1にStackが作られるため、そのリージョンでの`cdk bootstrap`を忘れていたのが原因。

<details><summary>Error</summary><div>

```
$npm run deploy

> cdk@0.1.0 deploy cdk
> cdk deploy  --all

edge-lambda-stack-xxx
edge-lambda-stack-xxx: deploying...

 ❌  edge-lambda-stack-xxx failed: Error: This stack uses assets, so the toolkit stack must be deployed to the environment 
(Run "cdk bootstrap aws://unknown-account/us-east-1")
    at Object.addMetadataAssetsToManifest   
    at Object.deployStack        
    at processTicksAndRejections (internal/process/task_queues.js:95:5)
    at CdkToolkit.deploy 
    at initCommandLine
This stack uses assets, so the toolkit stack must be deployed to the environment (Run "cdk bootstrap aws://unknown-account/us-east-1")
npm ERR! code ELIFECYCLE
npm ERR! errno 1
npm ERR! cdk@0.1.0 deploy: `cdk deploy --all`
npm ERR! Exit status 1
npm ERR!
npm ERR! Failed at the cdk@0.1.0 deploy script.
npm ERR! This is probably not a problem with npm. There is likely additional logging output above.
```

</div></details>

### CORSの同一オリジンポリシーにより画像のリクエストがブロックされる

S3のCORS設定。([*](https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/ManageCorsUsing.html))

```diff:cdk/lib/cdk-stack.ts
  private createS3(bucketName: string) {
    const bucket = new s3.Bucket(this, 'S3Bucket', {
      bucketName,
      accessControl: s3.BucketAccessControl.PRIVATE,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      removalPolicy: core.RemovalPolicy.DESTROY,
+      cors: [{ allowedMethods: [s3.HttpMethods.GET], allowedOrigins: ['*'], allowedHeaders: ['*'] }]
    })
    return bucket
  }
```

cloud frontのcors設定。([*](https://aws.amazon.com/jp/premiumsupport/knowledge-center/no-access-control-allow-origin-error/))
data以下のパスの場合にCORSのヘッダを許可する。

```diff:cdk/lib/cdk-stack.ts
    return new cf.Distribution(this, 'Distribution', {
      additionalBehaviors: {
+        'data': {
+          origin,
+          cachePolicy: imgCachePolicy,
+          allowedMethods: cf.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
+          viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS
+        }
      },
```


## 動作確認

### lambda@Edgeの確認

#### 実際のレスポンスの確認
* User Agentの有無でレスポンスが変わることを、直接URLを叩いて確認する。

```http
GET https://hoge.cloudfront.net/whitemap/scene/fuga/ HTTP/1.1

###

GET https://hoge.cloudfront.net/whitemap/scene/fuga/ HTTP/1.1
user-agent: Twitterbot
```

#### ログの確認

* Lambda@EdgeはCloudFrontの実行されたリージョンにログが出力される。
  * 図は[Run Your Code at the Edge with Low Latency at Any Scale](https://aws.amazon.com/jp/blogs/aws/introducing-cloudfront-functions-run-your-code-at-the-edge-with-low-latency-at-any-scale/) より
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/adfe1aff-b9cd-ce89-8240-3754f4064b72.png)


まずは`CloudFront`のモニタリングから動作ログを確認。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/66c67c2d-68db-6c3b-986c-a2e3991cc3ae.png)

どのリージョンで実行されているかを確認できる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/51a12b85-35f0-302d-f58a-71f3fbefcb56.png)

`Us-east`で動作していることを確認したので、そのリージョンのログを確認する。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/10cd9af8-6380-3ecb-a99a-985afd03ca65.png)


### OGPの確認
[【配布】OGP画像のテンプレートと作成方法](https://note.com/noe_ffff/n/nf8fac4c4e2a8)を参考にした。

[twitter - Card Validator](https://cards-dev.twitter.com/validator) ... 実際のOGP確認
[OGP画像シミュレータ](http://ogimage.tsmallfield.com/) ... OGP画像確認
[OGP確認](https://rakko.tools/tools/9/) ... OGP表示できず。各種OGP確認できるらしいが、今回は失敗した。

なお、ドメインの変更は、次の記事で行っている。
[Route53とCloudFrontの紐づけとSSL証明書の発行をCDKを使って行ったメモ](https://qiita.com/hibohiboo/items/ddae8b4740952e9ac373)

---

## 参考

[設定前のソース](https://github.com/hibohiboo/aws-whitemap-client/tree/b1109f577bb9888394231715fb5ee7bcf6d88c57)
[完了時点のソース](https://github.com/hibohiboo/aws-whitemap-client/tree/43fd09b59b4e568f2fa0e0c158246cd0ba96fc67)

### OGP

[Lambda@EdgeをCDKで書いてみる](https://zenn.dev/yizumi1012xxx/articles/9f4687ab9be52f)
[@AWS 環境の SPA で動的 OGP を実現する](https://zenn.dev/ysmtegsr/articles/bc00386d731bb3ab70bc)
[CloudFront+S3なSPAにLambda@EdgeでOGP対応する](https://dev.classmethod.jp/articles/lambda-edge-ogp/)
[AmplifyでOGP対応はできない。でもLambda@edgeを使えば大丈夫！](https://fixel.co.jp/blog/amplify-ogp/)
[Lambda@EdgeでSPAのOGPを動的に設定する](https://qiita.com/kurimoto/items/6212372ead6522161a60)
[Firebase + SPA + CloudFront + Lambda で SSR なしに OGP 対応](https://qiita.com/zaru/items/e7c2c11f8a9267dc8bb7)
[AWS Lambda@Edgeのログはどこ？AWS Lambda@Edgeのログ出力先について](https://dev.classmethod.jp/articles/where-is-the-lambda-edge-log/)
[AWS CDK — A Beginner’s Guide with Examples](https://enlear.academy/aws-cdk-a-beginners-guide-with-examples-424c600ac409)
[【配布】OGP画像のテンプレートと作成方法](https://note.com/noe_ffff/n/nf8fac4c4e2a8)
[AWS CDKでLambdaのTypescriptをトランスパイルしてローカル実行したメモ](https://qiita.com/hibohiboo/items/e8eeced37c40d0792df5)

### esbuild
[話題のesbuildをさっくりと調査してみた](https://qiita.com/hedrall/items/2548718cfdf7bef3efc0)
[build api](https://esbuild.github.io/api/#build-api)
[define](https://esbuild.github.io/api/#define)
[dotenv](https://www.npmjs.com/package/dotenv)

### そのほか
[CloudFront FunctionsはLambda@Edgeより安い。それ本当？！](https://dev.classmethod.jp/articles/sometimes-cf2-price-is-higher-than-lae/)
[[AWS CDK] CloudFront Functionでレスポンスにセキュリティヘッダーを追加する](https://dev.classmethod.jp/articles/add-security-headers-to-responses-in-cloudfront-function-in-aws-cdk/)
[@aws-cdk/aws-cloudfront module](https://docs.aws.amazon.com/cdk/api/latest/docs/aws-cloudfront-readme.html)
[class CachePolicy](https://docs.aws.amazon.com/cdk/api/latest/docs/@aws-cdk_aws-cloudfront.CachePolicy.html)
[node.js https](https://nodejs.org/api/https.html)
[Cloud Firestore REST API を使用する](https://firebase.google.com/docs/firestore/use-rest-api?hl=ja)
[Cloudfront+S3で最近話題のSPAを構築する(設定項目の解説有)](https://dev.classmethod.jp/articles/s3-cloudfront-spa-angular-403-access-denied/)
[Vue Router HTML5 History モード](https://router.vuejs.org/ja/guide/essentials/history-mode.html)
[OGP ](https://ogp.me/)
[OGP 和訳](http://www.asahi-net.or.jp/~ax2s-kmtn/internet/sns/ogp-20170727.html)
[twitter - 公式: ツイートをカードで最適化する](https://developer.twitter.com/ja/docs/tweets/optimize-with-cards/guides/getting-started)

### aws公式
[CloudFront ソリューションの概要](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/getting-started-secure-static-website-cloudformation-template.html#cloudformation-website-overview)
[リクエストトリガーでの HTTP レスポンスの生成](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/lambda-generating-http-responses-in-requests.html)
[Lambda@Edge](https://aws.amazon.com/jp/lambda/edge/)
[Lambda@Edge イベント構造](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/lambda-event-structure.html)
[Lambda@Edge 関数の例](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/lambda-examples.html)
[CloudFront Functions と Lambda@Edge の選択](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/edge-functions.html)
[Lambda 関数をトリガーできる CloudFront イベント](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/lambda-cloudfront-trigger-events.html)
[カスタムエラーレスポンスの生成](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/GeneratingCustomErrorResponses.html)