Remix で CloudFront の stale while revalidate サポートを利用する

stale while revalidateはNext.jsなどフロントエンドのWebフレームワークで活用されることが多いため、本ブログではそのひとつであるRemixで動かした様子を紹介します。
2023.05.19

ども、大瀧です。 昨日、Amazon CloudFrontがstale-while-revalidateとstale-if-errorをサポートしました。いわさの検証記事が早速詳しいです。

stale while revalidateはNext.jsなどフロントエンドのWebフレームワークで活用されることが多いため、本ブログではそのひとつであるRemixで動かしてみた様子をご紹介します。やることは以下の記事とほぼ同じです。

RemixとArchitectの構成

今回はCloudFrontと同じAWSという理由からArchitect(Amazon API Gateway + AWS Lambda)を選択しましたが、Remixのサポートする他のインフラ構成でもCloudFrontに適切なレスポンスヘッダを返すように構成することで同様の動作が期待できると思います。

動作確認環境

  • Remix: バージョン v1.16.1
  • Architect: バージョン 10.12.5

まずはRemixの初期化ウィザードを実行します。ウィザードの3つ目の質問、デプロイ先としてArchitectを選択します。

% npx create-remix@latest
? Where would you like to create your app? # 既定(./my-remix-app)のままEnterキー押下
? What type of app do you want to create? # 既定(Just the basics)のままEnterキー押下
? Where do you want to deploy? Choose Remix App Server if you're unsure; it's easy to change deployment targets. # Architect (AWS Lambda)を選択
? TypeScript or JavaScript? # 既定(TypeScript)のままEnterキー押下
? Do you want me to run `npm install`? # 既定(Yes)のままEnterキー押下
  :(略)
added 1286 packages, and audited 1287 packages in 1m

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

found 0 vulnerabilities
? That's it! `cd` into "/Users/takipone/Documents/my-remix-app" and check the README for development and deploy instructions!
% cd my-remix-app
% ls
README.md               app.arc                 package-lock.json       public                  remix.env.d.ts          server.ts
app                     node_modules            package.json            remix.config.js         server                  tsconfig.json

ルートパスへのアクセスは app/routes/_index.tsx にルーティングされるので、このファイルに stale while revalidate の様子がわかる簡単なロジックと表示を追加します。

app/routes/_index.tsx

import type { V2_MetaFunction, HeadersFunction } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

export const meta: V2_MetaFunction = () => {
  return [{ title: "New Remix App" }];
};

export const headers: HeadersFunction = () => {
  return { 'Cache-Control': 'max-age=0, s-maxage=30, stale-while-revalidate=30' };
};

export function loader() {
  return new Date().toUTCString();
};

export default function Index() {
  const data = useLoaderData<typeof loader>();
  return (
    <div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.4" }}>
      <h1>Welcome to Remix</h1>
      <ul>
        <li>
          <a
            target="_blank"
            href="https://remix.run/tutorials/blog"
            rel="noreferrer"
          >
            15m Quickstart Blog Tutorial
          </a>
        </li>
        <li>
          <a
            target="_blank"
            href="https://remix.run/tutorials/jokes"
            rel="noreferrer"
          >
            Deep Dive Jokes App Tutorial
          </a>
        </li>
        <li>
          <a target="_blank" href="https://remix.run/docs" rel="noreferrer">
            Remix Docs
          </a>
        </li>
      </ul>
      <h1>swr</h1>
      <p>{data}</p>
      <h1>csr</h1>
      <p>{new Date().toUTCString()}</p>
    </div>
  );
}

ではこのコードでRemixをビルドします。

% npm run build

> build
> remix build

Building Remix app in production mode...
⚠️ REMIX FUTURE CHANGE: The `serverModuleFormat` config default option will be changing in v2 from `cjs` to `esm`. You can prepare for this change by explicitly specifying `serverModuleFormat: 'cjs'`. For instructions on making this change see https://remix.run/docs/en/v1.16.0/pages/v2#servermoduleformat
Built in 233ms

これでデプロイする準備はOKです。Architectをインストールして、デプロイを実行します。

% sudo npm i -g @architect/architect aws-sdk
  :(略)
% arc deploy
         App ⌁ remix-architect-app
      Region ⌁ us-west-2
     Profile ⌁ default
     Version ⌁ Architect 10.12.5
         cwd ⌁ /Users/takipone/Documents/my-remix-app

⚬ Hydrate Hydrating dependencies in 1 path
✓ Hydrate Hydrated server/
  | npm i --production: added 62 packages, and audited 63 packages in 13s
  | npm i --production: 15 packages are looking for funding
  | npm i --production: run `npm fund` for details
  | npm i --production: found 0 vulnerabilities
  | npm i --production: npm WARN config production Use `--omit=dev` instead.
