Flutter Webでもシェア画像を動的に生成したい

この記事は[Flutter Advent Calendar 2023](https://qiita.com/advent-calendar/2023/flutter)の  
シリーズ1の16日目の記事です。

---

[Flutter on the Web](https://flutter.dev/multi-platform/web)でWebアプリが作れるようになったけど、 
SPAなので、SEO部分が悩ましい。。

SEO系のパッケージはいくつかあるけど、  
アプリを開いているときに切り替える形なので、  
Twitter/Xなどでシェアした場合だとうまくいかない。。

- [meta_seo | Flutter Package](https://pub.dev/packages/meta_seo)
- [seo | Flutter Package](https://pub.dev/packages/seo)

ただ、SPAなので、昔にNuxtを使って、  
head部分を置き換えるのが使えないかと試してみた(\*´ω｀\*)

https://www.memory-lovers.blog/entry/2019/08/07/150000

### 作ってみたアプリ

音声でしか投稿できないSNS
「VoitterX」

https://voitterx.web.app/

[クソアプリ Advent Calendar 2023](https://qiita.com/advent-calendar/2023/kuso-app)の記事で作ったアプリ。
(こっちでは音声認識パッケージの[speech_to_text](https://pub.dev/packages/speech_to_text)について書いてます)

https://qiita.com/kira_puka/items/707fe07e0091a2f98861



チャットルーム的な部屋があり、その中に投稿していくアプリ。  
各ルームのURLのときは、そのルーム名のOGP画像を生成する形。

https://voitterx.web.app/rooms/TY-itJa71A7emwoekki

### 構成

構成としてはこんな感じ。

![スクリーンショット 2023-12-13 10.06.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478782/faf6f076-eadc-467b-96ed-b3e7b02fceb2.png)


- アプリ部分
  - [Firebase Hosting](https://firebase.google.com/docs/hosting?hl=ja)
  - [Flutter](https://flutter.dev/) Web/CanvasKit
- サーバ部分
  - [Cloud Functions for Firebase(第2世代)](https://firebase.google.com/docs/functions/beta/get-started?hl=ja)
  - [unjs/nitro](https://nitro.unjs.io/)
  - OG画像生成: [satori](https://github.com/vercel/satori) + [sharp](https://github.com/lovell/sharp) + [satori-html](https://github.com/natemoo-re/satori-html)
- データ部分
  - [Firebase Firestore](https://firebase.google.com/docs/firestore?hl=ja)

慣れているので[unjs/nitro](https://nitro.unjs.io/)を使っているけど、  
シンプルなので、なんでもいけるはず。

### Flutter Webの資材

Web版のビルドを実行すると、

```sh
$ flutter build web --web-renderer canvaskit
```

こんな資材が`build/web/`ディレクトリ配下に生成される。

![スクリーンショット 2023-12-13 7.42.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478782/3e8539cf-a8d2-0d97-90c8-2f025f92f40f.png)

index.htmlなどは、`web/`ディレクトリ配下にあるものがベースで、

![スクリーンショット 2023-12-13 7.43.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478782/ae1f723c-f092-bc37-a4a1-c552234a098c.png)

`$FLUTTER_BASE_HREF`などの置き換えられている感じ。

![スクリーンショット 2023-12-13 7.45.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478782/5b3685b4-82c9-1205-17da-53ea8a0fbf83.png)


`build/web/`をそのまま、Hostingにデプロイしても動作するけど、  
今回は特定のパスだけ、OGP画像を切り替えたいので、  
`index.html`だけ、Cloud Functionsから返し、
他の資材は、Hostingから渡す形にしている。

### 全体の流れ

だいたいの流れとしてはこんな感じ。

![スクリーンショット 2023-12-13 7.32.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478782/3ab28278-f036-8a44-7676-0ad95cd8aa81.png)


#### デフォルトのパスなど

特に書き換えが不要な場合は、なにもせず、  
そのままの`index.html`を返す

#### 特定のパス

特定のパスの場合は、`index.html`を書き換えて返す。

HTMLをパースするほどでもないので、  
単純に文字列のreplaceする形。

今回、置き換えたのは、この4つ。

- タイトル(`<title>`)
- シェア時のタイトル(`og:title`)
- シェア時のURL(`og:url`)
- シェア時の画像(`og:image`)

```diff
  <head>
-   <title>VoitterX</title>
+   <title>おためしの部屋 | VoitterX</title>
    <meta name="description" content="音声でしか投稿できないSNS" />
    <meta name="og:title" property="og:title"
-     content="音声でしか投稿できないSNS | VoitterX"
+     content="おためしの部屋 | VoitterX"
    />
    <meta name="og:description" property="og:description"
      content="音声でしか投稿できないSNS"
    />
    <meta name="og:url" property="og:url"
-     content="https://voitterx.web.app"
+     content="https://voitterx.web.app/rooms/TY-itJa71A7emwoekki"
    />
    <meta name="og:image" property="og:image"
+     content="https://voitterx.web.app/ogp.png"
-     content="https://voitterx.web.app/__og-image__/image?roomId=TY-itJa71A7emwoekki"
    />
</head>
```

置き換えるデータは、パスパラメタからFirestoreのデータを取得してる。


#### OG画像生成用のパス

実際のシェア画像を生成もサーバでやるようにしているので、  
`/__og-image__/image?roomId=<roomId>`にアクセスすると、  
PNG画像を返す処理もしている。

[unjs/nitro](https://nitro.unjs.io/)+[satori](https://github.com/vercel/satori) + [sharp](https://github.com/lovell/sharp)をつかったOG画像生成はこちら。

https://www.memory-lovers.blog/entry/2023/12/08/173753

仕組みとしては、背景の画層を用意しておいて、  
ルーム名を埋め込んで、PNG画像にしている形。

OG画像生成は自前でつくらなくても[Cloudinary](https://cloudinary.com/products/programmable_media)とかを使う形でもOK

https://cloudinary.com/products/programmable_media

### 実際のディレクトリ構成

ディレクトリ構成はこんな感じ。

```
app/              ... Flutter部分
  lib/
    main.dart
  web/
    index.html
  build/
    web/
      index.html
      flutter.js
  pubspec.yaml
server/           ... Server(nitro)部分
  routes/
    __og-image__/
      image.ts    ... OG画像生成
    [...].ts      ... index.htmlの書き換え
  assets/
    index.html
  public/
    flutter.js
  .firebaserc
  firebase.json
```

- `app/`と`server/`をそれぞれ用意
- `firebase.json`は`server/`配下に配置

### ビルド/デプロイの流れ

#### Flutterをビルド

まずは、Flutter側をビルド。  
`app/build/web`を作成する。

```sh
$ cd app
$ flutter build web --web-renderer canvaskit
```

#### ビルドした資材をコピー

次に、ビルドした資材をサーバ側にコピーする。

```sh
$ cd ../server
# ビルドしたFlutter資材をサーバ側にコピー
$ cp -r ../app/build/web public
# 動的に返すものをassetsに移動
$ mv public/index.html assets/index.html
```

nitroのお作法では、以下のような感じ。

- public/ ... Hostingから配信
- それ以外 ... Functionsから配信

Firebase Hostingではパスにファイルが存在すると、
サーバ側を呼び出さなくなるため、  
`index.html`は`public/`から移動しておく必要がある。

- [ホスティング動作を構成する  |  Firebase Hosting](https://firebase.google.com/docs/hosting/full-config?hl=ja)

#### サーバ側のビルド

サーバ側にFlutterの資材を配置したら、  
サーバ側をビルドする。nitroだとこんな感じ。

```sh
$ NITRO_PRESET=firebase nitropack build
```

ビルドが完了すると、`server/.output`ディレクトリができあがる。

#### デプロイ

サーバ側もビルドしたら、Firebaseにデプロイする。
`firebase.json`はこんな感じ。

```json
// firebase.json
{
  "functions": {
    "source": ".output/server",
    "runtime": "nodejs18"
  },
  "hosting": {
    "public": ".output/public",
    "cleanUrls": true,
    "rewrites": [
      { "source": "**", "function": "server", "region": "asia-northeast1" }
    ],
    "ignore": [ "firebase.json", "**/.*", "**/node_modules/**" ]
  },
}
```

画像やフォント、jsなどの資材は必要に応じて、キャッシュするように、  
`headers`を設定しておくといい感じ。

- [ヘッダーを構成する | ホスティング動作を構成する  |  Firebase Hosting](https://firebase.google.com/docs/hosting/full-config?hl=ja#headers)

Firebaseの設定ファイルも用意できたら、  
Firebase CLIを使ってデプロイする。

```sh
$ firebase deploy --only hosting,functions
```

#### GitHub Actionsでの自動デプロイ

Flutter用のGitHub Actionsがあるので、  
それらを使えば自動デプロイもOK

- [subosito/flutter-action](https://github.com/subosito/flutter-action)
- [google-github-actions/auth](https://github.com/google-github-actions/auth)

```yaml
name: deploy
"on":
  push:
    branches:
      - develop
  workflow_dispatch:
env:
  APP_MODE: stag
  PROJECT_ID: "YOUR_PROJECT_ID"
  OIDC_SERVICE_ACCOUNT: "YOUR_OIDC_SERVICE_ACCOUNT"
  OIDC_PROVIDER: "YOUR_OIDC_PROVIDER"

jobs:
  build_and_deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      # *****************************************************
      # * SETUP
      # *****************************************************
      - uses: actions/checkout@v3
      - uses: pnpm/action-setup@v2
        with:
          version: 8
      - uses: actions/setup-node@v3
        with:
          node-version: "20"
          cache: "pnpm"
          cache-dependency-path: "server/pnpm-lock.yaml"
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: "3.13.4"
          cache: true

      # *****************************************************
      # * Build: Flutter
      # *****************************************************
      - name: "Build: Flutter"
        run: |
          cd app
          flutter pub get
          flutter build web --web-renderer canvaskit
          cd ../

      - name: "Build: Copy Public Assets"
        run: |
          cp -r app/build/web server/public
          mv server/public/index.html server/assets/index.html

      # Configure Workload Identity Federation via a credentials file.
      - uses: google-github-actions/auth@v1
        with:
          service_account: ${{ env.OIDC_SERVICE_ACCOUNT }}
          workload_identity_provider: ${{ env.OIDC_PROVIDER }}
      - name: "Build Server And Deploy"
        run: |
          cd server
          pnpm install
          NITRO_PRESET=firebase nitropack build
          pnpx firebase-tools deploy --only firestore:rules,functions,hosting --project ${{env.PROJECT_ID}}
```

このあたりは、過去に記事を書いたので、こちらもよかったら(\*´ω｀\*)

- [GitHub Actions+OIDCでCloud Runにデプロイする(Node.js) - くらげになりたい。](https://www.memory-lovers.blog/entry/2023/05/29/110000)
- [GitHub Actions+OIDCでFirebase Hostingにデプロイする(Node.js) - くらげになりたい。](https://www.memory-lovers.blog/entry/2023/05/29/150000)
- [GitHub ActionsでFlutterをビルドする(subosito/flutter-action) - くらげになりたい。](https://www.memory-lovers.blog/entry/2023/12/06/091710)

## おわりに

別途サーバが必要だけど、ちょっとしたもので、  
シェア画像も対応できた(\*´ω｀\*)

Flutter WebもSPAだと思っていろいろしてみると、  
よりいいかんじにできそう(\*´ω｀\*)

---

よかったら、ぜひ遊んでみてください〜(*´ω｀*)

https://voitterx.web.app/