← 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 · TypeScriptexport 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).`,
)
}
}