Closed6

Vercel + Next.js 12 で Vercel Edge Cache を有効にする

たつきちたつきち

https://nextjs.org/docs/pages/building-your-application/deploying/production-checklist

に書かれているように、getServerSideProps 内で

export const getServerSideProps: GetServerSideProps<Props> = async (ctx) => {
  ctx.res.setHeader(
    'Cache-Control',
    'max-age=0, s-maxage=0, stale-while-revalidate=86400',
  )

  return {
    props: {},
  }

ってやれば確かにレスポンスヘッダーをいじれてキャッシュの挙動も期待どおりになった。

また、
https://vercel.com/docs/functions/edge-functions/edge-caching#what-is-cached
に書かれているとおり、Authorization ヘッダーでBASIC認証をかけているステージング環境だと確かにキャッシュされなくなることも確認できた。

'max-age=0, s-maxage=0, stale-while-revalidate=86400' だと、

  • max-age=0 ブラウザではキャッシュしない
  • s-maxage=0 Edge Cacheは0秒でstaleになる(Edge Cacheは一瞬もfreshと見なさず、常にキャッシュを返しつつ裏ではrevalidateを必ず行う)
  • stale-while-revalidate=86400 Edge Cacheはstaleになったあとも86400秒間はキャッシュを返し、staleなキャッシュにリクエストがあった場合は裏でrevalidateを行う

なので、1日間隔以内のリクエストは常に X-Vercel-Cache: STALE になり、その裏で常にEdge Cacheが更新されるので次のリクエストに対してはまた最新のキャッシュを提供できる、という挙動になった(はず)。

たつきちたつきち

複数のページをまとめて同じ設定にしたい場合は

https://vercel.com/docs/edge-network/caching

に書かれているように next.config.js に設定を書けばよい(設定の書き方の詳細は https://nextjs.org/docs/pages/api-reference/next-config-js/headers を参照)

かと思ったが、やってみても( X-Custom-Header ヘッダーとかは確かにセットできるのに)なぜか Cache-Control ヘッダーは変更できなかった。

ググってたら

https://github.com/vercel/next.js/issues/22319

を見つけて、この中で言及されているように

https://nextjs.org/docs/pages/api-reference/next-config-js/headers#cache-control

ここに

You cannot set Cache-Control headers in next.config.js file as these will be overwritten in production to ensure that API Routes and static assets are cached effectively.

と書いてあって、Cache-Control ヘッダーだけはプロダクション環境では next.config.js 経由での変更ができない仕様っぽい。なんじゃそりゃ

たつきちたつきち

というわけで

export const setCacheControlHeader = (
  ctx: GetServerSidePropsContext,
  maxAge = 0,
  sMaxAge =0,
  staleWhileRevalidate = 86400,
): void => {
  ctx.res.setHeader(
    'Cache-Control',
    `max-age=${maxAge}, s-maxage=${sMaxAge}, stale-while-revalidate=${staleWhileRevalidate}`,
  )
}

こんなのを書いて、対象のページ(=コンテンツの内容がログイン状態に依存しないページ)の getServerSideProps

export const getServerSideProps: GetServerSideProps<Props> = async (ctx) => {
  // ...

  setCacheControlHeader(ctx)

  // ...
}

みたいにした

たつきちたつきち

UGCに関連するページで

https://zenn.dev/catnose99/articles/8bed46fb271e44

この問題が起こることに気づいた。

具体的には、例えば

  • ユーザーAがUGCを投稿する
  • まだ他のユーザーがUGC一覧ページを表示しておらずキャッシュが古いままになっている状態で、ユーザーAがUGC一覧ページを表示する
  • ユーザーAには、さっき自分が投稿したUGCが一覧にまだないように見える
  • リロードなどすると新しいキャッシュが読まれて一覧に現れる

というような挙動が起こり得る。

そこで今回は、「次回アクセス時に強制的に最新データをrefetchするべきページのパス」をCookieに持っておけるようにする、という対応をとった。

ちなみにSSRではなくISRを使っていたなら オンデマンドISR というやつでシュッとできるっぽい(?)

import {GetServerSidePropsContext} from 'next'
import {parseCookies, setCookie, destroyCookie} from 'nookies'

const key = 'pocitta-refetch-on'
const maxAge = 86400 /** @see setCacheControlHeader */

export const getRefetchOn = (ctx?: GetServerSidePropsContext): string[] => {
  return JSON.parse(parseCookies(ctx)[key] ?? '[]')
}

export const addRefetchOn = (path: string, ctx?: GetServerSidePropsContext) => {
  const paths = [...getRefetchOn(ctx), path]
  setCookie(ctx, key, JSON.stringify(paths), {path: '/', maxAge})
}

export const removeRefetchOn = (
  path: string,
  ctx?: GetServerSidePropsContext,
) => {
  const paths = getRefetchOn(ctx).filter((v) => v !== path)
  setCookie(ctx, key, JSON.stringify(paths), {path: '/', maxAge})
  if (paths.length === 0) {
    destroyCookie(ctx, key, {path: '/'})
  }
}

こんな感じの、Cookieに path の配列を持たせて追加・削除をするためのモジュールを作って、UGCを投稿・編集したタイミングで

addRefetchOn(目的のURLパス)

を実行するようにし、その上で、_app.tsx

  useEffect(() => {
    if (getRefetchOn().includes(router.asPath)) {
      removeRefetchOn(router.asPath)
      queryClient.refetchQueries()
    }
  }, [router.asPath, queryClient])

これを追加した。

このプロジェクトではReactQueryを使っているので queryClient.refetchQueries() すればそのページの全クエリを再取得できた(多分)。

これで、Cookieに保存されているパスに着地した場合に限り、強制的に最新データを取得しつつ、Cookieから当該パスを削除する(次回はもう強制refetchしない)、という動作が実現できた。

Cookie操作モジュールの maxAge をEdge Cacheの stale-while-revalidate と同じ 86400 にしているところがポイントだけど、厳密には、pathAをCookieに追加する→pathAにアクセスしないまま半日経過→pathBをCookieに追加する、みたいなことが起こると、pathBがCookieに追加された時点でまたCookie全体の寿命が86400秒に伸びるので、1日以上経ってもpathAがCookieに残ったままになる、ということが起こり得る。

ので、厳密にやるならパスと寿命をセットでCookieに保存するようにすべきだけど、今回のユースケースでは必要以上に長期間Cookieに残り続けてしまっても特に実害はないのでそこまでやらなかった。

このスクラップは2023/08/22にクローズされました