ブラウザに本物の夜空を描く第8節/全10節

配信基盤と独立ページ

サーバー側で動くコードを持たないサイトをどう配信し、大容量アセットをどこから配り、8つの独立3Dページが互いのコードを背負っていないことを、どうやって機械で検出できる形にしているか。


この節が答えるのは配信の話です。ビルドの成果物はどこに置かれるのか。メガバイト級のテクスチャやバイナリはどこから来るのか。そして、(サイトのトップで開く夜空のシーン。8つのと区別するための、この記事での呼び名です)と並ぶその8ページが、互いのコードを背負い込んでいないことを誰が確かめるのか。答えは、4つの設定と、4つの機械検査に収まります(4つの検査はこの節の最後に表で並べます)。

静的エクスポートとCloudflare Pages

配信構成は、Next.jsの設定ファイルnext.config.mjsにある4つの設定で言い尽くせます(全文は「全体像 — サーバー側で動くコードを持たないという制約」の節にあります)。リクエストに応じてサーバー側で動くコードが無いので、そもそも書くことがほとんどありません。

4つの設定には、それぞれ具体的な帰結があります。

  • output: 'export' — 全ルートが静的HTMLとして書き出されます。リクエスト時にサーバー側で分岐する余地が無いので、サイトマップのようなメタデータのルートも、ビルド時に一度だけ生成すると明示的に宣言する必要があります。
  • images: { unoptimized: true } — Next.jsの画像最適化はリクエストのたびにサーバーで走る機能です。では成立しないので、無効化が必須です。
  • trailingSlash: true — 出力がabout/index.htmlの形になります。したがって内部リンクは末尾スラッシュ付きの/about/と書かねばならず、/aboutは404になります。
  • env — 大容量アセットの配信元を決める環境変数を、ビルド時に成果物へ埋め込みます。

デプロイはのGit連携に任せています。メインブランチが更新されるとCloudflare側がリポジトリを取得してビルドし、静的エクスポートの出力ディレクトリout/をそのまま配信します。継続的インテグレーションはリポジトリに置いておらず、ビルド環境の再現性はCloudflareのビルドイメージに委ねています。自動で走らせる場が無いので、この節で挙げる4つの検査は、合否こそ機械が判定しますが、起動はいつも人の手です。4つとは、アセット台帳との照合、共通ナビゲーションのimport検査、バケットの実在確認、バンドル分離の検査です。それぞれ出てくる場所で説明し、この節の最後の表で一覧にします。はじめの2つは通常のテストに同乗しますが、そのテスト自体を回すのも人です。

大容量アセットとR2 — 環境変数1つで配信元を切り替える

大きなファイルはCloudflareのから配信します。分けている理由は合計の量です。Pagesにも1ファイル25MBという上限はありますが、個々のファイルはそれに収まるので、上限のほうは効いていません。R2に置くファイルは手元にも写しを持っていて、測ると合わせて約94MBあり、その大半はディープフィールドのシート(銀河1つ分の切り出しが一辺64の版と一辺32画素の版の2サイズで、290枚ずつ計580枚、約66MB)と、月球儀の法線マップ、つまり表面の凹凸を陰影で出すための一辺約4,000画素の画像1枚の約9MBです。これを静的エクスポートの出力に積めば、デプロイのたびに94MBを置き直すことになり、リポジトリにも同じバイト列を抱え込むことになります。そこで手元の写しは2か所に分けてあります。大半はgitで追跡しない作業ディレクトリr2-assets/に置き、リポジトリに入るpublic/には一部だけを残します。スプライトシートでいえば、public/にあるのは2サイズそれぞれの先頭15枚、計30ファイルだけで、残りの550枚はr2-assets/とバケットにしかありません。

R2へ置くのは、アセット台帳に載せたファイルだけです。アセット台帳とは、R2のバケットに置くファイルの一覧を開発者が手で書いた表のことです。大気の散乱をあらかじめ計算して表にした(4ファイルで合計約7.7MB)は台帳に無いので、Pagesと同じオリジンから配ります。配信元の切り替えは、関数1つで済みます。

