LOST IN BLUE

2024/01/16

Cloudflare Pagesで動かしているRemixでサーバーサイドキャッシュする

Cloudflare Pages (+ Functions) で動かしている Remix でサーバーサイドキャッシュする手法をご紹介します。

サーバーサイドキャッシュのうれしさ

Remix は、サーバーサイドレンダリング (SSR) のみと割り切ったフレームワークです(SPA モードが追加されるそうですが)。
SSR によって OGP の動的生成やクライアントでの負荷軽減などのメリットがある一方、サーバー側でレンダリングするコストがかかります。また、Remix のloaderで時間のかかる処理をする場合、ファーストビューが遅くなることもあります。このブログでも、OGP を用いたリンクカードにおいて、リンク先の OGP を取得する処理をloaderで行っているため、リンク先からのレスポンスを待つ時間だけファーストビューが遅くなってしまいます。

こういった記事のような更新頻度の低いページであれば、サーバーサイドでキャッシュすることでレンダリングコストを抑えたり、時間のかかる DB アクセスを回避したりすることができます。

この記事では、サーバーサイドキャッシュ (Server-Side Cache) とは、上記のようにサーバー側でレンダリングした結果をキャッシュしておき、同じリクエストが来た場合にキャッシュを返す仕組みのことを指しています。
他にもキャッシュとして HTTP キャッシュもあります。こちらは、CDN やブラウザに一定時間キャッシュさせることで、大本のサーバーにアクセスすることなくレスポンスを返すことができます。
サーバーサイドキャッシュとの使い分けについては、以下の記事が参考になります。

Cloudflare Pages + Functions でのサーバーサイドキャッシュ

Cloudflare Pages Functions の制約

Cloudflare Pages では、Cloudflare Pages Functions が使えます。
Cloudflare Pages Functions の中身は、エッジで JavaScript を実行することができる Cloudflare Workers です。

Cloudflare Workers で動かす Remix では、以下の記事のように、Workers 同士を連携させることができるService bindings を用いて、Remix が動いている Workers の前段に、キャッシュを担う Workers を配置することで、サーバーサイドキャッシュを実現することができます。

しかしながら、Pages Functions においては、Pages Functions から他の Worker を呼び出すことはできますが、Workers から Pages Functions を呼び出すことはできません。(2024 年 1 月現在)
よって、上の記事とは異なる実装をしてあげる必要があります。

実装

サーバーサイドキャッシュも含めたこのブログの実装は以下のリポジトリで公開しています。

Remix では、entry.server.tsx を編集することで HTTP レスポンスの生成をカスタマイズすることができます。
ここにキャッシュの処理を書くことで、サーバーサイドキャッシュが実現できます。

引数などを省略して重要な部分だけを取り出した全体の流れが以下になります。

export default async function handleRequest(...) {
  // ...

  const cache = caches.default
  const cachedResponse = await cache.match(cacheKey)
  if (cachedResponse) {
    return cachedResponse
  }

  const body = await renderToReadableStream(...)

  // ...

  responseHeaders.set('Content-Type', 'text/html')
  responseHeaders.set('Cache-Control', 'public, maxage=86400')
  const response = new Response(body, {
    headers: responseHeaders,
    status: responseStatusCode,
  })
  loadContext.ctx.waitUntil(cache.put(cacheKey, response.clone()))

  return response
}

キャッシュ自体の実装は、Cloudflare Workers の場合と同様の処理を書くだけです。 しかし、entry.server.tsx で上の処理を動かすためのちょっとした修正があります。

waitUntil(cache.put(...)) は、そのままでは entry.server.tsx で使えません。
waitUntil のあるEventContextentry.server.tsx まで持ってくるために、server.tsonRequest を以下のように書き換えます。

export const onRequest = createPagesFunctionHandler({
  build,
  getLoadContext: (context) => ({ env: context.env, ctx: context }),
  mode: build.mode,
});

併せて、remix.env.d.ts での型定義も書き換えておきます。

declare module "@remix-run/cloudflare" {
  interface AppLoadContext {
    env: Env;
    ctx: EventContext<Env>;
  }
}

これによって、handleRequestAppLoadContextEventContext が渡されるので、waitUntilが使えるようになります。

Cloudflare の Cache では、CacheStorageに Web 標準には無いdefaultプロパティがあります。
そのため、caches.default はそのままでは Property 'default' does not exist on type 'CacheStorage'. と怒られてしまうので、remix.env.d.ts で型定義を上書きします。

declare global {
  interface CacheStorage {
    default: Cache;
  }
}

Cloudflare Workers では以下の同様の issue が立っていたものの、現在は解消されていそうですが、今回は Pages Functions を用いる場合の問題かもしれません。

実際の動作

このサイトのページにアクセスした場合、レスポンスヘッダーにキャッシュを使ったかどうかを示す Custom-Cached-Response ヘッダーを付与しています。

実際にキャッシュヒットした場合と、キャッシュを使えなかった場合を比較してみます。

キャッシュを使えなかった場合

キャッシュを使えた場合

ヘッダーが変わっていることと大幅にレスポンス時間が違っていることが確認でき、ちゃんと効果があることがわかります。(それでも OGP のリンクカードに由来する、キャッシュヒットしなかった場合の速度は要改善ですが...)

さすがにキャッシュヒットしないときがやはり遅すぎたので、Workers KVで OGP データをキャッシュするようにしました。

まとめ

Cloudflare Pages + Functions で、サーバーサイドキャッシュを実装する方法をご紹介しました。
SSR のコストについて、OGP を使ったリンクカードによって痛感させられていたので、サーバーサイドキャッシュはとても助かります。

Cloudflare Workers で実装している例は多いものの、Cloudflare Pages と Pages Functions で実装している例を見つけることができず、実現できるのか不安でしたが、こうすれば普通にできるんじゃないかを試したらあっさりできたので良かったです。