PR
個人開発 API設計Cloudflare Workersセキュリティハングルレート制限個人開発推し韓マル開発ノート

Cloudflare Workers の無料プランで判定 API を運用する 正解データをブラウザに渡さないための設計

クラウドとサーバーのネットワークのイメージ 個人開発
スポンサーリンク

なぜサーバーで判定するのか

判定のコードは純粋関数なので、ブラウザでも動かせます。ただ、そうするには2,351 字分の正解の画の座標を、ブラウザに送らなければなりません。座標のデータは、人がひとつずつ検収した資産です。まるごと集められると困ります。

そこで Web では、次のように分けました。

  • 正解の座標はサーバーの中だけに置く
  • ブラウザは「今書いた画」だけを送り、サーバーは「合っている/違う+理由」だけを返す
  • 書き順の再生は、あらかじめ作った動画で見せる

書き順の動画の作り方は、前回にまとめています。

サーバーは Cloudflare Workers の無料プランです(1 日 10 万リクエスト)。

アプリと同じ判定コードを、そのまま import する

判定のロジックは、アプリ(React Native)の TypeScript のファイルです。Worker はそのファイルを相対パスで直接読み込みます。wrangler がバンドルするときに TypeScript も一緒に処理するので、アプリとサーバーが同じコード・同じテストを共有します。

import { judgeStep } from '../../../src/lib/write/stroke-step-core.ts';
import { CHARS, DAILY } from '../gen/data.mjs';   // ビルドのときに作るデータ(git 対象外)

gen/data.mjs は、配信の前にスクリプトが元データ(stroke-approved.json)から作ります。形式の検査に失敗したら、ファイルを作らずに止まります。

writeFileSync(OUT,
  `// 自動生成・直接直さない・git 対象外\n` +
  `export const CHARS = ${JSON.stringify(chars)};\n` +
  `export const DAILY = ${JSON.stringify(daily)};\n`);

API の設計:小さく、厳しく、多くを語らない

POST /judge   要求 { c: 音節, d: [[先に正しく書いた画], ...], w: [今の画] }   座標 = マス 1000 の整数
              応答 { ok: true, last } | { ok: false, k: 'order' | 'position' | 'shape' | 'direction' }
GET  /today?d=YYYY-MM-DD   その日の「今日の韓国語」1 つ(UTC の今日 ± 1 日だけ)

1)text/plain で受けて、プリフライトを避ける

Content-Type: application/json で送ると、ブラウザは毎回 OPTIONS のリクエストを先に送ります。1 画ごとにリクエストする API なので、往復が 2 倍になります。本文は JSON ですが text/plain で送り、サーバーで JSON.parse します。

2)入力を厳しく検査し、だめなら中身を言わずに断る

const MAX_BODY = 32768;      // 要求の本文の上限(バイト)
const MAX_POINTS = 600;      // 1 画の点の数の上限
const COORD_MIN = -200;      // マスから少しはみ出した点は許す
const COORD_MAX = 1200;

function toPoints(flat) {
  if (!Array.isArray(flat) || flat.length < 2 || flat.length % 2 !== 0 || flat.length > MAX_POINTS * 2) return null;
  const pts = [];
  for (let i = 0; i < flat.length; i += 2) {
    const x = flat[i], y = flat[i + 1];
    if (!Number.isInteger(x) || !Number.isInteger(y) ||
        x < COORD_MIN || x > COORD_MAX || y < COORD_MIN || y > COORD_MAX) return null;
    pts.push({ x: x / 1000, y: y / 1000 });
  }
  return pts;
}

/** 中身のない拒否(エラーの内容を見せない)。 */
function reject(status, origin) { return json({ ok: false }, status, origin); }
  • 本文の大きさは、content-length ヘッダーと実際の長さの両方を見る
  • 「書き終わった文字に、さらに画を送る」(d.length >= 正解の画数)も 400 で止める。こうしないと、応答の違いから画数を割り出せてしまう
  • 応答には正解の座標も画数も入れない。「最後の画か(last)」は、正しく書けたときだけ知らせる

3)許可するオリジンの一覧+接続元ごとの 1 分あたりの制限

export default {
  async fetch(request, env) {
    const allowed = String(env.ALLOWED_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean);
    const origin = request.headers.get('origin') ?? '';
    if (!allowed.includes(origin)) return reject(403, null);

    const ip = request.headers.get('cf-connecting-ip') ?? 'unknown';
    if (env.JUDGE_LIMIT && env.LIMIT_OFF !== '1') {          // ローカルの点検だけ LIMIT_OFF=1
      const { success } = await env.JUDGE_LIMIT.limit({ key: ip });
      if (!success) return reject(429, origin);
    }
    const url = new URL(request.url);
    try {
      if (request.method === 'POST' && url.pathname === '/judge') return await judge(request, origin);
      if (request.method === 'GET' && url.pathname === '/today') return today(url, origin);
      return reject(404, origin);
    } catch {
      return reject(500, origin);
    }
  },
};

