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

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

Next.js の skipTrailingSlashRedirect で trailing slash の設定をカスタマイズする

Next.js のv13.1.0で追加されたskipTrailingSlashRedirectを使うことで、 trailing slash に関する挙動を自由に設定できる。
この記事では、skipTrailingSlashRedirectによって具体的にどのようなことが可能になったのかを見ていく。
動作確認はv13.1.1で行った。

環境構築

まずは Next.js の環境構築から。

$ yarn create next-app sample --ts

こうするとsampleというディレクトリが作られるので、そこに移動して作業を進めていく。

まず、next.config.jsbasePath"/app"を指定する。

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  basePath: "/app", // これを追加
};

module.exports = nextConfig;

そして以下の内容のpages/foo.tsxを作成する。

export default function Foo() {
  return <h1>Foo Page</h1>;
}

これで下準備が完了。

trailing slash

skipTrailingSlashRedirectはその名の通り trailing slash に関するリダイレクトをスキップする機能なのだが、これを理解するためにはまず、 trailing slash について理解している必要がある。

trailing slash とは URL の末尾についている/のこと。
Next.js では、 trailing slash をつけるかどうか設定することができ、設定内容に応じて Next.js がリダイレクト処理を行ってくれる。

デフォルトでは/を取り除くようになっているので、確認してみる。

trailing slash のない URL にリクエストを送ると、ステータスコード200が返ってくる。

$ curl -IL http://localhost:3000/app
HTTP/1.1 200 OK

$ curl -IL http://localhost:3000/app/foo
HTTP/1.1 200 OK

そして trailing slash のある URL にリクエストを送ると、/を取り除いた URL へとリダイレクトされる。

$ curl -IL http://localhost:3000/app/
HTTP/1.1 308 Permanent Redirect
Location: /app

HTTP/1.1 200 OK

$ curl -IL http://localhost:3000/app/foo/
HTTP/1.1 308 Permanent Redirect
Location: /app/foo

HTTP/1.1 200 OK

trailing slash をつけたい場合は、next.config.jstrailingSlashを有効にする。

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  basePath: "/app",
  trailingSlash: true, // これを追加
};

module.exports = nextConfig;

サーバを再起動して、動作確認してみる。

$ curl -IL http://localhost:3000/app/
HTTP/1.1 200 OK

$ curl -IL http://localhost:3000/app/foo/
HTTP/1.1 200 OK

$ curl -IL http://localhost:3000/app
HTTP/1.1 308 Permanent Redirect
Location: /app/

HTTP/1.1 200 OK

$ curl -IL http://localhost:3000/app/foo
HTTP/1.1 308 Permanent Redirect
Location: /app/foo/

HTTP/1.1 200 OK

先程とは逆に trailing slash のある URL へとリダイレクトされるようになる。

trailingSlash の問題

このようにtrailingSlashオプションで trailing slash の有無を選べるのだが、ひとつ問題があり、 URL によって設定を変えることができない。
そのため例えば、ルートパスのみ trailing slash を付与してそれ以外のパスでは付与しない、ということはできない。

Next.js には、リクエストを受け取ってリダイレクト処理などを行える middleware という機能があるが、この機能を使っても上記の問題は解決しない。
trailingSlashによるリダイレクト処理が、 middleware よりも先に実行されてしまうためである。

実際に middleware を用意して確認してみる。

ルートディレクトリ(今回の例ではsample)にmiddleware.tsを作って以下のように書くと、 middleware が受け取ったリクエストのパスがログに流れるようになる。

import type { NextRequest } from "next/server";

export function middleware(request: NextRequest): void {
  const requestPathname = new URL(request.url).pathname;
  console.log(requestPathname);
}

この状態で trailing slash のない URL にリクエストを送っても、 middleware にリクエストが渡される前にリダイレクトされてしまい、ログには trailing slash 付きのパスのみが流れてくる。

/app/
/app/foo/

trailingSlashを無効にしても同じで、そうすると今度は trailing slash がつかないパスのみが流れてくるようになる。

