30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

Deno で 学ぶ React のサーバサイドレンダリング

Deno で React のサーバサイドレンダリング(以下、SSR)を実現する方法をハンズオン形式で書いていく。
自分が調べた範囲では、単に JSX で HTML を構築して終わり、という記事が多かった。それではあまり実用的ではないので、この記事ではハイドレーションまで行う。

また、React で SSR する方法を調べたところ、ほとんどの記事が Next.js を前提としていた。確かに Next.js を使わずに SSR するケースはあまりないだろうし、記事としても需要がないのだと思う。
しかし、Next.js のようなフレームワークが裏側で何をやってくれているのかを知ることで、SSR に対する理解を深めることができる。
事実、私は SSR をほとんど使ったことがなかったが、この記事を書くことでかなり考えを整理することができた。

Deno のバージョンは1.11.2で動作確認している。

前置き

予め断っておくと、TypeScript で書くことは断念した。理由は以下の通り。

まず前提として、React の本体をインポートすることが簡単ではない。
Deno では、import React from "https://jspm.dev/react@17.0.2"のように、CDN からインポートすることで npm パッケージを使うことができる。しかし多くの npm パッケージは、Deno で動かすことを想定していない。そのため、インポートしようとしても、クラッシュしてしまうことが多い。
React も、skypackesm.shといった CDN からインポートしようとしたが、上手くいかなかった。jspm.devからバージョン17.0.2をインポートすることで、ようやく動いた。

しかし TypeScript で書くためにはそれだけでは不十分で、型ファイルも手に入れなければならない。これもライブラリ本体と同じ問題を抱えており、既存の.d.tsファイルを使おうとしても、Deno はそれを理解できずにクラッシュしてしまう。Deno で動く React の型定義が有志によって提供されているのだが、そのバージョンは16.13.1であり、17.xは見つけられなかった。
それならばライブラリの本体を16.13.1にするという手もあるが、これも上手くいかない。jspm.devから16.13.1をインポートした場合、Hooks を使うとクラッシュする。dev.jspm.ioからインポートした16.13.1なら動きそうだったが、dev.jspm.io今月いっぱいで終了する予定なので、選択肢から除外した。

そのため最終的には、jspm.devが提供している17.0.2を TypeScript なしで使う、という結論になった。

TypeScript を利用できない時点で、Deno の持っている魅力が大きく損なわれることになる。
だがそれでも、ビルドツールを弄る必要がなく、Node.js の複雑なモジュールシステムから解放されるメリットは大きい。
個人的な感想だが、Deno は全体的にシンプルで、実験や検証のための環境を簡単に構築できるのがよい。そのため、Node.js よりも取り回しがよい印象を持っている。

最も単純なパターン

前向きも終わったので、早速コードを書いていく。

まずはサーバを立てる必要がある。
JSX を扱うためには拡張子を.tsx.jsxにする必要があるが、前述の通り TypeScript は使えないので、.jsx一択となる。
以下の内容のserver.jsxを書く。

import { listenAndServe } from "https://deno.land/std@0.99.0/http/mod.ts";
import React from "https://jspm.dev/react@17.0.2";
import ReactDOMServer from "https://jspm.dev/react-dom@17.0.2/server";

function App() {
  return <div>Hello SSR</div>;
}

listenAndServe({ port: 8080 }, (req) => {
  req.respond({
    status: 200,
    headers: new Headers({
      "Content-Type": "text/html",
    }),
    body: ReactDOMServer.renderToString(
      <html>
        <head></head>
        <body>
          <div id="app">
            <App />
          </div>
        </body>
      </html>
    ),
  });
});

$ deno run --watch --allow-net server.jsxを実行しhttp://localhost:8080/にアクセスすると、Hello SSRと表示される。

これだけでも SSR と呼ぶことはできるが、これではただ単に HTML を書いているのとほとんど変わらない。そのためここから、より「現実的な」内容に書き換えていく。
まずは、React の機能を使ってインタラクティブなページにする。

ページをインタラクティブにする

AppコンポーネントをApp.jsxとして別ファイルに切り出す。そして以下の内容にする。

import React, { useState } from "https://jspm.dev/react@17.0.2";

export function App() {
  const [count, setCount] = useState(0);

  const onClick = () => {
    setCount((currentCount) => currentCount + 1);
  };

  return (
    <div>
      {count}
      <br />
      <button type="button" onClick={onClick}>
        count up
      </button>
    </div>
  );
}

