blog
CSPを緩めずに常駐ゲームを載せた
以前、shiki が吐くインライン style が本番の CSP で全消しになる事故を書いた。あの回で style-src-attr 'none' と script-src-attr 'none' を守り切る構成にして、dist を実測する検査まで足した。
そのあとで、全ページの下端に canvas のミニゲームを常駐させることにした。ブラウザのタスクバーみたいに、どのページを開いても画面の一番下で動き続けるやつだ。
正直、着手する前は CSP をどこか1箇所は緩めることになるだろうと身構えていた。canvas を動かすとなると、サイズや位置を JS から element.style.width = ... で書き換えたくなる場面がいくらでもある。あれも style 属性への書き込みだから style-src-attr 'none' に引っかかるはずだ、と。
でも本当にそうか。手を動かす前に仕様を読んでみたら、思い込みだった。
style-src-attr 'none' が止めるのは style 属性 の評価で、具体的には setAttribute('style', ...) と element.style.cssText = ... だ。element.style.width = '100px' のような個別プロパティへの代入は、属性を文字列として解釈し直すのではなく CSSOM を直接触るので、このディレクティブの管轄外にある。JS から見た目を動かす経路が全部塞がるわけではない。
つまり守るべき線は「JS でスタイルを触らないこと」ではなく、「style 属性そのものを文字列として書かないこと」と「インラインの <style> と <script> を1つも出さないこと」の2つだった。だいぶ違う。
結果から言うと、ヘッダーは以前のままで済んだ。
Content-Security-Policy: default-src 'self'; script-src 'self' https://static.cloudflareinsights.com https://plausible.io; style-src 'self'; style-src-attr 'none'; script-src-attr 'none'; img-src 'self' data:; ...
style タグを1つも書かない
ゲーム本体は 1,415 行の .astro コンポーネントで、site/src/layouts/BaseLayout.astro から呼ばれている。レイアウトが1つなので、これで全ページに乗る。
このコンポーネントには <style> タグが1つも無い。数えると 0 だ。ゲームの見た目に必要な CSS は site/src/styles/global.css の 945 行目から 1136 行目までの 192 行に置いてある。
Astro の scoped CSS を使ってもよかった。astro.config.mjs で build.inlineStylesheets: 'never' にしてあるので、scoped CSS は必ず外部 .css ファイルとして出力され、インライン <style> にはならない。CSP 的には通る。
それでも global.css に寄せたのは、CSS 変数がサイト全体で共有されているからだ。ゲームの配色はサイトの配色から派生していて、切り離すと2箇所で色を管理することになる。同じ値を2箇所に書くと片方が腐る、というのはこのリポジトリで何度か踏んでいる。
<script> はコンポーネントに1つだけある。Astro は既定でこれをバンドルして外部ファイルにするので、生成される HTML にインライン script は出ない。is:inline は使っていない。
canvas も DOM も触らないロジック
ゲームのロジックは .astro の中には書かなかった。site/src/scripts/taskbar-hero/ に7ファイルに分けて置いてある。
hero.ts 475行 相棒の状態機械・物理・自律行動の判断
arsenal.ts 342行 武器10種の定義・ドロップ・拾得・消耗
foes.ts 288行 敵9種の性質
upgrades.ts 209行 強化ツリー5枝20ノード
stages.ts 109行 ステージ進行とボス
balance.ts 49行 バランス定数
impact.ts 34行 ヒットストップとノックバック
合わせて 1,506 行あって、このうち canvas に触るコードは1行も無い。document も window も参照しない。乱数は引数で差し替えられるようにしてある。
差し替えられる、と書いたのは正確を期すためで、既定値は Math.random だ。
_rand: () => number = Math.random,
呼び出し側が何も渡さなければ非決定的に動く。「必ず注入させる設計」ではなく「注入できる設計」で、テストのときだけシードつきの乱数を渡している。
分けた理由は CSP ではなくテストだ。canvas の描画結果を検証するのは現実的でないが、状態機械の遷移や当たり判定の距離計算なら vitest でそのまま回せる。実際、この純粋ロジックに対して 883 行のシミュレーションを書いて、18条件を固定5シードで測っている。時刻も DOM もネットワークも見ないので、同じシードを渡せば同じ実行環境で同じ結果になる。
結果として、CSP と相性が良くなったのは副産物だった。ロジックが DOM を触らないので、そもそも style 属性を書く動機が発生しない。canvas のサイズ変更は .astro 側の一箇所で canvas.width / canvas.height を代入するだけだが、これは style でも CSS でもないので、そもそも style-src-attr の管轄外にある。
高さ 45px で成立させる
設計で一番厄介だったのはここだった。
バーの通常時の高さは 84px で、そのうち実際に相棒や敵が動くプレイ領域は 45px しかない。拡大しても 61px だ。縦 45px の中で、ジャンプして、着地して、スライディングで敵弾の下をくぐる。
最初は物理定数を固定値で書いていて、通常時に合わせると拡大時に間延びし、拡大時に合わせると通常時で頭が天井を突き抜けた。なので重力もジャンプ力もノックバック量も、全部プレイ領域の高さからの比率で算出するようにした。ノックバックは高さの 1.6%、といった具合だ。
跳躍の頂点は比率だけでは足りなかった。高さの 0.55 倍で跳ばせると、相棒の背丈のぶんだけ頭が上端を超える。min(高さ × 0.55, 頭上の余白 - 3px) で頭打ちにして、通常45px と拡大61px の両方で「立ったとき足が地面線に一致」「跳躍の頂点でも頭が上端より下」をテストで固定した。
HUD の帯は、プレイ領域に重ねないことにした。重ねるとスコアの文字と敵が視覚的にぶつかる。狭い場所で情報を出すときに一番やりがちな失敗が、重ねて済ませることだと思っている。上から HUD の帯、プレイ領域、武器の帯、と場所を分けた。
全ページで動き続けることの後ろめたさ
これが一番迷ったところだ。読みに来た人が記事を読んでいる間、下でずっと requestAnimationFrame が回っている。バッテリーを削っている自覚はある。
いくつか手は打った。バーを畳んだら rAF を止める。タブが非表示になったら止める。起動そのものを off にできるボタンを置いて、状態は localStorage に残す。prefers-reduced-motion が指定されていればヒットストップは無効にする。
それでも、開いた瞬間に勝手に動き出すものを全ページに置く判断が正しかったかは、今のところ自分でも確信が持てていない。読み手の反応を測れるだけの訪問数がまだ無いので、判断材料が集まっていないというのが実情だ。
検査は dist を数える
CSP まわりは前回と同じで、ソースを眺めて「インライン style は書いていないはず」と推測するのはやめている。scripts/verify.py がビルド後の site/dist を読んで、style= 属性と on*= 属性の数を数える。src の無い <script> も数える。どちらも 0 でなければ落ちる。
ゲームを1,400行足しても、この検査が緑のままなら CSP の契約は保たれている。手で確認する運用にしていたら、10回目くらいで確認を飛ばしていたと思う。
腰を据えて遊べるほうのゲームはKludge Worksにある。