✓ Hydrate Successfully hydrated dependencies
⚬ Deploy This Lambda should ideally be under 5MB for optimal performance:
  | server (27,817KB)
⚬ Deploy Initializing deployment
  | Stack ... RemixArchitectAppStaging
  | Bucket .. remix-architect-app-cfn-deployments-549e1
⚬ Deploy Created deployment templates
✓ Deploy Generated CloudFormation deployment
⚬ Deploy Deploying static assets...
[  Uploaded  ] https://remixarchitectappstaging-staticbucket-XXXXXXXXXXXX.s3.us-west-2.amazonaws.com/favicon.ico
[  Uploaded  ] https://remixarchitectappstaging-staticbucket-XXXXXXXXXXXX.s3.us-west-2.amazonaws.com/build/entry.client-Q5B23NOJ.js
[  Uploaded  ] https://remixarchitectappstaging-staticbucket-XXXXXXXXXXXX.s3.us-west-2.amazonaws.com/build/entry.client-Q5B23NOJ.js.map
[  Uploaded  ] https://remixarchitectappstaging-staticbucket-XXXXXXXXXXXX.s3.us-west-2.amazonaws.com/build/manifest-4BD27654.js
[  Uploaded  ] https://remixarchitectappstaging-staticbucket-XXXXXXXXXXXX.s3.us-west-2.amazonaws.com/build/root-PFOPDO25.js
[  Uploaded  ] https://remixarchitectappstaging-staticbucket-XXXXXXXXXXXX.s3.us-west-2.amazonaws.com/build/root-PFOPDO25.js.map
[  Uploaded  ] https://remixarchitectappstaging-staticbucket-XXXXXXXXXXXX.s3.us-west-2.amazonaws.com/build/routes/_index-DAT442FO.js
[  Uploaded  ] https://remixarchitectappstaging-staticbucket-XXXXXXXXXXXX.s3.us-west-2.amazonaws.com/build/routes/_index-DAT442FO.js.map
[  Uploaded  ] https://remixarchitectappstaging-staticbucket-XXXXXXXXXXXX.s3.us-west-2.amazonaws.com/build/_shared/chunk-NY46PHOY.js
[  Uploaded  ] https://remixarchitectappstaging-staticbucket-XXXXXXXXXXXX.s3.us-west-2.amazonaws.com/build/_shared/chunk-NY46PHOY.js.map
✓ Deploy Deployed 10 static assets from public/
✓ Deploy Skipped 10 files (already up to date)
✓ Deploy Deployed & built infrastructure
✓ Success! Deployed app in 154.492 seconds

    https://XXXXXXXXXXXX.execute-api.us-west-2.amazonaws.com

%

これでArchitectによるAPI Gateway + AWS Lambdaの構成が完成しました。表示されたAPI Gatewayのエンドポイントにアクセスすると...

こんな表示になります。今回のAPI Gatewayの構成ではキャッシュが無効なので、swr(サーバーサイドの実行処理)とcsr(クライアントサイドの実行処理)の時刻はアクセスごとに毎回更新され、近いものになります。

CloudFrontの構成と動作検証

続いてCloudFrontのオリジンとして上記エンドポイントを指定します。いくつかのポリシーがAPI Gatewayの動的処理向けにプリセットされますが、今回はCloudFrontのデフォルトドメインでキャッシュが有効な動作を検証するために以下のポリシーを変更しています。

  • キャッシュポリシー: CachingOptimized
  • オリジンリクエストポリシー: UserAgentReferHeaders

では、これで作成したCloudFrontディストリビューションにWebブラウザからアクセスしてみます。

最初のアクセスではキャッシュが無いため、2つの時刻とも同じ表示になりました。リロードしてみると...

swrとcsrで差異が出てきました。サーバーサイドで実行した時刻取得結果をCloudFrontでキャッシュしているため、swrのみ古い時刻が出ます。再度リロードすると...

時刻が更新されました。swrの時刻が前回のcsrと同じなのがポイントで、最初にリロードした時点のアクセスでCloudFrontがstale while revalidateを理解しブラウザにキャッシュを返しつつ新しいデータをオリジンに取りに行き、swrの値としてキャッシュしていることがわかります。

ちなみに冒頭で紹介したブログ記事のVercelの場合と比べると、Vercelがx-vercel-cache: STALEを返すのに対してCloudFrontはX-Cahce: RefreshHit from cloudfrontを返してきました。

動的コンテンツ向けに使うので通常のRefreshHitと混同することは少ないと思いますが、Vercelのような専用の値が返るわけではないということを覚えておいて良いかも知れません。

まとめ

フロントエンドのフレームワークRemixでCloudFrontのstale while revalidateサポートの様子を試してみました。もう少しイマドキなHonoのようなフレームワークで試す様子も見てみたいので誰かブログに書いてください!

参考