mcpField
C
convex-http-actions

convex-http-actions

github.com/waynesutton开发4052.7k 次安装4 周前更新
代码仓库

关于 convex-http-actions

面向 Convex 开发者的技能指南,介绍如何构建部署于 `.convex.site` 的 HTTP 端点,涵盖 Webhook、普通 HTTP API、文件传输与流式响应。

主要功能

  1. 01创建 HTTP 路由与处理请求
  2. 02验证 Webhook 签名并去重事件
  3. 03通过内部函数调用查询和变更
  4. 04支持 CORS、身份验证与流式响应
  5. 05上传、提供和下载文件

适用场景

  1. 01接收第三方服务发送的 Webhook
  2. 02为不使用 Convex 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 actions

定义由部署的 .convex.site 域名提供服务的公共 HTTP 端点。唯一的规则:httpAction 处理程序将请求的每个字节都视为不可信,先验证,再通过 ctx.runQuery 或 ctx.runMutation 调用 internal.* 函数。它绝不会直接读取或写入 ctx.db。

适用场景

  • 第三方服务(Stripe、Clerk、Resend、GitHub)需要一个用于 POST 事件的 URL
  • 不使用 Convex SDK 的客户端(移动应用、CLI、另一个后端)需要普通的 HTTP API
  • 浏览器需要下载或流式传输字节,而不经过 useQuery
  • 脚本或表单需要通过 HTTP 上传字节,而不是使用上传 URL

httpAction 还是 mutation

调用方使用方式
使用 convex/react 的自有 React 或 Next.js 应用query 和 mutation。跳过 HTTP actions。
发送 Webhook 的外部服务httpAction
无法使用 Convex SDK 的客户端httpAction
定时或内部任务internalMutation 或 internalAction

HTTP actions 没有参数验证器,也不会自动进行身份验证。它们在默认的 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.site,而不是 .convex.cloud。path 必须完全匹配;对于动态路径段,请使用 pathPrefix: "/api/items/",并从 new URL(request.url).pathname 中读取剩余路径。

在处理程序中调用查询和 mutation

ctx.runQuery、ctx.runMutation、ctx.runAction、ctx.storage、ctx.scheduler 和 ctx.auth 都可在 httpAction 中使用。将它们指向 internal.* 函数,避免数据库逻辑也作为公共 Convex 函数公开。参数仍会经过目标函数的验证器,因此格式错误的参数会在该处触发清晰的错误。

// 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 });
  },
});

对于耗时较长的工作(发送电子邮件、调用大模型),应立即响应 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 响应,有些服务提供商会重试;遇到网络波动时也可能重试,因此同一事件可能会收到多次。在 mutation 内部(这是一个事务)按事件 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;
  },
});

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

打开 references/webhooks.md,查看 Stripe(在 Node action 中使用 constructEvent)、Clerk 和 Resend(svix 标头)、带时间戳的重放保护,以及包含状态跟踪的更完整幂等性表。

CORS、路径参数、身份验证标头、流式传输、文件

浏览器会在跨源 POST 请求前发送 OPTIONS 预检请求,因此每个面向浏览器的路径都需要一个 OPTIONS 路由,并返回与实际路由相同的 Access-Control-* 标头。使用已配置的身份验证服务提供商颁发的 Bearer 令牌时,通过 await ctx.auth.getUserIdentity() 读取;查询中也使用相同的调用。通过返回 new Response(readableStream) 即可进行流式传输;使用 ctx.storage.get(storageId) 提供文件,或通过 request.blob() 接收文件,再调用 ctx.storage.store(blob) 保存。

构建 REST 风格的 API 时,请打开 references/rest-and-cors.md,了解路径参数和 ID 验证、CORS 预检请求配对、JSON 错误约定、Bearer 和 API 密钥身份验证、流式响应,以及文件上传和下载路由。

常见错误

错误出错原因正确做法
将路由器放在 convex/api.ts 或 convex/routes.ts 中只有 convex/http.ts 会作为路由器加载保留一个 http.ts,并使用 export default http
调用 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 安排任务
对同一事件进行多次处理服务提供商会在任何非 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

文档

评论

支持 Markdown