このように、 middleware にリクエストが渡されるよりも先に、trailingSlashによるリダイレクトが実行されてしまう。
そのため middleware による制御もできず、 Next.js で trailing slash を柔軟に設定することは難しかった。

skipTrailingSlashRedirect による解決

だがv13.1.0からは、skipTrailingSlashRedirectによってこの問題を解決できるようになった。

まず、next.config.jsskipTrailingSlashRedirectを有効にしてサーバを再起動する。

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  basePath: "/app",
  skipTrailingSlashRedirect: true, // trailingSlash を削除してこれを追加する
};

module.exports = nextConfig;

こうすることで、trailingSlashに関するリダイレクトが何も行われなくなる。
trailing slash をつけるためのリダイレクトも、取り除くためのリダイレクトも、行われない。

$ curl -IL http://localhost:3000/app
HTTP/1.1 200 OK

$ curl -IL http://localhost:3000/app/
HTTP/1.1 200 OK

$ curl -IL http://localhost:3000/app/foo
HTTP/1.1 200 OK

$ curl -IL http://localhost:3000/app/foo/
HTTP/1.1 200 OK

あとは middleware で任意の処理を追加すればよい。
今回は root path のみ/をつけるようにする。

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(request: NextRequest): NextResponse {
  const requestPathname = new URL(request.url).pathname;

  // basePath の値は /app
  const { basePath } = request.nextUrl;

  // /app にリクエストがあった場合は /app/ にリダイレクトする
  if (requestPathname === basePath) {
    return NextResponse.redirect(new URL(`${basePath}/`, request.url));
  }

  // /app/ 以外で末尾が / になっているパスの場合は、末尾から / を取り除いたパスにリダイレクトする
  if (requestPathname.endsWith("/") && requestPathname !== `${basePath}/`) {
    return NextResponse.redirect(
      new URL(`${requestPathname.slice(0, -1)}`, request.url)
    );
  }

  // それ以外のパスはそのまま処理を続ける
  return NextResponse.next();
}

リクエストを送ってみると、意図した通りの挙動になっている。

$ curl -IL http://localhost:3000/app
HTTP/1.1 307 Temporary Redirect
location: /app/

HTTP/1.1 200 OK

$ curl -IL http://localhost:3000/app/
HTTP/1.1 200 OK

$ curl -IL http://localhost:3000/app/foo
HTTP/1.1 200 OK

$ curl -IL http://localhost:3000/app/foo/
HTTP/1.1 307 Temporary Redirect
location: /app/foo

HTTP/1.1 200 OK

skipMiddlewareUrlNormalize

上述したように curl でリクエストを送ると上手く動くのだが、実はブラウザで開くと問題が起きる。
http://localhost:3000/app/fooは問題ないのだが、http://localhost:3000/app/にアクセスすると発生する。

ブラウザの開発者ツールで通信状況を確認してみると、http://localhost:3000/app/_next/data/development/index.jsonへのリクエストが無限に発生し続けていることが分かる。

なぜこのようなことが起こるのかというと、この JSON ファイルへのリクエストを middleware で扱う際に URL の正規化が行われ、/appへのリクエストと解釈されてしまうのである。
その結果、この JSON ファイルにアクセスしようとする度にリダイレクトが発生し、それがいつまでも繰り返されるという状況になってしまったのである。

URL の正規化を無効にするには、skipTrailingSlashRedirectと同様にv13.1.0で導入されたskipMiddlewareUrlNormalizeを有効にする必要がある。

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  basePath: "/app",
  skipTrailingSlashRedirect: true,
  skipMiddlewareUrlNormalize: true, // これを追加する
};

module.exports = nextConfig;

これで正規化が行われなくなり、リクエストが繰り返される事象が解決される。

参考資料

JavaScript で Base64

この記事では Base64 やbtoa、そしてbtoaの挙動を理解するために必要な Latin1 について説明していく。

この記事に出てくるコードの動作確認は以下の環境で行った。

  • Deno 1.28.3
  • TypeScript 4.8.3

概要

Base64 はデータのエンコード方式の一種。
全てのデータをaz(26 文字)、AZ(26 文字)、09(10 文字)、そして+/を合わせた計 64 文字、さらにそこに=を組み合わせたテキストで表現する。

