← 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 · TypeScriptimport { 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)
}