在 R2 中存储应用程序请求和响应的 JSON 记录。此模板记录所选标头以及每个正文最多 256 KiB 的 JSON 正文文本,而不更改请求或响应。
使用其正文已获准捕获的受控测试端点。脚本会省略查询参数、cookie 和授权标头,但正文和 URL 路径仍可能包含敏感数据。保持存储桶为私有,并配置保留规则。
在 Worker 项目中,将 @cloudflare/zt-types ↗ 2026.10.1 或更高版本安装为开发依赖项:
npm i -D @cloudflare/zt-typesyarn add -D @cloudflare/zt-typespnpm add -D @cloudflare/zt-typesbun add -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:
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;
}
}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 检查顺序。