← Public packages
@kentcdodds/cloudflare
Cloudflare helpers: API v4, static site publishing to Pages, analytics, WAF rules, logs, and docs.
src/r2-put-object.ts
255 lines · 7.8 KB · TypeScriptimport {
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.`,
}
}