なぜサーバーで判定するのか
判定のコードは純粋関数なので、ブラウザでも動かせます。ただ、そうするには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 で昨日・今日・明日だけを許します。見る人のタイムゾーンの差は吸収し、翌月のコンテンツを先に抜き取ることは防ぎます。
スポンサーリンク
スポンサーリンク
ローカルでの点検:実際のデータでサーバーのつながりを見る
判定の品質そのものは、アプリの Jest のテストが保証しています。その方法は、連載の最初の回に書きました。
サーバー側の点検は「つながり」だけを見ます。
- 見本の文字の正解の画を順に送ると、すべて合格し、最後の画で
last = true - 長い画を逆向きに書くと向きで不合格、最初の画の位置に 2 番目の画を書くと不合格
- 許可していないオリジンは 403、壊れた要求は 400、遠い日付は 400
- 応答に、正解の座標も画数も入っていない
wrangler dev は、.dev.vars の値で設定を上書きします。ローカルのオリジンの許可と LIMIT_OFF=1 はそこに置きます。このファイルは配信には上がらず、秘密の値も入れません。
無料枠を超えたら?(通知は来ない)
無料プランには、リクエスト数の通知がありません(使用量の通知は有料プランの機能)。枠を超えると、その日(UTC の 0 時まで)はリクエストが失敗します。
備えは二重にしました。
- クライアントが静かに引き下がる。判定サーバーに届かないときは「判定はお休み中・なぞり書きはできます」に切り替わり、書くこと自体は続けられます
- 引き下がった回数を、アクセス解析で見張る。その瞬間に GA4 のイベント
judge_offlineを画面ごとに 1 回送ります。GA4 のカスタムインサイトで「1 日の judge_offline が 50 を超えたら」メールが来るようにしました。サーバーの代わりに、利用者の側で障害に気づく形です
ちなみに 1 文字はおよそ 3〜5 リクエストです。1 日 10 万リクエストは、2 万文字以上にあたります。
つまずいたこと・コツ
- wrangler と Node のバージョンを固定する。人や機器ごとに違うバージョンで配信すると、バンドルの結果が変わります。
npx wrangler@<固定のバージョン>を npm のスクリプトに書き込んでおきました - 配信のスクリプトが、判定サーバーを先に確かめる。Web の配信スクリプトは、始めに判定サーバーの応答(200)を確かめます。サーバーと Web を一緒に変えるときは、サーバーを先に配信します
- 正解データのキャッシュ。文字ごとの正解の座標は、最初の 1 回だけほどいて
Mapに置きます。同じ Worker のインスタンスが続けて要求を受けたときは、ほどき直しません - エラーの内容を返さない。入力の検査のどの条件に引っかかったかを教えると、攻撃する側のヒントになります。400 と
{ ok: false }だけです
スポンサーリンク
スポンサーリンク
この記事について
自社の Web「iffthen hangul」の 1 画判定サーバーを作ったときの記録です。コードは実際のソースから抜き出し、コメントを読みやすく縮めています。動作を変える修正はしていません。サーバーの実際の URL・アカウント・制限の設定番号は伏せ、設定ファイルはサンプルの値に置き換えています。
- この判定サーバーを使っている Web(無料):iffthen hangul
- アプリ:推し韓マル(App Store・Google Play)




コメント