zoe@dev:~$PID 1337
zoe@dev:~$ cat ~/blog/shipping-an-ai-tool-ja.mdx

AIツールをリリースする:壊れたものすべて

2026-07-20·Zoe Lin·4 min read·#nextjs#vercel#seo#security#postmortem
EN中文日本語한국어EspañolFrançaisDeutschPortuguês

~/tools/paper-revisionを作ること自体は簡単な部分だった。本番環境でもlocalhostと同じように動かすことの方がずっと時間がかかったし、そのバグのほとんどはAI部分ではなく、その周りの「配管」に潜んでいた。以下は実際に起きた不具合の記録だ。

静かな502:SSE接続にはハートビートが必要

BYOKのAnthropicキーを使った長いレビューは、約100秒後にたまに失敗することがあった——エラーメッセージは何もなく、ただ接続が切れるだけ。Vercelのログにはきれいな502が記録されているだけで、本当の原因を示すものは何もなかった。

本当の問題はこうだ:モデルは生成の途中でしばらく静かになることがある(最初に見えるトークンが出るまでの、長い内部的な「思考」の時間)。そしてレスポンスストリームがあまりに長くバイトを送らずにいると、クライアントとモデルの間にある何らかの中間層が接続をアイドル状態と判断し、切断してしまう。解決策はハートビートだった——一定間隔でストリームに不可視の文字を書き込み、バイトが途切れないようにする:

providers.ts
const HEARTBEAT_MS = 15_000
const HEARTBEAT_CHAR = '' // ゼロ幅スペース、レンダリングされたmarkdownでは見えない
 
const result = await Promise.race([pending, heartbeat])
if (result === 'heartbeat') {
  controller.enqueue(encoder.encode(HEARTBEAT_CHAR))
  continue
}

この問題を追いかけている過程で、その裏に隠れていたもっと厄介なバグも見つかった:abortハンドラーがAbortErrorという名前の例外をすべて捕まえて、ユーザーが意図的にキャンセルしたものとして扱っていたのだ——ネットワークが勝手に切れて発生したものまで含めて。本当の失敗が、気づかれないまま握りつぶされていた。この修正には、エラーの名前を信用する前に、ユーザーが実際に「abort」をクリックしたかどうかを追跡する必要があった。

BYOK(自分のAPIキーを使う)、落とし穴なしで

無料プランはレート制限付きのホスト型モデルで動いている。BYOKユーザーは自分のキーで支払う代わりに、この制限を完全にスキップできる——はずだった。ところが最初のバージョンではスキップされておらず、BYOKのリクエストも無料プランの割り当てを食いつぶしていた。これが発覚したのは、ある率直なバグ報告がきっかけだった(「自分のキーを使っているのに、それでもレート制限に引っかかる」)。

もうひとつのBYOKの教訓は、事後対応ではなく事前対策として得たものだ:リクエストが誤って一番高額なモデルに流れないよう、許可するモデルの一覧を妥当な価格帯に制限し、プロバイダーを問わず出力トークン数にも上限を設けた。これらはユーザーが自分のお金を無駄遣いすることまでは止められないが、少なくともこちら側のコードがその原因になることは防いでくれる。

Google検索が頑として表示しなかったfavicon

サイトのfaviconはどのブラウザタブでも問題なく表示されていたのに、Google検索結果のURL横にだけは一向に表示されなかった。原因はちょっと恥ずかしくなるくらい間接的だった:Next.jsの汎用アイコン規約は、ファイルのURLにコンテンツハッシュを付加する——/icon.png?d72260d...——そしてこのハッシュは、ファイルのバイト列が変わるたびに変化する。Googleのガイドライン自身が、まさにこの失敗パターンについてはっきりと書いている:「faviconのURLは安定している必要がある——頻繁にURLを変更しないこと」。あのアイコンを編集するたびに、Googleが古いURLに対して積み上げてきた信頼が、知らないうちにリセットされていたのだ。

