Skip to content

src/r2-put-object.ts

255 lines · 7.8 KB · TypeScript
Raw
import {
	cloudflareAuthHeaders,
	pickAuthOptions,
	type CloudflareAuthOptions,
} from './auth.ts'
import { KODY_CLOUDFLARE_ACCOUNT_ID } from './verify.ts'

const BASE_URL = 'https://api.cloudflare.com'
/** Soft guide: prefer sourceUrl above this decoded size when using bytesBase64. */
const SMALL_BYTES_HINT = 400_000

export type R2PutObjectInput = CloudflareAuthOptions & {
	/**
	 * Cloudflare account id that owns the R2 bucket. Required unless
	 * `account: 'kody'` (defaults to the known Kody account id).
	 */
	accountId?: string
	/** R2 bucket name. */
	bucket: string
	/**
	 * Object key. Path slashes stay literal; other reserved characters are
	 * percent-encoded per Cloudflare R2 API rules.
	 */
	key: string
	/** Object Content-Type. Defaults to `application/octet-stream`. */
	contentType?: string
	/**
	 * Preferred for large payloads: fetch these bytes inside execute (e.g. an
	 * `@kentcdodds/agent-files` `downloadUrl`), then PUT to R2. Mutually
	 * exclusive with `bytesBase64`.
	 */
	sourceUrl?: string
	/**
	 * Small-payload escape hatch (base64). Prefer `sourceUrl` for anything
	 * beyond a few hundred KB. Mutually exclusive with `sourceUrl`.
	 */
	bytesBase64?: string
	/** Preview the request without uploading. */
	dryRun?: boolean
	/** Required for the real PUT (`true`). Ignored when `dryRun: true`. */
	confirm?: boolean
}

export type R2PutObjectResult = {
	ok: boolean
	dryRun?: boolean
	accountId: string
	bucket: string
	key: string
	contentType: string
	size: number | null
	source: 'sourceUrl' | 'bytesBase64'
	httpStatus: number | null
	etag: string | null
	result: unknown
	errors: unknown[]
	guidance?: string
}

function resolveAccountId(params: R2PutObjectInput): string {
	if (typeof params.accountId === 'string' && params.accountId.trim()) {
		return params.accountId.trim()
	}
	if ((params.account ?? 'default') === 'kody') {
		return KODY_CLOUDFLARE_ACCOUNT_ID
	}
	throw new Error(
		'accountId is required (Cloudflare account that owns the R2 bucket). ' +
			'Only account: "kody" supplies a default account id.',
	)
}

/**
 * Encode an R2 object key for the API path: keep `/` literal; percent-encode
 * other reserved characters (Cloudflare R2 upload rules).
 */
function encodeObjectKeyForPath(key: string): string {
	return String(key)
		.split('/')
		.map((segment) => encodeURIComponent(segment))
		.join('/')
}

function decodeBytesBase64(value: string): Uint8Array {
	const normalized = String(value).replace(/\s+/g, '')
	const binary = atob(normalized)
	const out = new Uint8Array(binary.length)
	for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i)
	return out
}

async function loadBody(params: R2PutObjectInput): Promise<{
	body: Uint8Array
	source: 'sourceUrl' | 'bytesBase64'
}> {
	const hasUrl =
		typeof params.sourceUrl === 'string' && params.sourceUrl.trim() !== ''
	const hasBytes =
		typeof params.bytesBase64 === 'string' && params.bytesBase64.trim() !== ''
	if (hasUrl && hasBytes) {
		throw new Error('Pass either sourceUrl or bytesBase64, not both.')
	}
	if (!hasUrl && !hasBytes) {
		throw new Error(
			'Provide sourceUrl (preferred for large files) or bytesBase64 (small payloads).',
		)
	}
	if (hasUrl) {
		const response = await fetch(String(params.sourceUrl).trim())
		if (!response.ok) {
			throw new Error(
				`sourceUrl fetch failed with HTTP ${response.status}: ${await response.text().catch(() => '')}`.slice(
					0,
					500,
				),
			)
		}
		const buffer = new Uint8Array(await response.arrayBuffer())
		return { body: buffer, source: 'sourceUrl' }
	}
	const body = decodeBytesBase64(String(params.bytesBase64))
	return { body, source: 'bytesBase64' }
}

