Skip to content
← Public packages

@cameronpak/box

Full Box Public API v1 client: lifecycle, prompts, files, commands, snapshots, environments, webhooks, desktop, hosting, and account.

src/client.ts

198 lines · 5.9 KB · TypeScript
const BASE_URL = 'https://ascii.dev/api/box/v1'

export type BoxError = {
	/** Box error code, for example provider_not_configured or http_500. */
	code: string
	message: string
	status: number
	requestId?: string
}

export type Result<T> = { ok: true; data: T } | { ok: false; error: BoxError }

/** Raw bytes from a binary Box endpoint, base64-encoded so the package stays JSON-safe. */
export type BinaryPayload = {
	contentType: string
	contentDisposition?: string | null
	encoding: 'base64'
	size: number
	content: string
}

export function success<T>(data: T): Result<T> {
	return { ok: true, data }
}

export function failure<T = never>(error: BoxError): Result<T> {
	return { ok: false, error }
}

type QueryValue = string | number | boolean | null | undefined

type RequestOptions = {
	path: string
	method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
	query?: Record<string, QueryValue>
	body?: Record<string, unknown>
	headers?: Record<string, string>
	/** When true, non-JSON success bodies become a base64 BinaryPayload. */
	raw?: boolean
}

function invalid(code: string, message: string): BoxError {
	return { code, message, status: 400 }
}

/** Assert a box id looks like a Box public id before it reaches a URL path. */
export function assertBoxId(boxId: string): BoxError | null {
	if (typeof boxId === 'string' && /^bx_[23456789abcdefghjkmnpqrstuvwxyz]{8}$/.test(boxId)) {
		return null
	}
	return invalid('invalid_box_id', 'boxId must look like bx_23456789.')
}

/** Assert a webhook id looks like the public webhook id. */
export function assertWebhookId(webhookId: string): BoxError | null {
	if (typeof webhookId === 'string' && /^wh_[a-f0-9]{24}$/.test(webhookId)) {
		return null
	}
	return invalid('invalid_webhook_id', 'webhookId must look like wh_ followed by 24 hex characters.')
}

/** Assert a deletion operation id looks like the public operation id. */
export function assertOperationId(operationId: string): BoxError | null {
	if (typeof operationId === 'string' && /^bdop_[a-f0-9]{32}$/.test(operationId)) {
		return null
	}
	return invalid('invalid_operation_id', 'operationId must look like bdop_ followed by 32 hex characters.')
}

/** Assert a snapshot id is a non-empty string before it reaches a URL path. */
export function assertSnapshotId(snapshotId: string): BoxError | null {
	if (typeof snapshotId === 'string' && snapshotId.length > 0) {
		return null
	}
	return invalid('invalid_snapshot_id', 'snapshotId is required.')
}

/** Assert an environment id is a non-empty string before it reaches a URL path. */
export function assertEnvironmentId(environmentId: string): BoxError | null {
	if (typeof environmentId === 'string' && environmentId.length > 0) {
		return null
	}
	return invalid('invalid_environment_id', 'environmentId is required.')
}

function bytesToBase64(bytes: Uint8Array): string {
	let binary = ''
	const chunk = 0x8000
	for (let i = 0; i < bytes.length; i += chunk) {
		binary += String.fromCharCode(...bytes.subarray(i, i + chunk))
	}
	return btoa(binary)
}

function looksLikeJson(text: string): boolean {
	const trimmed = text.trim()
	return trimmed.startsWith('{') || trimmed.startsWith('[')
}

/**
 * Call the Box Public API v1 with the boxApiKey user secret.
 * Never throws on an API error; returns a failure Result instead.
 */
export async function boxRequest<T>(options: RequestOptions): Promise<Result<T>> {
	const url = new URL(BASE_URL + options.path)
	for (const [key, value] of Object.entries(options.query ?? {})) {
		if (value === undefined || value === null) continue
		url.searchParams.set(key, String(value))
	}

	const headers: Record<string, string> = {
		Authorization: 'Bearer {{secret:boxApiKey}}',
		Accept: options.raw ? '*/*' : 'application/json',
		...options.headers,
	}
	if (options.body !== undefined) headers['Content-Type'] = 'application/json'

	let response: Response
	try {
		response = await fetch(url.toString(), {
			method: options.method ?? 'GET',
			headers,
			body: options.body === undefined ? undefined : JSON.stringify(options.body),
		})
	} catch (cause) {
		return failure<T>({
			code: 'network_error',
			message: cause instanceof Error ? cause.message : String(cause),
			status: 0,
		})
	}

	const contentType = response.headers.get('content-type') ?? ''
	const isJsonContent = contentType.includes('json')

	if (options.raw && !isJsonContent) {
		const bytes = new Uint8Array(await response.arrayBuffer())
		if (!response.ok) {
			const text = new TextDecoder().decode(bytes)
			if (looksLikeJson(text)) {
				try {
					const payload = JSON.parse(text)
					return failure<T>({
						code: payload?.code ?? payload?.error?.code ?? 'http_' + response.status,
						message:
							payload?.message ??
							payload?.error?.message ??
							'Box request failed with status ' + response.status + '.',
						status: payload?.status ?? response.status,
						requestId: payload?.requestId,
					})
				} catch {
					// fall through to generic failure
				}
			}
			return failure<T>({
				code: 'http_' + response.status,
				message: text.slice(0, 300) || 'Box request failed with status ' + response.status + '.',
				status: response.status,
			})
		}
		return success({
			contentType,
			contentDisposition: response.headers.get('content-disposition'),
			encoding: 'base64',
			size: bytes.byteLength,
			content: bytesToBase64(bytes),
		} as T)
	}

	const text = await response.text()
	let payload: any = null
	if (text.length > 0) {
		try {
			payload = JSON.parse(text)
		} catch {
			return failure<T>({
				code: 'invalid_json_response',
				message: 'Box returned a non-JSON body: ' + text.slice(0, 300),
				status: response.status,
			})
		}
	}

	if (!response.ok || payload?.ok === false) {
		return failure<T>({
			code: payload?.code ?? payload?.error?.code ?? 'http_' + response.status,
			message:
				payload?.message ??
				payload?.error?.message ??
				'Box request failed with status ' + response.status + '.',
			status: payload?.status ?? response.status,
			requestId: payload?.requestId,
		})
	}

	return success(payload as T)
}