CloudFront Functions で Next.js のパス解決の真似をする

Next.js から static export した HTML を S3 に配置して CloudFront から配信する, というのはそこそこよくある構成なんじゃないかと思います. 知らんけど.

ところで素朴に CloudFront の origin に S3 を指定するだけでは, ページのパスの解決がうまくいきません. 例えば /pages/about.tsx というページコンポーネントがあった場合, 通常の Next.js であれば /about というパスでこのページにアクセスできますが, 素朴な構成の場合は static export されたファイル名と完全一致する /about.html というパスでないとページにアクセスできません1.

この問題を解決するための方法としては以下のようなものが知られています.

どれを使ってもそれなりに動くようにはなりますが, パスの解決だけをしたいという用途においては CloudFront Functions が一番マシな選択肢なんじゃないでしょうか. パス解決の方法はできるだけ Next.js のものを真似をできると良いはずですが, S3 の static website hosting にはそこまでの柔軟性がありませんし, 一方で Lambda@Edge は柔軟性は高いですが目的に対してはやや大袈裟な印象です.

Next.js のパス解決を真似た CloudFront Function

ということで CloudFront Function を書いていくわけですが, 上述のとおりパス解決の方法はできるだけ Next.js のものを真似できると良いはずです. 手元では本物の Next.js を使って開発をしているわけなので, 本番環境での動作もそれと同じである方が不安が少ないです.

ということで出来上がったものがこちら (参照している Next.js のバージョンは 13).

あらかじめ予防線を貼っておくと, 真面目に Next.js のコードを読んで挙動を再現したというよりは, Next.js の挙動を観察しながらいくつかの処理を追った程度なので, 正確性は保証しません. 不備などあればぜひ教えてください.

以下各部分の解説です.

メインの処理

メインの処理となるのは handler です. クライアントから送られてきたリクエストを受け取って, origin (S3) に送るリクエストまたはクライアントに戻すレスポンスを返します.

function handler(event) {
  var request = event.request;
  var uri = request.uri;

  // (1) パスが正規化されていなければリダイレクト
  var redirectUri = redirect(uri);
  if (redirectUri !== uri) {
    return {
      statusCode: 308,
      statusDescription: "Permanent Redirect",
      headers: {
        location: {
          value: redirectUri,
        },
      },
    };
  }

  // (2) S3 上のファイルパスに書き換え
  var routeUri = route(uri);
  request.uri = routeUri;
  return request;
}

処理の内容はコメントの通り大まかに 2 つで,

  • (1) パスが正規化されていなければリダイレクトする
  • (2) S3 上のファイルパスに書き換える

です. 前者は必ずしも必須ではないですが, やはり Next.js がこういった挙動をするのと, 同じページに複数の正しい (canonical な) URL が存在するのを防ぐことができます.

パスの正規化

redirect はリダイレクト先として使うためのパスの正規化を行います.

function redirect(uri) {
  // (1) 重複した / を取り除く (/foo//bar/ → /foo/bar/)
  uri = uri.replace(/\/+/g, "/");
  // (2) 末尾の / を取り除く (/foo/bar/ → /foo/bar)
  if (uri !== "/" && uri.endsWith("/")) {
    uri = uri.slice(0, -1);
  }
  return uri;
}

まずは (1) 重複した / を取り除きます. これはやや過剰なおもてなしのような気もしますが, Next.js がこうしているのでやっておきます.

続いて (2) 末尾の / も取り除きます. ただし Next.js の設定を trailingSlash: true に変更している場合はこの限りではないので注意です. オプションで変更できるというのもあってか, 本物の Next.js では重複した / の除去とは別にリダイレクトが行わるようですが, まあここは一回のリダイレクトとしてしまってもよいでしょう.

まとめると, redirect に期待される入出力の例は以下の通りです.

  • /foo/bar//foo/bar
  • /foo/bar.html//foo/bar.html
  • /foo/bar.png//foo/bar.png
  • ////
  • ///foo////bar///foo/bar
  • ///foo////bar.html///foo/bar.html
  • ///foo////bar.png///foo/bar.png

S3 上のファイルパスに書き換え

route は与えられたパスを S3 上のファイルパス, つまり Next.js から export されたファイルのパスに書き換えます.

