Skip to content
← Public packages

@cameronpak/zo-computer

Typed Zo Computer API client with Result-based error handling and SSE streaming.

src/core.ts

174 lines · 4.6 KB · TypeScript
import { Result } from 'better-result'
import {
	ZoApiError,
	ZoConfigError,
	ZoNetworkError,
	ZoParseError,
	type ZoRequestError,
} from './errors'
import type { ZoClientOptions, ZoRetryOptions } from './types'

/** Default Zo API origin. */
export const ZO_BASE_URL = 'https://api.zo.computer'

/** Kody secret name this package reads when you pass no `apiKey`. */
export const ZO_API_KEY_SECRET = 'ZO_API_KEY'

const MAX_ERROR_BODY = 2000

/**
 * Build the Authorization header value.
 *
 * With no key, this returns a Kody secret placeholder. Kody resolves it on
 * approved hosts only, so package code never holds the plaintext key.
 */
export function authorization(apiKey?: string): string {
	if (apiKey === undefined) return 'Bearer {{secret:ZO_API_KEY}}'
	const trimmed = apiKey.trim()
	if (trimmed.length === 0) {
		throw new Error('apiKey was an empty string')
	}
	return `Bearer ${trimmed}`
}

function retryPolicy(retry: ZoRetryOptions | undefined) {
	if (retry === undefined) return undefined
	return {
		times: retry.times,
		delayMs: retry.delayMs ?? 250,
		backoff: retry.backoff ?? ('exponential' as const),
		jitter: retry.jitter ?? true,
		shouldRetry: (error: ZoConfigError | ZoNetworkError) =>
			error._tag === 'ZoNetworkError' && error.retryable,
	}
}

/**
 * Strip the stack from a thrown value so the error stays small.
 */
function compactCause(cause: unknown): unknown {
	if (cause instanceof Error) {
		return { name: cause.name, message: cause.message }
	}
	return cause
}

/**
 * Kody throws this when the request references a secret that does not exist.
 */
function missingSecretName(cause: unknown): string | null {
	if (!(cause instanceof Error)) return null
	const match = /Secret "([^"]+)" was not found/.exec(cause.message)
	return match === null ? null : match[1]
}

async function readText(response: Response): Promise<string> {
	try {
		return await response.text()
	} catch {
		return ''
	}
}

export type ZoRequestInit = {
	method: 'GET' | 'POST'
	accept: 'application/json' | 'text/event-stream'
	body?: unknown
}

/**
 * Send one request to Zo and return the raw `Response` on 2xx.
 *
 * A non-2xx status is an `Err`, not a thrown exception.
 */
export async function zoRequest(
	path: string,
	init: ZoRequestInit,
	options: ZoClientOptions = {},
): Promise<Result<Response, ZoConfigError | ZoNetworkError | ZoApiError>> {
	const origin = (options.baseUrl ?? ZO_BASE_URL).replace(/\/+$/, '')
	const url = `${origin}${path}`

	const headers: Record<string, string> = {
		Authorization: authorization(options.apiKey),
		Accept: init.accept,
	}
	const body =
		init.body === undefined ? undefined : JSON.stringify(init.body)
	if (body !== undefined) headers['Content-Type'] = 'application/json'

	const attempted = await Result.tryPromise(
		{
			try: ({ signal }: { signal: AbortSignal }) =>
				fetch(url, { method: init.method, headers, body, signal }),
			catch: (cause: unknown) => {
				const secret = missingSecretName(cause)
				if (secret !== null) {
					return new ZoConfigError({
						field: 'apiKey',
						message: `The Kody secret "${secret}" does not exist. Save it, or pass an apiKey argument.`,
					})
				}
				return new ZoNetworkError({
					url,
					cause: compactCause(cause),
					retryable:
						cause instanceof TypeError ||
						(cause instanceof Error && cause.name === 'TimeoutError'),
					message: `Request to ${url} did not complete`,
				})
			},
		},
		{ signal: options.signal, retry: retryPolicy(options.retry) },
	)

	if (Result.isError(attempted)) return attempted

	const response = attempted.value
	if (!response.ok) {
		const text = await readText(response)
		return Result.err(
			new ZoApiError({
				status: response.status,
				url,
				body: text.slice(0, MAX_ERROR_BODY),
				message: `Zo returned HTTP ${response.status} for ${path}`,
			}),
		)
	}

	return Result.ok(response)
}

/**
 * Send one request to Zo and parse the JSON body.
 */
export async function zoJson(
	path: string,
	init: ZoRequestInit,
	options: ZoClientOptions = {},
): Promise<Result<unknown, ZoRequestError>> {
	const responseResult = await zoRequest(path, init, options)
	if (Result.isError(responseResult)) return responseResult

	const response = responseResult.value
	const text = await readText(response)
	try {
		return Result.ok(JSON.parse(text) as unknown)
	} catch {
		return Result.err(
			new ZoParseError({
				url: response.url,
				body: text.slice(0, MAX_ERROR_BODY),
				message: `Zo returned a body that is not JSON for ${path}`,
			}),
		)
	}
}

/**
 * Guard for plain objects returned by the API.
 */
export function isRecord(value: unknown): value is Record<string, unknown> {
	return typeof value === 'object' && value !== null && !Array.isArray(value)
}