ページに出すソースと curl に渡すファイルは同じ文字列でなければならない

スクリプト欄はソースを表示しつつ、同じファイルを /scripts/x.sh で配る必要がある。二つの複製は必ずずれる。import.meta.glob の ?raw でビルド時にインライン化する方法が、ビルド時のファイル読み込みや public への複製より確実な理由。

スクリプト欄には、些細に聞こえる要件が一つあります。ページに表示するソースは、利用者が curl で取得して実行するファイルと完全に同一でなければならない。

当たり前に聞こえます。しかし「完全に同一」は工学において決して無料ではありません。

素直な方法

要件は「一つのソース、二つの出口」です。人が読む HTML として描画する出口と、curl に平文として渡す出口。

方法一:ビルド時にファイルシステムを読む

最も直接的なのは、ページ内で fs.readFileSync('src/scripts/x/main.sh') と書くことです。

問題はパスの基準です。ビルド時の cwd はプロジェクトルート、dev でも同じ —— 次のことが起きるまでは:

  • バンドラがコードを別の場所へ移し、process.cwd() が想定したディレクトリでなくなる
  • デプロイ成果物に src/ が無い(このサイトの dist/ は純粋な静的出力で、ソースは入りません)
  • 静的解析が fs を見て「このモジュールは Node で動かねばならない」と判断し、再利用できなくなる

最後が最悪です。純粋なデータの問題を、実行環境の問題に変えてしまいます。

方法二:ソースを二か所に置く

src/scripts/x/main.sh をページ用、public/scripts/x.sh をダウンロード用に。

欠陥は説明不要です。二つの複製。片方を直してもう片方を忘れれば、ページの表示と実際に動くものが分かれます。そして利用者はページのソースを見たからこそ実行します。両者が分かれたとき、ページは嘘になります。

方法三:public/ だけに置き、ページがそれを読む

方法二よりましで、複製は一つです。しかし読むにはやはり fs が要る(方法一に戻る)か、HTTP リクエストが要ります(ビルド時にサーバは無いのでクライアント側 fetch —— すると JavaScript 無効時にソースが見えません)。

このサイトの方法

main.sh は Vite がビルド時に文字列へインライン化します。

const sources = import.meta.glob('../scripts/*/main.sh', {
  query: '?raw',
  import: 'default',
});

?raw は Vite の機能で、バンドル時にファイル内容をモジュールの既定エクスポートとして挿入します。したがって:

  • ページのソース表示はこの文字列を読む
  • /scripts/<slug>.sh エンドポイントは同じ文字列を返す

一つの変数、二つの出口。 間にファイルシステムも cwd も、二つ目の複製もありません。

得られるもの

三つあり、いずれも性能の話ではありません。

  1. ずれようがない。 「気をつければずれない」ではなく、ずれる先が存在しない。これは規律より信頼できます。
  2. dev と build の挙動が一致する。 fs が無いので、片側にしか出ない問題の種類が存在しません。
  3. 実行時依存が無い。 レジストリは純粋なデータで、ページ、エンドポイント、自检スクリプトのいずれからも消費できます。

代価

正直に言えばビルド時結合です。.sh を編集したら反映に再ビルドが要ります(dev には HMR があり即座に反映されます)。

スクリプト欄では問題ありません。スクリプトは実行時に変わるものではないからです。しかし内容が「利用者がアップロードし、即座に見える必要がある」ものであれば ?raw は不適切で、実行時の読み込みが必要です。

もう一つの代価はバンドルサイズです。全スクリプトのソースがバンドルに入ります。23 個で数十 KB なら許容範囲ですが、数千になれば実行時読み込みに切り替えるべきです。

より一般的な法則

この問題の形はよくあります。

一つの事実に二つの出口があるとき、二か所に保存されていれば、いずれ必ず食い違う。

解決は通常「同期を強める」ことではなく、一方の複製を存在させないこと —— 両方の出口を同じ源に向けることです。

静的サイトにおいて import.meta.glob と ?raw はきれいな実装です。ビルド時にファイルをデータへ変えるので、環境の協力が必要だった「ファイルを読む」が、純粋にコンパイル時の「モジュールを読み込む」になります。

このサイトの他の二か所も同じ考え方です。<head> の全タグは SiteHead.astro だけが出力します(ページごとに書き直しません)。一方 @/ エイリアスは三か所の設定が必要です —— その三つは独立していて統合できず、テストで一貫性を守るしかないからです。前者は二つ目の複製を消す、後者は消せないと認めて守る。消せるなら消し、消せないときに初めて規律に頼ります。

← 記事一覧に戻る

コメント

…