84 lines
2.9 KiB
JavaScript
84 lines
2.9 KiB
JavaScript
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();
|
|
}
|
|
}
|