export function assetUrl(path: string): string {
  const base = process.env.NEXT_PUBLIC_R2_BASE_URL ?? ''
  const normalized = path.startsWith('/') ? path : `/${path}`
  return base ? `${base}${normalized}` : normalized
}
大容量アセットの配信元を切り替える関数

環境変数が設定されていればR2のカスタムドメインへ、未設定なら同一オリジンの/data/stars-full.bin、つまり全星カタログのファイルへ解決します。呼び出し側は分岐を1行も書きません。

ここに落とし穴があります。本番では環境変数が必ず設定されているので、assetUrl()に渡したパスは必ずR2へ解決し、Pagesへはフォールバックしません。つまりassetUrl()で参照するファイルをR2に上げ忘れると、本番でだけ404になります。既定のビルドではassetUrl()が同一オリジンのままなので、この不具合はローカルでもブラウザ自動テストでも見えません。

対策は、アセット台帳と、それを使う2つの検査です。

  • 基準になるのはアセット台帳です。実体はTypeScriptの定数配列で、開発者が手で保守します。項目は、個別のオブジェクトと、「この接頭辞の下は丸ごとR2にある」という指定の2種類です。台帳に項目を足すと、そのファイルのアップロードがデプロイの必須手順になります。逆に、台帳に載っているのにバケットに無い、あるいはバケット側のサイズが違えば、デプロイ前の検査が落ちます。
  • 通常のテストの中で走る検査が、アプリのコードとビルドスクリプトにあるassetUrl()の全呼び出しを、コードを実行せずにソースのまま読んで拾い上げ、解決先が台帳にないパスがあればテストを落とします。引数が静的に読み取れない呼び出しは、同じ検査ファイルの中の許可リストに、生成しうるパスと安全な理由を書かないと通りません。
  • デプロイ前に走らせるコマンドが、実バケットへHEADリクエストを投げます。欠落だけでなく、手元の写し(r2-assets/public/)のファイルサイズと一致しないものも「古い」として落とします。すでにR2にあるファイルを作り直したのに再アップロードを忘れる、という別種の障害を捕まえるためです。判定はサイズの比較なので、作り直してもバイト数が変わらなかった場合は捕まりません。飛ばせば、気づくのは本番の404です。

バケット側には、Pagesのオリジンからの読み取りを許すCORS設定を入れます。ここでも一度つまずいています。CORSルールの反映前に一度でもアクセスされたオブジェクトは、許可ヘッダを持たないままキャッシュから配られ続けるのです。curlで叩くと200が返り中身も正しいのに、ブラウザだけが読み込みに失敗する。curlはCORSを評価しないからです。対処は該当URLのキャッシュパージで、以後はCORSを変えるたびに、変更前にアクセスされたURLのパージを検討しています。

バンドル分離 — 8つのシーンが混ざらないことをどう確かめるか

本編の空のほかに、8つの独立3Dページがあります。月球儀、月面ウォーク、ディープフィールド、火星儀、、恒星間フライト、日食・月食、投影機です。これらは本編とも互いとも3Dシーンのコードを共有してはいけません。混ざれば、訪問者は自分が開いてもいないシーンのやローダーまでダウンロードすることになります。

分離が問題にしているのは、あるページを開いたときにどのチャンクが落ちてくるか、です。

チャンクの境界は、ページごとにただ1つのnext/dynamicdynamic()呼び出し(ssr: falseを指定したもの)です。そしてその呼び出しはuse clientと宣言したモジュールに置かれていなければなりません。に同じdynamic()を書くと、それは境界になりません。境界にならなければ、共通レイアウトを持つ全ページにシーンのチャンクが引き込まれてしまいます。実際にそうなりました。

