import {
cloudflareAuthHeaders,
pickAuthOptions,
type CloudflareAuthOptions,
} from './auth.ts'
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
export type CloudflareApiV4Input = CloudflareAuthOptions & {
method?: HttpMethod | string
path?: string
query?: Record<string, string | number | boolean | null | undefined>
body?: unknown
}
export type CloudflareApiV4Result = {
status: number
httpStatus: number
body: unknown
success: boolean
result: unknown
resultInfo: unknown
errors: unknown[]
messages: unknown[]
}
const ALLOWED_METHODS = new Set<HttpMethod>(['GET', 'POST', 'PUT', 'PATCH', 'DELETE'])
const BASE_URL = 'https://api.cloudflare.com'
function assertSafePath(path) {
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) {
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/`.
* @param params.path - Relative path starting with `/client/v4/`.
* @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: [], ... }
*/
export default async function cloudflareApiV4(params: CloudflareApiV4Input = {}): Promise<CloudflareApiV4Result> {
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 headers = cloudflareAuthHeaders(pickAuthOptions(params))
const init = { method, headers }
if (params.body !== undefined && method !== 'GET') {
headers['Content-Type'] = 'application/json'
init.body = JSON.stringify(params.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 + ').')
}
}