AWS 環境の SPA で動的 OGP を実現する
はじめに
フロントエンドが SPA のプロジェクトで OGP 対応した際に、割と大変だったので備忘録としてまとめます。結論から言うと、Lambda@Edge を使って User-Agent で bot かどうか判断し、HTML を返すというお決まりの手法で行いました。
その中でも、今回は REST API から画像 URL を取得する必要があったので Lambda@Edge 内で API へアクセスする処理も実装しました。
前提
今回は、AWS 環境上で S3 + CloudFront によって構成されています。
SPA(シングルページアプリケーション) では、単一の Web ページがあたかもページ遷移するかのようにコンテンツの切り替えが行われます。それらのコンテンツの切り替えは、ブラウザ上で JavaScript によって処理されます。
しかし、SNS などでシェアされた際に OGP を表示したい場合は、一手間必要です。なぜなら URL を投稿された際にクローリングする Twitter や Facebook などの各クローラは JavaScript を解釈しないため今回の例に挙げたような SPA では動的に OGP を返すことができないからです。
方針
結論から言うと、Lambda@Edge という CloudFront の機能を使い動的にレスポンスを返すことを実現しました。この記事の最後に掲載させていただいている記事にも書いていらっしゃる方も多く、前例がたくさんあるので安心して実装できました。

User-Agent が bot の場合は、Lambda@Edge で動的にレスポンスを返す
実装
Lambda 関数を作成する
マネジメントコンソールを開き、下記のスクリーンショットのように Lambda 関数を作成していきます。下記のように関数名、ランタイム、ロールなどの任意のものを指定します。
実行ロールには AWSLambdaBasicExecutionRole という IAM ポリシーをアタッチした IAM ロールを事前に作成しておきます。

続いて実際の Lambda のコードです。上記で Node.js をランタイムとして指定したため JavaScript で書いていきます。
'use strict';
const DOMAIN = 'example.com';
const SERVICE_NAME = 'サービス名';
const DESCRIPTION = 'ディスクリプション';
// OGP を返したい User-Agent をリストで定義しておく。
const bots = [
    'Twitterbot',
    'facebookexternalhit',
    'Slackbot-LinkExpanding'
];
exports.handler = async (event, context, callback) => {
    const request = event.Records[0].cf.request;
    const userAgent = request.headers['user-agent'][0].value;
    const isBotAccess = bots.some((bot) => userAgent.includes(bot));
    // Create OGP response
    if (isBotAccess) {
        // 🌟 Do something
        const botResponse = {
            status: 200,
            headers: {
                'content-type': [{
                    key: 'Content-Type',
                    value: 'text/html; charset=UTF-8',
                }],
            },
            body: getHTML('', '', DOMAIN + request.uri)
        };
        callback(null, botResponse);
        return;
    }
    callback(null, request);
};
const getHTML = (title, ogImage, url) => {
  return `
<!doctype html>
<html lang="ja">
<head prefix="og: http://ogp.me/ns#">
  <meta charset="utf-8" />
  <meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>${title}|${SERVICE_NAME}</title>
  <meta name="description" content="${DESCRIPTION}" />
  <meta property="og:url" content="https://${url}" />
  <meta property="og:type" content="article" />
  <meta property="og:locale" content="ja_JP" />
  <meta property="og:title" content="${title}|${SERVICE_NAME}" />
  <meta property="og:description" content="${DESCRIPTION}" />
  <meta property="og:image" content="${ogImage}" />
  <meta name="twitter:card" content="summary_large_image" />
  <meta name="twitter:site" content="@Twitter"  />
  <meta name="twitter:title" content="${title}|${SERVICE_NAME}" />
  <meta name="twitter:description" content="${DESCRIPTION}" />
  <meta name="twitter:image" content="${ogImage}" />
</head>
<body></body>
</html>
`;
};
大枠はこんな感じです。
これにプラスして、REST API から画像 URL 取得する処理を挟んで getHTML() の引数に渡してあげる必要があります。
Lambda@Edge で外部 API を叩く
API を叩く処理を追記していきます。
今回は、ethanent/phin という JS 製の HTTP クライアントライブラリを使用しました。理由としては、axios や request といった比較的メジャーなものよりも軽量だったからです。
'use strict';
+ const p = require('phin');
const DOMAIN = 'example.com';
const SERVICE_NAME = 'サービス名';
const DESCRIPTION = 'ディスクリプション';
// OGP を返したい User-Agent をリストで定義しておく。
const bots = [
    'Twitterbot',
    'facebookexternalhit',
    'Slackbot-LinkExpanding'
];
exports.handler = async (event, context, callback) => {
    const request = event.Records[0].cf.request;
    const userAgent = request.headers['user-agent'][0].value;
    const isBotAccess = bots.some((bot) => userAgent.includes(bot));
+    const urlPaths = request.uri.split('/').slice(-2);
    // Create OGP response
+   // リクエスト URL が "https://example.com/posts/:id" だった場合の条件を追加
+   if (isBotAccess && urlPaths[0] == 'posts' && !isNaN(urlPaths[1])) {
-   if (isBotAccess) {
-       // 🌟 Do something
+       const res = await p({ 'url': `https://api.example.com/posts/${urlPaths[1]}`, 'parse': 'json' });
        const botResponse = {
            status: 200,
            headers: [{ 'Content-Type': 'text/html' }],
-           body: getHTML('', '', DOMAIN + request.uri)
+           body: getHTML(res.body.title, res.body.image_url, DOMAIN + request.uri)
        };
        callback(null, botResponse);
        return;
    }
    callback(null, request);
};
const getHTML = (title, ogImage, url) => {
  // 省略
};
コードを .zip に圧縮する
最後に index.js と node_modules/ を .zip に圧縮してアップロードします。
$ tree
.
├── index.js
├── node_modules/
└── package.json
$ zip -r ../ogp-response-sample.zip .
CloudFront の設定を変更する
マネジメントコンソールより、アタッチしたい CloudFront の Distribution を開いて Behavior タブに切り替えて編集画面を開きます。

Edge Function Association の項目に、イベントタイプは Viewer-Request を、Function ARN は先ほど作成した Lambda 関数の ARN を ARN:${VERSION} 形式で入力します。
確認
OGP が正しく反映されたかの動作確認は下記のツールを使いました。
まとめ
SPA なフロントエンド環境に OGP 対応をしました。最後に簡単にポイントだけまとめます。
- CloudFront の機能 Lambda@Edge を使うと、SPA でも動的なレスポンスを返すことができる。
- REST API を叩くこともできる。
 
 - Lambda@Edge は制限が多いので事前に確認しよう。
 - 動作確認がちょっと面倒なので、ツールを使いましょう。
 
ここまで読んでくださりありがとうございます。
他にも「こういった方法があるよ」「こっちが楽にできるよ」などありましたらコメントいただけると幸いです:pray:
参考にさせていただいたサイト
Discussion
こんばんは。
分かりやすくまとめありがとうございます。
response headersのcontent-typeが正しくtext/htmlと認識されませんでした。
下記に修正したら正常に認識されました。
⇨
@ellepin ご指摘ありがとうございます。
修正しておきます。