そうすることで、扱えるデータに制限のある環境において、その制限を超えたデータを扱えるようになる。
例えば電子メールではテキストデータしか扱えないが、バイナリデータを Base64 にエンコードしてしまうことで、問題なくバイナリデータを送信できるようになる。あとは受信側で Base64 をデコードすればよい。
他にも、 Data URL でバイナリデータを扱う際にも Base64 が使われている。

btoa と atob

JavaScript には、文字列を Base64 でエンコード・デコードするための関数が予め用意されている。

エンコードにはbtoaを使い、デコードにはatobを使う。

console.log(btoa("a")); // YQ==
console.log(atob("YQ==")); // a

だがbtoaは、あらゆる文字をエンコードできるわけではない。むしろエンコードできない文字のほうが多い。
例えば日本語を渡すとエラーになってしまう。

// error: Uncaught InvalidCharacterError: The string to be encoded contains characters outside of the Latin1 range.
console.log(btoa("あ"));

Latin1 とは何か

エラーメッセージはThe string to be encoded contains characters outside of the Latin1 range.。「エンコードする文字列に、Latin1 の範囲外の文字が含まれている」とのこと。
これにより、btoaには Latin1 範囲内の文字しか渡せないことが分かる。

では Latin1 とは何か。

ISO/IEC 8859 という文字コードのパート 1 の通称が、 Latin1 。
ISO/IEC 8859 は 8 ビット 256 文字の文字コードであり、複数の「パート」がある。256 文字のうち前半 128 文字は全パート共通で、その部分は ASCII 文字コードと同一になっている。そして後半 128 文字は、パートによって内容が異なる。
目的に応じてパートを選択して使用するようになっているのだが、そのうちのパート 1 が Latin1 である。

Latin1 で使える文字は以下で見れる。
https://ja.wikipedia.org/wiki/ISO/IEC_8859-1#%E7%AC%A6%E5%8F%B7%E8%A1%A8

そして Unicode の最初の 256 個の Code Point(U+0000..U+00FF)は Latin1 と同じ内容になっている(Unicode や Code Point についてはこの記事で説明している)。
JavaScript は Unicode を採用しているため、 JavaScript の文脈においては「Latin1」と言ったときは、その範囲の文字を指すと考えてよい。

U+0100以降の文字をbtoaに渡してみると、確かに失敗する。

let str = String.fromCodePoint(0x61);
console.log(str); // a
console.log(btoa(str)); // YQ==

str = String.fromCodePoint(0xff);
console.log(str); // ÿ
console.log(btoa(str)); // /w==

str = String.fromCodePoint(0x100);
console.log(str); // Ā
console.log(btoa(str)); // error: Uncaught InvalidCharacterError: The string to be encoded contains characters outside of the Latin1 range.

このため、 Latin1 範囲外の文字をbtoaでエンコードするためには、その文字を何らかの方法で Latin1 範囲内の文字に変換する必要がある。そしてそれは、元の文字に戻せる方法でなければならない。そうしないとデコードできない。

具体的な処理の流れは以下のようになる。

  1. 文字を Latin1 範囲内の文字に変換する
  2. 変換後の文字をbtoaに渡す
  3. Base64 でエンコードされた文字が手に入る

デコードして元の文字を手に入れるときは、これと逆のことをやればよい。

  1. エンコードされた文字をatobに渡す
  2. atobから渡された文字に対して、「文字を Latin1 範囲内の文字に変換する」で行ったのと逆の処理を行う
  3. 元の文字が手に入る

encodeURIComponent

Latin1 の文字列を手に入れるための手段のひとつとして、encodeURIComponentがある。

この関数は以下の文字以外の全ての文字をエスケープする。

A-Z a-z 0-9 - _ . ! ~ * ' ( )

アルファベットや数字はもちろん、記号も全て Latin1 の範囲内に収まっている。
つまり Latin1 範囲外の文字は全てエスケープ対象となる。

