[React+TypeScript+Firebase]FunctionsとHostingで動的にOGP表示するSPAを作った

# と思う〇〇であったジェネレーター

アプリのURL： https://thinking-generator.web.app/

![Screenshot_20191028-002059_60.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299625/4a02f5da-fa10-9b8f-fc1d-eebb2c2e67db.png)

Twitterでログインして投稿すると「と思う〇〇であった」という画像をOGP表示してツイートします。

## 作成理由

僕のフォロワーがこのテンプレ画像(？)を自作して投稿していました。
それを見て、「『と思う〇〇であった』画像を自動生成してツイートするアプリ需要あるな！！！！！！」



とは思いませんでしたが、Firebase Functionsを使ってSPAなのにTwitterでの動的なOGP表示の知見を得られるなと思い作成に至りました。

## アプリの特徴

Reactで作成されたSPA(Single Page Application)です。

Twitterアカウントでログインして名前とツイート内容を入力して「ツイートする！」ボタンを押すと、Twitterの投稿画面にツイート内容とURLとともに遷移します。
リンクを消さずに投稿すればTwitterがURLを解析してOGP画像を生成してくれて、「と思う〇〇であった」という画像を付けて投稿したような体験が得られます。

あくまでツイートにはURLが記載してあるだけなので、Twitterクライアントの画像一覧である「メディア」タブを汚染することがありません。(ここ重要。たぶん)
また、「ユーザーの使用 = アプリのURLをシェア」とみなせるので、アプリの利用拡大を見込むことができます。

TwitterクライアントがOGP表示を実装しているので利用できますが、非公式クライアントユーザーからはただのURLに見えてしまうので、公式クライアントで閲覧してください。(非公式クライアントユーザーのために画像だけを保存できる機能の実装を検討しています)


## ソースコード

Github： https://github.com/stin-dev/thinking-generator

汚いソースコードを公開しています。コンポーネントの分け方のベストプラクティスを知らないので読みにくいかもしれませんがご容赦ください。

## SPAでも動的OGP表示について

TwitterやFacebookのクローラーはJavaScriptを実行しません。
なのでクライアントでレンダリングを行うSPAは、そのままでは1種類のOGP表示しかできません。

サーバーサイドを自前で用意している場合は、URLの形によって動的にHTMLのmetaタグを構築してクローラーに返却することができますが、今回はFirebase Hostingを利用しているのでそれは叶いません。

しかしFirebase Hostingは特定のURLへのリクエストを受けた場合、Functionsに処理を委譲する機能を備えています。その機能を使えば動的なOGP生成が可能になります。


同じことを考えて解説している先駆者がたくさんいらっしゃるのでもっとわかりやすい記事を読みたい方は以下を参照してください。(僕も参考にして非常に助かりました。)

https://qiita.com/yuneco/items/5e526464939082862f5d
https://qiita.com/serinuntius/items/3017fb6ef51cd47352f6
https://qiita.com/mitsudaman/items/1956b94dc8faf8fb8c59


### Hosting設定

https://github.com/stin-dev/thinking-generator/blob/master/firebase.json

```json:firebase.json
  "hosting": {
    "public": "build",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "rewrites": [
      {
        "source": "/share/*",
        "function": "share"
      },
      {
        "source": "/ogp/*",
        "function": "getOgpImage"
      },
      {
        "source": "**",
        "destination": "/index.html"
      }
    ]
  }
```

`rewrites`にどのURLへのアクセスをFunctionsに委譲するかを記載します。

この場合、たとえば

```
https://thinking-generator.web.app/share/example-url
```
にアクセスすると、Functionsに作成した"share"関数をコールします。

### Storageへ画像保存

https://github.com/stin-dev/thinking-generator/blob/master/src/component/TweetButtonArea.tsx#L37-L57

```typescript:/src/component/TweetButtonArea.tsx
	const handleClick = (container: GlobalStateContainer) => async () => {
		const currentUser = firebase.auth().currentUser;
		if (!currentUser) return;

		const storageRef = firebase.storage().ref();
		const createRef = storageRef.child(`ogp-images/${currentUser.uid}.jpg`);
		const canvas = document.getElementById("canvas") as HTMLCanvasElement;

		const imagedata = canvas.toDataURL("image/jpeg").split(",")[1];
		await createRef.putString(imagedata, "base64").then(snapshot => {
			const tweeturl = `http://twitter.com/share`
				+ `?url=${container.ogpUrl}`
				+ `&text=${container.state.tweetText.replace(/\r?\n/g, "%0a")}%0a%0a`

			if (window.open(tweeturl, "_blank")) {

			} else {
				window.location.href = tweeturl;
			};
		})
	}
```

「ツイートする！」ボタンの`onClick`イベントです。Storageに`ogp-images/{currentUser.uid}.jpg`という名前でCanvasの画像を保存します。
その後、TweetShareのurlパラメータに`https://thinking-generator.web.app/share/{currentUser.uid}`を付与して画面遷移を行います。

### Functions `share`関数

