// Thin fetch wrapper + job polling for the FastAPI backend. import { saveBundle, deleteBundle } from './persist' async function request(path, options = {}) { const res = await fetch(path, { headers: { 'Content-Type': 'application/json' }, ...options, }) if (!res.ok) { let detail = res.statusText try { const body = await res.json() detail = body.detail || JSON.stringify(body) } catch { /* keep statusText */ } throw new Error(detail) } return res.json() } // --- client-side persistence hooks ----------------------------------------- // After any mutation touching a project, refresh that project's cached bundle // in the browser (debounced, so a burst of edits makes one export). See // persist.js. `import`/`export` are excluded so we never recurse. const PROJECT_RE = /^\/api\/projects\/([^/]+)/ const _syncTimers = {} function projectIdFromPath(path) { const m = PROJECT_RE.exec(path) const id = m && m[1] return id && id !== 'import' ? id : null } async function syncProjectBundle(projectId) { try { saveBundle(await request(`/api/projects/${projectId}/export`)) } catch { /* project may have just been deleted — ignore */ } } function scheduleBundleSync(projectId) { clearTimeout(_syncTimers[projectId]) _syncTimers[projectId] = setTimeout(() => { delete _syncTimers[projectId] syncProjectBundle(projectId) }, 700) } async function mutate(path, options) { const result = await request(path, options) const pid = projectIdFromPath(path) if (pid) { // DELETE of the whole project (no trailing segment) drops its cache; // any other mutation re-syncs the bundle. if (options.method === 'DELETE' && path.replace(/\/$/, '') === `/api/projects/${pid}`) { deleteBundle(pid) } else { scheduleBundleSync(pid) } } return result } export const api = { get: (path) => request(path), post: (path, body) => mutate(path, { method: 'POST', body: JSON.stringify(body ?? {}) }), put: (path, body) => mutate(path, { method: 'PUT', body: JSON.stringify(body) }), patch: (path, body) => mutate(path, { method: 'PATCH', body: JSON.stringify(body) }), del: (path) => mutate(path, { method: 'DELETE' }), } // Multipart upload (files). Don't set Content-Type — the browser adds the boundary. export async function upload(path, formData) { const res = await fetch(path, { method: 'POST', body: formData }) if (!res.ok) { let detail = res.statusText try { detail = (await res.json()).detail || detail } catch { /* keep */ } throw new Error(detail) } return res.json() } // Poll a job until done/error. onProgress receives the list of messages. export async function pollJob(jobId, onProgress, intervalMs = 1200) { for (;;) { const job = await api.get(`/api/jobs/${jobId}`) if (onProgress) onProgress(job.progress || []) if (job.status === 'done') return job.result if (job.status === 'error') throw new Error(job.error || 'Job failed') await new Promise((r) => setTimeout(r, intervalMs)) } } // Convenience: POST that returns {job_id}, then poll it. export async function runJob(path, body, onProgress) { const { job_id } = await api.post(path, body) const result = await pollJob(job_id, onProgress) // The job writes state as it runs, so re-cache the bundle once it's done // (the POST above only saw pre-job state). const pid = projectIdFromPath(path) if (pid) syncProjectBundle(pid) return result }