初めて Cloudflare Workers にJSON APIをリリースしたとき、私は夜11時に自分のノートPCからデプロイし、お茶を飲み終わる前に300以上のデータセンターで稼働していました。 Dockerfileも、Kubernetesクラスタも、コールドスタートのドラマもなし。たった1回のwrangler deployと 1.2 KBのバンドルです。この体験こそが、JSONを入力しJSONを出力するサービス — webhook、プロキシ、API集約、エッジ認証 — で Workersを私のデフォルトにした理由です。バックエンドの80%が「JSONをパースし、何かして、JSONを返す」なら、 これは私が始めた頃にあって欲しかった記事です。

Cloudflare Workerは基本的に、CloudflareのエッジのV8 isolate上で動作する、単一のJavaScript(またはTypeScript)関数です。 Requestを受け取り、 Responseを返し、 標準のFetch APIにアクセスできます。 ブラウザでfetch() を使ったことがあるなら、ランタイムの90%はすでに理解しています。知らないのは、 おもちゃのWorkerを、実際に本番環境で動かせるWorkerから区別する、小さなパターンの集合です。この記事はそれについてです。

最初のJSONエンドポイント

こちらが最小の有用なJSONエンドポイントです。タイムスタンプとメッセージを持つ単一のオブジェクトを返します。 Wranglerプロジェクト内でsrc/index.tsとして保存してください:

js
export default {
  async fetch(request, env, ctx) {
    const payload = {
      message: 'Hello from the edge',
      servedAt: new Date().toISOString(),
      colo: request.cf?.colo ?? 'unknown',
    };

    return Response.json(payload);
  },
};

2つの注目点。まず、Response.json()はオブジェクトをシリアライズし、 Content-Type: application/jsonを自動で設定してくれる静的ヘルパーです。カスタムcontent-typeが必要でない限り、 自前でnew Response(JSON.stringify(x))を書かないでください — そのうちヘッダーを忘れるだけです。 次に、request.cf.coloは、どのCloudflareデータセンターがリクエストを処理しているかを教えてくれます。 ベルリンからのリクエストはFRAを表示し、東京からならNRTを表示します。 「エッジ」というセールストークの全てが、このひとつのフィールドに詰まっています。

JSONリクエストボディのパース

POSTエンドポイントはボディを読む必要があります。Fetch APIは request.json() を提供し、ボディストリームを読み込み、1回の呼び出しでパースします:

js
export default {
  async fetch(request) {
    if (request.method !== 'POST') {
      return new Response('Method not allowed', { status: 405 });
    }

    const body = await request.json();

    // body is now a regular JavaScript object
    const { email, plan } = body;

    return Response.json({
      received: { email, plan },
      ok: true,
    });
  },
};

きれいに見えますが、このコードには出荷から24時間以内に遭遇するバグがあります: クライアントが空のボディや不正なJSONを送信すると、request.json()SyntaxErrorをスローし、Workerがクラッシュし、Cloudflareは汎用的な500を返します。 顧客の前で見せたい応答ではありません。

不正なJSONの扱い — 500を返させない

ボディのパースは常にtry/catchで囲み、適切な400を返しましょう。すべてのWorkerで私が使っているパターンがこれです:

js
async function readJson(request) {
  try {
    return { ok: true, data: await request.json() };
  } catch (err) {
    return {
      ok: false,
      error: 'Invalid JSON body',
      detail: err.message,
    };
  }
}

export default {
  async fetch(request) {
    const result = await readJson(request);

    if (!result.ok) {
      return Response.json(result, { status: 400 });
    }

    const { email, plan } = result.data;

    if (!email || !plan) {
      return Response.json(
        { ok: false, error: 'email and plan are required' },
        { status: 422 },
      );
    }

    return Response.json({ ok: true, email, plan });
  },
};
デバッグのヒント: クライアントが「あなたのAPIが壊れている」と主張しているのに Workerが400を示している場合、10回中9回は相手のJSONが不正です — 末尾カンマ、 クォートなしのキー、または先頭に紛れ込んだBOM文字。ペイロードを JSONバリデーターに貼り付けてもらえば、通常1分以内に問題が浮かび上がります。

JSON APIのためのCORS

Workerが異なるオリジンのブラウザから呼び出される場合 — これは通常のケースですが — CORS ヘッダーが必要です。ブラウザは単純なGET以上のものに対して、実際のリクエストの前に OPTIONSプリフライトを送信します。両方を一箇所で処理しましょう:

js
const CORS_HEADERS = {
  'Access-Control-Allow-Origin': 'https://app.example.com',
  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
  'Access-Control-Allow-Headers': 'Content-Type, Authorization',
  'Access-Control-Max-Age': '86400',
};

export default {
  async fetch(request) {
    if (request.method === 'OPTIONS') {
      return new Response(null, { status: 204, headers: CORS_HEADERS });
    }

    const data = { ping: 'pong', at: Date.now() };

    return Response.json(data, { headers: CORS_HEADERS });
  },
};

認証情報を読んだり、ユーザーデータを返したりするものにはAccess-Control-Allow-Origin: *を避けてください。 開発環境では無害に見えて、本番でセキュリティインシデントになるショートカットのひとつです。 実際にサービス提供するオリジンをハードコードするか、envの許可リストから読み込みましょう。