これでページにアクセスすると、0やボタンの描画は上手くいっているのだが、ボタンを押下しても何の反応もない。
これは当然で、サーバはただ単に<div>0<br/><button type="button">count up</button></div>という HTML(ただの文字列)を返しているだけであり、JavaScript が存在しないのだからインタラクティブになるわけがない。
そのため、サーバが返す HTML にscript要素を追加して、ブラウザに JavaScript を読み込ませる必要がある。

JavaScript ファイルの作成

ブラウザに読み込ませたい JavaScript ファイルを、client.jsという名前で作成する。

import React from "https://jspm.dev/react@17.0.2";
import ReactDOM from "https://jspm.dev/react-dom@17.0.2";

import { App } from "./App.jsx";

ReactDOM.hydrate(<App />, document.getElementById("app"));

ここでハイドレーションすることで、サーバサイドで予め描画された HTML を、JavaScript と紐付けている。こうすると、ただの文字列だったAppコンポーネントが、インタラクティブなものになる。

client.jsはそのままではブラウザで読み込めないので、$ deno bundle client.jsx bundle.jsを実行してビルドする。生成されたbundle.jsは、ブラウザで読み込むことができる。

あとは、server.jsxを書き換えて、ブラウザにbundle.jsを読み込ませればよい。
以下が、変更後のserver.jsx。ルーティングの実装と、HTML のなかにscript要素を追加したことが、主な変更点。

import { listenAndServe } from "https://deno.land/std@0.99.0/http/mod.ts";
import React from "https://jspm.dev/react@17.0.2";
import ReactDOMServer from "https://jspm.dev/react-dom@17.0.2/server";

import { App } from "./App.jsx";

const BUNDLE_JS_FILE_URL = "/bundle.js";

const js = await Deno.readFile(`.${BUNDLE_JS_FILE_URL}`);

listenAndServe({ port: 8080 }, (req) => {
  switch (true) {
    case req.url === "/": {
      req.respond({
        status: 200,
        headers: new Headers({
          "Content-Type": "text/html",
        }),
        body: ReactDOMServer.renderToString(
          <html>
            <head></head>
            <body>
              <div id="app">
                <App />
              </div>
              <script type="module" src={BUNDLE_JS_FILE_URL}></script>
            </body>
          </html>
        ),
      });
      break;
    }
    case req.url === BUNDLE_JS_FILE_URL: {
      req.respond({
        status: 200,
        headers: new Headers({
          "Content-Type": "text/javascript",
        }),
        body: js,
      });
      break;
    }
    default: {
      req.respond({
        status: 404,
        headers: new Headers({
          "Content-Type": "text/plain",
        }),
        body: "Not found\n",
      });
      break;
    }
  }
});

$ deno run --watch --allow-net --allow-read server.jsxでサーバを起動しページにアクセスすると、ボタンが動作するようになっている。

ただこのやり方だと、App.jsxに変更がある度にその都度client.jsをビルドしないといけない。これでは不便なので、server.jsxのなかでビルドが行われるようにする。具体的には、以下のようにする。

    case req.url === BUNDLE_JS_FILE_URL: {
      const js = await Deno.emit("./client.jsx", { bundle: "module" }).then(
        (res) => {
          return res.files["deno:///bundle.js"];
        }
      );
      req.respond({
        status: 200,
        headers: new Headers({
          "Content-Type": "text/javascript",
        }),
        body: js,
      });
      break;
    }

ブラウザから/bundle.jsにアクセスがある度に、Deno.emitでビルドし、その結果を取り出してレスポンスボディとして使っている。
ページをリロードする度にビルドされるので、開発環境ではこちらのやり方のほうがよいはず。本番環境では事前にビルドさせておくべきなので、環境変数を使って処理を分けるのがよいと思う。
また、Deno.emitは unstable な機能なので、--unstableフラグが必要になる。具体的には、$ deno run --allow-net --allow-read --unstable server.jsxでサーバを起動させる必要がある。

アクセスの度に動的に HTML を生成する

これでページがインタラクティブになった。だがこれでもやはり、SSR にする意味はほとんどない。クライアントからアクセスがあった際に動的に HTML を作りそれを返してこそ、SSR にする意味がある。そのためこれから、そのような状況を擬似的に再現していく。

