Skip to content
← 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 · TypeScript
import {
	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,
	}
}