← Public packages
@kentcdodds/agent-files
Mint short-lived R2 PUT URLs for agent file handoffs; public download via managed r2.dev.
src/lib/files.ts
277 lines · 6.3 KB · TypeScriptimport {
defaultExpiresInSeconds,
maxMcpUploadBytes,
} from './constants.ts'
import {
createSignedR2Request,
ensureBucketReady,
resolvePublicUrl,
signedR2Fetch,
} from './r2.ts'
import {
bytesFromUploadInput,
clampExpiresInSeconds,
guessContentType,
inputOrParams,
normalizeText,
requireConfirm,
safeFileName,
shortId,
todayUtcDate,
} from './util.ts'
export type CreateUploadInput = {
filename: string
contentType?: string
prefix?: string
expiresInSeconds?: number
browser?: boolean
confirm?: boolean
dryRun?: boolean
}
export type GetInput = {
key: string
}
export type DeleteInput = {
key: string
confirm?: boolean
dryRun?: boolean
}
export type UploadInput = {
filename: string
contentType?: string
prefix?: string
bytesBase64?: string
base64?: string
dataBase64?: string
dataUrl?: string
data?: Uint8Array | ArrayBuffer
bytes?: number[]
text?: string
confirm?: boolean
dryRun?: boolean
}
function sanitizePrefix(value: string): string {
const cleaned = value
.trim()
.replace(/^\/+|\/+$/g, '')
.replace(/[^a-zA-Z0-9/_-]+/g, '-')
.replace(/\/{2,}/g, '/')
return cleaned || 'handoff'
}
export function buildObjectKey(input: {
filename: string
contentType: string
prefix?: string
}): string {
const prefix = sanitizePrefix(input.prefix || 'handoff')
const safeName = safeFileName(input.filename)
const date = todayUtcDate()
const id = shortId(8)
return `${prefix}/${date}/${id}-${safeName}`
}
/**
* Mint a short-lived signed PUT target for agent file handoffs.
*/
export async function createUpload(input?: unknown) {
const body = inputOrParams(input)
const filename = normalizeText(body.filename)
if (!filename) throw new Error('createUpload requires filename.')
const contentType =
normalizeText(body.contentType) ||
guessContentType(filename, 'application/octet-stream')
const prefix = normalizeText(body.prefix) || 'handoff'
const expiresInSeconds = clampExpiresInSeconds(
body.expiresInSeconds,
defaultExpiresInSeconds,
)
const key = buildObjectKey({ filename, contentType, prefix })
const dryRun = body.dryRun === true
if (dryRun) {
let downloadUrl: string | null = null
let bucketReady = false
let lifecycle: { ok: boolean; message?: string } | undefined
let error: string | undefined
try {
const ready = await ensureBucketReady()
bucketReady = true
lifecycle = ready.lifecycle
downloadUrl = `https://${ready.publicDomain}/${key
.split('/')
.map((s) => encodeURIComponent(s))
.join('/')}`
} catch (err) {
error = err instanceof Error ? err.message : String(err)
}
return {
ok: true as const,
dryRun: true as const,
key,
filename,
contentType,
prefix: sanitizePrefix(prefix),
expiresInSeconds,
expiresAt: new Date(Date.now() + expiresInSeconds * 1000).toISOString(),
downloadUrl,
bucketReady,
lifecycle,
error,
hint: 'Pass confirm: true to mint upload.url + headers for a real PUT.',
}
}
requireConfirm(body, 'createUpload')
const browser = body.browser === true
const upload = await createSignedR2Request({
method: 'PUT',
key,
contentType,
browser,
ttlSeconds: expiresInSeconds,
})
const downloadUrl = await resolvePublicUrl(key)
const expiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString()
return {
ok: true as const,
dryRun: false as const,
key,
upload: {
method: 'PUT' as const,
url: upload.url,
headers: upload.headers,
},
downloadUrl,
expiresAt,
expiresInSeconds,
contentType,
filename,
}
}
/**
* Resolve metadata + public download URL for an object key.
*/
export async function getFile(input?: unknown) {
const body = inputOrParams(input)
const key = normalizeText(body.key)
if (!key) throw new Error('get requires key.')
const downloadUrl = await resolvePublicUrl(key)
const head = await signedR2Fetch({ method: 'HEAD', key })
if (head.status === 404) {
return {
ok: false as const,
found: false as const,
key,
downloadUrl,
}
}
const sizeHeader = head.headers.get('content-length')
const size =
sizeHeader && /^\d+$/.test(sizeHeader) ? Number(sizeHeader) : undefined
const contentType = head.headers.get('content-type') || undefined
const etag = head.headers.get('etag') || undefined
const lastModified = head.headers.get('last-modified') || undefined
return {
ok: true as const,
found: true as const,
key,
size,
contentType,
etag,
lastModified,
downloadUrl,
}
}
/**
* Delete an object by key.
*/
export async function deleteFile(input?: unknown) {
const body = inputOrParams(input)
const key = normalizeText(body.key)
if (!key) throw new Error('delete requires key.')
if (body.dryRun === true) {
return {
ok: true as const,
dryRun: true as const,
key,
hint: 'Pass confirm: true to delete.',
}
}
requireConfirm(body, 'delete')
const response = await signedR2Fetch({ method: 'DELETE', key })
return {
ok: true as const,
dryRun: false as const,
key,
deleted: response.status !== 404,
status: response.status,
}
}
/**
* Small-file escape hatch: PUT bytes through MCP (prefer createUpload).
*/
export async function uploadFile(input?: unknown) {
const body = inputOrParams(input)
const filename = normalizeText(body.filename)
if (!filename) throw new Error('upload requires filename.')
const contentType =
normalizeText(body.contentType) ||
guessContentType(filename, 'application/octet-stream')
const prefix = normalizeText(body.prefix) || 'handoff'
const data = bytesFromUploadInput(body)
if (data.byteLength > maxMcpUploadBytes) {
throw new Error(
`upload rejected: ${data.byteLength} bytes exceeds MCP-safe limit of ${maxMcpUploadBytes}. Prefer createUpload → client PUT → hand downloadUrl to the agent.`,
)
}
if (body.dryRun === true) {
const key = buildObjectKey({ filename, contentType, prefix })
return {
ok: true as const,
dryRun: true as const,
key,
size: data.byteLength,
contentType,
hint: 'Pass confirm: true to PUT bytes via signed R2 request.',
}
}
requireConfirm(body, 'upload')
const key = buildObjectKey({ filename, contentType, prefix })
await signedR2Fetch({
method: 'PUT',
key,
body: data,
contentType,
})
const downloadUrl = await resolvePublicUrl(key)
return {
ok: true as const,
dryRun: false as const,
key,
size: data.byteLength,
contentType,
downloadUrl,
}
}