function route(uri) {
  // (1) /_next/ 以下は触らない
  if (uri.startsWith("/_next/")) {
    return uri;
  }
  // (2) インデックスページにまつわるパスを変換
  if (uri === "/index" || uri.startsWith("/index/")) {
    uri = "/index" + uri;
  } else if (uri === "/") {
    uri = "/index";
  }
  // (3) 必要に応じて拡張子 .html を付与
  var filename = uri.split("/").pop();
  if (!filename.includes(".") || filename.endsWith(".html")) {
    uri = uri + ".html";
  }
  return uri;
}

まず (1) /_next/ 以下は何も変換しません. このディレクトリには Next.js が扱う様々なファイルが含まれていて, やはりある種のパスの解決が必要な部分はあるのですが, そこは Next.js のクライアントサイドのスクリプトが既にうまくやってくれています. 触らぬ神に祟りなしです.

次に (2) インデックスページにまつわるパスの変換をします. 例えば /foo に対応するファイルパスは /foo.html ですが, / に対応するファイルパスは /.html ではなく /index.html です.

ところがこの変換規則だけでは, /index (ページのコンポーネント/pages/index/index.tsx) に対応するファイルも /index.html となってしまい, / と重複してしまいます. これを避けるため, /index に対応するファイルは /index/index.html のように, ファイルパスの先頭にさらに /index が追加されるようになっています. /index/ 以下のページについても同様に処理します.

最後に (3) ファイル名に拡張子 .html を付与します. ここには一つ仮定があって, /pages/foo.png.tsx のような .html 以外の拡張子を持つページは存在しない (アクセスできない) ものとしています. ファイルの存在有無を確認することができればうまくハンドリングできるかもしれませんが, CloudFront Functions からはそういったことはできないので妥協するしかありません.

ところで /public/ 以下に配置された静的ファイルについても言及しておく必要があります. ここに配置されたファイルの扱いは特殊で, 本物の Next.js では /pages/ 以下のページとは区別され, そのままのファイルパスでアクセスできるようになっています.

一方で static export した場合, /public/ 以下のファイルは元のファイルパスを保ちながら, /pages/ から出力されたファイルと同じディレクトリにコピーされます. これではファイルが /public//pages/ どちらに由来するものか原理的に区別ができませんし, もしファイルパスの上で区別できるようになっていたとしても, やはり CloudFront Functions のようにファイルの存在有無が確認できなくてはどうしようもありません.

ということで /public/ 以下のファイルについては以下のような仮定をしておきます.

  • /index/ 以下にファイルは存在しない
  • 拡張子のないファイルや, 拡張子が .html のファイルは存在しない

このように route の実装にはいくつか仮定や妥協が含まれていますが, まあ普通に暮らしていれば困ることはないでしょう...

話を元に戻してここまでをまとめると, route に期待される入出力の例は以下の通りです.

  • //index.html
  • /index/index/index.html
  • /index/foo/index/index/foo.html
  • /foo/index/foo/index.html
  • /foo/index/bar/foo/index/bar.html
  • /foo/bar/foo/bar.html
  • /foo/bar.html/foo/bar.html.html
  • /foo/bar.png/foo/bar.png
  • /_next/foo/bar/_next/foo/bar
  • /_next/foo/bar.html/_next/foo/bar.html
  • /_next/foo/bar.js/_next/foo/bar.js

テスト

テストは CloudFront のコンソールや API からも行えますが, 高速に TDD のサイクルを回したかったので手元で完結するように書いていました. とはいえ CloudFront Functions で使える JS の機能には手元と比べるといくらか制限があるので, 最終的には本物でも動作確認しておくとよいです (それはそう). たとえばうっかり const など書いてしまうと動きません.

偶然そこに node:test があったので使っていますが, そこにあった以上の理由はそんなにないです.

まとめ

あらためてコード全体はこちら.

Next.js の static export はさもそのまま汎用的な静的サーバーで配信できるような見た目をしていますし, 実際のところほとんどの場合はそれで上手くいくのですが, いくつか例外的なケースもあるので気をつけましょう. ちゃんとするならここで紹介したように Next.js の真似をしてやるとよいです (とはいえ普通はそこまで真剣にやらなくても良いと思いますが).