// 2d
// 5f
// 2e
// 21
// 7e
// 2a
// 27
// 28
// 29
Array.from("-_.!~*'()").forEach((item) => {
  console.log(item.codePointAt(0)?.toString(16));
});

そしてencodeURIComponentの返り値は%XXXXは 16 進数)という形式の文字列になる。%も Latin1 なので(Code Point U+0025)、返り値は必ず Latin1 範囲内の文字列になることが保証される。

そのためencodeURIComponentを使えば、 Latin1 範囲外の文字を含む文字列を、 Latin1 範囲内に収まる形に変換できる。あとはそれをbtoaに渡せばよい。

const original = "あ";

const latin1 = encodeURIComponent(original);
console.log(latin1); // %E3%81%82

const base64 = btoa(latin1);
console.log(base64); // JUUzJTgxJTgy

// 逆の処理を行うと元の文字列が手に入る

const decoded = atob(base64);
console.log(decoded); // %E3%81%82

const restore = decodeURIComponent(decoded);
console.log(restore); // あ

ArrayBuffer を活用する

ArrayBuffer を使うことでも、 Latin1 範囲内の文字列に変換することができる(ArrayBuffer そのものについてはこの記事で説明している)。

JavaScript では文字を、符号なし 16 ビット整数を使って表現している(UTF-16)。
これを、符号なし 8 ビット整数による表現に変えてしまう。そうするとひとつひとつの要素は0..255の範囲内に収まるので、それを Code Point として利用すればそれは必ず Latin1 の範囲内に文字になる。

const str = "あ";

// 符号なし 16 ビット整数を入れていくための「箱」として Uint16Array を用意する
const ta16 = new Uint16Array(str.length);

// JavaScript の Code Unit は符号なし 16 ビット整数なので、そのまま Uint16Array に格納できる
const codeUnit = str.charCodeAt(0);
console.log(codeUnit); // 12354
ta16[0] = codeUnit;
console.log(ta16); // Uint16Array(1) [ 12354 ]

// Uint8Array による表現を手に入れる
const ta8 = new Uint8Array(ta16.buffer);
console.log(ta8); // Uint8Array(2) [ 66, 48 ]

// Uint8Array の各要素は 0..255 の範囲内になるので、それを Code Point として利用すれば必ず Latin1 の範囲内の文字列になる
const latin1 = String.fromCodePoint(...ta8);
console.log(latin1); // B0

// 問題なく btoa を使える
const encoded = btoa(latin1);
console.log(encoded); // QjA=

元の文字列を得るには逆の処理を行う。

const decoded = atob("QjA=");
console.log(decoded); // B0

const ta8 = new Uint8Array(decoded.length);
ta8[0] = decoded.charCodeAt(0);
ta8[1] = decoded.charCodeAt(1);
console.log(ta8); // Uint8Array(2) [ 66, 48 ]

const ta16 = new Uint16Array(ta8.buffer);
console.log(ta16); // Uint16Array(1) [ 12354 ]

const original = String.fromCodePoint(...ta16);
console.log(original); // あ

上記の内容を任意の文字列に対して行えるように関数化したものが、以下になる。

const toBase64 = (str: string): string => {
  const ta16 = new Uint16Array(str.length);
  for (let i = 0; i < ta16.length; i += 1) {
    ta16[i] = str.charCodeAt(i);
  }
  const latin1 = String.fromCodePoint(...new Uint8Array(ta16.buffer));
  return btoa(latin1);
};

const fromBase64 = (encoded: string): string => {
  const decoded = atob(encoded);
  const ta8 = new Uint8Array(decoded.length);
  for (let i = 0; i < ta8.length; i += 1) {
    ta8[i] = decoded.charCodeAt(i);
  }
  return String.fromCodePoint(...new Uint16Array(ta8.buffer));
};

console.log(toBase64("あ")); // QjA=
console.log(fromBase64("QjA=")); // あ

console.log(toBase64("abcあいうえお🐶")); // YQBiAGMAQjBEMEYwSDBKMD3YNtw=
console.log(fromBase64("YQBiAGMAQjBEMEYwSDBKMD3YNtw=")); // abcあいうえお🐶

参考資料