1 分あたりの制限は、Workers の Rate Limiting のバインディングでかけました。数行のコードと設定だけで済みます。

# wrangler.toml(値はサンプル)
name = "hangul-judge"
main = "src/index.mjs"
compatibility_date = "2026-10-01"
workers_dev = true
preview_urls = false

[vars]
ALLOWED_ORIGINS = "https://example.com"

[[ratelimits]]
name = "JUDGE_LIMIT"
namespace_id = "1001"          # アカウントの中で一意の番号(サンプル)

[ratelimits.simple]
limit = 120                    # 指を離すたびに 1 回・人が書く速さの 2 倍の余裕
period = 60
  • オリジンの確認は、ブラウザではないクライアント(curl など)なら回避できます。だから、1 分あたりの制限と入力の検査を一緒に置きます。この API の目的は「大量に集めることを高くつかせる」ことです
  • preview_urls = false でプレビュー用の URL を切ります。プレビューの URL から回り込む道をなくします

4)「これからの予定」は 1 日の範囲だけ

/today は日付を受け取りますが、UTC で昨日・今日・明日だけを許します。見る人のタイムゾーンの差は吸収し、翌月のコンテンツを先に抜き取ることは防ぎます。

スポンサーリンク

モバイルアプリ開発の知識地図:一冊でiOS/Android/Flutterの開発技術が学べる本

モバイルアプリ開発の知識地図:一冊でiOS/Android/Flutterの開発技術が学べる本

楽天で見る

スポンサーリンク

モバイルアプリ開発の知識地図 / 國師誠也

モバイルアプリ開発の知識地図 / 國師誠也

Yahoo!ショッピングで見る

ローカルでの点検:実際のデータでサーバーのつながりを見る

判定の品質そのものは、アプリの Jest のテストが保証しています。その方法は、連載の最初の回に書きました。

サーバー側の点検は「つながり」だけを見ます。

  • 見本の文字の正解の画を順に送ると、すべて合格し、最後の画で last = true
  • 長い画を逆向きに書くと向きで不合格、最初の画の位置に 2 番目の画を書くと不合格
  • 許可していないオリジンは 403、壊れた要求は 400、遠い日付は 400
  • 応答に、正解の座標も画数も入っていない

wrangler dev は、.dev.vars の値で設定を上書きします。ローカルのオリジンの許可と LIMIT_OFF=1 はそこに置きます。このファイルは配信には上がらず、秘密の値も入れません。

無料枠を超えたら?(通知は来ない)

無料プランには、リクエスト数の通知がありません(使用量の通知は有料プランの機能)。枠を超えると、その日(UTC の 0 時まで)はリクエストが失敗します。

備えは二重にしました。

  1. クライアントが静かに引き下がる。判定サーバーに届かないときは「判定はお休み中・なぞり書きはできます」に切り替わり、書くこと自体は続けられます
  2. 引き下がった回数を、アクセス解析で見張る。その瞬間に GA4 のイベント judge_offline を画面ごとに 1 回送ります。GA4 のカスタムインサイトで「1 日の judge_offline が 50 を超えたら」メールが来るようにしました。サーバーの代わりに、利用者の側で障害に気づく形です

ちなみに 1 文字はおよそ 3〜5 リクエストです。1 日 10 万リクエストは、2 万文字以上にあたります。

つまずいたこと・コツ

  1. wrangler と Node のバージョンを固定する。人や機器ごとに違うバージョンで配信すると、バンドルの結果が変わります。npx wrangler@<固定のバージョン> を npm のスクリプトに書き込んでおきました
  2. 配信のスクリプトが、判定サーバーを先に確かめる。Web の配信スクリプトは、始めに判定サーバーの応答(200)を確かめます。サーバーと Web を一緒に変えるときは、サーバーを先に配信します
  3. 正解データのキャッシュ。文字ごとの正解の座標は、最初の 1 回だけほどいて Map に置きます。同じ Worker のインスタンスが続けて要求を受けたときは、ほどき直しません
  4. エラーの内容を返さない。入力の検査のどの条件に引っかかったかを教えると、攻撃する側のヒントになります。400 と { ok: false } だけです

スポンサーリンク

Coding Assistant対応 iPhoneアプリ開発集中講座 Xcode 26/iOS 26

Coding Assistant対応 iPhoneアプリ開発集中講座 Xcode 26/iOS 26

楽天で見る

スポンサーリンク

iPhoneアプリ開発集中講座 iOS 26 Xcode 26 AIアシスタント(Coding Assistant)も解説

iPhoneアプリ開発集中講座 iOS 26 Xcode 26 AIアシスタント(Coding Assistant)も解説

Yahoo!ショッピングで見る

この記事について

自社の Web「iffthen hangul」の 1 画判定サーバーを作ったときの記録です。コードは実際のソースから抜き出し、コメントを読みやすく縮めています。動作を変える修正はしていません。サーバーの実際の URL・アカウント・制限の設定番号は伏せ、設定ファイルはサンプルの値に置き換えています。

コメント

タイトルとURLをコピーしました