NuxtjsをCloudrun+Firebase Hostingで動かす上での小ネタ集

# まえがき
Nuxt3+MicroCMSを組み合わせたWebサイトを制作・運用する過程で得た知見をまとめた記事。書いてみると意外と文量が多くなったので、そのままアドベントカレンダー用の記事にしてしまったという話。

## 技術構成
- 使用言語: Typescript/HTML/CSS
- フレームワーク: Nuxt3(SSRモード)
- UIフレームワーク:Vue3
- CSSフレームワーク: TailwindCSS
- バックエンド: Google Cloud run
- ホスティング: Firebase Hosting

# TL;DR
- 調子が悪くなったら"nuxi cleanup"
- SSRとTeleportの組み合わせは相性が悪い
- 画像が204になったらnitro.prerender.routesに直書きすれば直る
- 極力静的ページ化+CDNに投げるようにして費用を節約する
- Cloud runのインスタンス数制限はやったほうが安心

# 小ネタ
## 調子が悪くなったら"nuxi cleanup"
オートインポート機能関連で不具合が出たり、ホットリロード時の挙動が不安定になったらとりあえず["nuxi cleanup"](https://nuxt.com/docs/api/commands/cleanup)しておけばなんとかなるという印象。

```
nuxi cleanup
```

## SSR時はTeleportの取り扱いに注意
[Teleport](
https://nuxt.com/docs/api/components/teleports)を使って別のVueコンポーネント内に要素を転送する場合、実行タイミングに注意が必要。<ClientOnly>コンポーネントでラップしても以下の画像のようにエラーが出る。
この画像ではレイアウト内の要素にページ側から転送しているのだが、ページ側の描画完了後にレイアウトの描画が完了するため転送先が見つからずにエラーとなっている。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/425022/8e853ac6-f608-505d-d57d-4ec1854fa048.png)
これについては公式ドキュメントを引用すると、
> The to target of <Teleport> expects a CSS selector string or an actual DOM node. Nuxt currently has SSR support for teleports to body only, with client-side support for other targets using a <ClientOnly> wrapper.

 要するに「nuxtは現在SSR時にはbody以外への転送はサポートしていない。」とある。
 基本的には描画完了かどうかのフラグを用意してv-ifで表示させるのが無難。クライアントサイドでの描画完了フラグの管理はnuxtのpage:startとpage:finishを使い、100msほど遅らせると正常に動作する。ただしLayoutTransitionを設定している場合は更にその時間分切り替えを遅らせないとやはりエラーが発生する。

```typescript:composable/isLoading.ts
import { Ref } from 'nuxt/dist/app/compat/capi'
import { useState } from '#app'

interface State{
  isLoading:boolean
}

export const useLoadingStore = () => {
  const state = useState<State>(() => { return { isLoading: true } })
  return {
    state,
    set: set(state)
  }
}

const set = (state:Ref<State>) => (t:boolean) => { state.value.isLoading = t }
```

こういうときの状態管理はnuxt3で追加されたcomposableが便利。

```typescript:composable/app.vue
const nuxtApp = useNuxtApp()
const isLoading = useLoadingStore()
nuxtApp.hook('page:start', () => {
  isLoading.set(true)
})
nuxtApp.hook('page:finish', () => {
  setTimeout(() => {
    isLoading.set(false)
  }, 500) // 怖いので500ms遅延させている。
})

```
ちなみにページコンポーネントをSuspenseでラップすると見かけ上エラーが消えるのだが、ビルド後にエラーが復活するので注意

## MicroCMSの画像もレスポンシブ画像にできる。
[別の記事](https://qiita.com/ddg171/items/578313738d1e81fd3cbe)で書いたが、nuxt/imageモジュールは外部URLの画像でもimgixなどに対応していればレスポンシブ画像を含んだsrcsetを用意してくれる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/425022/9eb2c094-9a16-ac13-fe4d-1e7558b82098.png)

## レスポンシブ画像が生成されないなら直書き
Firebase Hosting経由で静的ページでないページにアクセスした際、一部のレスポンシブ画像が204を返して表示できないという不具合に遭遇した。とりあえず[nuxt/imageモジュールのドキュメント](https://image.nuxt.com/advanced/static-images)に従ってnuxt.config.tsのnitro.prerender.routesにパスを追加すると直る。静的コンテンツとしてhosting側が持っていれば204になるはずがない。
```typescript:nuxt.config.ts
  nitro: {
    compressPublicAssets: true,
    prerender: {
      routes: ['/',
               '/works',
               '/about',
               '/disclaimer',
               // パスを指定するとビルド段階で生成してくれる。
               '/_ipx/w_1536&f_webp/images/webp/blanktitle01w2000.webp'
              ]
    }
  },
  // 以下省略
```
なお原因はよくわからない。

## 極力静的ページにする。
わざわざSSRモードで動かしているのこういう話になってしまうのだが、Cloudrunの課金とコールドスタート時の遅延を避けるために極力静的ページにしてしまった方が良い。

## APIにもキャッシュ設定
Server Routesに追加したAPIもCDNのキャッシュ設定を追加することが可能。[公式ドキュメント](https://firebase.google.com/docs/hosting/manage-cache?hl=ja)にある通りヘッダを追加すればOK。
```typescript
import client from '~~/lib/microCMS'
import { Article } from '~~/types/articles'

export default defineEventHandler(async (event):Promise<Article> => {
  const contentId = event?.context?.params?.id
  // ヘッダーを追加する。
  setHeader(event, 'Cache-Control', 'public, max-age=600, s-maxage=600')
  if (!contentId) {
    throw createError({ statusCode: 400, statusMessage: 'err' })
  }
  try {
    const res = await client
      .get({
        endpoint: 'blogs',
        contentId
      })
    return res
  } catch {
    throw createError({ statusCode: 404, statusMessage: 'err' })
  }
})
```

クライアント側からAPIを叩く際にキャッシュが効けば高速化と費用の節約が期待できる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/425022/c7171b49-5730-3bf2-d387-34bd4644ff95.png)

## Cookieの取り扱いに注意
[以前記事にまとめた](https://qiita.com/ddg171/items/25142c5d26231150313f
)が、cloudrun/Firebase Hosting/Cloud functionsはリクエスト内のcookieを基本的に削除してしまう。
[公式ドキュメント](https://firebase.google.com/docs/hosting/manage-cache?hl=ja#using_cookies)によれば
>Firebase Hosting と Cloud Functions または Cloud Run を併用すると、通常は受信リクエストから Cookie が削除されます。この処理は、CDN キャッシュの動作を効率化するために必要になります。__session という特別な名前の Cookie だけがアプリに到達できます。

ハイドレーションエラーを避けるために基本的にはクライアント側だけでcookieを扱うほうが楽。

## cloudrunのCPUブーストとインスタンス数制限
先述した通りcloudrunは基本コールドスタートなので、アクセス数が少ないときはSSR時に時間がかかってしまう。そこでCPUブーストを設定することで若干起動を高速化できる。あと費用の急激な増大を防ぐためインスタンス数を制限したほうが安心できる。ちなみにデフォルトの最大インスタンス数は100。
これはデプロイ時のコマンドにオプションを追加するだけでOK。
```bash
          gcloud run deploy $SERVICE_NAME \
            --cpu-boost \
            --max-instances 3
```
ただ[検証してくださっている方の記事](https://zenn.dev/cloud_ace/articles/bd95501cb0cd3f)を見ると、Node.jsの場合はCPUブーストを使っても恩恵は大きくないそうなので、お気持ち高速化程度の意味しかなさそう。
最大インスタンス制限はやっておかないと何かあったときに「みんなはさーん」するらしい。

- [CPUブーストについてのドキュメント](https://cloud.google.com/blog/ja/products/serverless/announcing-startup-cpu-boost-for-cloud-run--cloud-functions)
- [最大インスタンス数についてのドキュメント](https://cloud.google.com/run/docs/configuring/max-instances?hl=ja)
# おわりに
あれこれ不具合や問題に遭遇しながら作ったサイトは[こちら](https://poripority.com)。気が向いたら見てくれるとほんの少しうれしい。