推測される原因は、サーバーコンポーネントに書いた呼び出しがビルド時のモジュールの依存関係の中で解決されてしまい、ブラウザへ送るコードの分割点にならないことです。ただし確かなのは、実際に混ざったという結果のほうです。教訓は、同じに見えるコードが置き場所によって正反対の結果になる、ということです。これはこの設計で最も見落としやすい一点です。取り違えたときに気づける場所は、成果物のバイト列を読む分離検査が落ちたときだけで、その検査は手で起動するものなので、走らせなければ誰も気づきません。

境界が守られていることは、ビルド成果物を検査するスクリプトが確かめます。この検査はimportの依存グラフを解析しません。ビルド後のout/に入ったJavaScriptのチャンクを文字列として読み、目印となる文字列があるかどうかを調べます。守りたいのはソースの依存関係ではなく、訪問者が実際にダウンロードするバイト列だからです。importグラフを見るツールなら、チャンク分割や共通化や未使用コード除去の結果を予測せねばならず、予測が外れれば検査は当てになりません。検査の単位は独立3Dページ1つ分で、これをグループと呼びます。8ページあるので8グループです。

目印には、による名前の書き換えを生き延びる文字列を選びます。の名前は、シェーダーへ渡すためにJavaScript側では文字列リテラルの中に置かれるので、通常の変数名と違って書き換えられません。DOMの属性名や、取得するデータのパスもやはり文字列リテラルとして現れるので、同じ性質を持ちます。

    name: 'moon',
    // uDisplacementScale / uNormalFlipV — moonMaterial.ts's GLSL uniforms,
    // survive minification inside string literals. moon-features — the IAU
    // gazetteer extract fetched by the label overlay.
    markers: ['uDisplacementScale', 'uNormalFlipV', 'moon-features'],
バンドル分離の検査スクリプト — 月球儀グループの目印の定義

選び方には慎重さが要ります。形式のテクスチャを復号するbasis_transcoderは複数のページが共有するので、1つのグループの目印にすると他のページで偽陽性になります。恒星間フライトのuLimitingMagは本編の空のシェーダーにも同じ名前があるため使えません。単語projectorも使えません。この語は二役だからです。独立3Dページ「投影機」(/projector/)の名であると同時に、本編の空の(screening、全画面にして操作パネルを隠し、空だけを見せる表示)を表すアイコンの名前ProjectorIconとして、全ページに載る共通ナビゲーションのアイコンの定義に以前から入っています。これを目印にすれば、本編の空のページが初日から違反として報告されてしまいます。「そのグループにしか無い」ことが目印の必要条件です。ただし、どの文字列を目印にするかを選ぶのは人です。選んだ目印がそのシーンの本体を代表しているかどうかまで機械は確かめないので、選び方が甘ければ、検査は通るのに混線している、という取りこぼしはあり得ます。

検査はグループごとに2方向へ走ります。目印が現れてはいけない場所に無いことを見る否定側と、目印が成果物のどこかに残っていることを見る肯定側です。否定側が調べる相手は、そのグループから隔離されているべきページ、すなわち本編の空、サイトの紹介ページ、そしてそのグループ以外の7つの独立3Dページで、どのページがどのチャンクを読み込むかは、そのページのHTMLに並ぶスクリプトタグと先読み(preload)の指定が指すURLから得ます。肯定側がないと、シェーダーの変数名を1つ改名しただけで否定側の主張が「どこにも無い文字列がどこにも無い」という自明な真になり、検査は静かに何も守らなくなります。

このスクリプトはです。判断がつかないときは、合格させずに止まります。あるページが参照するチャンクが0個だったときは、成果物が壊れているかNext.jsがスクリプトタグの出し方を変えたかのどちらかで、いずれにせよ検査には何も見えていないとして、合格させずに止まります。目印が1つでも成果物から消えていれば「Markers rotted」と表示して止まり、選び直し方まで書き出します。

分離の例外は、を一切含まないモジュールに限られます。3Dの描画に触れないモジュールなら、共有しても相手のチャンクへ流れ込むのはその小さなモジュールだけで、シーングラフもシェーダーもテクスチャの読み込みも動的読み込みの境界の向こうに残ります。分離が守ろうとしているのは、まさにその向こう側の重さです。

