← Public packages
@kentcdodds/cloudflare
Cloudflare helpers: API v4, static site publishing to Pages, analytics, WAF rules, logs, and docs.
src/api-v4.ts
163 lines · 5.4 KB · TypeScriptimport {
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<string, string | number | boolean | null | undefined>
/** 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<string>(['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<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 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 + ').')
}
}