跳转到内容
本站为社区维护的非官方中文翻译,与 Cloudflare, Inc. 无关联。本站不提供账户登录,请勿在任何非官方站点输入您的 Cloudflare 账户信息。官方文档:developers.cloudflare.com

将标头和正文存储为 JSON

最后更新 查看 MarkdownAgent 设置

在 R2 中存储应用程序请求和响应的 JSON 记录。此模板记录所选标头以及每个正文最多 256 KiB 的 JSON 正文文本,而不更改请求或响应。

使用其正文已获准捕获的受控测试端点。脚本会省略查询参数、cookie 和授权标头,但正文和 URL 路径仍可能包含敏感数据。保持存储桶为私有,并配置保留规则。

配置 Worker

在 Worker 项目中,将 @cloudflare/zt-types ↗ 2026.10.1 或更高版本安装为开发依赖项:

npm i -D @cloudflare/zt-types

保留 src/index.ts 中 GatewayMiddlewareContext 和 GatewayMiddlewareNext 的 import type 声明。它们描述 Gateway 上下文和延续函数,并会在 TypeScript 编译时被移除。从 cloudflare:workers 导入运行时 WorkerEntrypoint 类。

创建一个名为 gateway-json-captures 的 R2 存储桶。将此绑定添加到 Worker 配置:

{
  "$schema": "./node_modules/wrangler/config-schema.json",
  "r2_buckets": [
    {
      "binding": "CAPTURES",
      "bucket_name": "gateway-json-captures"
    }
  ]
}
[[r2_buckets]]
binding = "CAPTURES"
bucket_name = "gateway-json-captures"

将此脚本用作 src/index.ts:

src/index.jsjs
import { WorkerEntrypoint } from "cloudflare:workers";

const LIMIT = 256 * 1024;

function headersToKeep(headers) {
	return Object.fromEntries(
		["content-type", "accept"].flatMap((name) => {
			const value = headers.get(name);
			return value === null ? [] : [[name, value]];
		}),
	);
}

async function bodyToKeep(message) {
	if (!message.body) return { state: "empty" };
	const reader = message.body.getReader();
	const type = message.headers.get("content-type") ?? "";
	if (
		message.headers.has("content-encoding") ||
		!/^application\/(?:[\w.-]+\+)?json\b/i.test(type)
	) {
		void reader.cancel().catch(() => {});
		reader.releaseLock();
		return { state: "skipped-content-type-or-encoding" };
	}
	const decoder = new TextDecoder();
	let size = 0;
	let text = "";
	try {
		while (true) {
			const { done, value } = await reader.read();
			if (done) return { state: "captured", text: text + decoder.decode() };
			size += value.byteLength;
			if (size > LIMIT) {
				void reader.cancel().catch(() => {});
				return { state: "skipped-size-limit" };
			}
			text += decoder.decode(value, { stream: true });
		}
	} catch {
		return { state: "capture-failed" };
	} finally {
		reader.releaseLock();
	}
}

export class GatewayMiddleware extends WorkerEntrypoint {
	async handle(request, context, next) {
		const upload = bodyToKeep(request.clone());
		const response = await next(request);
		const download = bodyToKeep(response.clone());
		const url = new URL(request.url);
		this.ctx.waitUntil(
			(async () => {
				const record = {
					timestamp: new Date().toISOString(),
					policyId: context.policyId,
					request: {
						method: request.method,
						url: url.origin + url.pathname,
						headers: headersToKeep(request.headers),
						body: await upload,
					},
					response: {
						status: response.status,
						headers: headersToKeep(response.headers),
						body: await download,
					},
				};
				await this.env.CAPTURES.put(
					`${crypto.randomUUID()}.json`,
					JSON.stringify(record),
					{
						httpMetadata: { contentType: "application/json" },
					},
				);
			})().catch(() => console.error("JSON capture failed")),
		);
		return response;
	}
}
src/index.tsts
import { WorkerEntrypoint } from "cloudflare:workers";
import type {
	GatewayMiddlewareContext,
	GatewayMiddlewareNext,
} from "@cloudflare/zt-types";

