IT技術ブログ

Cloudflare Pages Functionsのミドルウェアで、ホストの統一(apex・pages.devからwwwへ)と末尾スラッシュを1回の301にまとめる実装と、壊さないためのテスト。Nodeのテスト、wrangler pages dev、本番の転送回数の数え方まで

Cloudflare Pagesのミドルウェアで、wwwなしとpages.devをwwwへ301し、末尾スラッシュも同時に付ける実装です。Nodeのテスト、wrangler pages devでの確認、本番の転送回数を載せます。

この記事の結論:Cloudflare Pagesでホストの統一と末尾スラッシュを1回の301で済ませるなら、functions/_middleware.jsでホストを見て転送し、それ以外はnext()で静的な配信に渡します。壊さないためには、①ミドルウェアの関数をNodeのテストから直接呼んで規則を固定する、②wrangler pages devにHostヘッダーを変えたcurlを当てて、_redirects やPages自身の転送との組み合わせを見る、③本番で転送の回数を数える、の3段で確かめます。このブログで流したところ、テストは11件すべて通り、本番ではwwwなしのホストに旧パスで来た場合など3つの形で、転送が2回かかっていることが分かりました。

この記事は、このブログ(やまやまブログ)の functions/_middleware.js を題材にした、転送の実装とテストの記録です。WordPressから移したときに、なぜ301を置く場所を分けたかはWordPressからAstroとCloudflare Pagesへ移行した記事に書きました。ここでは、そのミドルウェアの中身と、変更したときに転送を壊さないための確かめ方を扱います。テストとcurlは2026年10月5日に、手元のMacと公開中のサイトで実際に流しています。

転送は3か所で起きていて、ミドルウェアが受け持つのはホストだけ

このブログの転送は、ミドルウェア、_redirects、Cloudflare Pages自身の3か所で起きています。ミドルウェアはホスト名の違いだけを扱い、パスの付け替えには手を出しません。

起きる場所 受け持つこと 例 ステータス
functions/_middleware.js ホストの統一と、そのときの末尾スラッシュ yamayamabloglink.com/about → www.yamayamabloglink.com/about/ 301
public/_redirects パスの付け替え /category/likelove/ → /category/bridal/ 301
Cloudflare Pages の配信 .html やディレクトリの末尾の扱い /about → /about/、/about/index.html → /about/ 308

_redirects にホスト名を条件にした転送を書けないのは、Cloudflareの説明でドメイン単位の転送が対象外とされているためです。逆に、パスの付け替えを _redirects に残しているのは、規則を1行ずつ並べて読める方が、増えたときに見通しがよいからです。

なお、本番で http://www.yamayamabloglink.com/about/ を開くと、https:// への301が返ります。ミドルウェアはホスト名しか見ておらず、www は転送の対象に入れていないので、この301はミドルウェアの外で起きています。どの設定によるものかは、この記事では確かめていません。

ミドルウェアの全文

ミドルウェアは、転送するホストなら301を返し、そうでなければ next() で静的な配信に渡すだけの作りです。あとから、AIのクローラーの訪問を記録する処理を同じファイルに足しました。

次が全文です。クローラーの名前の一覧(BOTS)だけ、実物の55件を11件に縮めています。縮めた一覧のままでも、そのまま動きます。

functions/_middleware.js(BOTS の一覧だけ短くしたもの)js
// www なし・yamayamabloglink.pages.devへのアクセスを、同じパスとクエリのまま
// https://www.yamayamabloglink.com へ 301 で送る。正式なアドレスを1つにそろえて、検索の評価を分散させないため。
const CANONICAL = "www.yamayamabloglink.com";
// 転送するホスト。デプロイごとの確認用アドレス(<id>.yamayamabloglink.pages.dev)は確認に使うので対象外
const REDIRECT_HOSTS = new Set(["yamayamabloglink.com", "yamayamabloglink.pages.dev"]);

