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 { 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.`, } }