Cloudflare Pagesで末尾スラッシュ(/)へのリダイレクトをなくす方法
Cloudflare Pagesに静的サイトを置くと、/xのURLが/x/へリダイレクトされることがあります。原因であるx/index.htmlのファイル構成を、ビルド後にx.htmlへ変換して末尾スラッシュなしのURLを維持した方法をまとめます。
Cloudflare Pagesで/xが/x/へリダイレクトされる問題
このブログはCloudflare Pagesに置いている静的サイトです。静的サイトとは、リクエストのたびにサーバーでページを作るのではなく、ビルド時にすべてのページをHTMLファイルとして作っておき、そのファイルをそのまま返すサイトのことです。
最初はNext.jsの静的エクスポート(export)で作っていて、記事のURLはhttps://sncstudio.net/win-acme-validation-portのように末尾に/が付かない形でした。検索エンジンにもこのURLで登録されていました。
ところがサイトをReact Router 8に移行してデプロイしたところ、以前のURLにアクセスすると末尾に/が付いたURL(/win-acme-validation-port/)へ移動するようになりました。ページ自体は表示されるので大きな問題には見えませんが、検索の面では困ります。
- 検索エンジンに登録されたすべての記事URLが、一度リダイレクト(別のURLへの自動移動)を経由することになります。
- 検索エンジンは
/xと/x/を別のURLとして扱います。これまでの評価が新しいURLに引き継がれるまで時間がかかり、サイトマップやcanonical(正規URLの指定)タグと実際のURLもずれてしまいます。
そこで、URLは以前どおり末尾スラッシュなしで維持することにしました。
原因:Cloudflare PagesのHTMLファイルとURLの対応ルール
原因はビルド成果物のファイル構成にありました。Cloudflare Pagesはリクエストされたパスに合うHTMLファイルを返しますが、ファイル名の形によって「正規のURL」を決めています1。
| ビルド成果物のファイル | Cloudflare Pagesでの正規URL | 別のURLでリクエストした場合 |
|---|---|---|
contact.html | /contact | /contact.html → /contactへ移動 |
about/index.html | /about/ | /about → /about/へ移動 |
Next.jsの静的エクスポートはwin-acme-validation-port.htmlのように1つ目の形でファイルを作っていました。一方、React Routerのprerender(ビルド時にページをHTMLとして事前生成する機能)はwin-acme-validation-port/index.htmlのように2つ目の形で作ります。そのため同じページなのに、Cloudflare Pagesは末尾に/が付いた方を正規URLとみなしてリダイレクトしていたのです。
つまり、URLを変えるにはCloudflareの設定ではなくファイル名を変えればよいということです。
ビルド後にx/index.htmlをx.htmlへ平坦化する
React Routerのビルドが終わった後、成果物フォルダを走査してx/index.htmlをx.htmlへ移すスクリプトを追加しました。フォルダの中のファイルを一つ上の階層に出す処理なので、ここでは「平坦化」と呼びます。
package.jsonのビルドコマンドの最後にこのスクリプト(scripts/postbuild.ts)を付け足しました。このブログはbunで実行しています。
"build": "bun scripts/contentlayer.ts && react-router build && bun scripts/postbuild.ts",
scripts/postbuild.tsの全体は次のとおりです。React Routerの成果物(build/client)をCloudflare Pagesにアップロードするoutフォルダへコピーし、その後ファイル名を変えます。
import {cpSync, existsSync, readdirSync, renameSync, rmSync, mkdirSync, statSync} from "node:fs";
import path from "node:path";
const SRC = 'build/client';
const OUT = 'out';
rmSync(OUT, {recursive: true, force: true});
cpSync(SRC, OUT, {recursive: true});
rmSync(path.join(OUT, '__spa-fallback.html'), {force: true});
function walk(dir: string): string[] {
return readdirSync(dir).flatMap(name => {
const file = path.join(dir, name);
return statSync(file).isDirectory() ? walk(file) : [file];
});
}
function move(from: string, to: string) {
if (from === to) return;
if (existsSync(to)) throw new Error(`이미 있는 파일입니다: ${to}`);
mkdirSync(path.dirname(to), {recursive: true});
renameSync(from, to);
}
for (const file of walk(OUT)) {
// 사이트맵 리소스 라우트도 클라이언트 내비게이션용 .data를 만드는데 쓰이지 않는다.
if (file.endsWith('.xml.data')) {
rmSync(file);
continue;
}
const rel = path.relative(OUT, file);
let target = rel.split(path.sep).map(decodeURIComponent).join(path.sep);
if (path.basename(target) === 'index.html' && path.dirname(target) !== '.') {
target = `${path.dirname(target)}.html`;
}
move(file, path.join(OUT, target));
}
// 비어 버린 디렉터리 정리
function prune(dir: string) {
for (const name of readdirSync(dir)) {
const sub = path.join(dir, name);
if (statSync(sub).isDirectory()) prune(sub);
}
if (dir !== OUT && readdirSync(dir).length === 0) rmSync(dir, {recursive: true});
}
prune(OUT);
console.log(`[postbuild] ${SRC} → ${OUT}`);
ハイライトした部分がポイントです。順に見ていきます。
index.htmlをフォルダ名.htmlへ移す
ファイル名がindex.htmlで、最上位のフォルダでなければ、そのフォルダ名に.htmlを付けた名前へ移します。win-acme-validation-port/index.htmlはwin-acme-validation-port.htmlに、en/win-acme-validation-port/index.htmlはen/win-acme-validation-port.htmlになります。
サイトのトップページである最上位のindex.htmlはそのままにします。トップページのURLはもともと/なので、変える必要がありません。
パーセントエンコードされたフォルダ名を戻す
このブログはカテゴリやタグの名前に韓国語を使っています。ビルド成果物では、そうしたパスが%EC%9D%B8...のようにパーセントエンコード(URLに使えない文字を%と数字で表した形)のまま残ることがあるので、decodeURIComponentで元の文字に戻します。Cloudflare Pagesはデコードされた名前でファイルを探すためです。
同じ名前があればビルドを止める
move関数は、移動先にすでにファイルがあるとエラーを出します。たとえばa.htmlとa/index.htmlが両方あると、どちらかが上書きされてしまいます。黙って上書きされると、どのページが消えたのか気づきにくいので、いっそビルドを失敗させるようにしました。
空のフォルダを片付ける
ファイルを外に出すと、空のフォルダが残ります。最後のprune関数が空のフォルダを削除します。他のファイルが入っているフォルダ(たとえば英語の記事が入っているen/)は残します。
結果の確認
ビルド前後でファイル構成は次のように変わります。
# react-router build の成果物 (build/client)
win-acme-validation-port/index.html
en/win-acme-validation-port/index.html
# postbuild 実行後 (out)
win-acme-validation-port.html
en/win-acme-validation-port.html
デプロイ後、curl -sIでレスポンスヘッダーだけを確認しました。末尾スラッシュなしのURLは、そのまま200(正常)が返ります。
$ curl -sI https://sncstudio.net/win-acme-validation-port
HTTP/2 200
content-type: text/html; charset=utf-8
逆に末尾に/や.htmlを付けてリクエストすると、Cloudflare Pagesが308(恒久的リダイレクト)で末尾スラッシュなしのURLへ移動させてくれます。狙いどおり、正規URLが/win-acme-validation-portの一つにまとまりました。
$ curl -sI https://sncstudio.net/win-acme-validation-port/
HTTP/2 308
location: /win-acme-validation-port
$ curl -sI https://sncstudio.net/win-acme-validation-port.html
HTTP/2 308
location: /win-acme-validation-port
注意点
- この方法は、サーバーを使わずHTMLファイルだけをアップロードする静的デプロイで使えます。リクエストごとにサーバーでページを作る方式なら、URLの扱いはそのサーバーの設定で行う必要があります。
- ページ内のリンクも末尾スラッシュなしのURL(
/win-acme-validation-port)で張りましょう。/x/の形のリンクだと、クリックのたびにリダイレクトが1回発生します。 - サイトマップやcanonicalタグのURLも同じ形になっているか、あわせて確認するとよいです。
- Cloudflare Pagesと同じ動作をローカルで確かめたいときは、
bunx wrangler pages dev outでoutフォルダを起動してみてください。