Firebase FunctionsでHTML出力をカスタマイズ

この記事は、[「Flutter Advent Calendar 2021」](https://qiita.com/advent-calendar/2021/flutter)に投稿した「Flutter on the WebをFirebase Hostingで公開した」の一部となります。

# クローラーへの対応
Flutterで動的にタイトル等を変えることは出来ても、OGP、TwitterCards等からページが参照される際は多くの場合javascriptは実行されません。

せめてリンクのシェアでタイトルと概要(description)が反映されるように、Firebase Funcionsを利用してHTMLの生成をしてみました。
もちろん、その気になればサマリー画像とかのカスタマイズもできます。

この記事は以下のFirebase Hostingへの公開ができていることを前提としています。

https://qiita.com/hidea/items/bb1428ff17bc93aeb2f4

# Firebase Functionsをセットアップ

Firebase CLIはHostingの時にセットアップ済みという前提でセットアップします。

Firebase Functionsのドキュメントは以下を参照下さい。
https://firebase.google.com/docs/functions/get-started

プロジェクトのルートへ移動して初期化します。

```console
$ ch /Users/hidea/Documents/workspace/myapp
$ firebase init functions

     ######## #### ########  ######## ########     ###     ######  ########
     ##        ##  ##     ## ##       ##     ##  ##   ##  ##       ##
     ######    ##  ########  ######   ########  #########  ######  ######
     ##        ##  ##    ##  ##       ##     ## ##     ##       ## ##
     ##       #### ##     ## ######## ########  ##     ##  ######  ########

You're about to initialize a Firebase project in this directory:

  /Users/hidea/Documents/workspace/myapp

Before we get started, keep in mind:

  * You are initializing within an existing Firebase project directory


=== Project Setup

First, let's associate this project directory with a Firebase project.
You can create multiple project aliases by running firebase use --add,
but for now we'll just set up a default project.
```

- Firebase上のプロジェクトを選択 **myapp**

```console
i  Using project myapp (myapp)

=== Functions Setup

A functions directory will be created in your project with sample code
pre-configured. Functions can be deployed with firebase deploy.
```

- 言語を選択 **JavaScript**
- ESLintを利用するか？ **Yes**

```console
? What language would you like to use to write Cloud Functions? JavaScript
? Do you want to use ESLint to catch probable bugs and enforce style? Yes
✔  Wrote functions/package.json
✔  Wrote functions/.eslintrc.js
✔  Wrote functions/index.js
✔  Wrote functions/.gitignore
```

- npm installを今すぐ行うか？ **Yes**

```console
? Do you want to install dependencies with npm now? Yes

added 327 packages, and audited 328 packages in 35s

25 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

i  Writing configuration info to firebase.json...
i  Writing project information to .firebaserc...

✔  Firebase initialization complete!
```

完了すると`functions`以下にファイルが追加、`firebase.json`が更新されます。

- functions
 - .eslintrc.js
 - .gitignore
 - index.js
 - package-lock.json
 - package.json
- firebase.json (更新)

`firebase.json`には以下の項が追加されていました。

```json:firebase.json
  "functions": {
    "predeploy": [
      "npm --prefix \"$RESOURCE_DIR\" run lint"
    ]
  }
```

# HostingへのアクセスでFunctionsが呼ばれるようにする

Firebase Hostingの`rewrites`を編集してURLのパスが`/horse/*`の時だけFunctionsを呼び出すようにします。
 
```json:firebase.json
{
  "hosting": {
    "public": "build/web",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "rewrites": [ {
      "source": "/horse/*",
      "function": "app"
    }, {
      "source": "**",
      "destination": "/index.html"
    } ]
  },
  "functions": {
    "predeploy": [
      "npm --prefix \"$RESOURCE_DIR\" run lint"
    ]
  }
}
```

# Functionを書く

サンプルとして以下のスクリプトで試します。
ここでは、URLで指定された値をそのまま反映してHTMLファイルを生成します。
Firebase FunctionsでタイトルやOGPを書き換える先人にはここからリダイレクトする例が多いのですが、まるごとindex.htmlを生成してしまえば不要ではないかとしていません。

:::note
実際の「ウマ娘ダイアグラム」では、functions/assets にFlutterで利用しているのと同じ（馬、レース等の）JSONリソースを複製してデプロイしています。
データベースは参照せず、assets内にあるJSONファイルをrequireして情報を生成しています。
:::

```js:functions/index.js
const functions = require("firebase-functions");
const app = require("express")();

const template = require("./template");

const TITLE = "ウマ娘ダイアグラム";

const renderApplication = (res, title, description) => {
  const templatedHtml = template({title: title, description: description});
  res.send(templatedHtml);
};

app.get("/horse/:horse", async (req, res) => {
  res.set("Cache-Control", "public, max-age=60, s-maxage=180");
  const name = req.params.horse;
  renderApplication(
      res,
      `${name} | ${TITLE}`,
      `競走馬「${name}」、ウマ娘の銘と魂を受け継ぐ源泉となった異世界（現実世界）の記録。`,
  );
});

exports.app = functions.https.onRequest(app);
```

HTMLの生成元となる template.js の中味は、/web/index.html そのままです。置き換えたいところだけ変数を置いてあります。
本当であれば /web/index.html を都度参照するような前処理をFunctionsへデプロイする前に入れるのが正道だと思うのですがサボっています。

:::note warn
Functionが呼び出される時の index.html は template.js だと言うことを忘れていると後で手を入れた時に困るので気をつけて下さい。
:::

```js:functions/template.js
const template = (opts) => {
  /* eslint-disable max-len */
  return `<!DOCTYPE html>
<html>
<head>
    <base href="/">

    <meta charset="UTF-8">
    <meta content="IE=Edge" http-equiv="X-UA-Compatible">
    <meta name="description" content="${opts.description}">

    <!-- iOS meta tags & icons -->
    <meta name="apple-mobile-web-app-capable" content="yes">
    <meta name="apple-mobile-web-app-status-bar-style" content="black">
    <meta name="apple-mobile-web-app-title" content="myapp281firebasehosting">
    <link rel="apple-touch-icon" href="icons/Icon-192.png">

    <!-- Twitter Card -->
    <meta name="twitter:card" content="summary_large_image" />
    <meta name="twitter:site" content="@umadiagram" />
    <meta name="twitter:creator" content="@rukari" />
    <meta property="og:title" content="${opts.title}" />
    <meta property="og:description" content="${opts.description}" />
    <meta property="og:image" content="icons/Icon-192.png" />

    <!-- Favicon -->
    <link rel="icon" type="image/png" href="favicon.png"/>

    <title>${opts.title}</title>
    <link rel="manifest" href="manifest.json">
</head>
<body>
    <!-- This script installs service_worker.js to provide PWA functionality to
        application. For more information, see:
        https://developers.google.com/web/fundamentals/primers/service-workers -->
    <script>
    var serviceWorkerVersion = '1854857776';
    var scriptLoaded = false;
    function loadMainDartJs() {
        if (scriptLoaded) {
        return;
        }
        scriptLoaded = true;
        var scriptTag = document.createElement('script');
        scriptTag.src = 'main.dart.js';
        scriptTag.type = 'application/javascript';
        document.body.append(scriptTag);
    }

    if ('serviceWorker' in navigator) {
        // Service workers are supported. Use them.
        window.addEventListener('load', function () {
        // Wait for registration to finish before dropping the <script> tag.
        // Otherwise, the browser will load the script multiple times,
        // potentially different versions.
        var serviceWorkerUrl = 'flutter_service_worker.js?v=' + serviceWorkerVersion;
        navigator.serviceWorker.register(serviceWorkerUrl)
            .then((reg) => {
            function waitForActivation(serviceWorker) {
                serviceWorker.addEventListener('statechange', () => {
                if (serviceWorker.state == 'activated') {
                    console.log('Installed new service worker.');
                    loadMainDartJs();
                }
                });
            }
            if (!reg.active && (reg.installing || reg.waiting)) {
                // No active web worker and we have installed or are installing
                // one for the first time. Simply wait for it to activate.
                waitForActivation(reg.installing || reg.waiting);
            } else if (!reg.active.scriptURL.endsWith(serviceWorkerVersion)) {
                // When the app updates the serviceWorkerVersion changes, so we
                // need to ask the service worker to update.
                console.log('New service worker available.');
                reg.update();
                waitForActivation(reg.installing);
            } else {
                // Existing service worker is still good.
                console.log('Loading app from service worker.');
                loadMainDartJs();
            }
            });

        // If service worker doesn't succeed in a reasonable amount of time,
        // fallback to plaint <script> tag.
        setTimeout(() => {
            if (!scriptLoaded) {
            console.warn(
                'Failed to load app from service worker. Falling back to plain <script> tag.',
            );
            loadMainDartJs();
            }
        }, 4000);
        });
    } else {
        // Service workers not supported. Just drop the <script> tag.
        loadMainDartJs();
    }
    </script>
</body>
</html>`;
  /* eslint-enable max-len */
};

module.exports = template;
```

合わせて、Flutterアプリの方も「Flutter on the WebでURL形式を変更」で示した対応をしておいた方がいいでしょう。
知らないパスだとルートにリダイレクトしてしまいます。

# Funcsionsへデプロイ

これらを、Functionsへデプロイします。

```console
$ firebase deploy --only functions

=== Deploying to 'myapp'...

i  deploying functions
Running command: npm --prefix "$RESOURCE_DIR" run lint

> lint
> eslint .

✔  functions: Finished running predeploy script.
i  functions: ensuring required API cloudfunctions.googleapis.com is enabled...
i  functions: ensuring required API cloudbuild.googleapis.com is enabled...
i  functions: ensuring required API artifactregistry.googleapis.com is enabled...
⚠  functions: missing required API cloudbuild.googleapis.com. Enabling now...
⚠  functions: missing required API artifactregistry.googleapis.com. Enabling now...
✔  functions: required API cloudfunctions.googleapis.com is enabled

Error: Your project myapp must be on the Blaze (pay-as-you-go) plan to complete this command. Required API cloudbuild.googleapis.com can't be enabled until the upgrade is complete. To upgrade, visit the following URL:

https://console.firebase.google.com/project/myapp/usage/details

Having trouble? Try firebase [command] --help
```

怒られました。

:::note alert
Error: Your project myapp must be on the Blaze (pay-as-you-go) plan to complete this command.
:::

料金プランを従量制のBlazeに設定いけないようです。

Functionsのコンソールにアクセスすると「Functions を使用するには、プロジェクトの料金プランをアップグレードしてください」とあったので、プロジェクトをアップグレードします。
https://console.firebase.google.com/project/myapp/functions
![スクリーンショット 2021-12-23 8.13.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4668/1d3d4199-091e-b10c-6437-4a06f7c4f5d8.png)
![スクリーンショット 2021-12-23 8.14.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4668/0e1a00ed-21b5-bf3a-95c4-411e9c3eafa1.png)

Blazeにアップグレードして、ふたたびデプロイ!!

```console
% firebase deploy --only functions

=== Deploying to 'myapp'...

i  deploying functions
Running command: npm --prefix "$RESOURCE_DIR" run lint

> lint
> eslint .

✔  functions: Finished running predeploy script.
i  functions: ensuring required API cloudfunctions.googleapis.com is enabled...
i  functions: ensuring required API cloudbuild.googleapis.com is enabled...
i  functions: ensuring required API artifactregistry.googleapis.com is enabled...
⚠  functions: missing required API cloudbuild.googleapis.com. Enabling now...
⚠  functions: missing required API artifactregistry.googleapis.com. Enabling now...
✔  functions: required API cloudfunctions.googleapis.com is enabled
✔  functions: required API cloudbuild.googleapis.com is enabled
✔  functions: required API artifactregistry.googleapis.com is enabled
i  functions: preparing functions directory for uploading...
i  functions: packaged functions (74.37 KB) for uploading
✔  functions: functions folder uploaded successfully
i  functions: creating Node.js 16 function app(us-central1)...
✔  functions[app(us-central1)] Successful create operation.
Function URL (app(us-central1)): https://us-central1-myapp-8a130.cloudfunctions.net/app
i  functions: cleaning up build files...

✔  Deploy complete!

Project Console: https://console.firebase.google.com/project/myapp-8a130/overvie
```

こんどは無事、成功しました。

URLを指定してソースを取得してみると無事反映されているのがわかります。

![スクリーンショット 2021-12-23 15.18.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4668/2d2f57bd-9957-dbe1-134a-289ca928ae6c.png)

Funcsionsのコンソールでログを確認することも出来ます。

![スクリーンショット 2021-12-23 15.15.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4668/a43a0adc-52bf-fd38-2d35-2f3241bd6fdc.png)

以上です。