Cloudflare Pages Functionsのミドルウェアで、ホストの統一(apex・pages.devからwwwへ)と末尾スラッシュを1回の301にまとめる実装と、壊さないためのテスト。Nodeのテスト、wrangler pages dev、本番の転送回数の数え方まで
Cloudflare Pagesの
この_redirects や
このfunctions/_middleware.js を
転送は3か所で起きていて、ミドルウェアが受け持つのはホストだけ
この_redirects、
| 起きる場所 | 受け持つこと | 例 | ステータス |
|---|---|---|---|
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 に_redirects に
なhttp://www.yamayamabloglink.com/about/ をhttps:// へのwww は
ミドルウェアの全文
ミドルウェアは、next() で
次がBOTS)だけ、
// 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;
}転送の
- パスと
クエリは そのまま 残す。 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 は、env.BOTLOG(Analytics Engine の
このwriteDataPoint を try で
手元の
もうother-bot のcurl/ が
Nodeの組み込みテストで、転送の規則を固定する
転送のonRequest をnext() を
Node 18以降には、fetch と同じ Request・Response がResponse.redirect も
// 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 で
✔ 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テストがMW で
✖ 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 2wrangler pages devにHostを変えたcurlを当てて、組み合わせを確かめる
Nodeの_redirects やwrangler pages dev で
npx [email protected] pages dev dist --port 8788 --compatibility-date 2026-10-01pages dev は、functions/ をdist/_redirects と dist/_headers も
✨ Parsed 19 valid redirect rules.
✨ Parsed 6 valid header rules.
[wrangler:info] Ready on http://localhost:8788このとき、_redirects に*)/category/likelove/* のような
次の
#!/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/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/ -> 404wwwのhttp://localhost:8788/… に_redirects とhttps://www.yamayamabloglink.com/… と
この_redirects の_redirects がnext() で/feed も /category/likelove/ も _redirects どおりにfunctions/about.js のような、
本番では、転送の回数を数える
最後に、
// 転送を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}`);
} 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回目 |
|---|---|---|
wwwなし+_redirects に |
ミドルウェアが |
_redirects が |
| pages.dev+旧サイトマップの |
ミドルウェアが |
_redirects が |
http:// の |
ミドルウェアのhttps:// に直る |
ミドルウェアが |
Googleの_redirects の
すべてのリクエストがFunctionsを通ることも、知っておく
functions/_middleware.js は、
Cloudflareの_routes.json の exclude で
転送を変える前に流す3つの確認
転送の
サイトを
動作確認した環境
確認日は
- 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の
301と308のどちらを使えばよいですか?
Googleの
ミドルウェアを置くと、静的なページの配信にも料金がかかりますか?
Cloudflareの
テストのためだけに、Cloudflareにデプロイする必要はありますか?
ありません。