// ---- ボットの訪問記録(GEO・LLMO の計測用)----
// クローラーの User-Agent か、下の計測対象ファイルへのアクセスだけを Workers Analytics Engine(binding: BOTLOG)へ1件書く。
// 応答・転送には一切影響させない(失敗しても握りつぶす)。IP は丸ごとは保存せず /24(IPv4)・/48(IPv6)だけ。
// 先に書いたものから順に照合する(Googlebot-Image を Googlebot より前に置く、など)。
const BOTS = [
  ["OAI-SearchBot", /oai-searchbot/i], ["ChatGPT-User", /chatgpt-user/i], ["GPTBot", /gptbot/i],
  ["Claude-SearchBot", /claude-searchbot/i], ["Claude-User", /claude-user/i], ["ClaudeBot", /claudebot/i],
  ["PerplexityBot", /perplexitybot/i],
  ["Googlebot-Image", /googlebot-image/i], ["Googlebot", /googlebot/i],
  ["Bingbot", /bingbot/i],
  // 上のどれでもないクローラー
  // "bot/1.0" のような名前+バージョンの形だけを拾う(Cubot などスマホの機種名で人を誤判定しないため)
  ["other-bot", /bot\/|bot;|\bbot\b|crawler|spider|crawl|fetcher|scrapy|python-requests|go-http-client|headlesschrome|curl\/|wget\//i],
];
const WATCH_PATHS = new Set(["/llms.txt", "/llms-full.txt", "/robots.txt", "/sitemap-index.xml", "/sitemap-0.xml"]);

function botNameOf(ua) {
  for (const [name, re] of BOTS) if (re.test(ua)) return name;
  return "";
}

// IPv4 は /24、IPv6 は /48 に丸める(個人を特定できる完全な IP は残さない)
function ipPrefix(ip) {
  if (!ip) return "";
  if (ip.includes(".") && !ip.includes(":")) {
    const p = ip.split(".");
    return p.length === 4 ? `${p[0]}.${p[1]}.${p[2]}.0/24` : "";
  }
  if (ip.includes(":")) {
    const [head, tail = ""] = ip.split("::");
    const h = head ? head.split(":") : [];
    const t = tail ? tail.split(":") : [];
    const full = ip.includes("::") ? [...h, ...Array(Math.max(0, 8 - h.length - t.length)).fill("0"), ...t] : h;
    return `${full.slice(0, 3).map((x) => (x || "0").toLowerCase()).join(":")}::/48`;
  }
  return "";
}

function logBot(request, env, url, status) {
  try {
    const ds = env && env.BOTLOG;
    if (!ds || typeof ds.writeDataPoint !== "function") return;
    const ua = request.headers.get("user-agent") || "";
    const bot = botNameOf(ua);
    if (!bot && !WATCH_PATHS.has(url.pathname)) return;
    const name = bot || "(not-bot)";
    const cf = request.cf || {};
    ds.writeDataPoint({
      indexes: [name],
      blobs: [
        name, // blob1 ボット名
        url.pathname.slice(0, 512), // blob2 パス
        ua.slice(0, 256), // blob3 User-Agent
        String(cf.asn ?? ""), // blob4 ASN
        String(cf.asOrganization ?? "").slice(0, 128), // blob5 ASN の組織名
        ipPrefix(request.headers.get("cf-connecting-ip") || ""), // blob6 IP の /24・/48
        String(status), // blob7 応答ステータス
        url.hostname, // blob8 ホスト(転送元かどうかの判別用)
        String(cf.country ?? ""), // blob9 国
      ],
      doubles: [1],
    });
  } catch (_) {
    // 記録の失敗はサイトの動作に関係させない
  }
}

export async function onRequest({ request, next, env }) {
  const url = new URL(request.url);
  if (REDIRECT_HOSTS.has(url.hostname)) {
    const to = new URL(url);
    to.hostname = CANONICAL;
    to.protocol = "https:";
    to.port = "";
    // 末尾スラッシュも同時に付けて、転送を1回で済ませる(拡張子のあるファイルは対象外)
    if (!to.pathname.endsWith("/") && !/\.[a-z0-9]+$/i.test(to.pathname)) to.pathname += "/";
    const res = Response.redirect(to.toString(), 301);
    logBot(request, env, url, 301);
    return res;
  }
  const res = await next();
  logBot(request, env, url, res.status);
  return res;
}

転送の部分で気をつけているのは、次の4点です。

  • パスとクエリはそのまま残す。 new URL(url) で元のURLを写し、ホストだけを書き換えるので、?utm_source= などの値は落ちません
  • プロトコルとポートも直す。 http:// や :8080 で来ても、転送先は必ず https://www.… になります
  • 末尾スラッシュをここで付ける。 wwwなしで /about に来た人を、まず www…/about に送ると、そこでPagesがもう一度 /about/ へ308を返します。ミドルウェアで先に / を付けて、1回で着くようにしています。robots.txt のように拡張子のあるファイルには付けません
  • 確認用のアドレスは転送しない。 デプロイごとに発行される <id>.yamayamabloglink.pages.dev は、公開前の確認に使うので対象外です

ボットの記録は、bindingが無い環境では何もしない

後半の logBot は、AIのクローラーなどの訪問を Workers Analytics Engine に書くための処理です。env.BOTLOG(Analytics Engine のデータセットにつなぐ binding)が無い環境では、最初の行で何もせずに戻ります。

この記録は、応答にも転送にも影響しないように作っています。writeDataPoint を try で囲み、失敗しても握りつぶします。IPアドレスは /24(IPv6は /48)に丸めてから書くので、1人を特定できる形では残りません。

手元のテストや wrangler pages dev では binding を設定していないので、この処理は動いていません。本番について言うと、この記事の時点では、記録が実際に溜まり始めているかをまだ確かめていません。集計に使う権限を用意してから確かめる予定で、ここでは「binding が無ければ何もしない」「あっても応答を変えない」という作りの部分だけを、次のテストで確かめています。

もう一つ、このテストの最中に気づいたことがあります。other-bot の判定には curl/ が入っているので、記録が動いている環境に curl で確認しに行くと、その確認自体が「other-bot」の訪問として残ります。集計するときは、自分の確認の分を見分けられるようにしておく必要があります。

Nodeの組み込みテストで、転送の規則を固定する

転送の規則は、ミドルウェアの onRequest をNodeのテストから直接呼べば、Cloudflareなしで確かめられます。next() を「静的なページを返した」ことにする偽物に差し替え、呼ばれたかどうかも数えます。

Node 18以降には、fetch と同じ Request・Response がはじめから入っています。Response.redirect もそのまま動くので、追加のパッケージは要りません。

test/middleware.test.mjsjs
// functions/_middleware.js を Node の組み込みテストで確かめる(Cloudflare なしで動く部分だけ)
import { test } from "node:test";
import assert from "node:assert/strict";
// MW を指定すると、別のファイル(わざと壊した版など)を試せる
const { onRequest } = await import(process.env.MW ?? new URL("../functions/_middleware.js", import.meta.url).href);

// next() は「静的ファイルを返した」ことにする偽物。呼ばれたかどうかを数える
function ctx(url, { ua = "Mozilla/5.0", env = {} } = {}) {
  const calls = { next: 0 };
  const request = new Request(url, { headers: { "user-agent": ua, "cf-connecting-ip": "203.0.113.45" } });
  const next = async () => { calls.next++; return new Response("ok", { status: 200 }); };
  return { context: { request, next, env }, calls };
}

const cases = [
  // [入力URL, 期待する Location(null なら転送しない)]
  ["https://yamayamabloglink.com/about", "https://www.yamayamabloglink.com/about/"],
  ["https://yamayamabloglink.com/about/?utm_source=x", "https://www.yamayamabloglink.com/about/?utm_source=x"],
  ["http://yamayamabloglink.pages.dev:8080/feed", "https://www.yamayamabloglink.com/feed/"],
  ["https://yamayamabloglink.com/robots.txt", "https://www.yamayamabloglink.com/robots.txt"],
  ["https://yamayamabloglink.com/", "https://www.yamayamabloglink.com/"],
  ["https://www.yamayamabloglink.com/about", null],
  ["https://abc123.yamayamabloglink.pages.dev/about", null],
];

for (const [input, expected] of cases) {
  test(`${input} -> ${expected ?? "転送しない"}`, async () => {
    const { context, calls } = ctx(input);
    const res = await onRequest(context);
    if (expected) {
      assert.equal(res.status, 301);
      assert.equal(res.headers.get("location"), expected);
      assert.equal(calls.next, 0, "転送するときは静的ファイルまで進まない");
    } else {
      assert.equal(res.status, 200);
      assert.equal(calls.next, 1);
    }
  });
}

test("BOTLOG が無い環境(手元など)でも応答は変わらない", async () => {
  const { context } = ctx("https://www.yamayamabloglink.com/llms.txt", { ua: "GPTBot/1.1" });
  const res = await onRequest(context);
  assert.equal(res.status, 200);
});

test("BOTLOG があればボットの訪問を1件書く", async () => {
  const points = [];
  const env = { BOTLOG: { writeDataPoint: (p) => points.push(p) } };
  const { context } = ctx("https://yamayamabloglink.com/llms.txt", { ua: "Mozilla/5.0 (compatible; GPTBot/1.1)", env });
  const res = await onRequest(context);
  assert.equal(res.status, 301);
  assert.equal(points.length, 1);
  assert.deepEqual(points[0].blobs.slice(0, 2), ["GPTBot", "/llms.txt"]);
  assert.equal(points[0].blobs[5], "203.0.113.0/24", "IP は /24 に丸める");
  assert.equal(points[0].blobs[6], "301");
});

test("人のアクセスで、計測対象外のパスは書かない", async () => {
  const points = [];
  const env = { BOTLOG: { writeDataPoint: (p) => points.push(p) } };
  const { context } = ctx("https://www.yamayamabloglink.com/about/", { env });
  await onRequest(context);
  assert.equal(points.length, 0);
});

test("記録が失敗しても応答は壊れない", async () => {
  const env = { BOTLOG: { writeDataPoint: () => { throw new Error("down"); } } };
  const { context } = ctx("https://www.yamayamabloglink.com/robots.txt", { ua: "ClaudeBot/1.0", env });
  const res = await onRequest(context);
  assert.equal(res.status, 200);
});

node --test --test-reporter=spec test/middleware.test.mjs で流した結果です。

テストの結果(2026年10月5日)
✔ https://yamayamabloglink.com/about -> https://www.yamayamabloglink.com/about/ (10.467833ms)
✔ https://yamayamabloglink.com/about/?utm_source=x -> https://www.yamayamabloglink.com/about/?utm_source=x (0.156417ms)
✔ http://yamayamabloglink.pages.dev:8080/feed -> https://www.yamayamabloglink.com/feed/ (0.166833ms)
✔ https://yamayamabloglink.com/robots.txt -> https://www.yamayamabloglink.com/robots.txt (0.089208ms)
✔ https://yamayamabloglink.com/ -> https://www.yamayamabloglink.com/ (0.160458ms)
✔ https://www.yamayamabloglink.com/about -> 転送しない (0.391959ms)
✔ https://abc123.yamayamabloglink.pages.dev/about -> 転送しない (0.113042ms)
✔ BOTLOG が無い環境(手元など)でも応答は変わらない (0.080083ms)
✔ BOTLOG があればボットの訪問を1件書く (0.756042ms)
✔ 人のアクセスで、計測対象外のパスは書かない (0.664209ms)
✔ 記録が失敗しても応答は壊れない (0.160875ms)
ℹ tests 11
ℹ pass 11
ℹ fail 0

テストが本当に壊れた変更を止めるかも確かめました。ミドルウェアの写しから、末尾スラッシュを付ける1行を消したファイルを作り、MW で指定して流すと、スラッシュの無いパスの2件が失敗しました。

末尾スラッシュの行を消した版で流した結果
✖ https://yamayamabloglink.com/about -> https://www.yamayamabloglink.com/about/ (9.528709ms)
✖ http://yamayamabloglink.pages.dev:8080/feed -> https://www.yamayamabloglink.com/feed/ (0.210042ms)
ℹ pass 9
ℹ fail 2

wrangler pages devにHostを変えたcurlを当てて、組み合わせを確かめる

Nodeのテストで分かるのは、ミドルウェア単体の規則までです。_redirects や Pages 自身の転送との組み合わせは、wrangler pages dev で書き出し済みのサイトを手元で配信し、Hostヘッダーを変えたcurlを当てて確かめます。

手元で配信する(ビルド済みの dist と functions を使う)bash
npx [email protected] pages dev dist --port 8788 --compatibility-date 2026-10-01

pages dev は、カレントディレクトリの functions/ をFunctionsとして読み込み、dist/_redirects と dist/_headers も読みます。起動したときに、規則をいくつ読んだかが表示されます。

起動時の表示(抜粋)
✨ Parsed 19 valid redirect rules.
✨ Parsed 6 valid header rules.
[wrangler:info] Ready on http://localhost:8788

このとき、_redirects について「splat(*)やプレースホルダーを含む行より上に置くと、より速く処理できる」という案内が13件出ました。固定のパスの規則を、/category/likelove/* のような行より後ろに書いているためです。転送の結果は変わらないので、この記事の時点では並べ替えていません。

次のスクリプトで、Hostを変えながら1回目の応答だけを見ます。

local.shbash
#!/bin/bash
# wrangler pages dev(http://localhost:8788)に Host ヘッダーを変えて当て、1回目の応答だけを見る
B=http://localhost:8788
check () { # $1=Host $2=パス
  printf '%-28s %-34s -> ' "$1" "$2"
  curl -s -o /dev/null -H "Host: $1" -w '%{http_code} %{redirect_url}\n' "$B$2"
}
check yamayamabloglink.com          /about
check yamayamabloglink.com          "/about/?utm_source=x"
check yamayamabloglink.pages.dev    /feed
check yamayamabloglink.com          /robots.txt
check abc123.yamayamabloglink.pages.dev /about/
check www.yamayamabloglink.com      /about/
check www.yamayamabloglink.com      /about
check www.yamayamabloglink.com      /about/index.html
check www.yamayamabloglink.com      /feed
check www.yamayamabloglink.com      /category/likelove/
check www.yamayamabloglink.com      /sitemap.xml
check www.yamayamabloglink.com      /no-such-page/
bash local.sh の出力
yamayamabloglink.com         /about                             -> 301 https://www.yamayamabloglink.com/about/
yamayamabloglink.com         /about/?utm_source=x               -> 301 https://www.yamayamabloglink.com/about/?utm_source=x
yamayamabloglink.pages.dev   /feed                              -> 301 https://www.yamayamabloglink.com/feed/
yamayamabloglink.com         /robots.txt                        -> 301 https://www.yamayamabloglink.com/robots.txt
abc123.yamayamabloglink.pages.dev /about/                            -> 200 
www.yamayamabloglink.com     /about/                            -> 200 
www.yamayamabloglink.com     /about                             -> 308 http://localhost:8788/about/
www.yamayamabloglink.com     /about/index.html                  -> 308 http://localhost:8788/about/
www.yamayamabloglink.com     /feed                              -> 301 http://localhost:8788/feed/
www.yamayamabloglink.com     /category/likelove/                -> 301 http://localhost:8788/category/bridal/
www.yamayamabloglink.com     /sitemap.xml                       -> 301 http://localhost:8788/sitemap-index.xml
www.yamayamabloglink.com     /no-such-page/                     -> 404

wwwの行の転送先が http://localhost:8788/… になっているのは、_redirects と Pages の転送が、ホストを含まない相対の Location を返していて、curl がそれを接続先の localhost を基準に表示しているためです。本番では同じ相対の Location が、https://www.yamayamabloglink.com/… として解決されます。

この結果で、もう一つ確かめたかったことがあります。Cloudflareの _redirects の説明には、「Pages Functionsが応答するリクエストには _redirects が適用されない」と書かれています。このブログでは、ミドルウェアが転送しないリクエストを next() で静的な配信に渡していて、その場合は /feed も /category/likelove/ も _redirects どおりに301が返りました。本番でも同じ結果でした。ルートのファイル(functions/about.js のような、そのパスに応答する関数)を置いた場合は話が変わるはずなので、Functionsの構成を変えたときは、この確認をやり直します。

本番では、転送の回数を数える

最後に、公開中のサイトで、最終的なページに着くまでに何回転送されるかを数えます。1回ずつの応答は正しくても、組み合わさると2回、3回と重なることがあるためです。

hops.mjsjs
// 転送を1回ずつたどり、最終的なURL・ステータス・転送の回数を出す
// 使い方: node hops.mjs URL...
const MAX = 5;
for (const start of process.argv.slice(2)) {
  let url = start;
  const chain = [];
  for (let i = 0; i <= MAX; i++) {
    const res = await fetch(url, { method: "HEAD", redirect: "manual" });
    chain.push(res.status);
    const loc = res.headers.get("location");
    if (res.status < 300 || res.status >= 400 || !loc) break;
    url = new URL(loc, url).href; // 相対の Location は、いまのURLを基準に解決する
  }
  const hops = chain.length - 1;
  console.log(`${hops > 1 ? "注意" : "  OK"} ${start}\n     ${chain.join(" → ")} (転送${hops}回)→ ${url}`);
}
node hops.mjs の出力(2026年10月5日、本番)
  OK https://yamayamabloglink.com/about
     301 → 200 (転送1回)→ https://www.yamayamabloglink.com/about/
  OK https://www.yamayamabloglink.com/about
     308 → 200 (転送1回)→ https://www.yamayamabloglink.com/about/
注意 https://yamayamabloglink.com/category/likelove/
     301 → 301 → 200 (転送2回)→ https://www.yamayamabloglink.com/category/bridal/
注意 https://yamayamabloglink.pages.dev/sitemap.xml
     301 → 301 → 200 (転送2回)→ https://www.yamayamabloglink.com/sitemap-index.xml
注意 http://yamayamabloglink.com/about/
     301 → 301 → 200 (転送2回)→ https://www.yamayamabloglink.com/about/

wwwなしのホストに今のパスで来た場合は、末尾スラッシュを先に付けているので1回で着きます。2回かかったのは、次の3つの形でした。

来方 1回目 2回目
wwwなし+_redirects にある旧パス ミドルウェアがホストを直す _redirects がパスを直す
pages.dev+旧サイトマップのURL ミドルウェアがホストを直す _redirects がパスを直す
http:// のwwwなし ミドルウェアの外で https:// に直る ミドルウェアがホストを直す

Googleの説明では、Googleのクローラーは既定で10回まで転送をたどるとされているので、2回で取りこぼされることはありません。ただ、人にとっては待ち時間が増えます。ミドルウェアの中で _redirects の規則も読んで1回にまとめる方法はありますが、規則を2か所で持つことになるので、この記事の時点では手を入れていません。それより先に、サイト内のリンクとサイトマップが、転送の無い最終的なURLだけを指しているかを保つ方を優先しています。

すべてのリクエストがFunctionsを通ることも、知っておく

functions/_middleware.js は、すべてのパスに効きます。そのため、画像やCSSを含むすべてのリクエストがFunctionsを通ります。

Cloudflareの料金の説明では、Functionsを呼ばないリクエストが「静的なリクエスト」として無料・無制限で、Functionsへのリクエストは Workers のリクエストとして数えられます。_routes.json の exclude で静的なパスをFunctionsから外すこともできますが、外したパスでは、ホストの転送もボットの記録も動かなくなります。wwwなしで画像のURLに来た場合も www にそろえたいので、このブログでは外していません。アクセスが増えて上限が気になってきたら、外すパスと、外したときに転送されなくなるURLを一覧にしてから決めます。

転送を変える前に流す3つの確認

転送の設定は、一度入れると普段は目に入らず、壊れても気づきにくい所です。変えるときは、Nodeのテスト、wrangler pages devとcurl、本番の転送回数の3つをこの順で流し、どれか1つでも結果が変わったら理由を説明できるまで反映しません。

サイトを作り直すときに、URLの一覧づくりから301の設定までをどう進めるかは、会社のコラムのホームページのリニューアルは必要か。作り直すか直すかの判断基準と、進め方の手順にまとめています。株式会社bundlyzeでは、業務システムの開発を、業務の整理から設計・開発、公開した後の運用・保守まで一緒に進めています。仕組みづくりの進め方は、システム開発のページに載せています。

動作確認した環境

確認日は2026年10月5日です。

  • macOS 26.6.2、Node.js 22.23.2(node:test と組み込みの fetch・Request・Response)
  • wrangler 4.147.0(pages dev、互換性の日付は 2026-10-01)
  • curl 8.7.1
  • 配信したのは、Astro 7.3.5 で書き出したこのブログの dist(_redirects の規則19件、_headers の規則6件)と、functions/_middleware.js
  • 本番の確認は、公開中の www.yamayamabloglink.com・yamayamabloglink.com・yamayamabloglink.pages.dev に対して行いました

確かめていないこと:

  • 本番でボットの記録が実際に溜まっているか(集計の権限をまだ用意していません)
  • http:// から https:// への301が、Cloudflareのどの設定によるものか
  • _routes.json で静的なパスを外した場合の挙動
  • _redirects の並べ替えや、転送を1回にまとめる変更(どちらもまだ反映していません)

参照した公式ドキュメント(確認日:2026年10月5日)

  • _middleware.js が置いたディレクトリと配下のすべてのパスに効くこと、next() で次の処理に渡すこと → Cloudflare「Pages Functions の Middleware」
  • _redirects は Functions が応答するリクエストには適用されないこと、上にある規則が優先されること、ドメイン単位の転送は対象外であること、使えるステータス(301・302・303・307・308)と規則の上限 → Cloudflare「Redirects(_redirects)」
  • .html を拡張子なしへ、/index.html をディレクトリの末尾 / へ転送する Pages の配信の挙動 → Cloudflare「Serving Pages」
  • _routes.json の include と exclude、除いたパスは Functions を呼ばないこと → Cloudflare「Functions の Routing」
  • 静的なリクエストは無料・無制限で、Functions へのリクエストは Workers のリクエストとして数えられること → Cloudflare「Functions の Pricing」
  • 301 と 308 がどちらも恒久的な移動を表すこと → Google 検索セントラル「リダイレクトと Google 検索」
  • Google のクローラーが既定で10回まで転送をたどること、301・308 が転送先を処理すべき強いシグナルとして扱われること → Google「HTTP ステータス コードの扱い」

よくある質問

Pages Functionsのミドルウェアを置くと、_redirectsは効かなくなりますか?

Cloudflareの説明には、Pages Functionsが応答するリクエストには_redirectsが適用されないと書かれています。ただ、このブログのようにミドルウェアが転送しないリクエストをnext()で静的な配信に渡す形では、手元のwrangler pages devでも本番でも、_redirectsの301がそのまま返りました。構成によって変わりうるので、自分のサイトでもcurlで確かめることをおすすめします。

301と308のどちらを使えばよいですか?

Googleの説明では、301と308はどちらもページが恒久的に移ったことを表し、Googleは2つを同じように扱うとされています。このブログでは、ホストの統一と_redirectsの転送は301にしています。/about を /about/ に寄せる転送はCloudflare Pages自身が308で返していて、こちらは設定で変えていません。

ミドルウェアを置くと、静的なページの配信にも料金がかかりますか?

Cloudflareの料金の説明では、Functionsを呼ばないリクエストが静的なリクエストとして無料・無制限で、Functionsへのリクエストは Workers のリクエストとして数えられます。functions/_middleware.js はすべてのパスに効くので、全リクエストがFunctionsを通ります。_routes.json で静的なパスを除くこともできますが、除いたパスではホストの転送も効かなくなるので、このブログでは除いていません。

テストのためだけに、Cloudflareにデプロイする必要はありますか?

ありません。転送の規則は、ミドルウェアの関数を Node のテストから直接呼び、next() を偽物に差し替えれば確かめられます。静的な配信や _redirects との組み合わせは、wrangler pages dev を手元で動かし、curl で Host ヘッダーを変えて当てれば確かめられます。最後に本番で、転送の回数を数えるスクリプトを流します。