feat(health): деплоить probe-Worker из CFDM и опрашивать цели с edge
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 <[email protected]>
This commit is contained in:
Denozordec
2026-08-19 17:22:07 +07:00
co-authored by Cursor
parent 2c92e78b24
commit 4c4908558b
38 changed files with 1941 additions and 555 deletions
+53
View File
@@ -7,6 +7,8 @@ import type {
} from "@cfdm/shared";
import { createDnsAdapter } from "./cloudflare/dns-service.js";
import { createHealthCheckAdapter, type CfHealthCheckPayload } from "./cloudflare/healthcheck-service.js";
import { createKvAdapter } from "./cloudflare/kv-service.js";
import { createWorkersAdapter } from "./cloudflare/workers-service.js";
import { createZoneAdapter } from "./cloudflare/zone-service.js";
export type { CfHealthCheckPayload };
@@ -15,11 +17,21 @@ export class CloudflareClient {
private readonly zones;
private readonly dns;
private readonly healthchecks;
private readonly kv;
private readonly workers;
private readonly token;
constructor(token: string) {
this.token = token.trim();
this.zones = createZoneAdapter(token);
this.dns = createDnsAdapter(token);
this.healthchecks = createHealthCheckAdapter(token);
this.kv = createKvAdapter(token);
this.workers = createWorkersAdapter(token);
}
get isConfigured(): boolean {
return this.token.length > 0;
}
listZones(): Promise<CfZone[]> {
@@ -81,4 +93,45 @@ export class CloudflareClient {
deleteHealthCheck(zoneId: string, id: string): Promise<void> {
return this.healthchecks.deleteHealthCheck(zoneId, id);
}
listAccounts() {
return this.workers.listAccounts();
}
listKvNamespaces(accountId: string) {
return this.kv.listNamespaces(accountId);
}
createKvNamespace(accountId: string, title: string) {
return this.kv.createNamespace(accountId, title);
}
kvGet(accountId: string, namespaceId: string, key: string) {
return this.kv.getValue(accountId, namespaceId, key);
}
kvPut(accountId: string, namespaceId: string, key: string, value: string) {
return this.kv.putValue(accountId, namespaceId, key, value);
}
putWorkerScript(opts: {
accountId: string;
scriptName: string;
source: string;
kvNamespaceId: string;
}) {
return this.workers.putScript(opts);
}
putWorkerSchedules(accountId: string, scriptName: string, crons: string[]) {
return this.workers.putSchedules(accountId, scriptName, crons);
}
enableWorkersDev(accountId: string, scriptName: string) {
return this.workers.enableWorkersDev(accountId, scriptName);
}
getWorkersSubdomain(accountId: string) {
return this.workers.getWorkersSubdomain(accountId);
}
}
+43
View File
@@ -17,6 +17,15 @@ export function mapCloudflareFailure(
): 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.",
);
@@ -67,6 +76,40 @@ export async function handleCfResponse<T>(
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,
+100
View File
@@ -0,0 +1,100 @@
import { CF_API_BASE, handleCfResponse, handleCfSuccess, mapCloudflareFailure } from "./http.js";
export interface CfKvNamespace {
id: string;
title: string;
}
export function createKvAdapter(token: string) {
return {
async listNamespaces(accountId: string): Promise<CfKvNamespace[]> {
const all: CfKvNamespace[] = [];
let page = 1;
while (true) {
const url = new URL(
`${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces`,
);
url.searchParams.set("per_page", "100");
url.searchParams.set("page", String(page));
const response = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
});
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure("kv_list", response.status, String(response.status));
}
const batch = await handleCfResponse<CfKvNamespace[]>(response, "kv_list");
all.push(...batch);
if (batch.length < 100) break;
page += 1;
}
return all;
},
async createNamespace(accountId: string, title: string): Promise<CfKvNamespace> {
const response = await fetch(
`${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ title }),
signal: AbortSignal.timeout(30_000),
},
);
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure("kv_create", response.status, String(response.status));
}
return handleCfResponse<CfKvNamespace>(response, "kv_create");
},
async getValue(
accountId: string,
namespaceId: string,
key: string,
): Promise<string | null> {
const response = await fetch(
`${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${encodeURIComponent(key)}`,
{
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
},
);
if (response.status === 404) return null;
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure("kv_get", response.status, String(response.status));
}
if (!response.ok) {
const text = await response.text().catch(() => "");
throw mapCloudflareFailure("kv_get", response.status, text.slice(0, 180));
}
return response.text();
},
async putValue(
accountId: string,
namespaceId: string,
key: string,
value: string,
): Promise<void> {
const response = await fetch(
`${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${encodeURIComponent(key)}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "text/plain",
},
body: value,
signal: AbortSignal.timeout(30_000),
},
);
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure("kv_put", response.status, String(response.status));
}
await handleCfSuccess(response, "kv_put");
},
};
}
@@ -0,0 +1,142 @@
import {
CF_API_BASE,
handleCfResponse,
handleCfSuccess,
mapCloudflareFailure,
} from "./http.js";
export interface CfAccount {
id: string;
name?: string;
}
export interface CfWorkersSubdomain {
subdomain?: string;
enabled?: boolean;
}
export function createWorkersAdapter(token: string) {
return {
async listAccounts(): Promise<CfAccount[]> {
const response = await fetch(`${CF_API_BASE}/accounts?per_page=50`, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
});
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure("list_accounts", response.status, String(response.status));
}
return handleCfResponse<CfAccount[]>(response, "list_accounts");
},
async putScript(opts: {
accountId: string;
scriptName: string;
source: string;
kvNamespaceId: string;
filename?: string;
}): Promise<void> {
const filename = opts.filename ?? "index.mjs";
const metadata = {
main_module: filename,
compatibility_date: "2025-04-01",
bindings: [
{
type: "kv_namespace",
name: "HEALTH_KV",
namespace_id: opts.kvNamespaceId,
},
],
};
const form = new FormData();
form.append(
"metadata",
new Blob([JSON.stringify(metadata)], { type: "application/json" }),
);
form.append(
filename,
new Blob([opts.source], { type: "application/javascript+module" }),
filename,
);
const response = await fetch(
`${CF_API_BASE}/accounts/${opts.accountId}/workers/scripts/${opts.scriptName}`,
{
method: "PUT",
headers: { Authorization: `Bearer ${token}` },
body: form,
signal: AbortSignal.timeout(60_000),
},
);
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure("workers_put_script", response.status, String(response.status));
}
await handleCfSuccess(response, "workers_put_script");
},
async putSchedules(
accountId: string,
scriptName: string,
crons: string[],
): Promise<void> {
const response = await fetch(
`${CF_API_BASE}/accounts/${accountId}/workers/scripts/${scriptName}/schedules`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(crons.map((cron) => ({ cron }))),
signal: AbortSignal.timeout(30_000),
},
);
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure("workers_put_schedules", response.status, String(response.status));
}
await handleCfSuccess(response, "workers_put_schedules");
},
async enableWorkersDev(
accountId: string,
scriptName: string,
): Promise<void> {
const response = await fetch(
`${CF_API_BASE}/accounts/${accountId}/workers/scripts/${scriptName}/subdomain`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ enabled: true }),
signal: AbortSignal.timeout(30_000),
},
);
if (response.status === 409) return;
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure("workers_subdomain", response.status, String(response.status));
}
if (!response.ok && response.status !== 200 && response.status !== 201) {
await handleCfSuccess(response, "workers_subdomain");
}
},
async getWorkersSubdomain(accountId: string): Promise<string | null> {
const response = await fetch(
`${CF_API_BASE}/accounts/${accountId}/workers/subdomain`,
{
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
},
);
if (response.status === 404) return null;
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure("workers_get_subdomain", response.status, String(response.status));
}
const result = await handleCfResponse<CfWorkersSubdomain>(
response,
"workers_get_subdomain",
);
return result.subdomain?.trim() || null;
},
};
}