なぜアクセスがある度に HTML を生成するのか。それは、リクエストに先立ってコンテンツを用意することが難しいからだ。
例えば、人によってコンテンツの内容が変わるケースがこれに該当する。ログインしているか否かで見せるべきコンテンツが変わったり、マイページのようにユーザー毎に内容が変わったり。また、コンテンツが可変である場合も、事前にコンテンツを用意することが難しくなる。ブログ記事の「コメント」や「いいね」は時間の経過によって変化していくわけだが、アクセスがあったタイミングでの最新の状態を返す必要がある。そのため、事前にビルドしておく、ということが難しい。
リクエストに先立ってコンテンツを用意できるのであれば、わざわざ SSR する必要性は薄い。SSG のように事前にビルドしておけばよいし、内容によっては自分で HTML ファイルを書いたってよい。
アクセスの度にコンテンツを用意する場合、SSR を使わずクライアントサイドレンダリング(以下、CSR)だけで済ませるという選択肢もある。だが CSR の場合、クライアントで API へのアクセスや UI の構築を行うため、ページの初回表示が遅くなりやすい。検索エンジンや SNS のクローラーがコンテンツを正しく読み込んでくれない、という問題もある。これらを許容できない場合に、SSR の導入が有力な選択肢となってくる。

これから作っていくサンプルも、リクエストに先立ってコンテンツを用意することが何らかの要因で出来ず、アクセスの度にコンテンツを生成する状況を想定している。そして同じく何らかの要因で、SSR が採用された。

server.jsxに手を加えて、簡易的な API を作る。/api/で始まる API にアクセスすると、食品のアンケートに関するデータを JSON 形式で取得できる。
DUMMY_DBという名前を付けているように、実際にはデータベースにアクセスしてデータを取得することを想定している。あるいは、外部 API を叩いていると見做してもよい。そのため、実際にはこれらの値は固定値ではなく、随時変化していく。

const DUMMY_DB = new Map([
  ["potato", { name: "potato", like: 10, dislike: 0 }],
  ["carrot", { name: "carrot", like: 6, dislike: 4 }],
  ["tomato", { name: "tomato", like: 3, dislike: 7 }],
]);

// 中略

    case /^\/api\//.test(req.url): {
      req.respond({
        status: 200,
        headers: new Headers({
          "Content-Type": "application/json",
        }),
        body: JSON.stringify(DUMMY_DB.get(req.url.slice(5))),
      });
      break;
    }

まずは API の動作確認も兼ねて、CSR だけで UI を構築する。
App.jsxを以下のようにすると、ボタンを押下する度に API から情報を取得し、それを元に UI を作り変えるようになる。

import React, { useState, useEffect } from "https://jspm.dev/react@17.0.2";

export function App() {
  const [food, setFood] = useState(null);

  const onClick = (name) => {
    fetch(`/api/${name}`)
      .then((res) => res.json())
      .then((data) => setFood(data));
  };

  useEffect(() => {
    fetch("/api/potato")
      .then((res) => res.json())
      .then((data) => setFood(data));
  }, []);

  return (
    <div>
      {food && (
        <p>
          name: {food.name}
          <br />
          like: {food.like}
          <br />
          dislike: {food.dislike}
        </p>
      )}
      <p>
        <button type="button" onClick={() => onClick("potato")}>
          potato
        </button>{" "}
        <button type="button" onClick={() => onClick("carrot")}>
          carrot
        </button>{" "}
        <button type="button" onClick={() => onClick("tomato")}>
          tomato
        </button>
      </p>
    </div>
  );
}

きちんと動作していることを確認できる。

f:id:numb_86:20210627160616g:plain

ここからいよいよ、動的に HTML を作っていく。
今は、常に同じ HTML が返される。そしてAppコンポーネントのマウント後にuseEffectが実行され、/api/potatoからデータを取ってきている。まずボタンだけが描画されデータは時間差で描画されるのは、このためである。
これを、サーバサイドで予めpotatoのデータを取得して、それを元に HTML を生成して返すようにする。そうすると、データベースの最新の値が反映された HTML がサーバから返されることになり、アクセスのタイミングによって HTML の内容が変化することになる。

App.jsxからuseEffectを削除して、props.initialFoodを受け取るようにする。そしてそれをfoodの初期値にする。