Next.jsには、ちょうど1つのファイル名だけに対する特別扱いがある——favicon.icoはコンテンツハッシュの付加を完全にスキップし、永久に安定したURLを持つ。同じ画像を最小限のICOコンテナに包んで、このファイル名に切り替えるだけで修正は完了した。ついでにドメイン設定も確認する価値がある:Googleはfaviconに関して、異なるホスト名を完全に別の「サイト」として扱う。そのためwww.とデフォルトの*.vercel.appドメインの両方を、唯一の正規ホスト名へ恒久的にリダイレクトするよう設定した。

一度しかレンダリングされないCloudflare Turnstile

Turnstileの暗黙的レンダリングモードは、スクリプトが最初に読み込まれたときに一度だけ、ページ上の.cf-turnstile要素をスキャンする。従来型のマルチページサイトなら、ページ遷移のたびにスクリプトがまっさらな状態で再読み込みされるので、この挙動は問題にならない——しかしこれはSPAで、レビューフォームの状態は、進行中のレビューが中断されてしまわないよう、意図的にクライアントサイドのナビゲーションをまたいで保持される。他のページに移動してから戻ってくると、ウィジェットのマウントポイントは真新しいDOMノードになっていて、すでに読み込み済みのスクリプトはそれを二度とスキャンしない。トークンは気づかれないままundefinedになる。これは——共有エンドポイントにサーバーサイドの検証を追加した後は——ページ移動から戻ってきた後の共有操作がすべて、目に見える説明もなく失敗することを意味していた。

解決策はTurnstileの明示的レンダリングAPIを使うことだった:スクリプトの読み込みは一度だけにして、その一回限りの自動スキャンに頼る代わりに、マウントのたびに自分でturnstile.render()を呼び出し、アンマウントのたびにturnstile.remove()を呼び出す。

Windows開発環境、Linux本番環境

2つの別々のバグに、同じ根本原因があった:Windows上での開発とVercelのLinuxランタイムへのデプロイは、同じ環境ではない。そしてPDFや画像を扱うライブラリは、まさにそのギャップが表面化しやすい場所だ。pdf-parseは本番環境でDOMMatrixエラーを起こしてクラッシュしたが、ローカルでは一度も再現しなかった——サーバーレス環境でも安全なPDFライブラリ(unpdf)に切り替えたことで、問題は完全に解決した。別件では、next/ogの画像生成がフォント読み込みでクラッシュし、原因を辿るとスペースを含むWindowsのファイルパスにライブラリのURLパース処理がつまずいていたことが分かった。どちらのバグも、ローカルのnpm run devでは存在すらしなかったが、デプロイのたびに100%再現した。

セキュリティレビューが実際に見つけるもの

コードベース全体を通しでレビューした結果、地味ではあるが実在する問題がいくつか見つかった:ボット検証が一切ない状態のスナップショット共有エンドポイント——技術的には誰でも公開URLに任意のテキストを発行できてしまう状態だった。アップロードされたファイルがそもそもパース可能かどうかを確認する前に課金されるレート制限——不正な形式のアップロードが、決して実行されることのないレビューのためにユーザーの割り当てを消費してしまう。宣言されたサイズを確認する前にアーカイブエントリを展開してしまうzipハンドラー——これはまさにzip爆弾脆弱性そのものの形をしている。そして、ページ移動後に戻ってきてもう一度クリックすると、気づかないうちに重複したスナップショットを作成してしまう共有ボタン——「すでにリンクを持っているか」という状態が、直前に破棄されて再生成されたばかりのコンポーネントの中に保存されていたためだ。

どれも特に珍しいものではなかった。ハッピーパスが動くことを確認するだけでなく、何がまずくなり得るかを意識しながら実際にコードを読んだときにだけ見えてくる、そういう種類のバグだった。

最後に

このリストに挙げたバグはどれも、リリースされ、動作し、完成したように見えていた——特定の入力、特定のナビゲーション、特定のホスティング環境がそれを覆すまでは。丁寧に検証された地味なインフラは、本番環境で一度も試されたことのない気の利いたインフラに勝る。exit 0

cd ~/blog ←