文化祭当日の朝、サーバーにするはずだった私物のノートPCを、来場者が遊ぶゲーム機にすることにしました。ほかの端末から、ゲームを開けなかったからです。
3日前は校内Wi-Fiでつながった。前日の部活では、壊れたランキングも直した。「これでいける!」と思っていたんですけどね。
文化祭に出すタイピングゲームを作った
Manusで、ブラウザから遊べるタイピングゲームを作りました。来場者にスコアを競ってほしくて、プレイ後にスコアを送ると、サーバーに記録されて順位が出るようにしました。
手元で動かしていた構成は、こんな感じでした。
- Node.js上のExpressが、画面用のファイルを配信する
- スコアの送信先として
POST /api/record-scoreを受け付ける - サーバーのソースはTypeScriptの
server/index.ts - 実際に起動するのは、ビルド後の
dist/index.js
保存先は、開発中はManusのデータベース機能でした。手元で動かし直したときは、ローカルのJSONファイルにスコアを保存する形です。
当日は私物のノートPCをサーバーにして、来場者には別の端末から校内ネットワーク経由で遊んでもらう予定でした。
前日、ランキングが出ない
朝までちゃんと表示されていた順位が、急に出なくなりました。よりによって前日です。
さらに、Manusのクレジットはもう使い切っていて、コード画面は読み取り専用でした。「直して」とお願いしたくても、その場でManusに頼むことができません。
最初は「データベースにアクセスできなくなったのかな」と思いました。Geminiに相談しながら開発者ツールを開き、スコア送信のレスポンスを見ると、こう返ってきていました。
{"ok": false, "message": "許可されていない送信元です。"}
……自分のゲームから送ってるんだけど??
しかも、Manusのプレビュー画面では送れるのに、公開ページでは弾かれます。
送信元チェックの正体
スコアを受け取る処理には、送信元を確認する仕組みが入っていました。会話の記録に残っていたのは、Origin と Host を取り出して isSameHostOrigin という関数で判定し、一致しなければ403を返す処理です。
-
Originは、ブラウザがリクエストの送信元を伝えるヘッダー -
Hostは、リクエストの宛先のホスト名やポートを表すヘッダー
ほかのサイトのページから勝手にスコアを送りつけられるのを防ぎたい、という意図は分かります。ただ、当時実際に届いていた Origin と Host の値を、私は記録していません。公開環境で何が食い違っていたのかは、今も分からないままです。
手元に持ってきて、チェックを外す
Manus上では直せないので、コードを手元に持ってきて、ローカルで動かす方向に切り替えました。送信元のチェックは、一時的に外して起動しました。
すると、テストが1つ落ちました。「異なる送信元からのリクエストは403で拒否する」というテストが、期待した403ではなく422を返して失敗したのです。
最初は「なんで落ちるの」と思ったのですが、少し考えると当たり前でした。チェックを外したのだから、「拒否される」という期待が崩れるのは当然です。テストは、私が壊したものをちゃんと見つけてくれていました。チェックを外したのは、ローカルで動作を確認するための一時的な措置です。
通ったと思ったら、次は保存できない
送信元チェックを抜けられるようになって、ゲームをプレイして、スコアを送ってみると、
「ランキングを保存できませんでした」
今度はそっちか。
画面だけ見れば、どちらも「順位が出ない」です。最初は入口で拒否されていて、次は入口を通ったあとの保存で失敗していました。
ここからは、開発者ツールで通信を確認しながら、サーバー側のログも見て探しました。「ルート直下って、プロジェクトの一番上のフォルダのこと?」みたいな確認を挟みつつ、です。あと、ブラウザの開発者ツールは本当に便利でした。「なんか動かない」が、「この通信で、このエラーが返っている」まで分かるだけで、全然違います。
保存先に書けない
ログを追うと、保存先がこう指定されていました。
/var/lib/computer-typing-arena/scores.json
そして、EACCES: permission denied が出ていました。手元の実行環境には、そこに書き込む権限がなかったのです。/var/lib 以下は、一般ユーザーのプロセスがそのまま書けるとは限らない場所です。
保存する処理があっても、保存先に書き込めなければ記録は残りません。「保存処理がある」と「保存できる」を、私はこのときまで一緒に考えていました。
そこで、保存先をプロジェクトの中の ./data/scores.json に変えることにしました。
直したのに、変わらない
ところが、直しても直しても、挙動が変わりません。
あれ?と思って、起動コマンドを見直しました。私は server/index.ts を編集しているのに、動いているのは dist/index.js だったのです。
ソースを保存してサーバーを再起動すれば、変更が反映されると思っていました。でも実際に動いているのは、ビルドで生成されたファイルです。ソースをいくら直しても、ビルドし直さない限り、そっちは何も変わりません。
直したコードで試していたつもりが、ずっと修正前のビルドを動かしていました。これは地味にダメージが大きかったです。
起動中のサーバーを止めて、保存用のフォルダを作って、ビルドし直して、保存先を指定して起動しました。記録に残っている手順を整理すると、次の形になります。
# 対象のサーバーを停止した後、プロジェクト直下で実行
mkdir -p data
npm run build
HOST=0.0.0.0 \
PORT=3000 \
NODE_ENV=production \
RANKING_STORE_PATH=./data/scores.json \
node dist/index.js
保存先を相対パスにしているので、どのディレクトリから起動するかも関係します。data フォルダの場所と、動いているプロセスがそこへ書き込めるかを確認する必要がありました。
そして、もう一度プレイして、スコアを送ってみます。
ランキングに、順位が出ました。
前日の、部活動中のことです。会話のログにも、そのまま「出来たーー!!」と残っています。順位が出るだけで、こんなにうれしくなるとは思いませんでしたw
当日の朝、他の端末からつながらない
ランキングが直って、「これでいける!」と確信して迎えた本番当日の朝。
他の端末から、ゲームを開けなくなっていました。
昨日、直したはずなのに。いや、ランキングの保存は直っています。今度は他の端末から、ゲームの画面を開くところまでたどり着けないのです。
しかも、3日前には校内Wi-Fi経由で実際にアクセスできていました。それなのに、当日はつながらない。
- Webサーバー自体は起動している
- 同じノートPCの中からなら、ゲームを開ける
- 他の端末からは開けない
その場で、有線LANを新しく引いて試してみました。でも、他の端末からアクセスできる状態には戻せませんでした。
原因は、今も分かっていません
テストをした3日前と当日とで、IPアドレスや接続先のネットワーク、アクセスしたURLがどう違っていたのか。比べられるだけの記録を残していませんでした。校内Wi-Fiの制限、IPアドレスの変更、ファイアウォールなど、候補はいくつもありますが、どれも確認できていません。
起動時の HOST=0.0.0.0 は、すべてのIPv4インターフェースで待ち受けるための設定です。別端末から実際に通信が届くかは、別に確認する必要があります。当日に確認できたのは、同じPCからゲームを開けるところまででした。
今ならこう切り分ける
当日は焦っていて、体系的に切り分けられていませんでした。次なら、こう分けて確認します。
- 同じPCの中で、
localhostでゲームが開けるか - 同じPCの中で、そのPCのIPアドレスを指定して開けるか
- 別の端末で、接続先のネットワークとアクセス先IPを確認する。必要に応じて
pingも試す - 別の端末から、サーバーのTCPポート
3000に接続できるか。pingが通らなくても、ここは別に確認する - 別の端末のブラウザで、HTTPの応答が返ってくるか
どの段階で止まるかが分かれば、サーバー、PCの設定、ネットワークのどこから調べるかを絞れます。学校側のネットワーク設定が関係していそうなら、管理者の方に相談するときの材料にもなります。
サーバーが、そのままゲーム機になった
他の端末から開けない。でも、私のノートPCの中では、ちゃんと動いている。
というわけで、サーバーにする予定だったそのPC 1台だけで、本番をやることにしました。
| 項目 | 予定していた運用 | 実際の運用 |
|---|---|---|
| 遊ぶ端末 | 複数の端末 | 私物ノートPC 1台 |
| Webサーバー | 私物ノートPC | 同じ私物ノートPC |
| 接続 | 校内ネットワーク経由 | 同一PC内 |
| 同時に遊べる人数 | 複数人を想定 | このゲームは1人ずつ |
動くと確認できている範囲まで、運用を縮めました。当然、1台になったぶん、待ち時間が出ました。ただ、ほかの部員もゲームを作っていましたし、私ももう一つゲームを用意していたので、展示全体としては、なんとか回し切ることができました。
タイピングゲームは、1日で200回くらい遊んでもらえました。
そしてこの1台は、最後までエラーを吐かずに動いてくれました。本当は裏でサーバーをしているはずのPCが、一番前に出てずっと働いていた、というわけです。お疲れさまでした。
「動いた」と判断する前に
3日前に確かめたのは、別端末から画面を開けること。前日に確かめたのは、手元でスコアを保存できることでした。「別端末から開いて、遊んで、スコアが残る」までを同じ条件で通していれば、当日の判断材料がもう一つありました。
私の側にも、反省点はあります。Manusが動くものを作ってくれたので、保存先や起動方法を、よく理解しないまま使っていました。困ったらまたAIに直してもらえばいい、と思っていたら、そのクレジットが切れていました。
とはいえ、Manusを使わずに、当時の自分がここまで作れたかというと、簡単ではなかったと思います。Geminiにも助けてもらいました。AIはすごく頼りになるけれど、動かなくなったときに自分で追える状態にしておかないと、本番ではただ焦ることになる、というのが今回の学びです。
ログに出たエラーを追いかけすぎた
前日の調査では、こんなログにも引っかかりました。
URIError: Failed to decode param '/%VITE_ANALYTICS_ENDPOINT%/umami'
アクセス解析用の設定が入るはずのところに、%VITE_ANALYTICS_ENDPOINT% がそのまま残っていました。Viteには、HTML内の %変数名% を環境変数の値に置き換える仕組みがあり、変数が定義されていないとその文字列が残ります。今回、どのファイルからこの通信が出ていたのかは確定できていません。Vite公式
ランキングを直したいだけなのに、アクセス解析の通信先まで調べることになりました。Geminiから提案された対処を試しては、「なおってない」と返す、を何度か繰り返しています。
アクセス解析まわりのエラーに対処しても、ランキングは保存できないままでした。「原因が分かりました」「これで直ります」と言われても、実際に直ったかは動かして確かめるしかないんですよね。
次に同じことをしないために
- 実際に使うURLと端末で、通しで確認する。 ゲーム開始、スコア送信、順位表示までやって、再起動したあとも記録が残っているかを見る。
- 起動手順をメモする。 停止、ビルド、起動の手順と保存先をまとめ、実行ユーザーの書き込み権限と、動かすファイルを確認できるようにする。
- 送信元チェックを検証する。 必要な送信元からの通信が通り、それ以外が拒否されることの両方をテストする。
- ネットワークは、本番と同じ条件で確認する。 同じ場所、同じ端末、同じ接続方法で試して、成功したときのURLとIPアドレスも控えておく。
- 1台で回す場合の運用も、先に決めておく。 1回のプレイ時間や待ち列、ほかの展示への案内まで準備する。
次の文化祭では、開場前に別端末でまず1回遊んで、ランキングに自分のスコアが載るところまで見届けたいです。そのあとで「これでいける!」と言いたい。