-export function App() {
-  const [food, setFood] = useState(null);
+export function App({ initialFood }) {
+  const [food, setFood] = useState(initialFood);

   const onClick = (name) => {
     fetch(`/api/${name}`)
@@ -9,12 +9,6 @@
       .then((data) => setFood(data));
   };

-  useEffect(() => {
-    fetch("/api/potato")
-      .then((res) => res.json())
-      .then((data) => setFood(data));
-  }, []);
-

そして、HTML を生成する際にデータベース(DUMMY_DB)からデータを取得し、それをAppに渡すようにする。

    case req.url === "/": {
      req.respond({
        status: 200,
        headers: new Headers({
          "Content-Type": "text/html",
        }),
        body: ReactDOMServer.renderToString(
          <html>
            <head></head>
            <body>
              <div id="app">
                <App initialFood={DUMMY_DB.get("potato")} />
              </div>
              <script type="module" src={BUNDLE_JS_FILE_URL}></script>
            </body>
          </html>
        ),
      });
      break;
    }

これで、potatoの最新のデータが埋め込まれた HTML が生成され、それがブラウザに渡されるようになる。
しかし動作確認してみると、初期表示は確かに意図したものになっているが、すぐに消えてしまう。

f:id:numb_86:20210627160721g:plain

これは、ハイドレーションする際にAppコンポーネントにprops.initialFoodを渡していないために発生している。
ページアクセス時に発生している具体的な処理の流れは、以下の通り。

  1. potatoの情報を含んだ HTML がサーバサイドで構築され、それがブラウザに渡される
  2. ブラウザはその HTML を表示するため、potatoのデータが表示される
  3. ブラウザはbundle.jsclient.jsをビルドしたもの)を読み込む
  4. bundle.js<App />をハイドレーションする
  5. initialFoodが渡されていないので、foodundefinedになり、potatoに関する表示が消えてしまう

つまり、ブラウザで読み込まれる JavaScript ファイル(bundle.js、ビルド前はclient.js)においても、AppコンポーネントにinitialFoodを渡す必要がある。
だがinitialFoodはサーバサイドで作られるため、ブラウザで実行される JavaScript は、その内容を知ることができない。
そこで、initialFoodとして使われるデータを HTML に埋め込んでおくことで、この問題に対処する。

server.jsxの HTML 生成部分を、以下のように書き換える。
新しくscript要素を作成し、そのdata-json属性に、potatoのデータを埋め込んでいる。これで、initialFoodとして使われているpotatoのデータをブラウザに渡せるようになる。

    case req.url === "/": {
      const initialFood = DUMMY_DB.get("potato");
      req.respond({
        status: 200,
        headers: new Headers({
          "Content-Type": "text/html",
        }),
        body: ReactDOMServer.renderToString(
          <html>
            <head></head>
            <body>
              <div id="app">
                <App initialFood={initialFood} />
              </div>
              <script
                id="initial-food"
                type="text/plain"
                data-json={JSON.stringify(initialFood)}
              ></script>
              <script type="module" src={BUNDLE_JS_FILE_URL}></script>
            </body>
          </html>
        ),
      });
      break;
    }

そしてclient.jsで、その埋め込まれたデータを HTML から取り出してAppコンポーネントに渡せばよい。

import React from "https://jspm.dev/react@17.0.2";
import ReactDOM from "https://jspm.dev/react-dom@17.0.2";

import { App } from "./App.jsx";

const initialFood = JSON.parse(
  document.getElementById("initial-food").getAttribute("data-json")
);

ReactDOM.hydrate(
  <App initialFood={initialFood} />,
  document.getElementById("app")
);

これで、意図した通りに動くようになる。

f:id:numb_86:20210627160806g:plain

まとめ

整理すると、ハイドレーションも含む SSR を実現するためには、以下の要素を実現する必要がある。

  • 動的なデータをコンポーネントに渡した上で、それ使って HTML を構築する
  • ハイドレーションするためのスクリプトをビルドし、HTML に挿入しておく
  • HTML を構築する際に使ったデータをハイドレーションでも利用するため、そのデータを(JavaScript から利用可能な形で)HTML に埋め込んでおく

これらをどのように実現しているかはフレームワークによって異なるだろうが、基本的な考え方や仕組みは、このようなものだと思われる。

参考資料

Cloudflare Workers KV をキャッシュとして使う

Cloudflare Workers KV を使うと、ストアに保存した値を HTTP レスポンスとして使うことができる。
そのため、オリジンサーバに問い合わせて得られた結果をストアに保存しておくことで、キャッシュのように使うことができる。
この記事では、Workers KV をキャッシュとして使う Workers スクリプトのサンプルを書いていく。

Workers KV のセットアップについては以下を参照。

numb86-tech.hatenablog.com

処理の大まかな流れ

今回は、以下の仕様の Workers スクリプトを作ることにする。

  • 対象の URL は/js/foo.js/js/bar.js
  • 対象の URL にアクセスがあった場合、KV にデータが格納されていないか確認する
    • 格納されていた場合はそれをクライアントに返す
    • 格納されていなかった場合はオリジンサーバに問い合わせ、そのレスポンスをそのままクライアントに返す
      • その際にオリジンサーバからのレスポンスを KV に格納しておき、以降のアクセスに対してはそれを返せるようにしておく
  • KV にレスポンスを格納する際にx-from-kvというフィールドをヘッダに付与し、KV からのレスポンスだとクライアントが分かるようにしておく

コードで表現すると以下のようになる。

const TARGET_PATHNAME_LIST = ["/js/foo.js", "/js/bar.js"];

addEventListener("fetch", (event) => {
  event.respondWith(handleRequest(event));
});

async function handleRequest(event) {
  const url = new URL(event.request.url);

  if (TARGET_PATHNAME_LIST.includes(url.pathname)) {
    // URL をキーにして KV の中身を探し、見つかればそれをクライアントにレスポンスする
    // 見つからなければ以降の処理を続行する

    const freshResponse = await fetch(url);

    // オリジンサーバからのレスポンス(freshResponse)をクローンして KV に保存する
    // その際に x-from-kv フィールドを付与しておく

    return freshResponse;
  }

  return fetch(url);
}

コメントで書いた 2 箇所を実装すれば、実際に動くようになる。

オリジンサーバからのレスポンスを KV に保存する

まずは KV への保存から実装する。

オリジンサーバからのレスポンスを元にして新しくレスポンスオブジェクトを作り、x-from-kvフィールドを付与する。

const freshResponse = await fetch(url);

let response = freshResponse.clone();
response = new Response(response.body, response);
response.headers.append("x-from-kv", "true");

そしてこのレスポンスオブジェクトを KV に保存すればよいのだが、KV に保存できるのはstreamarrayBuffertextのみ。
そのため、レスポンスオブジェクトをそのまま保存することはできない。そこで、ヘッダとボディをそれぞれで保存することにする。ヘッダはtextに変換できるし、ボディはstreamなのでそのまま保存できる。

const SUFFIX_OF_HEADER = "::HEADER";
const SUFFIX_OF_BODY = "::BODY";

function putHeaderAndBody(key, header, body) {
  return Promise.all([
    MY_KV.put(`${key}${SUFFIX_OF_HEADER}`, JSON.stringify(header)), // 渡されたオブジェクトを文字列に変換して保存している
    MY_KV.put(`${key}${SUFFIX_OF_BODY}`, body),
  ]);
}

// ヘッダをオブジェクトに変換する
const headerObj = Array.from(response.headers.entries()).reduce(
  (acc, entry) => {
    return {
      ...acc,
      [entry[0]]: entry[1],
    };
  },
  {}
);

event.waitUntil(putHeaderAndBody(url.href, headerObj, response.body));

これで、MY_KVという名前の KV に保存されるようになる。
https://example.com/js/foo.jsにアクセスがあった場合、https://example.com/js/foo.js::HEADERというキーでヘッダを、https://example.com/js/foo.js::BODYというキーでボディを、それぞれ保存する。

KV からレスポンスを取り出す

次に、保存したレスポンスを KV から取り出す。getメソッドにキーを渡せばよいだけなので、難しいことは何もない。
typeとして"json"を指定すると、JSON をオブジェクトに変換して返してくれる。

const cachedHeader = await MY_KV.get(`${url.href}${SUFFIX_OF_HEADER}`, {
  type: "json",
});
const cachedBody = await MY_KV.get(`${url.href}${SUFFIX_OF_BODY}`);

if (cachedHeader && cachedBody) {
  return new Response(cachedBody, {
    headers: cachedHeader,
  });
}

KV は全てのエッジサーバで共有されるので、保存を行ったエッジサーバ以外でも値を取り出すことができる。
ただ、変換が即時で反映されるわけではない。結果整合性なので、強整合性が求められるケースでは使えない。

KV achieves this performance by being eventually-consistent. Changes are immediately visible in the edge location at which they're made, but may take up to 60 seconds to propagate to all other edge locations.

https://developers.cloudflare.com/workers/learning/how-kv-works

完成形

ここまでの内容をまとめ、関数の切り出しなどを行って整理したのが、以下のコード。

const TARGET_PATHNAME_LIST = ["/js/foo.js", "/js/bar.js"];

const SUFFIX_OF_HEADER = "::HEADER";
const SUFFIX_OF_BODY = "::BODY";

async function getHeaderAndBody(key) {
  const cachedHeader = await MY_KV.get(`${key}${SUFFIX_OF_HEADER}`, {
    type: "json",
  });
  const cachedBody = await MY_KV.get(`${key}${SUFFIX_OF_BODY}`);
  return { cachedHeader, cachedBody };
}

function putHeaderAndBody(key, header, body) {
  return Promise.all([
    MY_KV.put(`${key}${SUFFIX_OF_HEADER}`, JSON.stringify(header)),
    MY_KV.put(`${key}${SUFFIX_OF_BODY}`, body),
  ]);
}

addEventListener("fetch", (event) => {
  event.respondWith(handleRequest(event));
});

async function handleRequest(event) {
  const url = new URL(event.request.url);

  if (TARGET_PATHNAME_LIST.includes(url.pathname)) {
    const { cachedHeader, cachedBody } = await getHeaderAndBody(url.href);

    if (cachedHeader && cachedBody) {
      return new Response(cachedBody, {
        headers: cachedHeader,
      });
    }

    const freshResponse = await fetch(url);

    let response = freshResponse.clone();
    response = new Response(response.body, response);
    response.headers.append("x-from-kv", "true");

    const headerObj = Array.from(response.headers.entries()).reduce(
      (acc, entry) => {
        return {
          ...acc,
          [entry[0]]: entry[1],
        };
      },
      {}
    );
    event.waitUntil(putHeaderAndBody(url.href, headerObj, response.body));

    return freshResponse;
  }

  return fetch(url);
}

このコードをデプロイすると、/js/foo.js/js/bar.jsにアクセスがあった場合はオリジンサーバからのレスポンスを KV に保存し、以降のアクセスでは KV から取り出した値をクライアントに返すようになる。
KV から取り出した場合はレスポンスヘッダにx-from-kvフィールドが付与されているので、クライアント側で確認できる。

有効期限を設定する

上記のコードだと、明示的に削除しない限り、KV に保存したデータはそのまま残り続ける。
データを保存する際に有効期限を設定することで、有効期限を経過した際に自動的に KV から削除されるようになる。そうすることで、Cache-Controls-maxage相当のことを実現できる。

具体的には、putメソッドにexpirationTtlオプションを渡せばよい。
以下のように記述すると、レスポンスを保存してから60秒後に自動的に KV から削除される。

MY_KV.put(`${key}${SUFFIX_OF_HEADER}`, JSON.stringify(header), { expirationTtl: 60 }),
MY_KV.put(`${key}${SUFFIX_OF_BODY}`, body, { expirationTtl: 60 }),

キャッシュとの競合

ここまで、KV をキャッシュのように使う方法を書いてきたが、そもそも Cloudflare には KV とは別にキャッシュ機能が備わっている。KV をキャッシュとして使おうとするのなら、本来のキャッシュとの競合や二重管理について考えないといけない。

例えば、先程データの有効期限を60秒に設定した。このような設定をした場合、60秒間は同じデータを使い続け、それ以降はオリジンサーバに問い合わせを行うことが期待されていると思う。
しかし、60秒経過して KV からデータが削除されたとしても、その時点でエッジサーバにキャッシュが残っていれば、それが使われてしまう。オリジンサーバへの問い合わせは行われない。
具体的には以下のような処理になる。

  1. KV からデータを取り出そうとしたが、既に削除されており見つけられなかった
  2. fetch(url)でリクエストを発行する
  3. オリジンサーバにリクエストを送る前に、エッジサーバにキャッシュが残っていないか確認する
  4. キャッシュが残っていたため、それがfetch(url)の結果として返され、オリジンサーバへのリクエストは行われない

このような事態を避けるためには、エッジサーバにキャッシュさせないようにしたり、Cache API を使ってキャッシュを削除したりする必要がある。

Cache API による削除について検証してみたが、動作が不安定というか、意図通りに消されないことも多かったので、そもそもキャッシュさせないようにするのが確実だと思う。
ページルールで「キャッシュ レベル」を「スキップ」にしたり、Cache-Controlprivateを指定したりすることで、エッジサーバへのキャッシュを防ぐことができる。

参考資料