/** * Request body builders for Cloudflare API v4 calls: JSON, multipart form, or * raw bytes/text. Pure (no network). */ import { base64ToBytes } from './static-site.ts' /** * One multipart field: a plain string, or a file part. File parts take UTF-8 * `content` or `base64` bytes, plus optional `contentType`. */ export type FormFieldValue = | string | { filename: string content?: string base64?: string contentType?: string } export type FormFields = Record /** Raw body: text sent as-is, bytes, or `{ base64 }` for JSON callers. */ export type RawBody = string | Uint8Array | ArrayBuffer | { base64: string } export type BuiltBody = { body: BodyInit | undefined /** Content-Type to set, or `null` to let fetch set it (multipart boundary). */ contentType: string | null kind: 'none' | 'json' | 'form' | 'raw' } /** Build a FormData from string / file fields. */ export function buildFormData(fields: FormFields): FormData { if (!fields || typeof fields !== 'object' || Array.isArray(fields)) { throw new Error('form must be an object of field names to string or { filename, content | base64 }.') } const form = new FormData() for (const [name, value] of Object.entries(fields)) { if (typeof value === 'string') { form.append(name, value) continue } if (!value || typeof value !== 'object' || typeof value.filename !== 'string' || !value.filename) { throw new Error(`form["${name}"] must be a string or { filename, content | base64, contentType? }.`) } const unknown = Object.keys(value).filter( (key) => !['filename', 'content', 'base64', 'contentType'].includes(key), ) if (unknown.length) { throw new Error(`form["${name}"] has unknown key(s): ${unknown.join(', ')}.`) } const hasContent = typeof value.content === 'string' const hasBase64 = typeof value.base64 === 'string' if (hasContent === hasBase64) { throw new Error(`form["${name}"] needs exactly one of content or base64.`) } const bytes = hasBase64 ? base64ToBytes(value.base64 as string) : new TextEncoder().encode(value.content) const type = value.contentType ?? (hasBase64 ? 'application/octet-stream' : 'text/plain') form.append(name, new File([bytes as BlobPart], value.filename, { type })) } return form } /** Normalize a raw body into something fetch accepts. */ export function buildRawBody(raw: RawBody): BodyInit { if (typeof raw === 'string') return raw if (raw instanceof Uint8Array) return raw as BodyInit if (raw instanceof ArrayBuffer) return new Uint8Array(raw) as BodyInit if (raw && typeof raw === 'object' && typeof (raw as { base64?: unknown }).base64 === 'string') { return base64ToBytes((raw as { base64: string }).base64) as BodyInit } throw new Error('rawBody must be a string, Uint8Array, ArrayBuffer, or { base64 }.') } /** * Pick exactly one of `body` (JSON), `form` (multipart), or `rawBody`. * GET requests never carry a body (a JSON `body` on GET is ignored, as before; * `form` / `rawBody` on GET throw). */ export function buildRequestBody( params: { body?: unknown; form?: FormFields; rawBody?: RawBody; contentType?: string }, method: string, ): BuiltBody { const provided = (['body', 'form', 'rawBody'] as const).filter( (key) => params[key] !== undefined, ) if (provided.length > 1) { throw new Error(`Pass only one of body, form, or rawBody (got ${provided.join(', ')}).`) } if (params.contentType !== undefined && provided[0] !== 'rawBody') { throw new Error('contentType only applies with rawBody (JSON and form set their own).') } if (provided.length === 0) return { body: undefined, contentType: null, kind: 'none' } if (method === 'GET') { // Historical api-v4 behavior: a JSON body on GET is ignored. if (provided[0] === 'body') return { body: undefined, contentType: null, kind: 'none' } throw new Error(`GET requests cannot send ${provided[0]}.`) } if (provided[0] === 'form') { return { body: buildFormData(params.form as FormFields), contentType: null, kind: 'form' } } if (provided[0] === 'rawBody') { return { body: buildRawBody(params.rawBody as RawBody), contentType: params.contentType ?? 'application/octet-stream', kind: 'raw', } } return { body: JSON.stringify(params.body), contentType: 'application/json', kind: 'json' } }