import { KUBER_API_BASE_URL } from "../const"; import type { ApiProblemDetails } from "../shared/api"; import { readSession, type KuberSession } from "./session"; export type { ApiProblemDetails } from "../shared/api"; export const DEFAULT_API_TIMEOUT_MS = 30_000; export type ApiRequestInit = RequestInit & { /** JSON is serialized here so callers do not need to manage content headers. */ json?: unknown; }; export type ApiRequestOptions = { authenticated?: boolean; baseUrl?: string; session?: KuberSession; /** Set to 0 to disable the deadline, primarily for long-lived streams. */ timeoutMs?: number; /** Byte offset for resumable binary upload requests. */ uploadOffset?: number; }; export type ApiUploadOptions = ApiRequestOptions & { offset: number; contentType?: string; }; export class KuberApiError extends Error { readonly code: string; readonly requestId?: string; readonly operationId?: string; readonly problem: ApiProblemDetails; constructor(message: string, status: number, problem?: ApiProblemDetails) { super(message); this.name = "KuberApiError"; this.status = status; this.code = problem?.code ?? `HTTP_${status}`; this.requestId = problem?.requestId; this.operationId = problem?.operationId; this.problem = { ...problem, status, title: problem?.title ?? message, code: this.code, }; } readonly status: number; } type RequestDeadline = { signal: AbortSignal; clear: () => void; }; function requestDeadline( signal: AbortSignal | null | undefined, timeoutMs = DEFAULT_API_TIMEOUT_MS, ): RequestDeadline { if (!Number.isFinite(timeoutMs) || timeoutMs < 0) { throw new RangeError("timeoutMs must be a finite non-negative number"); } const controller = new AbortController(); const abortFromCaller = () => controller.abort(signal?.reason); if (signal?.aborted) abortFromCaller(); else signal?.addEventListener("abort", abortFromCaller, { once: true }); const timer = timeoutMs === 0 ? undefined : setTimeout(() => { controller.abort( new DOMException( `API request timed out after ${timeoutMs}ms`, "TimeoutError", ), ); }, timeoutMs); return { signal: controller.signal, clear: () => { if (timer !== undefined) clearTimeout(timer); signal?.removeEventListener("abort", abortFromCaller); }, }; } type FetchBody = NonNullable; function isBinaryBody(body: FetchBody): boolean { return ( body instanceof ArrayBuffer || ArrayBuffer.isView(body) || (typeof Blob !== "undefined" && body instanceof Blob) || (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) ); } async function requestHeaders( init: ApiRequestInit, options: ApiRequestOptions, ): Promise { const headers = new Headers(init.headers); if (!headers.has("accept")) headers.set("accept", "application/json"); if (Object.hasOwn(init, "json")) { headers.set("content-type", "application/json"); } else if ( init.body !== undefined && init.body !== null && !headers.has("content-type") && !isBinaryBody(init.body) ) { // Preserve the original apiRequest convention: string bodies are JSON. headers.set("content-type", "application/json"); } if (options.uploadOffset !== undefined) { if ( !Number.isSafeInteger(options.uploadOffset) || options.uploadOffset < 0 ) { throw new RangeError("uploadOffset must be a non-negative safe integer"); } headers.set("upload-offset", String(options.uploadOffset)); } if (options.authenticated !== false) { const session = options.session ?? (await readSession()); if (!session) throw new Error("Not logged in. Run kuber login first."); headers.set("authorization", `Bearer ${session.token}`); } return headers; } async function responseProblem(response: Response): Promise { let problem: ApiProblemDetails | undefined; try { const value: unknown = JSON.parse(await response.text()); if (value && typeof value === "object") { problem = value as ApiProblemDetails; } } catch { // The status and response headers still provide a stable error shape. } return { ...problem, status: response.status, title: problem?.title || response.statusText || `HTTP ${response.status}`, code: problem?.code || `HTTP_${response.status}`, requestId: problem?.requestId ?? response.headers.get("x-request-id") ?? undefined, operationId: problem?.operationId ?? response.headers.get("x-operation-id") ?? undefined, }; } async function assertResponseOk(response: Response): Promise { if (response.ok) return; const problem = await responseProblem(response); throw new KuberApiError( problem.detail || problem.message || problem.title, response.status, problem, ); } async function sendRequest( path: string, init: ApiRequestInit, options: ApiRequestOptions, signal: AbortSignal, ): Promise { const headers = await requestHeaders(init, options); const { json, ...requestInit } = init; const body = Object.hasOwn(init, "json") ? JSON.stringify(json) : init.body; return fetch(`${options.baseUrl ?? KUBER_API_BASE_URL}${path}`, { ...requestInit, body, headers, signal, }); } export async function apiRequest( path: string, init: ApiRequestInit = {}, options: ApiRequestOptions = {}, ): Promise { const deadline = requestDeadline(init.signal, options.timeoutMs); try { const response = await sendRequest(path, init, options, deadline.signal); await assertResponseOk(response); if ( init.method?.toUpperCase() === "HEAD" || response.status === 204 || response.status === 205 || response.body === null ) { return undefined as T; } const text = await response.text(); if (!text.trim()) return undefined as T; return JSON.parse(text) as T; } finally { deadline.clear(); } } export function apiUpload( path: string, body: Blob | ArrayBuffer | ArrayBufferView | ReadableStream, options: ApiUploadOptions, ): Promise { const { contentType = "application/octet-stream", offset, ...requestOptions } = options; return apiRequest( path, { method: "PATCH", headers: { "content-type": contentType }, body: body as FetchBody, }, { ...requestOptions, uploadOffset: offset }, ); } /** Parses records as they arrive instead of buffering the complete response. */ export async function* apiStreamNdjson( path: string, init: ApiRequestInit = {}, options: ApiRequestOptions = {}, ): AsyncGenerator { const deadline = requestDeadline(init.signal, options.timeoutMs); try { const headers = new Headers(init.headers); headers.set("accept", "application/x-ndjson"); const response = await sendRequest( path, { ...init, headers }, options, deadline.signal, ); await assertResponseOk(response); if (!response.body) return; const reader = response.body.getReader(); try { const decoder = new TextDecoder(); let buffer = ""; for (;;) { const { done, value } = await reader.read(); buffer += decoder.decode(value, { stream: !done }); let newline = buffer.indexOf("\n"); while (newline !== -1) { const line = buffer.slice(0, newline).replace(/\r$/, "").trim(); buffer = buffer.slice(newline + 1); if (line) yield JSON.parse(line) as T; newline = buffer.indexOf("\n"); } if (done) break; } const finalLine = buffer.replace(/\r$/, "").trim(); if (finalLine) yield JSON.parse(finalLine) as T; } finally { try { await reader.cancel(); } catch { // Cancellation can race with an upstream abort. } reader.releaseLock(); } } finally { deadline.clear(); } }