import { browser } from '$app/environment'; import type { JobRow } from './types.js'; export const TOKEN_STORAGE_KEY = 'evobgp_api_token'; export type Problem = { type?: string; title?: string; status?: number; detail?: string; }; function getToken(): string | null { if (!browser) return null; return localStorage.getItem(TOKEN_STORAGE_KEY); } function mergeHeaders(init?: RequestInit, extraHeaders?: Record): Headers { const h = new Headers(init?.headers); if (!h.has('Accept')) h.set('Accept', 'application/json'); const t = getToken(); if (t && !h.has('Authorization')) h.set('Authorization', `Bearer ${t}`); if (extraHeaders) { for (const [k, v] of Object.entries(extraHeaders)) { if (!h.has(k)) h.set(k, v); } } return h; } /** * Idempotency keys: `crypto.randomUUID()` exists only in secure contexts (HTTPS / localhost). * Over plain HTTP to a LAN IP it is often undefined — use getRandomValues or a fallback. */ function newIdempotencyKey(): string { const c = typeof globalThis !== 'undefined' ? globalThis.crypto : undefined; if (c?.randomUUID) return c.randomUUID(); if (c?.getRandomValues) { const buf = new Uint8Array(16); c.getRandomValues(buf); buf[6] = (buf[6]! & 0x0f) | 0x40; buf[8] = (buf[8]! & 0x3f) | 0x80; const hex = [...buf].map((b) => b.toString(16).padStart(2, '0')).join(''); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } return `idem-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 14)}`; } export class ApiError extends Error { constructor( public readonly status: number, message: string, public readonly problem?: Problem ) { super(message); } } export async function apiFetch(path: string, init?: RequestInit): Promise { if (!browser) throw new Error('API is only available in the browser'); return fetch(path, { ...init, headers: mergeHeaders(init) }); } /** GET / DELETE без тела */ export async function apiJSON(path: string, init?: RequestInit): Promise { const res = await apiFetch(path, init); return parseResponse(res); } /** POST / PATCH / PUT с JSON-телом и автоматическим Idempotency-Key */ export async function apiMutate( path: string, method: 'POST' | 'PATCH' | 'PUT' | 'DELETE', body?: unknown, opts?: { idempotent?: boolean } ): Promise { const headers: Record = {}; if (body !== undefined) headers['Content-Type'] = 'application/json'; if (opts?.idempotent !== false) { headers['Idempotency-Key'] = newIdempotencyKey(); } const res = await fetch(path, { method, headers: mergeHeaders({ headers }, headers), body: body !== undefined ? JSON.stringify(body) : undefined }); return parseResponse(res); } async function parseResponse(res: Response): Promise { if (res.status === 204 || res.status === 205) return undefined as T; const text = await res.text(); if (!res.ok) { let problem: Problem | undefined; let detail = `HTTP ${res.status}`; try { problem = JSON.parse(text) as Problem; detail = problem.detail ?? problem.title ?? detail; } catch { if (text) detail = text; } throw new ApiError(res.status, detail, problem); } if (!text) return undefined as T; return JSON.parse(text) as T; } const terminalJobStatuses = new Set(['succeeded', 'failed', 'cancelled']); /** Ожидает завершения фоновой задачи (poll GET /v1/jobs/{id}). */ export async function waitForJob( jobId: string, opts?: { pollMs?: number; timeoutMs?: number } ): Promise { const pollMs = opts?.pollMs ?? 400; const timeoutMs = opts?.timeoutMs ?? 120000; const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const j = await apiJSON(`/v1/jobs/${jobId}`); if (terminalJobStatuses.has(j.status)) return j; await new Promise((r) => setTimeout(r, pollMs)); } throw new Error(`Таймаут ожидания задачи ${jobId}`); }