mcpField
C
convex-http-actions

convex-http-actions

github.com/waynesutton開発4052.7k 回インストール4 週間前に更新
リポジトリ

convex-http-actions について

`.convex.site` 上の HTTP エンドポイントを構築する Convex 開発者向けのスキルガイドです。Webhook、HTTP API、ファイル転送、ストリーミング応答を扱います。

主な機能

  1. 01HTTP ルートの定義とリクエスト処理
  2. 02Webhook 署名の検証とイベント重複排除
  3. 03内部関数経由でのクエリとミューテーション呼び出し
  4. 04CORS、認証、ストリーミングへの対応
  5. 05ファイルのアップロード、提供、ダウンロード

活用シーン

  1. 01サードパーティサービスから Webhook を受信
  2. 02Convex SDK を使わないクライアントへの API 提供
  3. 03ブラウザーやスクリプト経由でのファイル転送

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/waynesutton/builder-skills --skill convex-http-actions

インストール後、Claude Code で/convex-http-actionsを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

agents/
openai.yaml
assets/
small-logo.svg
references/
rest-and-cors.md
webhooks.md

詳細

Convex HTTP アクション

デプロイメントの .convex.site ドメインで提供されるパブリック HTTP エンドポイントを定義します。ルールは 1 つです。httpAction ハンドラーはリクエストのすべてのバイトを信頼できないものとして扱い、検証してから、ctx.runQuery または ctx.runMutation で internal.* 関数を呼び出します。ctx.db を直接読み書きすることはありません。

どのような場合に使うか

  • サードパーティ(Stripe、Clerk、Resend、GitHub)がイベントを POST する URL を必要としている
  • Convex SDK を使わないクライアント(モバイルアプリ、CLI、別のバックエンド)が通常の HTTP API を必要としている
  • ブラウザーが useQuery を経由せずにバイトをダウンロードまたはストリーミングする必要がある
  • スクリプトやフォームがアップロード URL ではなく HTTP でバイトをアップロードする必要がある

httpAction または mutation

呼び出し元使用するもの
convex/react を使用する独自の React または Next.js アプリquery と mutation。HTTP アクションは使いません。
Webhook を送信する外部サービスhttpAction
Convex SDK を使えないクライアントhttpAction
スケジュール済みの処理または内部処理internalMutation または internalAction

HTTP アクションには引数バリデーターも自動認証もありません。デフォルトの Convex ランタイムで実行されるため、Web API(fetch、crypto.subtle、Response、ReadableStream)は "use node" なしで動作します。リクエストとレスポンスの本文は 20MB に制限されています。

ルーターの基本構成

ルーターは convex/http.ts に配置し、デフォルトエクスポートにする必要があります。ほかのファイルにあるルーターは無視されます。

// convex/http.ts
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { internal } from "./_generated/api";

const http = httpRouter();

http.route({
  path: "/api/items",
  method: "POST",
  handler: httpAction(async (ctx, request) => {
    let body: { name?: unknown };
    try {
      body = await request.json();
    } catch {
      return json({ error: "Invalid JSON body" }, 400);
    }
    if (typeof body.name !== "string") {
      return json({ error: "name is required" }, 400);
    }
    const id = await ctx.runMutation(internal.items.create, { name: body.name });
    return json({ id }, 201);
  }),
});

export default http;

// JSON response helper shared by every route in this file
function json(data: unknown, status = 200): Response {
  return new Response(JSON.stringify(data), {
    status,
    headers: { "Content-Type": "application/json" },
  });
}

エンドポイントには https://<deployment>.convex.site/api/items でアクセスできます。.convex.cloud ではなく .convex.site であることに注意してください。path は完全一致です。動的セグメントには pathPrefix: "/api/items/" を使い、new URL(request.url).pathname から末尾のパスを読み取ってください。

ハンドラーからクエリとミューテーションを呼び出す

ctx.runQuery、ctx.runMutation、ctx.runAction、ctx.storage、ctx.scheduler、ctx.auth はすべて httpAction 内で使用できます。データベースのロジックが Convex のパブリック関数としても公開されないよう、internal.* 関数を指定してください。引数は引き続き呼び出し先のバリデーターを通るため、形式が不正な場合はそこで明確なエラーになります。

// convex/items.ts
import { internalMutation } from "./_generated/server";
import { v } from "convex/values";

export const create = internalMutation({
  args: { name: v.string() },
  returns: v.id("items"),
  handler: async (ctx, args) => {
    return await ctx.db.insert("items", { name: args.name });
  },
});

メール送信や LLM の呼び出しなど、時間のかかる処理では、すぐに 200 を返し、ctx.scheduler.runAfter(0, internal.jobs.process, args) で処理を引き継ぎます。ハンドラーが停滞すると、プロバイダーはタイムアウトして再試行します。

署名検証付き Webhook

request.text() で生の本文を一度だけ読み取ります。先に解析するとバイト列が変わり、署名が無効になります。この汎用的な HMAC SHA-256 のパターンはほとんどのプロバイダーに対応しています。Stripe、Clerk、Resend 固有の情報は以下のリファレンスを参照してください。

// convex/http.ts (add to the router above)
http.route({
  path: "/webhooks/provider",
  method: "POST",
  handler: httpAction(async (ctx, request) => {
    const signature = request.headers.get("x-signature");
    if (!signature) return new Response("Missing signature", { status: 400 });

    const raw = await request.text();
    const secret = process.env.PROVIDER_WEBHOOK_SECRET;
    if (!secret) return new Response("Webhook secret not configured", { status: 500 });
    if (!(await verifyHmac(raw, signature, secret))) {
      return new Response("Invalid signature", { status: 401 });
    }

    const event = JSON.parse(raw) as { id: string; type: string; data: unknown };
    // The mutation returns early if this event id was already processed.
    await ctx.runMutation(internal.webhooks.record, {
      source: "provider",
      eventId: event.id,
      type: event.type,
      payload: event.data,
    });
    return new Response(null, { status: 200 });
  }),
});