JSONをアップストリームAPIに転送する

Workerの最も一般的な用途の1つは、薄いプロキシとして使うことです:APIキーを隠す、 レスポンスを整形する、クライアントが不要なフィールドを削る、2つのアップストリーム呼び出しをひとつにまとめる。 アップストリームサービスを呼び出し、関心のあるフィールドだけを選び、 よりきれいなJSONペイロードを返すWorkerがこちらです:

js
export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const userId = url.searchParams.get('id');

    if (!userId) {
      return Response.json({ error: 'id required' }, { status: 400 });
    }

    const upstream = await fetch(
      `https://api.internal.example.com/users/${userId}`,
      {
        headers: { 'Authorization': `Bearer ${env.UPSTREAM_TOKEN}` },
      },
    );

    if (!upstream.ok) {
      return Response.json(
        { error: 'upstream failed', status: upstream.status },
        { status: 502 },
      );
    }

    const full = await upstream.json();

    // Strip internal fields before returning to the client
    const safe = {
      id: full.id,
      displayName: full.display_name,
      avatarUrl: full.avatar_url,
      joinedAt: full.created_at,
    };

    return Response.json(safe);
  },
};

2つの注意点。まず、.json()を呼ぶ前に必ずupstream.okをチェックしてください — アップストリームからの500はHTMLやエラーページを含み、それに対して.json()を呼ぶと、 他の不正JSONと同じようにスローします。 次に、UPSTREAM_TOKENのようなシークレットはWranglerシークレット (wrangler secret put UPSTREAM_TOKEN)に保管しましょう — wrangler.tomlには決して入れず、gitにもコミットしないでください。

エッジでJSONレスポンスをキャッシュする

アップストリームが遅いまたは高価な場合、 Cache API でエッジ上にJSONをメモ化できます。各データセンターが独自のキャッシュを持つため、 フランクフルトの最初のユーザーがアップストリームコストを負担し、次の10,000人は近くのRAMから5ms以内で取得できます:

js
export default {
  async fetch(request, env, ctx) {
    const cache = caches.default;
    const cacheKey = new Request(request.url, request);

    let response = await cache.match(cacheKey);
    if (response) {
      return response;
    }

    const upstream = await fetch('https://api.example.com/popular-items');
    const data = await upstream.json();

    response = Response.json(data, {
      headers: {
        'Cache-Control': 'public, max-age=60',
      },
    });

    // Don't block the response on the cache write
    ctx.waitUntil(cache.put(cacheKey, response.clone()));

    return response;
  },
};

ctx.waitUntil()が非自明な部分です。これがないと、 cache.put()がawaitされ、レスポンスは気にしなくていいディスク書き込みを待ってしまいます。 waitUntilを使えば、ランタイムがバックグラウンドでキャッシュ書き込みを生かし続けている間に、 レスポンスを即座に返すことができます。アナリティクスビーコン、ログ転送、 あらゆるfire-and-forgetに同じパターンが使えます。

Wranglerでローカル開発

反復のためにCloudflareアカウントは不要です。 Wrangler をインストールし、プロジェクトを生成すれば、本番に近いローカルWorkersランタイムが手に入ります:

bash
npm create cloudflare@latest my-json-api
cd my-json-api
npm run dev

# Worker is now live at http://localhost:8787
# Hit it from another terminal:
curl -X POST http://localhost:8787 \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","plan":"pro"}'

ローカルランタイムはworkerdを使っており、これはCloudflareが本番で動かしているのと同じエンジンです。 異なる挙動(KVレイテンシ、キャッシュセマンティクス、request.cfフィールド)は よく文書化されており、シンプルなJSON APIでは稀にしか噛みつかれません。 wrangler deployでデプロイすれば、同じコードが数秒でグローバルに稼働します。

Worker JSON APIを構築するのに便利なツール

JSONを扱うWorkerを構築する際に私が頻繁に使うツールをいくつか: リバースエンジニアリングしようとしている汚いアップストリームレスポンスをきれいに表示するJSONフォーマッター、 POSTペイロードが失敗して正確にどこか知る必要があるときのJSONバリデーター、 書く前にフィールド選択のロジックを計画するためのJSON Path、そして 特定のエンドポイントでワイヤサイズが本当に問題になるかチェックしたいときのJSONミニファイアー

JSON形式自体は RFC 8259 で仕様化されています — 「自分のパーサーはNaNを許可するか?」のような境界ケースに遭遇したとき、 一読する価値があります(答え:許可すべきでない)。Cloudflare自身の Workersサンプルギャラリーには、 JWT検証、A/Bテスト、HTML書き換え、その他この記事の基本を超えた12以上のパターンのレシピがあります。

まとめ

Cloudflare WorkersはJSON APIに非常にマッチします — 小さく、速く、グローバルに分散し、 サイドプロジェクトを動かしっぱなしにできる程度に安い。ハッピーパスは request.json()Response.json()だけですが、本番パスには 4つの追加の習慣があります:ボディパースをtry/catchで囲む、CORSヘッダーを意図的に追加する、 プロキシされたレスポンスをパースする前にupstream.okをチェックする、 キャッシュ書き込みや他のバックグラウンド作業にはctx.waitUntilを使う。 この4つをきちんとやれば、落ちないWorkerを出荷できるはずです。