/**
 * PUT an object into an R2 bucket via the Cloudflare API v4 binary upload
 * endpoint. Use this (or a raw `fetch` with a token placeholder) for binary
 * payloads — `./api-v4` JSON-stringifies `body` and cannot upload files.
 *
 * Prefer `sourceUrl` (e.g. an agent-files `downloadUrl`) when bytes are large;
 * use `bytesBase64` only for small payloads.
 *
 * @param params.bucket - R2 bucket name
 * @param params.key - Object key (slashes allowed)
 * @param params.accountId - Cloudflare account id (required unless `account: 'kody'`)
 * @param params.account - Token alias (`default`, `kody`, `pages`)
 * @param params.apiTokenSecret - Explicit Kody secret name when no alias fits
 * @param params.sourceUrl - Fetch bytes from this URL then PUT
 * @param params.bytesBase64 - Small base64 body (escape hatch)
 * @param params.contentType - Defaults to `application/octet-stream`
 * @param params.confirm - Must be `true` for the real PUT
 * @returns Upload metadata (`ok`, `etag`, `size`, …); never includes token values
 *
 * @example
 * import r2PutObject from 'kody:@kentcdodds/cloudflare/r2-put-object'
 * const result = await r2PutObject({
 *   accountId: 'ACCOUNT_ID',
 *   bucket: 'my-bucket',
 *   key: 'images/hero.png',
 *   contentType: 'image/png',
 *   sourceUrl: 'https://pub-….r2.dev/handoff/…/hero.png',
 *   confirm: true,
 * })
 */
export default async function r2PutObject(
	params: R2PutObjectInput,
): Promise<R2PutObjectResult> {
	const bucket = String(params.bucket || '').trim()
	const key = String(params.key || '').trim()
	if (!bucket) throw new Error('bucket is required.')
	if (!key) throw new Error('key is required.')
	if (bucket.includes('/') || bucket.includes('..')) {
		throw new Error('bucket must be a simple R2 bucket name.')
	}

	const accountId = resolveAccountId(params)
	const contentType =
		typeof params.contentType === 'string' && params.contentType.trim()
			? params.contentType.trim()
			: 'application/octet-stream'
	const dryRun = params.dryRun === true
	if (!dryRun && params.confirm !== true) {
		throw new Error(
			'Refusing to PUT without confirm: true (or pass dryRun: true to preview).',
		)
	}

	const auth = pickAuthOptions(params)
	const encodedKey = encodeObjectKeyForPath(key)
	const path = `/client/v4/accounts/${accountId}/r2/buckets/${encodeURIComponent(bucket)}/objects/${encodedKey}`
	const url = `${BASE_URL}${path}`

	if (dryRun) {
		const hasUrl =
			typeof params.sourceUrl === 'string' && params.sourceUrl.trim() !== ''
		return {
			ok: true,
			dryRun: true,
			accountId,
			bucket,
			key,
			contentType,
			size: null,
			source: hasUrl ? 'sourceUrl' : 'bytesBase64',
			httpStatus: null,
			etag: null,
			result: { method: 'PUT', url, contentType },
			errors: [],
			guidance:
				'Dry run only — no upload. Re-call with confirm: true. Prefer sourceUrl for large files; api-v4 cannot upload binary bodies.',
		}
	}

	const { body, source } = await loadBody(params)
	if (source === 'bytesBase64' && body.byteLength > SMALL_BYTES_HINT) {
		throw new Error(
			`bytesBase64 payload is ${body.byteLength} bytes — prefer sourceUrl (e.g. agent-files downloadUrl) for large objects.`,
		)
	}

	const headers = cloudflareAuthHeaders(auth)
	headers['Content-Type'] = contentType

	const response = await fetch(url, {
		method: 'PUT',
		headers,
		body,
	})
	const text = await response.text().catch(() => '')
	let parsed: {
		success?: boolean
		result?: { etag?: string; key?: string; size?: string | number }
		errors?: unknown[]
	} | null = null
	try {
		parsed = text ? JSON.parse(text) : null
	} catch {
		parsed = null
	}

	const etag =
		(parsed?.result && typeof parsed.result.etag === 'string'
			? parsed.result.etag
			: null) ||
		response.headers.get('etag') ||
		null
	const ok = response.ok && (parsed == null || parsed.success !== false)

	return {
		ok,
		accountId,
		bucket,
		key,
		contentType,
		size: body.byteLength,
		source,
		httpStatus: response.status,
		etag,
		result: parsed?.result ?? (text ? text.slice(0, 500) : null),
		errors: Array.isArray(parsed?.errors) ? parsed!.errors! : [],
		guidance: ok
			? undefined
			: `R2 PUT failed HTTP ${response.status}. Check bucket/key/accountId and that secret ${auth.apiTokenSecret ?? auth.account ?? 'default'} is approved for api.cloudflare.com with R2 write.`,
	}
}