reel-studio-2 / web /src /api.js
xvepkj's picture
Add hosting: Docker, browser-backed persistence, named-account auth
c5e0205
Raw
History Blame
3.49 kB
// 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
}