quality / changes (push) Successful in 9s
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / web (push) Successful in 1m4s
quality / api (push) Successful in 54s
CD / quality (push) Successful in 2m17s
CD / publish (push) Successful in 2m21s
Worker сам ходит на origin по Cron Trigger; CFDM кладёт цели в KV и забирает результаты без POST /probe. Co-authored-by: Cursor <cursoragent@cursor.com>
135 lines
4.3 KiB
TypeScript
135 lines
4.3 KiB
TypeScript
import type { CfDnsRecord } from "@cfdm/shared";
|
||
import { AppError } from "../../errors.js";
|
||
import { parseRetryAfter } from "../cf-retry.js";
|
||
|
||
export const CF_API_BASE = "https://api.cloudflare.com/client/v4";
|
||
|
||
export interface CfResponse<T> {
|
||
success: boolean;
|
||
result?: T;
|
||
errors?: Array<{ code: number; message: string }>;
|
||
}
|
||
|
||
export function mapCloudflareFailure(
|
||
operation: string,
|
||
status: number,
|
||
message: string,
|
||
): AppError {
|
||
const lower = message.toLowerCase();
|
||
if (status === 401 || status === 403 || lower.includes("authentication")) {
|
||
if (
|
||
operation.includes("workers") ||
|
||
operation.includes("kv_") ||
|
||
operation.includes("accounts")
|
||
) {
|
||
return AppError.cloudflareAuthFailed(
|
||
"Токену нужны права Account: Workers Scripts Write и Workers KV Storage Write. Zone DNS недостаточно.",
|
||
);
|
||
}
|
||
return AppError.cloudflareAuthFailed(
|
||
"Cloudflare отклонил токен. Проверьте CLOUDFLARE_API_TOKEN.",
|
||
);
|
||
}
|
||
if (status === 429 || lower.includes("rate limit")) {
|
||
return AppError.rateLimited();
|
||
}
|
||
if (lower.includes("zone") && (lower.includes("not found") || status === 404)) {
|
||
return AppError.zoneNotFound();
|
||
}
|
||
if (
|
||
operation.includes("healthcheck") &&
|
||
(lower.includes("plan") ||
|
||
lower.includes("not entitled") ||
|
||
lower.includes("not allowed") ||
|
||
lower.includes("permission"))
|
||
) {
|
||
return AppError.healthcheckCreateFailed(
|
||
"Cloudflare Health Checks недоступны для этой зоны. Используйте локальные проверки.",
|
||
);
|
||
}
|
||
if (operation.includes("dns") || operation.includes("dns_record")) {
|
||
return AppError.dnsUpdateFailed(`Не удалось обновить DNS в Cloudflare: ${message}`);
|
||
}
|
||
return AppError.cloudflare(`${operation}: ${message}`);
|
||
}
|
||
|
||
export async function handleCfResponse<T>(
|
||
response: Response,
|
||
operation: string,
|
||
): Promise<T> {
|
||
if (response.status === 429) {
|
||
const wait = parseRetryAfter(response.headers) ?? 5000;
|
||
throw AppError.rateLimited(
|
||
`Cloudflare временно ограничил запросы. Повторите через ${Math.ceil(wait / 1000)} с.`,
|
||
);
|
||
}
|
||
|
||
const body = (await response.json()) as CfResponse<T>;
|
||
if (!body.success) {
|
||
const msg =
|
||
body.errors?.map((e) => e.message).join("; ") ?? "unknown cloudflare error";
|
||
throw mapCloudflareFailure(operation, response.status, msg);
|
||
}
|
||
if (body.result === undefined) {
|
||
throw mapCloudflareFailure(operation, response.status, "empty result");
|
||
}
|
||
return body.result;
|
||
}
|
||
|
||
/** KV PUT / schedules often return `{ success: true }` without `result`. */
|
||
export async function handleCfSuccess(
|
||
response: Response,
|
||
operation: string,
|
||
): Promise<void> {
|
||
if (response.status === 429) {
|
||
const wait = parseRetryAfter(response.headers) ?? 5000;
|
||
throw AppError.rateLimited(
|
||
`Cloudflare временно ограничил запросы. Повторите через ${Math.ceil(wait / 1000)} с.`,
|
||
);
|
||
}
|
||
const text = await response.text();
|
||
if (!text) {
|
||
if (!response.ok) {
|
||
throw mapCloudflareFailure(operation, response.status, String(response.status));
|
||
}
|
||
return;
|
||
}
|
||
let body: CfResponse<unknown>;
|
||
try {
|
||
body = JSON.parse(text) as CfResponse<unknown>;
|
||
} catch {
|
||
if (!response.ok) {
|
||
throw mapCloudflareFailure(operation, response.status, text.slice(0, 180));
|
||
}
|
||
return;
|
||
}
|
||
if (!body.success) {
|
||
const msg =
|
||
body.errors?.map((e) => e.message).join("; ") ?? "unknown cloudflare error";
|
||
throw mapCloudflareFailure(operation, response.status, msg);
|
||
}
|
||
}
|
||
|
||
export async function cfRequest<T>(
|
||
token: string,
|
||
path: string,
|
||
operation: string,
|
||
init: RequestInit = {},
|
||
): Promise<T> {
|
||
const response = await fetch(`${CF_API_BASE}${path}`, {
|
||
...init,
|
||
headers: {
|
||
Authorization: `Bearer ${token}`,
|
||
...(init.body ? { "Content-Type": "application/json" } : {}),
|
||
...init.headers,
|
||
},
|
||
signal: init.signal ?? AbortSignal.timeout(30_000),
|
||
});
|
||
if (response.status >= 500 || response.status === 429) {
|
||
throw mapCloudflareFailure(operation, response.status, String(response.status));
|
||
}
|
||
return handleCfResponse<T>(response, operation);
|
||
}
|
||
|
||
export type DnsRecordResult = CfDnsRecord;
|