async function verifyHmac(payload: string, signature: string, secret: string): Promise<boolean> {
  const enc = new TextEncoder();
  const key = await crypto.subtle.importKey(
    "raw",
    enc.encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );
  const mac = await crypto.subtle.sign("HMAC", key, enc.encode(payload));
  const expected = Array.from(new Uint8Array(mac))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
  return safeEqual(expected, signature);
}

// Constant time compare so response timing does not leak the signature
function safeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  return diff === 0;
}

プロバイダーは 2xx 以外の応答やネットワークの一時的な障害があると再試行するため、同じイベントが複数回届くことがあります。トランザクションであるミューテーション内で、イベント ID による重複排除を行います。

// convex/webhooks.ts
import { internalMutation } from "./_generated/server";
import { v } from "convex/values";

export const record = internalMutation({
  args: { source: v.string(), eventId: v.string(), type: v.string(), payload: v.any() },
  returns: v.null(),
  handler: async (ctx, args) => {
    const seen = await ctx.db
      .query("webhookEvents")
      .withIndex("by_source_and_event_id", (q) =>
        q.eq("source", args.source).eq("eventId", args.eventId),
      )
      .unique();
    if (seen) return null;
    await ctx.db.insert("webhookEvents", args);
    // Apply the event's side effects here, in the same transaction.
    return null;
  },
});

スキーマ:webhookEvents: defineTable({ source: v.string(), eventId: v.string(), type: v.string(), payload: v.any() }).index("by_source_and_event_id", ["source", "eventId"])。

Stripe(Node アクション内の constructEvent)、Clerk と Resend(svix ヘッダー)、タイムスタンプを使ったリプレイ保護、ステータス追跡を含む、より詳しい冪等性テーブルについては、references/webhooks.md を参照してください。

CORS、パスパラメーター、認証ヘッダー、ストリーミング、ファイル

ブラウザーはクロスオリジンの POST の前に OPTIONS プリフライトを送信するため、ブラウザーからアクセスされる各パスには、実際のルートと同じ Access-Control-* ヘッダーを返す OPTIONS ルートが必要です。設定済みの認証プロバイダーのベアラートークンは、クエリで使うものと同じ await ctx.auth.getUserIdentity() を使って読み取ります。new Response(readableStream) を返すことでストリーミングでき、ctx.storage.get(storageId) でファイルを提供するか、request.blob() で受け取ってから ctx.storage.store(blob) で保存できます。

REST 形式の API を構築する場合は、パスパラメーターと ID の検証、CORS プリフライトのペア、JSON エラーの規約、ベアラー認証と API キー認証、ストリーミングレスポンス、ファイルのアップロードとダウンロードのルートについて説明している references/rest-and-cors.md を参照してください。

よくある間違い

間違い問題が発生する理由代わりに行うこと
convex/api.ts または convex/routes.ts にルーターを置くルーターとして読み込まれるのは convex/http.ts のみですexport default http を含む http.ts を1つだけ用意します
https://<deployment>.convex.cloud/webhooks/... を呼び出すHTTP アクションは .convex.site 上で動作しますプロバイダーのダッシュボードで .convex.site の URL を使用します
署名を検証する前に await request.json() を呼び出す再シリアライズされた本文は、署名されたバイト列と一致しなくなりますrequest.text() を一度だけ呼び出し、署名を検証してから JSON.parse します
「Stripe から送られてきた」からといって本文を信用する誰でも公開 URL に POST できますすべてのリクエストで署名を検証します
runMutation から api.* のターゲットを使用する同じロジックが未認証のまま二重に公開されますinternal.* 関数をターゲットにします
ブラウザーから呼び出すエンドポイントに OPTIONS ルートがないプリフライトに失敗し、実際のリクエストが送信されませんCORS ヘッダーを付けた OPTIONS ルートをパスごとに追加します
ハンドラー内で数分かかる処理を実行するプロバイダーがタイムアウトして再試行し、処理が重複しますすぐに 200 を返し、ctx.scheduler.runAfter でスケジュールします
同じイベントを2回処理するプロバイダーは 2xx 以外の応答を受け取ると再試行しますミューテーション内でイベント ID による重複排除を行います
応答に Content-Type: application/json がないクライアントが解析できないテキストを受け取りますすべての JSON 応答で json() ヘルパーを使用します

チェックリスト

  • ルーターが convex/http.ts にあり、デフォルトエクスポートされている
  • すべてのルートで try/catch 内に本文を解析し、不正な入力には 400 を返す
  • ハンドラーは internal.* 関数を呼び出し、ctx.db を直接呼び出さない
  • Webhook ルートは request.text() を一度だけ読み取り、解析する前に署名を検証する
  • Webhook のシークレットは process.env から取得し、ハードコードせずデプロイ環境に設定する
  • ミューテーション内でプロバイダーのイベント ID による重複排除を行う
  • 時間のかかる処理をスケジュールし、ハンドラーは速やかに 2xx を返す
  • ブラウザーから呼び出すルートには、対応する OPTIONS ルートがある
  • プロバイダーのダッシュボードで .convex.site の URL を指定している
  • JSON 応答に Content-Type: application/json を設定する

ドキュメント

コメント