跳转到内容
搜索文档

在本地测试 webhook

使用 Cloudflare Worker 和 Cloudflare Tunnel 在本地测试 Cloudflare Stream webhook 通知。

最后更新 查看 MarkdownAgent 设置

Cloudflare Stream 无法向 localhost 或本地 IP 地址发送 webhook 通知。要在本地开发期间测试 webhook,您需要一个可公开访问的 URL,将请求转发到本地机器。

本示例展示如何:

  1. 启动 Cloudflare Tunnel 以获取本地环境的公开 URL。
  2. 将该 URL 注册为 webhook 端点,并返回签名密钥。
  3. 创建 Cloudflare Worker 接收 Stream webhook 事件并验证其签名。

所需条件

1. 创建 Worker 项目

创建一个接收 webhook 请求的新 Worker 项目:

npm create cloudflare@latest stream-webhook-handler

2. 启动 Cloudflare Tunnel

在注册 webhook URL 之前,您需要一个指向本地机器的公开 URL。在终端中,启动快速隧道,转发到默认 Wrangler 开发服务器端口(8787):

npx cloudflared tunnel --url http://localhost:8787

cloudflared 将输出类似以下的公开 URL:

https://example-words-here.trycloudflare.com

复制此 URL。每次重启隧道时 URL 都会变化。

3. 将隧道 URL 注册为 webhook 端点

使用 Stream API 将隧道 URL 设置为 webhook 通知 URL。API 响应包含 secret 字段 — 您需要此字段来验证 webhook 签名。

Required API token permissions

At least one of the following token permissions is required:
  • Stream Write
Create webhooksbash
curl "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/stream/webhook" \
	--request PUT \
	--header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
	--json '{
		"notificationUrl": "https://example-words-here.trycloudflare.com"
	}'

响应将包含 secret 字段:

示例响应json
{
	"result": {
		"notificationUrl": "https://example-words-here.trycloudflare.com",
		"modified": "2024-01-01T00:00:00.000000Z",
		"secret": "85011ed3a913c6ad5f9cf6c5573cc0a7"
	},
	"success": true,
	"errors": [],
	"messages": []
}

保存 secret 值。下一步将使用它。

4. 存储 webhook 密钥以供本地开发

在 Worker 项目根目录创建 .dev.vars 文件,并添加 API 响应中的 webhook 密钥:

.dev.varstxt
WEBHOOK_SECRET=85011ed3a913c6ad5f9cf6c5573cc0a7

将值替换为步骤 3 中的实际密钥。运行 wrangler dev 时,Wrangler 会自动加载 .dev.vars

5. 添加 webhook 处理器

将 Worker 项目中 src/index.ts 的内容替换为以下代码。此 Worker 接收 webhook POST 请求,验证签名,并记录 payload。

src/index.tsts
export interface Env {
	WEBHOOK_SECRET: string;
}

async function verifyWebhookSignature(
	request: Request,
	secret: string,
): Promise<{ valid: boolean; body: string }> {
	const signatureHeader = request.headers.get("Webhook-Signature");
	if (!signatureHeader) {
		return { valid: false, body: "" };
	}

	const body = await request.text();

	// Parse "time=<unix_ts>,sig1=<hex_signature>"
	const parts = Object.fromEntries(
		signatureHeader.split(",").map((part) => {
			const [key, value] = part.split("=");
			return [key, value];
		}),
	);

	const time = parts["time"];
	const receivedSig = parts["sig1"];

	if (!time || !receivedSig) {
		return { valid: false, body };
	}

	// Build the source string: "<time>.<body>"
	const sourceString = `${time}.${body}`;
	const encoder = new TextEncoder();

	const key = await crypto.subtle.importKey(
		"raw",
		encoder.encode(secret),
		{ name: "HMAC", hash: "SHA-256" },
		false,
		["sign"],
	);

	const signature = await crypto.subtle.sign(
		"HMAC",
		key,
		encoder.encode(sourceString),
	);

	const expectedSig = [...new Uint8Array(signature)]
		.map((b) => b.toString(16).padStart(2, "0"))
		.join("");

	// Use a timing-safe comparison.
	// Do not return early when lengths differ — that leaks the expected
	// signature's length through timing.  Compare against self and negate instead.
	const expectedBytes = encoder.encode(expectedSig);
	const receivedBytes = encoder.encode(receivedSig);

	const lengthsMatch = expectedBytes.byteLength === receivedBytes.byteLength;
	const signaturesMatch = lengthsMatch
		? crypto.subtle.timingSafeEqual(expectedBytes, receivedBytes)
		: !crypto.subtle.timingSafeEqual(expectedBytes, expectedBytes);

	return { valid: signaturesMatch, body };
}

export default {
	async fetch(request: Request, env: Env): Promise<Response> {
		if (request.method !== "POST") {
			return new Response("Method not allowed", { status: 405 });
		}

		if (!env.WEBHOOK_SECRET) {
			console.error("WEBHOOK_SECRET is not set");
			return new Response("Server misconfigured", { status: 500 });
		}

		const { valid, body } = await verifyWebhookSignature(
			request,
			env.WEBHOOK_SECRET,
		);

		if (!valid) {
			console.error("Invalid webhook signature");
			return new Response("Invalid signature", { status: 403 });
		}

		console.log("Webhook signature verified successfully");

		const payload = JSON.parse(body);

		console.log("Stream webhook received:", JSON.stringify(payload, null, 2));
		console.log("Video UID:", payload.uid);
		console.log("Status:", payload.status?.state);
		console.log("Ready to stream:", payload.readyToStream);

		// Add your own processing logic here — for example, update a database
		// or notify a downstream service.

		return new Response("OK", { status: 200 });
	},
} satisfies ExportedHandler<Env>;

6. 启动本地开发服务器

在另一个终端中(保持隧道运行),使用 Wrangler 在本地启动 Worker:

npx wrangler dev

Wrangler 会自动从 .dev.vars 文件加载 WEBHOOK_SECRET

7. 触发测试事件

向 Stream 上传视频以触发 webhook 事件。视频处理完成后,您将在运行 wrangler dev 的终端中看到 webhook payload 日志,以及签名已验证的确认信息。

迁移到生产环境

本地测试完成后,部署 Worker 并将 webhook URL 更新为生产端点:

npx wrangler deploy

然后将 webhook 订阅更新为指向已部署的 Worker URL:

Required API token permissions

At least one of the following token permissions is required:
  • Stream Write
Create webhooksbash
curl "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/stream/webhook" \
	--request PUT \
	--header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
	--json '{
		"notificationUrl": "https://your-worker.your-subdomain.workers.dev"
	}'

这篇文档对您有帮助吗?