実例は2つあります。1つは、独立3Dページの枠となるReact側の表示 — 読み取り値や操作パネル — が、そのシーンの時刻を保持するシングルトンだけを読み込むことです。たとえばオーラリーの操作パネルは、いま何年何月何日の太陽系を描いているかを表示するために、シーンの時計を毎読みます。もう1つは、星をどう描くかを定めたGLSLの文字列と数値の定数を1か所にまとめたモジュールで、これを本編の空と恒星間フライトの両方が読み込みます。GLSLはシェーダーに渡す前の段階では文字列にすぎず、定数はただの数値なので、このモジュールはthree.jsへの依存を持ちません。恒星間フライトが太陽の位置から描いた空は、本編の空と見分けがつかないことが狙いです。太陽と地球の距離1天文単位は、最も近い恒星までの距離の約27万分の1にすぎないので、太陽から見ても星の並びは変わりません。同じ並びであるためには同じ式と同じ定数を文字どおり共有する必要があり、写して2つ持てば静かにずれていきます。

全ページに載るものだけは特別に扱う

8つのページが分離を保てるかどうかは、全ページに載るものが何をimportするかにかかっています。サイト共通のナビゲーションメニューは、あらゆるルートにマウントされます。したがって、そこから空のシーンのストアや3Dシーンのコンポーネント木、あるいはを1回importするだけで、それらが8ページすべてのバンドルへ流れ込みます。

そこでこのメニューは、表示する言語をlangという引数で受け取り、文字列は専用のラベル表から引きます。本編の空は、i18nをReactのコンテキストで持っていて、そのコンテキストは表示言語を空のストアから読みます。メニューがそれを使えば、同じ理由でストアが全ページに載ってしまいます。言語の決まり方は、本編の空と独立3Dページで違います。本編の空は、前回選んだ表示言語を保存しておいてそのまま開くので、同じURLが日本語にも英語にもなり得ます。独立3Dページは日本語版と英語版が別のURLなので、どちらの言語で描くかはURLだけで決まり、引数はそこから渡すだけで済みます。例外は言語切替ボタンだけで、ここだけは空側の状態が本当に必要です。本編の空では表示言語をZustandのストアが保持していて、切替はそのストアへの書き込みになるので、メニューはボタンの置き場所だけを用意し、実物は空のページが差し込みます。

規則を守るのは2つの検査です。1つは通常のテストの中で走り、このメニューの全ファイルを読んで禁止されたimportがあれば落とします。もう1つは先に述べたバンドル分離の検査で、同じ規則違反をビルド後のバイト列から数分後に捕まえます。前者は速い代わりにimport文しか見ておらず、バンドラが実際に何をどのチャンクへ入れたかは知りません。後者は遅い代わりに、訪問者へ実際に配られるバイト列そのものを見ます。

この節で触れた4つの機械検査を並べると、次のようになります。

検査何を見るかいつ走るか
アセット台帳との照合assetUrl()の全呼び出しを走査し、解決先が台帳に無いものを探す通常のテスト(npm test
共通ナビゲーションのimport検査メニューの全ファイルを読み、禁止されたimportを探す通常のテスト(npm test
バケットの実在確認R2へHEADを投げ、欠落と、サイズの合わない古いオブジェクトを探すmainへpushする前に、手元で専用コマンドを走らせる
バンドル分離の検査ビルド後のJavaScriptを文字列として読み、目印の有無を両方向で調べるビルドの後、mainへpushする前に、手元で専用コマンドを走らせる

配信の設計は、結局のところ「本番でしか壊れないもの」を機械が見つけられる形に置き換えていく作業でした。R2への上げ忘れは、呼び出し箇所をソースのまま読む走査とバケットへのHEADに、バンドルの混線は成果物のバイト列検査に置き換わりました。このうちバンドル分離の検査だけは、検査そのものが効かなくなったとき(目印が成果物から消えたときや、あるページの読み込むチャンクが1つも見つからないとき)に、黙って合格しないように作ってあります。