gitpilot / frontend /utils /api.js
github-actions[bot]
Deploy from 53884f28
6078519
Raw
History Blame Contribute Delete
8.7 kB
/**
* API utilities for authenticated requests
*/
import { resolveBackendUrl } from "./backend.js";
/**
* Backend base URL β€” VITE_BACKEND_URL if set, else the hosted GitPilot backend
* (so production works without the env var), else relative for local dev.
*/
const BACKEND_URL = resolveBackendUrl();
/**
* Check if backend URL is configured
* @returns {boolean} True if backend URL is set
*/
export function isBackendConfigured() {
return BACKEND_URL !== '' && BACKEND_URL !== undefined;
}
/**
* Get the configured backend URL
* @returns {string} Backend URL or empty string
*/
export function getBackendUrl() {
return BACKEND_URL;
}
/**
* Construct full API URL
* @param {string} path - API endpoint path (e.g., '/api/chat/plan')
* @returns {string} Full URL to API endpoint
*/
export function apiUrl(path) {
// Idempotent: an already-absolute URL is returned untouched, so wrapping a
// call site in apiUrl() is always safe even if the value was already absolute.
if (/^https?:\/\//i.test(path)) return path;
// Ensure path starts with /
const cleanPath = path.startsWith('/') ? path : `/${path}`;
return `${BACKEND_URL}${cleanPath}`;
}
/**
* Enhanced fetch with better error handling for JSON parsing
* @param {string} url - URL to fetch
* @param {Object} options - Fetch options
* @returns {Promise<any>} Parsed JSON response
*/
export async function safeFetchJSON(url, options = {}) {
try {
// Add timeout to prevent hanging when backend is starting up.
// Default raised to 15s to tolerate first-load GitHub API checks.
const timeout = options.timeout || 15000;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
const fetchOptions = { ...options, signal: options.signal || controller.signal };
delete fetchOptions.timeout;
let response;
try {
response = await fetch(url, fetchOptions);
} finally {
clearTimeout(timer);
}
const contentType = response.headers.get('content-type');
// Check if response is actually JSON
if (!contentType || !contentType.includes('application/json')) {
// If not JSON, it might be an HTML error page
const text = await response.text();
// Check if it looks like HTML (starts with <!doctype or <html)
if (text.trim().toLowerCase().startsWith('<!doctype') ||
text.trim().toLowerCase().startsWith('<html')) {
throw new Error(
`Backend not reachable. Received HTML instead of JSON. ` +
`${!isBackendConfigured() ? 'VITE_BACKEND_URL environment variable is not configured. ' : ''}` +
`Please check your backend configuration.`
);
}
// Try to return the text as-is if not HTML
throw new Error(`Unexpected response type: ${contentType || 'unknown'}. Response: ${text.substring(0, 100)}`);
}
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || data.error || data.message || `Request failed with status ${response.status}`);
}
return data;
} catch (error) {
// Re-throw with better error message
if (error.name === 'AbortError') {
throw new Error(
`Backend server did not respond in time. ` +
`Please check that the backend is running at ${BACKEND_URL || 'localhost:8000'}.`
);
}
if (error.message.includes('Failed to fetch') || error.message.includes('NetworkError')) {
throw new Error(
`Cannot connect to backend server. ` +
`${!isBackendConfigured() ? 'VITE_BACKEND_URL environment variable is not configured. ' : ''}` +
`Please check that the backend is running and accessible.`
);
}
throw error;
}
}
// ─── GitPilot account session (portable token) ───────────────────────────
// The account session also rides in localStorage + the X-GitPilot-Session
// header (not just the cookie) so email/password accounts stay signed in when
// the SPA (Vercel) and API (HF Space) are on different origins.
const SESSION_TOKEN_KEY = 'gitpilot_session_token';
export function getSessionToken() {
try { return localStorage.getItem(SESSION_TOKEN_KEY) || ''; } catch { return ''; }
}
export function setSessionToken(token) {
try {
if (token) localStorage.setItem(SESSION_TOKEN_KEY, token);
else localStorage.removeItem(SESSION_TOKEN_KEY);
} catch { /* storage may be blocked */ }
}
export function clearSessionToken() {
setSessionToken('');
}
/**
* Get authorization headers: GitHub bearer token (repos) and/or the GitPilot
* account session header. Either/both may be present.
* @returns {Object} Headers object
*/
export function getAuthHeaders() {
const headers = {};
let githubToken = '';
let sessionToken = '';
try { githubToken = localStorage.getItem('github_token') || ''; } catch { /* blocked */ }
sessionToken = getSessionToken();
if (githubToken) headers['Authorization'] = `Bearer ${githubToken}`;
if (sessionToken) headers['X-GitPilot-Session'] = sessionToken;
return headers;
}
/**
* Make an authenticated fetch request
* @param {string} url - API endpoint URL
* @param {Object} options - Fetch options
* @returns {Promise<Response>} Fetch response
*/
export async function authFetch(url, options = {}) {
const headers = {
...getAuthHeaders(),
...options.headers,
};
// Resolve root-relative paths against the configured backend so callers that
// pass "/api/..." work on the split frontend(Vercel)/backend(HF) deployment.
return fetch(apiUrl(url), {
...options,
headers,
});
}
/**
* Make an authenticated JSON request
* @param {string} url - API endpoint URL
* @param {Object} options - Fetch options
* @returns {Promise<any>} Parsed JSON response
*/
export async function authFetchJSON(url, options = {}) {
const headers = {
'Content-Type': 'application/json',
...getAuthHeaders(),
...options.headers,
};
const response = await fetch(apiUrl(url), {
...options,
headers,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Request failed' }));
throw new Error(error.detail || error.message || 'Request failed');
}
return response.json();
}
// ─── Redesigned API Endpoints ────────────────────────────
/**
* Get normalized server status
*/
export async function fetchStatus() {
return safeFetchJSON(apiUrl("/api/status"));
}
/**
* Get server status with retry (for startup when backend may still be booting).
* Retries up to `maxRetries` times with `delayMs` between attempts.
* @param {number} maxRetries - Maximum retry attempts (default: 8)
* @param {number} delayMs - Delay between retries in ms (default: 2000)
* @returns {Promise<any>} Parsed status response or null
*/
export async function fetchStatusWithRetry(maxRetries = 8, delayMs = 2000) {
for (let i = 0; i < maxRetries; i++) {
try {
return await safeFetchJSON(apiUrl("/api/status"), { timeout: 5000 });
} catch {
if (i < maxRetries - 1) {
await new Promise((r) => setTimeout(r, delayMs));
}
}
}
return null;
}
/**
* Get detailed provider status
*/
export async function fetchProviderStatus() {
return safeFetchJSON(apiUrl("/api/providers/status"));
}
/**
* Test a provider configuration
*/
export async function testProvider(providerConfig) {
return safeFetchJSON(apiUrl("/api/providers/test"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(providerConfig),
});
}
/**
* Start a session by mode
*/
export async function startSession(sessionConfig) {
return safeFetchJSON(apiUrl("/api/session/start"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(sessionConfig),
});
}
/**
* Send a chat message (redesigned endpoint)
*/
export async function sendChatMessage(messageConfig) {
return safeFetchJSON(apiUrl("/api/chat/send"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(messageConfig),
});
}
/**
* Get workspace summary
*/
export async function fetchWorkspaceSummary(folderPath) {
const query = folderPath ? `?folder_path=${encodeURIComponent(folderPath)}` : "";
return safeFetchJSON(apiUrl(`/api/workspace/summary${query}`));
}
/**
* Run security scan on workspace
*/
export async function scanWorkspace(path) {
const query = path ? `?path=${encodeURIComponent(path)}` : "";
return safeFetchJSON(apiUrl(`/api/security/scan-workspace${query}`));
}