export const csrfToken = () => document.cookie .split("; ") .find((v) => v.startsWith("rp_csrf=")) ?.split("=")[1] || ""; export const protectedFetch = (url, options = {}) => fetch(url, { ...options, headers: { ...options.headers, "x-csrf-token": csrfToken() }, }); export async function api(url, options = {}) { const response = await protectedFetch(url, { ...options, headers: { "content-type": "application/json", ...options.headers }, }); const body = await response.json().catch(() => ({})); if (response.status === 401 && body.code === "unauthorized") { // This is specifically requireAuth's code for "no valid session" — the session expired // or was revoked mid-page (account/admin pages otherwise show a permanent "could not // load" error with no indication the user was signed out). A 401 with any other code // (e.g. a wrong current password on an account form) is a normal request failure, not a // sign-out, and must not redirect the user away mid-form. location.assign("/login"); return new Promise(() => {}); // navigation is already underway; never resolve } if (!response.ok) { const error = new Error(body.error || body.code || "request_failed"); error.code = body.code; error.status = response.status; throw error; } return body; } // Fetch-based SSE keeps the existing cookie authentication and CSRF protection. export async function streamPlanChat(body, onText) { const response = await protectedFetch("/api/plan-chat", { method: "POST", headers: { "content-type": "application/json", accept: "text/event-stream" }, body: JSON.stringify(body), }); if (!response.ok) { const data = await response.json().catch(() => ({})); const error = new Error(data.error || data.code || "Request failed"); error.code = data.code; if (response.status === 401 && data.code === "unauthorized") location.assign("/login"); throw error; } // Supports an older server during rolling deployment. if (!response.headers.get("content-type")?.includes("text/event-stream")) return response.json(); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; try { while (true) { const { value, done } = await reader.read(); buffer += decoder.decode(value, { stream: !done }); let end; while ((end = buffer.indexOf("\n\n")) !== -1) { const frame = buffer.slice(0, end); buffer = buffer.slice(end + 2); const event = frame.match(/^event: (.+)$/m)?.[1]; const raw = frame.match(/^data: (.+)$/m)?.[1]; if (!raw) continue; const data = JSON.parse(raw); if (event === "reply") onText(data.text); if (event === "done") return data; if (event === "error") { const error = new Error(data.error || "Reply interrupted"); error.code = data.code; throw error; } } if (done) throw new Error("Connection interrupted before Bean Bro finished. Please retry."); } } finally { await reader.cancel().catch(() => {}); reader.releaseLock(); } }