エッジの上のパーティー:一人と一つの AI で、パーティーゲームを Cloudflare Workers に載せる
林政賢 ·
これは「AI が hello world を書いてくれた」という話ではありません。すでに配信中のアプリの話です。カード 1,428 枚、22 ページ、17 のデータテーブル、リアルタイム多人数接続、アプリ内サブスクリプション、App Store 審査通過。この記事で書くのは、この組み合わせが実際に通用するのか、そして誰も先に教えてくれない落とし穴です。
まず規模をはっきりさせておきます。そうでないと、この後の話に重みが出ません。
パープルティプシーナイトX · 現状
すべてが Cloudflare Workers の上で動いています。1 本の Worker が API と静的サイトを兼ね、データは D1、画像は R2、リアルタイム通信は Ably、ログインは better-auth、サブスクは RevenueCat。iOS アプリは同じサイトを Capacitor で包んだだけ——つまり画面もルールもカードもデータベースも、変更に新しいビルドが要りません。
開発のほとんどは私と Claude の対話でした。私が何を作りたいか説明し、実機を見て、どこが違うかを指摘する。相手はコードを読み、原因を探し、直し、デプロイし、検証する。この記事はそれが魔法だと納得させるためのものではありません。書きたいのはどの部分が本当に大量の時間を節約したのか、そしてどの部分を自信たっぷりに間違えるのかです。
なぜ Cloudflare なのか
安いからではありません(実際に安いのですが)。1 本の Worker がすべてだからです。
従来のやり方だと、フロントのホスティング、API サーバー、データベース、CDN、ファイルストレージの 5 か所に分かれ、それぞれ設定し、それぞれ課金され、それぞれ独自のデプロイ手順を持ちます。Workers では、それらは同じ wrangler.json の数行のバインディングです。
"d1_databases": [{ "binding": "DB", "database_name": "ppnx-db" }],
"r2_buckets": [{ "binding": "MEDIA", "bucket_name": "ppnx-media" }],
"assets": { "directory": "./dist/client", "binding": "ASSETS" }
あとは npm run deploy で一度に上がります。「一人で製品をまるごと作る」にとって、これは節約できる金額よりずっと大きな意味があります——維持すべき頭の中のモデルが 1 つで済むからです。そして協働相手が AI であるとき、この点はさらに効きます。システム全体を一度に読み切れて初めて、相手はあなたが何を作っているのかを本当に理解できる。そうでなければ、残り 4 つのサービスが今どうなっているかを毎回推測することになります。
なぜ Claude なのか
「コードが書けるから」ではありません。その基準はとっくに超えています。本当に差がつくのは、記憶で答えるのではなく、確かめに行くことです。
今週実際に起きた例を 1 つ。ユーザーからの報告は「ルームの中が全部遅い、ボタンを押しても反応しない」でした。
記憶で答えるなら「ネットワークが遅いのでキャッシュを足しましょう」です。実際にやったのは、まず台湾から Worker までの往復時間を測ること(0.43〜0.70 秒、確かに遅い。台湾の回線がサンノゼに向けられ、データベースはアジア太平洋にあるため)。最適化を入れて——そしてユーザーはまだ遅いと言いました。
ネットワークが遅いからといって、ボタンが反応しなくなることはない。
この一言が方向をひっくり返しました。二度目に測ったのはまったく別のもの——ページが待機している間の DOM 変更回数です。
同じ画面、操作なし、10 秒間の DOM 変更
原因は、チャットバブルの期限切れを判定する毎秒のタイマーが無条件で回り続けていたこと。そして 1 回動くたびにルーム全体のコンポーネントが再描画されていました。この時計が必要なのはバブルが出ている 6 秒間だけで、それ以外の時間はまったく不要です。
このバグはずっと前から存在していました。見つけられたのは、より賢い推測ではなく、別のものを測ったからです。そして「直す前に測る」という規律は、AI のほうが人間よりはるかに辛抱強い——すでに答えを知っている気がするからといって、手順を飛ばしたりしません。
誰も先に教えてくれない Cloudflare の落とし穴 4 つ
この節がこの記事の本題です。以下はどれも実際に私を半日以上止めたもので、しかもひと目にはまったく別の原因に見えます。
01見つからない JS ファイルは 404 ではなく 200 を返す
症状:デプロイ後、すでにアプリを開いているユーザーが特定のページに進むと壊れる。エラーメッセージからは原因がまったく分からない。
/assets/ のファイル名には内容のハッシュが入り、デプロイのたびに変わります。古いタブは古いファイル名を要求しますが、not_found_handling: "single-page-application" は見つからないパスすべてにトップページの HTML を返します——.js も含めて。ブラウザは「HTTP 200 と text/html」を受け取り、それを JS モジュールとして解析しようとして MIME エラーになります。
対処:動的読み込みに失敗したらページを 1 回だけ再読み込みする(再読み込みで新しい HTML と新しいファイル名が来る)。無限ループを避けるため sessionStorage に記録します。これは SPA + エッジ静的ホスティング全般の問題で Cloudflare 固有ではありませんが、この設定のせいで「明らかな 404」が「奇妙な MIME エラー」に化けます。
02ゾーンのキャッシュ設定が Worker の返すヘッダーを上書きする
症状:Worker で Cache-Control を設定したのに、内容を変えても反映に何時間もかかる。
Cloudflare 管理画面のゾーンレベル Browser Cache TTL(既定 4 時間)は、Worker が返す Cache-Control より優先されます。対になるもう一つの誤解として、Worker のレスポンスは既定ではエッジにキャッシュされません。してくれていると思っていても、実際はしていない——caches.default を自分で使う必要があります。
対処:ゾーン TTL を回避するには、URL に変化するパラメータを付けます(分単位の ?v= など)。エッジキャッシュが欲しければ caches.default と明示的に書きます。どちらも自動では起きません。
03OAuth のコールバックが静的アセット層に横取りされる
症状:Google ログインのコールバックが 404、Apple が 405。ところが同じ URL を curl で叩くとまったく正常。
静的アセット層は「ナビゲーションリクエスト」を横取りし、SPA フォールバックのせいでコールバックが Worker まで届きません。curl で再現できないのは、送っているのがナビゲーションリクエストではないからです——Sec-Fetch-Mode: navigate を付けて初めて再現します。
対処:該当パスを run_worker_first に入れます。また Worker をサブパスにマウントする場合は html_handling: "none" を忘れずに。さもないと訪問者が 307 でメインサイトのトップに飛ばされます。
04wrangler のログイン権限は思っているより狭い
症状:ある種の wrangler コマンドが必ず Unauthorized [code: 2036] を返す。コマンドをどう書き直しても変わらない。
wrangler login で得られる OAuth トークンには DNS 書き込み権限も Email 権限もありません。ですから「Pages のカスタムサブドメイン用に CNAME を作る」「Email Sending を有効化する」といった操作は必ず失敗しますし、コマンドの正しさとは無関係です。
対処:管理画面で必要な権限を持つ API トークンを発行するか、迂回するか。私は DNS を Worker のパスマウントに変え、メール送信はすでに使っている外部サービスに寄せました。コマンドのデバッグに時間を使う前に、権限の問題かどうかを先に確認すること。これが一番時間を節約しました。
エッジの地理的な現実
Workers は世界中のエッジで動くので、どこでも速そうに聞こえます。しかしデータベースは 1 か所にしかありません。
台湾のマシンから当社の API まで実測すると、往復 0.43〜0.70 秒。理由は、Cloudflare が台湾の回線をサンノゼ(colo=SJC)に向ける一方、D1 はアジア太平洋にあるからです。リクエストはまず太平洋を渡って Worker に届き、Worker はまた渡り返してデータベースを引きます。
診断はとても簡単です。wrangler tail は cpuTime と wallTime を同時に出します。CPU が 6 ミリ秒で実時間が 470 ミリ秒なら、遅いのはあなたのコードではないという証拠です。
これが分かってしまえば、設計の原則は 2 つだけになります。
- 往復の回数を減らす。ルーム作成はもともと 3 回の連続往復(部屋番号が使われていないか確認 → 部屋を作る → プレイヤーを作る)でしたが、バックエンドの 1 エンドポイントにまとめて 1 回で済むようになりました。入室も同じようにまとめています。
- 往復を待たない。自分で押した操作で、結果がローカルで分かるもの——カードを引く、回答する、採点する、手番を渡す、退出する——はすべて先に画面を更新し、リクエストは裏で送ります。
ただし楽観的更新に噛まれます
これは今週いちばん気に入っているバグです。1 つ直したら、そのまま別のものを作り出した教科書のような例だからです。
楽観的更新を入れた後、ユーザーから画面が「あちこち飛ぶ」という報告が来ました。カードを引くと一度デッキ選択に戻ってからカードに飛ぶ。回答を送ると画面が一度戻ってからまた進む。
原因はポーリングが独立して回っていること。あなたの書き込みより前に送られたリクエストは、古いデータを持って返ってきます。それをそのまま適用すると、たった今作った画面が押し戻され、次のポーリングでまた進む。遅延を直しているつもりで、実際にはちらつきを作っているわけです。
解決にタイムスタンプは要りませんし、時計のずれも怖くありません。単調増加のカウンターでどちらが新しいかを判定するだけです。
function mergeRoom(prev, incoming){
const pT = prev.turn_count, iT = incoming.turn_count;
const pC = prev.current_turn, iC = incoming.current_turn;
if (iC > pC || iT > pT) return incoming; // サーバーが新しい → まるごと採用
if (iC < pC || iT < pT) return prev; // ポーリングが遅れている → まるごと破棄
// 同じターン:サーバーがこちらの操作をまだ受け取っていない → その項目だけ残す
if (prev.last_pick && !incoming.last_pick) return { ...incoming,
last_pick: prev.last_pick, last_by: prev.last_by };
return incoming;
}
状態のなかに「前にしか進まない数字」が 1 つあれば——ターン数、バージョン番号、シーケンス番号、何でも——この問題には非常に安い解があります。調停の仕組みを伴わない楽観的更新は、遅延をちらつきに交換しているだけです。
Claude はどこで間違えるか
この節はこのやり方を試そうとしている人に向けたものです。万能ではありませんし、間違え方には規則性があります。
一、自信たっぷりに「直りました」と言う
いちばんよくある失敗は、コードを直してデプロイし、そのあと自分のブラウザの古いキャッシュか、ローカルのビルド成果物を見て「修正済み」と報告することです。このプロジェクトでも一度ならず起きました。
有効な規律は 1 つだけ、本番で実際に配信されているファイルを検証することです。デプロイ後のチャンクを curl で取得し、キーワードを突き合わせます。そして grep が本当にヒットしたかも確認してください——圧縮すると空白が消えるので、空白を含む検索文字列は当たらず、「上がっていない」ように見える誤報が出ます。
二、自分の選択にきれいな理由を後付けする
「なぜこうしたのか」と尋ねれば、ほぼ必ず専門的に聞こえる説明が返ってきます——本当の理由が「前のステップがたまたまそう書かれていたから」であっても。これは README や技術文書を書くときに特に危険です。その文章は他人に設計判断として読まれるからです。
私のやり方は、重要なものは公開前に独立した敵対的レビューを 1 回通すこと。別のまっさらな会話を開き、コードだけを渡して粗探しを頼み、元の説明は渡しません。
三、「まだ違う」と言う人間が必要
先ほどの「全部遅い」の例では、一度目の最適化は正しく、計測も本物で、方向だけが間違っていました。それを軌道に戻したのは、より良いプロンプトではありません。「まだ遅い」という一言です。
おそらくこれが全体でいちばん実用的な一文です。あなたの価値はそのコードを書けるかどうかではなく、まだ終わっていないと分かることにあります。実機で試し、具体的な症状を述べ、「たぶん大丈夫です」を受け入れない——これを代われるものは、今のところ存在しません。
で、これは真似できるのか
できます。ただし題材を選んでください。
この組み合わせがいちばん強いのは一人で全部を背負うプロジェクトです。フロント・バック・運用の区別がなく、チーム間の調整コストがなく、1 行直して公開までコマンド 1 つ。Workers はインフラを一人で担げる大きさまで圧縮し、Claude は「三万行を読んでから一行を正しく直す」コストを下げます。この 2 つが同時に成り立って初めて意味があり、どちらか一方では足りません。
逆に、システムがすでに 5 つのクラウド、3 つのチーム、2 つのデプロイ手順に散らばっているなら、ボトルネックはコードを書くことではないので、この組み合わせはあまり助けになりません。
「AI はエンジニアを置き換えるのか」については、この数週間の経験から言えば、置き換えられるのはドキュメントを調べる、定型を書く、誰が何を壊したかを追うという作業です。人に残るのは、何をやる価値があるかの判断と、実機を見て「ここが違う」と言うこと。後者の比重は、軽くなるどころか重くなる一方です。
この記事について:本文中の数字はすべて実測であり、見積もりではありません。0.43〜0.70 秒は台湾のマシンから本番 API への実測。78 → 0 は同じ画面を 10 秒放置したときの DOM 変更回数。カード数とテーブル数はデータベースを直接引いた値です。Cloudflare の落とし穴 4 つには原因と対処を必ず添えました。「地雷がある」とだけ言っても読む人の役に立たないからです。
パープルティプシーナイトX は TangYi Studio の 16+ パーティークイズカードアプリです。スタジオのオープンソースは github.com/tangyistudio にあります。
やさしい版
技術の話抜きで要点だけ知りたい方へ
同じプロジェクトについて、エンジニアではない人に向けて書いた記事もあります。論じている軸は別で、AI がここまで強くなったあと人に何が残るのか。主線は方向をひっくり返したあの一言「まだ遅い」です。
やさしい版を読む →新着記事
二つの AI ランプ、どっちを擦る?
Claude Fable 5.1 と GPT-6 Astra が二日違いで登場、価格は同じ。スコア比べではなく、問いはひとつ——vibe coding を始めるなら誰に払うか。
著者:林政賢(監督 · Gen AI クリエイター兼エンジニア · 瑭宜影像創意工作室 創業者)