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/util.ts

139 lines · 3.9 KB · TypeScript
export function inputOrParams(input: unknown): Record<string, unknown> {
	return (input ?? {}) as Record<string, unknown>
}

export function normalizeText(value: unknown): string {
	return typeof value === 'string' ? value.trim() : ''
}

export function bytesToHex(bytes: ArrayBuffer | Uint8Array): string {
	const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
	return Array.from(view, (byte) => byte.toString(16).padStart(2, '0')).join('')
}

export async function sha256Hex(
	value: string | ArrayBuffer | Uint8Array,
): Promise<string> {
	const bytes =
		typeof value === 'string'
			? new TextEncoder().encode(value)
			: value instanceof Uint8Array
				? value
				: new Uint8Array(value)
	return bytesToHex(await crypto.subtle.digest('SHA-256', bytes))
}

export async function hmac(
	key: ArrayBuffer | Uint8Array,
	value: string,
): Promise<Uint8Array> {
	const cryptoKey = await crypto.subtle.importKey(
		'raw',
		key instanceof Uint8Array ? key : new Uint8Array(key),
		{ name: 'HMAC', hash: 'SHA-256' },
		false,
		['sign'],
	)
	return new Uint8Array(
		await crypto.subtle.sign('HMAC', cryptoKey, new TextEncoder().encode(value)),
	)
}

export function encodePathSegment(value: string): string {
	return encodeURIComponent(value).replace(
		/[!'()*]/g,
		(char) => '%' + char.charCodeAt(0).toString(16).toUpperCase(),
	)
}

export function encodeObjectPath(bucket: string, key: string): string {
	return '/' + [bucket, ...key.split('/')].map(encodePathSegment).join('/')
}

export function decodeBase64(value: string): Uint8Array {
	const normalized = value
		.trim()
		.replace(/^data:[^,]*,/, '')
		.replace(/\s+/g, '')
	const binary = atob(normalized)
	const bytes = new Uint8Array(binary.length)
	for (let index = 0; index < binary.length; index++) {
		bytes[index] = binary.charCodeAt(index)
	}
	return bytes
}

export function bytesFromUploadInput(input: Record<string, unknown>): Uint8Array {
	if (input.data instanceof Uint8Array) return input.data
	if (input.data instanceof ArrayBuffer) return new Uint8Array(input.data)
	if (Array.isArray(input.bytes)) {
		return new Uint8Array(
			input.bytes.map((byte) =>
				Math.max(0, Math.min(255, Math.trunc(Number(byte)))),
			),
		)
	}
	const base64 =
		input.bytesBase64 ?? input.base64 ?? input.dataBase64 ?? input.dataUrl
	if (typeof base64 === 'string' && base64) return decodeBase64(base64)
	if (typeof input.text === 'string') return new TextEncoder().encode(input.text)
	throw new Error(
		'upload requires bytesBase64, base64, dataBase64, dataUrl, data, bytes, or text.',
	)
}

export function safeFileName(value: string): string {
	return (
		value
			.trim()
			.replace(/[^a-zA-Z0-9._-]+/g, '-')
			.replace(/^-+|-+$/g, '') || 'file'
	)
}

export function shortId(length = 8): string {
	return crypto.randomUUID().replace(/-/g, '').slice(0, length)
}

export function todayUtcDate(): string {
	return new Date().toISOString().slice(0, 10)
}

export function guessContentType(filename: string, fallback?: string): string {
	const ext = filename.includes('.')
		? filename.split('.').pop()?.toLowerCase()
		: ''
	const map: Record<string, string> = {
		jpg: 'image/jpeg',
		jpeg: 'image/jpeg',
		png: 'image/png',
		webp: 'image/webp',
		gif: 'image/gif',
		heic: 'image/heic',
		heif: 'image/heif',
		pdf: 'application/pdf',
		txt: 'text/plain',
		md: 'text/markdown',
		json: 'application/json',
		zip: 'application/zip',
		bin: 'application/octet-stream',
	}
	if (ext && map[ext]) return map[ext]
	return fallback || 'application/octet-stream'
}

export function clampExpiresInSeconds(value: unknown, fallback: number): number {
	const n =
		typeof value === 'number' && Number.isFinite(value)
			? Math.trunc(value)
			: fallback
	return Math.max(60, Math.min(604_800, n))
}

export function requireConfirm(body: Record<string, unknown>, action: string): void {
	if (body.confirm !== true) {
		throw new Error(
			`${action} requires confirm: true (or pass dryRun: true to preview).`,
		)
	}
}