CloudFront Functions は書いていて Atom でカーソルの移動をカスタマイズしたときに近い感覚になりました. 空気みたいな普段の意識の外にあるものに触れてしまうというか...


  1. 正確にはリンクを辿って SPA 的なページ遷移をしている限りはアクセスできるものの, その他の方法 (別のタブで開いたりリロードしたり) ではアクセスできない, という挙動になります.

Node.js の perf_hooks で雑に関数のパフォーマンス計測

イントロ

TypeScript で Array#filter を使うとき, 型が期待通りに絞り込まれないというトラブルがよくあります.

const xs: (number | undefined)[] = [1, undefined, 2];

const ys: number[] = xs.filter((x) => x !== undefined);
//    ~~
// Type '(number | undefined)[]' is not assignable to type 'number[]'.

これを解決するには as number[] のようにキャストしてやる (1) か,

const ys: number[] = xs.filter((x) => x !== undefined) as number[];

コールバック関数の戻り値の型を明に x is number のようにして型を絞り込むようにしてやる (2) か,

const ys: number[] = xs.filter((x): x is number => x !== undefined);

あるいは上の 2 つは安全ではない (例えば (x): x is number => true などいくらでも嘘をつけてしまう) ので, かわりに要素の型を変えられる Array#flatMap を使う (3) といった方法があります.

const ys: number[] = xs.flatMap((x) => x !== undefined ? [x] : []);

型安全性の面では, 型の絞り込みが静的に検査できる (3) が最も良く, 次いでコールバック関数を共通化することで安全でない is を書く箇所を減らすこともできる (2), 毎回安全でない as を書く必要がありミスの入りやすい (1) という順に悪くなっていきます.

ということで基本的には flatMap を使っておくとよろしいのですが, ところでパフォーマンスはどの程度違うのでしょうか? (1) と (2) はコンパイルされた JavaScript に差はないのでもちろんパフォーマンスは同じですが, (3) はそれに比べて中間的な配列を作ったりしていていかにも遅そうです.

Node.js のパフォーマンス計測 API

パフォーマンス計測のためのライブラリはいろいろあると思うのですが, いろいろありすぎてどれを使うと良いのかよくわからない!

と思っていたら Node.js に perf_hooks というパフォーマンス計測ためのモジュールが標準で用意されていたので, これを使ってみましょう. このモジュールには Web 標準の Performance API の一部に加えて, Node.js 独自の拡張や便利な関数などが含まれています.

関数のパフォーマンスを計測するには, まずは計測対象の関数を用意して,

// xs: number[] は事前に用意した [0, 1) の数のリスト

// filter.js
function func() {
  xs.filter((x) => x > 0.5);
}

// flatMap.js
function func() {
  xs.flatMap((x) => (x > 0.5 ? [x] : []));
}

perf_hooks に含まれる timerify で関数をラップして,

import perf_hooks from "node:perf_hooks";

const histogram = perf_hooks.createHistogram();
const perf_func = perf_hooks.performance.timerify(func, { histogram });

あとは適当なサンプル数だけ関数を実行するだけ.

const N = 100000;

for (let i = 0; i < N; i++) {
  perf_func();
}

console.log(histogram.toJSON());

本当は関数を実行している間に JIT コンパイルされて最適化されていくので, 最初の方と後の方では随分パフォーマンスが違ったりするのですが, 雑に計測したいだけなので何も考えず全て計測結果に含めています. 最適化後のパフォーマンスのみを計測したい場合は先にいくらか (100 〜 1000 回程度?) 関数を実行して最適化を済ませておくとよいです.

計測結果

あくまで雑な計測なので, スケール感を把握する程度にとどめて結果を間に受けすぎないように.

計測環境は以下.

  • MacBook Pro 14-inch, 2021 (Apple M1 Max Chip, 32 GB Memory)
  • Node.js 18.12.1 (V8 10.2.154.15)

まずは小さめの配列 (要素数 100) を対象にした場合は以下のとおり (時間を表す数値の単位はナノ秒).

// filter.js
{
  count: 100000,
  min: 125,
  max: 182527,
  mean: 334.88866,
  exceeds: 0,
  stddev: 1754.852949373082,
  percentiles: {
    '0': 125,
    '50': 208,
    '75': 209,
    '87.5': 209,
    '93.75': 1126,
    '96.875': 1208,
    '98.4375': 1293,
    '99.21875': 1668,
    '99.609375': 1750,
    '99.8046875': 3542,
    '99.90234375': 37280,
    '99.951171875': 39936,
    '99.9755859375': 41280,
    '99.98779296875': 44864,
    '99.993896484375': 52032,
    '99.9969482421875': 63104,
    '99.99847412109375': 104704,
    '99.99923706054688': 182400,
    '100': 182400
  }
}