上記フェーズにてURLを含むツイートが投稿されたらTwitterクローラーは`https://thinking-generator.web.app/share/{uid}`にアクセスします。
しかし実際は`firebase.json`の設定によってFunctionsの`share`関数がコールされます。


https://github.com/stin-dev/thinking-generator/blob/master/functions/src/share.ts#L20-L48

```typescript:/functions/src/share.ts
const createHtml = (uid: string) => {
	const SITEURL = `https://${appDomain}`
	const TITLE = `と思う〇〇であったジェネレーター`
	const DESCRIPTION = '「と思う〇〇であった」という画像をTwitterに投稿するサービスです。'

	return `<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <title>と思う〇〇であったジェネレーター</title>
    <meta property="og:title" content="${TITLE}">
    <meta property="og:image" content="${SITEURL}/ogp/${uid}">
    <meta property="og:description" content="${DESCRIPTION}">
    <meta property="og:url" content="${SITEURL}">
    <meta property="og:type" content="article">
    <meta property="og:site_name" content="と思う〇〇であったジェネレーター">
    <meta name="twitter:site" content="${SITEURL}">
    <meta name="twitter:card" content="summary_large_image">
    <meta name="twitter:title" content="${TITLE}">
    <meta name="twitter:image" content="${SITEURL}/ogp/${uid}">
    <meta name="twitter:description" content="${DESCRIPTION}">
  </head>
  <body>
    <script type="text/javascript">window.location="/";</script>
  </body>
</html>
`
}
```

引数`uid: string`からhtmlを作成する関数です。
ユーザー(ブラウザ)がこのURLにアクセスしてきた場合は、`<script type="text/javascript">window.location="/";</script>`が実行されてルートURLにアクセスしSPAを表示します。

上記関数で生成したHTML文字列をResponseとして返却します。
https://github.com/stin-dev/thinking-generator/blob/master/functions/src/share.ts#L6-L18

```typescript:/functions/src/share.ts
const share = async (request: functions.https.Request, response: functions.Response) => {
	const [, , uid] = request.path.split("/");

	try {
		await admin.auth().getUser(uid); // Check if a user with the specified uid exists. If user doesn't, throw error.
		response.set("Cache-Control", "public, max-age=600, s-maxage=600");
		const html = createHtml(uid);
		response.status(200).send(html);
	}
	catch (error) {
		response.status(404).send("404 Not Found");
	}
}
```
[先駆者の方も述べています](https://qiita.com/yuneco/items/5e526464939082862f5d#functions%E3%81%A7%E3%83%AA%E3%82%AF%E3%82%A8%E3%82%B9%E3%83%88url%E3%82%92%E5%85%83%E3%81%ABogp%E3%82%BF%E3%82%B0%E3%82%92%E7%B5%84%E3%81%BF%E7%AB%8B%E3%81%A6%E3%81%A6html%E3%82%92%E5%87%BA%E5%8A%9B)が、キャッシュを設定しないとFunctionsのコール回数がすごいことになります。気を付けましょう。

`"og:image"`や`"twitter:image"`に指定するURLは

```
https://thinking-generator.web.app/ogp/{uid}
```
という形をしています。
これにより、TwitterクローラーはOGPの画像リソース取得のため`https://thinking-generator.web.app/ogp/{uid}`にアクセスしようとします。


### Functions `getOgpImage`関数

Twitterクローラーが`https://thinking-generator.web.app/ogp/{uid}`にアクセスすると、`firebase.json`の設定によって実際に実行されるのがFunctionsの`getOgpImage`関数になります。

https://github.com/stin-dev/thinking-generator/blob/master/functions/src/getOgpImage.ts#L6-L20

```typescript:/functions/src/getOgpImage.ts
const getOgpImage = async (request: functions.https.Request, response: functions.Response) => {
	const [, , uid] = request.path.split("/");

	const ogpImage = storage.bucket().file(`ogp-images/${uid}.jpg`);

	if (!await ogpImage.exists()) {
		response.status(404).end("404 Not Found.");
		return;
	}

	response.set("Cache-Control", "public, max-age=600, s-maxage=600");
	response.writeHead(200, { "Content-Type": "image/jpeg" });

	ogpImage.createReadStream().pipe(response);
}
```

クライアントが保存してくれた`ogp-images/{uid}.jpg`をStorageから取得して`createReadStream()`メソッドで`response`に流し込みます。
ここでもキャッシュの設定を忘れずに行いましょう。

### まとめ

Firebase HostingのアクセスをFunctionsに委譲する設定、Twitterがmetaタグを収集する`share`関数、Twitterが画像を取得する`getOgpImage`関数の3つを正しく構築すればSPAでも動的OGP表示を行うことができます。

## 終わりに

Firebaseアプリの作業フォルダ、絶対にFunctionsとHostingで分けた方がいいなと開発中ずっと思っていました...。エディタが煩雑になる...。

## リンク

Github: https://github.com/stin-dev
ポートフォリオ: https://stin-dev.github.io/
Twitter: https://twitter.com/stin_factory