import { cloudflareAuthHeaders, pickAuthOptions, type CloudflareAuthOptions, } from './auth.ts' import { buildRequestBody, type FormFields, type RawBody, } from './request-body.ts' export type { FormFieldValue, FormFields, RawBody } from './request-body.ts' type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' export type CloudflareApiV4Input = CloudflareAuthOptions & { method?: HttpMethod | string path?: string query?: Record /** JSON body (stringified, `Content-Type: application/json`). */ body?: unknown /** * Multipart form body. Values are strings or file parts * `{ filename, content | base64, contentType? }`. Mutually exclusive with * `body` and `rawBody`. */ form?: FormFields /** * Raw request body: text, bytes, or `{ base64 }`. Sent with `contentType` * (default `application/octet-stream`). Mutually exclusive with `body` and `form`. */ rawBody?: RawBody /** Content-Type for `rawBody` only. */ contentType?: string } export type CloudflareApiV4Result = { status: number httpStatus: number body: unknown success: boolean result: unknown resultInfo: unknown errors: unknown[] messages: unknown[] } const ALLOWED_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']) const BASE_URL = 'https://api.cloudflare.com' function assertSafePath(path: unknown): string { const trimmed = String(path || '').trim() if (!trimmed.startsWith('/')) { throw new Error('path must start with `/` and must not include a host.') } if (!trimmed.startsWith('/client/v4/')) { throw new Error('path must start with `/client/v4/`.') } if (trimmed.includes('..')) { throw new Error('path must not contain `..`.') } if (/\s|#/.test(trimmed)) { throw new Error('path contains disallowed characters.') } if (trimmed.length > 2048) { throw new Error('path exceeds maximum length.') } return trimmed } function normalizeMethod(method: unknown): string { const normalized = String(method || 'GET').trim().toUpperCase() if (!ALLOWED_METHODS.has(normalized)) { throw new Error('method must be one of GET, POST, PUT, PATCH, or DELETE.') } return normalized } /** * Call a Cloudflare API v4 endpoint under `/client/v4/` with the selected * token alias. Sends JSON (`body`), multipart (`form`), or raw bytes/text * (`rawBody` + `contentType`) — pass at most one. * * For lists, prefer `./api-v4-list` (auto-paginates). For static sites, prefer * `./publish-static` (handles the Pages upload JWT and multipart deploy). For * R2 object PUTs from a URL, `./r2-put-object` streams `sourceUrl` for you. * * @param params.path - Relative path starting with `/client/v4/`. * @param params.method - GET (default), POST, PUT, PATCH, or DELETE. * @param params.query - Query string params (null/undefined skipped). * @param params.body - JSON body. * @param params.form - Multipart fields: string or `{ filename, content | base64, contentType? }`. * @param params.rawBody - Raw body: string, bytes, or `{ base64 }`. * @param params.contentType - Content-Type for `rawBody`. * @param params.account - Token alias (`default`, `kody`, `pages`); defaults to `default`. * @param params.apiTokenSecret - Explicit Kody secret name when no alias fits (not a raw token). * @returns Parsed JSON body with `success`, `result`, `errors`, and HTTP status. * @example * import apiV4 from 'kody:@kentcdodds/cloudflare/api-v4' * const result = await apiV4({ * account: 'kody', * method: 'POST', * path: '/client/v4/accounts/ACCOUNT_ID/d1/database/DB_ID/query', * body: { sql: 'SELECT 1 AS ok' }, * }) * // => { success: true, result: [ ... ], errors: [], ... } * * // Multipart (e.g. a Workers script or Pages deployment): * await apiV4({ * method: 'POST', * path: '/client/v4/accounts/ACCOUNT_ID/pages/projects/my-site/deployments', * account: 'pages', * form: { manifest: '{}', _headers: { filename: '_headers', content: '/*\n X-Robots-Tag: noindex' } }, * }) */ export default async function cloudflareApiV4(params: CloudflareApiV4Input = {}): Promise { const method = normalizeMethod(params.method) const path = assertSafePath(params.path) const url = new URL(path, BASE_URL) const query = params.query && typeof params.query === 'object' ? params.query : null if (query) { for (const [key, value] of Object.entries(query)) { if (value === undefined || value === null) continue url.searchParams.set(String(key), String(value)) } } const built = buildRequestBody(params, method) const headers = cloudflareAuthHeaders(pickAuthOptions(params)) if (built.contentType) headers['Content-Type'] = built.contentType const init: RequestInit = { method, headers } if (built.body !== undefined) init.body = built.body const response = await fetch(url.toString(), init) const text = await response.text() if (response.status === 204 || !text.trim()) { return { status: response.status, httpStatus: response.status, body: null, success: response.ok, result: null, resultInfo: null, errors: [], messages: [], } } try { const body = JSON.parse(text) return { status: response.status, httpStatus: response.status, body, success: body.success === true, result: body.result, resultInfo: body.result_info, errors: body.errors || [], messages: body.messages || [], } } catch { throw new Error('Cloudflare API returned non-JSON (' + response.status + ').') } }