interface Env {
	CAPTURES: R2Bucket;
}
const LIMIT = 256 * 1024;

function headersToKeep(headers: Headers): Record<string, string> {
	return Object.fromEntries(
		["content-type", "accept"].flatMap((name) => {
			const value = headers.get(name);
			return value === null ? [] : [[name, value]];
		}),
	);
}

async function bodyToKeep(
	message: Request | Response,
): Promise<{ state: string; text?: string }> {
	if (!message.body) return { state: "empty" };
	const reader = message.body.getReader();
	const type = message.headers.get("content-type") ?? "";
	if (
		message.headers.has("content-encoding") ||
		!/^application\/(?:[\w.-]+\+)?json\b/i.test(type)
	) {
		void reader.cancel().catch(() => {});
		reader.releaseLock();
		return { state: "skipped-content-type-or-encoding" };
	}
	const decoder = new TextDecoder();
	let size = 0;
	let text = "";
	try {
		while (true) {
			const { done, value } = await reader.read();
			if (done) return { state: "captured", text: text + decoder.decode() };
			size += value.byteLength;
			if (size > LIMIT) {
				void reader.cancel().catch(() => {});
				return { state: "skipped-size-limit" };
			}
			text += decoder.decode(value, { stream: true });
		}
	} catch {
		return { state: "capture-failed" };
	} finally {
		reader.releaseLock();
	}
}

export class GatewayMiddleware extends WorkerEntrypoint<Env> {
	async handle(
		request: Request,
		context: GatewayMiddlewareContext,
		next: GatewayMiddlewareNext,
	): Promise<Response> {
		const upload = bodyToKeep(request.clone());
		const response = await next(request);
		const download = bodyToKeep(response.clone());
		const url = new URL(request.url);
		this.ctx.waitUntil(
			(async () => {
				const record = {
					timestamp: new Date().toISOString(),
					policyId: context.policyId,
					request: {
						method: request.method,
						url: url.origin + url.pathname,
						headers: headersToKeep(request.headers),
						body: await upload,
					},
					response: {
						status: response.status,
						headers: headersToKeep(response.headers),
						body: await download,
					},
				};
				await this.env.CAPTURES.put(
					`${crypto.randomUUID()}.json`,
					JSON.stringify(record),
					{
						httpMetadata: { contentType: "application/json" },
					},
				);
			})().catch(() => console.error("JSON capture failed")),
		);
		return response;
	}
}

Worker 在 next() 消费原始请求之前开始读取克隆的请求。它克隆上游响应以进行捕获,并返回原始响应。每个读取器统计流式传输的字节数,并在超出限制时取消其捕获分支。记录会报告跳过或失败的捕获,而不是将部分正文存储为完整正文。

正文值是归档 JSON 结构中的 UTF-8 文本。其他内容类型和带有 Content-Encoding 的正文会被跳过。将测试端点配置为交换未压缩 JSON。要添加标头,请有意扩展允许列表,而不是复制每个标头。

转发并测试

部署 Worker,并为特定测试 API 主机和路径创建 Forward 策略,范围限定到你的测试用户组。

发送小型 JSON 请求,并验证应用程序收到原始正文、客户端收到原始响应。检查 R2 记录中的两个正文。测试大于 256 KiB 的正文,并确认其记录显示 skipped-size-limit。检查测试 cookie、授权标头和查询参数是否未出现在归档中。

克隆消费者可能以不同速度缓冲数据,后台捕获可能超时。请保持策略范围较窄,并测试传输速度以及正文大小。此脚本在 Gateway 的最终响应检查之前记录上游响应。之后的 Gateway Block 决定不会移除已捕获到 R2 中的数据。请参阅 Forward 检查顺序。

这篇文档对您有帮助吗?