// flatMap.js
{
  count: 100000,
  min: 7788,
  max: 325631,
  mean: 8434.45926,
  exceeds: 0,
  stddev: 4153.5817042451745,
  percentiles: {
    '0': 7788,
    '50': 7916,
    '75': 7960,
    '87.5': 8124,
    '93.75': 8416,
    '96.875': 8832,
    '98.4375': 11000,
    '99.21875': 45728,
    '99.609375': 49408,
    '99.8046875': 50144,
    '99.90234375': 52096,
    '99.951171875': 57984,
    '99.9755859375': 64736,
    '99.98779296875': 72192,
    '99.993896484375': 82624,
    '99.9969482421875': 100288,
    '99.99847412109375': 132608,
    '99.99923706054688': 325376,
    '100': 325376
  }
}

続いて大きめの配列 (要素数 10,000) を対象にした場合.

// filter.js
{
  count: 100000,
  min: 19488,
  max: 665599,
  mean: 32934.85112,
  exceeds: 0,
  stddev: 29796.692681422126,
  percentiles: {
    '0': 19488,
    '50': 22992,
    '75': 28704,
    '87.5': 40416,
    '93.75': 132736,
    '96.875': 135680,
    '98.4375': 136704,
    '99.21875': 141056,
    '99.609375': 176640,
    '99.8046875': 185856,
    '99.90234375': 204416,
    '99.951171875': 208768,
    '99.9755859375': 232704,
    '99.98779296875': 311296,
    '99.993896484375': 373504,
    '99.9969482421875': 475648,
    '99.99847412109375': 560128,
    '99.99923706054688': 665088,
    '100': 665088
  }
}

// flatMap.js
{
  count: 100000,
  min: 728064,
  max: 5099519,
  mean: 746459.3536,
  exceeds: 0,
  stddev: 47869.930365069136,
  percentiles: {
    '0': 728064,
    '50': 736768,
    '75': 739840,
    '87.5': 748544,
    '93.75': 784896,
    '96.875': 868864,
    '98.4375': 876032,
    '99.21875': 881664,
    '99.609375': 899584,
    '99.8046875': 942592,
    '99.90234375': 979968,
    '99.951171875': 1072128,
    '99.9755859375': 1467392,
    '99.98779296875': 2342912,
    '99.993896484375': 3614720,
    '99.9969482421875': 4358144,
    '99.99847412109375': 4997120,
    '99.99923706054688': 5095424,
    '100': 5095424
  }
}

配列の要素数を増やした場合は, 単純に線形に時間が増えるだけでした.

percentiles を見るとかなり分布の幅が広くなっていることがわかります. これは実行されるにつれて最適化が進んだりするのもあるのですが, それを除いてもたまに遅いこともあるみたいです (GC は発生していなさそう. deopt されたりするタイミングがある?)

どちらも 90%ile 付近を見ると flatMapfilter の数倍〜数十倍程度遅いです. とはいえマイクロ秒の世界なので, 大量のデータを処理したり, あるいは 1 フレームに何回も実行されるような計算であれば気にした方が良いか, という印象. 結局はバランスの問題ですが, まあ普通は闇雲にパフォーマンスを気にして (型安全性を犠牲にして) 全てを filter に置き換えるよりは, 先にどこがボトルネックになっているかを明らかにするのがよいと思います.

Node.js 以外の場合

ここでは Node.js の API を使って計測を行いましたが, 高精度なタイマー (Web 標準の performance.now() など) と適当なヒストグラム記録のためのライブラリ (hdr-histogram-js など) さえ用意できれば同じような計測ができるはずです. ブラウザやその他 Node.js 以外の環境で計測したい方はお試しください.

まとめ

  • Node.js には標準でシュッと関数のパフォーマンス計測ができる API がある
  • flatMapfilter の数倍〜数十倍遅いものの, 型安全性の面では優れているので要はバランス
  • この文脈では as はノーメリット. 使うな