const API_BASE = '/api'; // Global auth error handler - will be set by useAuth hook let onAuthError = null; // Request debouncing and deduplication const pendingRequests = new Map(); // key: endpoint+params, value: Promise const requestQueue = new Map(); // key: endpoint+params, value: { resolve, reject, timestamp } const DEBOUNCE_DELAY = 100; // 100ms debounce delay const DEDUPE_WINDOW = 5000; // 5 seconds - same request within this window uses cached promise // Generate cache key from endpoint and params function getCacheKey(endpoint, options = {}) { const params = new URLSearchParams(); Object.keys(options).sort().forEach(key => { if (options[key] !== undefined && options[key] !== null) { params.append(key, String(options[key])); } }); const query = params.toString(); return `${endpoint}${query ? '?' + query : ''}`; } // Sentinel value to indicate a request was superseded (instead of rejecting) // Export it so components can check for it export const REQUEST_SUPERSEDED = Symbol('REQUEST_SUPERSEDED'); // Debounced request wrapper function debounceRequest(key, requestFn, delay = DEBOUNCE_DELAY) { return new Promise((resolve, reject) => { // Check if there's a pending request for this key if (pendingRequests.has(key)) { const pending = pendingRequests.get(key); // If request is very recent (within dedupe window), reuse it const now = Date.now(); if (pending.timestamp && (now - pending.timestamp) < DEDUPE_WINDOW) { pending.promise.then(resolve).catch(reject); return; } } // Clear any existing timeout for this key if (requestQueue.has(key)) { const queued = requestQueue.get(key); clearTimeout(queued.timeout); // Resolve with sentinel value instead of rejecting - this prevents errors from propagating // The new request will handle the actual response queued.resolve(REQUEST_SUPERSEDED); } // Queue new request const timeout = setTimeout(() => { requestQueue.delete(key); const promise = requestFn(); const timestamp = Date.now(); pendingRequests.set(key, { promise, timestamp }); promise .then(result => { pendingRequests.delete(key); resolve(result); }) .catch(error => { pendingRequests.delete(key); reject(error); }); }, delay); requestQueue.set(key, { resolve, reject, timeout }); }); } export const setAuthErrorHandler = (handler) => { onAuthError = handler; }; const handleAuthError = (response) => { if (response.status === 401 || response.status === 403) { // Trigger auth error handler if set (this will clear user state) if (onAuthError) { onAuthError(); } // Force a re-check of auth status to ensure login is shown // This ensures the App component re-renders with user=null if (typeof window !== 'undefined') { // Dispatch a custom event that useAuth can listen to window.dispatchEvent(new CustomEvent('auth-error')); } } }; export const api = { async get(endpoint) { const response = await fetch(`${API_BASE}${endpoint}`, { credentials: 'include', // Include cookies for session }); if (!response.ok) { // Handle auth errors before parsing response // Don't redirect on /auth/me - that's the auth check itself if ((response.status === 401 || response.status === 403) && !endpoint.startsWith('/auth/')) { handleAuthError(response); // Don't redirect - let React handle UI change through state } const errorData = await response.json().catch(() => null); const errorMessage = errorData?.error || response.statusText; throw new Error(errorMessage); } return response.json(); }, async post(endpoint, data) { const response = await fetch(`${API_BASE}${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), credentials: 'include', // Include cookies for session }); if (!response.ok) { // Handle auth errors before parsing response // Don't redirect on /auth/* endpoints - those are login/logout if ((response.status === 401 || response.status === 403) && !endpoint.startsWith('/auth/')) { handleAuthError(response); // Don't redirect - let React handle UI change through state } const errorData = await response.json().catch(() => null); const errorMessage = errorData?.error || response.statusText; throw new Error(errorMessage); } return response.json(); }, async delete(endpoint) { const response = await fetch(`${API_BASE}${endpoint}`, { method: 'DELETE', credentials: 'include', // Include cookies for session }); if (!response.ok) { // Handle auth errors before parsing response // Don't redirect on /auth/* endpoints if ((response.status === 401 || response.status === 403) && !endpoint.startsWith('/auth/')) { handleAuthError(response); // Don't redirect - let React handle UI change through state } const errorData = await response.json().catch(() => null); const errorMessage = errorData?.error || response.statusText; throw new Error(errorMessage); } return response.json(); }, async uploadFile(endpoint, file, onProgress, mainBlendFile) { return new Promise((resolve, reject) => { const formData = new FormData(); formData.append('file', file); if (mainBlendFile) { formData.append('main_blend_file', mainBlendFile); } const xhr = new XMLHttpRequest(); // Track upload progress if (onProgress) { xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable) { const percentComplete = (e.loaded / e.total) * 100; onProgress(percentComplete); } }); } xhr.addEventListener('load', () => { if (xhr.status >= 200 && xhr.status < 300) { try { const response = JSON.parse(xhr.responseText); resolve(response); } catch (err) { resolve(xhr.responseText); } } else { // Handle auth errors if (xhr.status === 401 || xhr.status === 403) { handleAuthError({ status: xhr.status }); // Don't redirect - let React handle UI change through state } try { const errorData = JSON.parse(xhr.responseText); reject(new Error(errorData.error || xhr.statusText)); } catch { reject(new Error(xhr.statusText)); } } }); xhr.addEventListener('error', () => { reject(new Error('Upload failed')); }); xhr.addEventListener('abort', () => { reject(new Error('Upload aborted')); }); xhr.open('POST', `${API_BASE}${endpoint}`); xhr.withCredentials = true; // Include cookies for session xhr.send(formData); }); }, }; export const auth = { async getMe() { return api.get('/auth/me'); }, async logout() { return api.post('/auth/logout'); }, async getProviders() { return api.get('/auth/providers'); }, async isLocalLoginAvailable() { return api.get('/auth/local/available'); }, async localRegister(email, name, password) { return api.post('/auth/local/register', { email, name, password }); }, async localLogin(username, password) { return api.post('/auth/local/login', { username, password }); }, async changePassword(oldPassword, newPassword, targetUserId = null) { const body = { old_password: oldPassword, new_password: newPassword }; if (targetUserId !== null) { body.target_user_id = targetUserId; } return api.post('/auth/change-password', body); }, }; export const jobs = { async list(options = {}) { const key = getCacheKey('/jobs', options); return debounceRequest(key, () => { const params = new URLSearchParams(); if (options.limit) params.append('limit', options.limit.toString()); if (options.offset) params.append('offset', options.offset.toString()); if (options.status) params.append('status', options.status); if (options.sort) params.append('sort', options.sort); const query = params.toString(); return api.get(`/jobs${query ? '?' + query : ''}`); }); }, async listSummary(options = {}) { const key = getCacheKey('/jobs/summary', options); return debounceRequest(key, () => { const params = new URLSearchParams(); if (options.limit) params.append('limit', options.limit.toString()); if (options.offset) params.append('offset', options.offset.toString()); if (options.status) params.append('status', options.status); if (options.sort) params.append('sort', options.sort); const query = params.toString(); return api.get(`/jobs/summary${query ? '?' + query : ''}`); }); }, async get(id, options = {}) { const key = getCacheKey(`/jobs/${id}`, options); return debounceRequest(key, async () => { if (options.etag) { // Include ETag in request headers for conditional requests const headers = { 'If-None-Match': options.etag }; const response = await fetch(`${API_BASE}/jobs/${id}`, { credentials: 'include', headers, }); if (response.status === 304) { return null; // Not modified } if (!response.ok) { const errorData = await response.json().catch(() => null); throw new Error(errorData?.error || response.statusText); } return response.json(); } return api.get(`/jobs/${id}`); }); }, async create(jobData) { return api.post('/jobs', jobData); }, async cancel(id) { return api.delete(`/jobs/${id}`); }, async delete(id) { return api.post(`/jobs/${id}/delete`); }, async uploadFile(jobId, file, onProgress, mainBlendFile) { return api.uploadFile(`/jobs/${jobId}/upload`, file, onProgress, mainBlendFile); }, async uploadFileForJobCreation(file, onProgress, mainBlendFile) { return api.uploadFile(`/jobs/upload`, file, onProgress, mainBlendFile); }, async getFiles(jobId, options = {}) { const key = getCacheKey(`/jobs/${jobId}/files`, options); return debounceRequest(key, () => { const params = new URLSearchParams(); if (options.limit) params.append('limit', options.limit.toString()); if (options.offset) params.append('offset', options.offset.toString()); if (options.file_type) params.append('file_type', options.file_type); if (options.extension) params.append('extension', options.extension); const query = params.toString(); return api.get(`/jobs/${jobId}/files${query ? '?' + query : ''}`); }); }, async getFilesCount(jobId, options = {}) { const key = getCacheKey(`/jobs/${jobId}/files/count`, options); return debounceRequest(key, () => { const params = new URLSearchParams(); if (options.file_type) params.append('file_type', options.file_type); const query = params.toString(); return api.get(`/jobs/${jobId}/files/count${query ? '?' + query : ''}`); }); }, async getContextArchive(jobId) { return api.get(`/jobs/${jobId}/context`); }, downloadFile(jobId, fileId) { return `${API_BASE}/jobs/${jobId}/files/${fileId}/download`; }, getVideoUrl(jobId) { return `${API_BASE}/jobs/${jobId}/video`; }, async getTaskLogs(jobId, taskId, options = {}) { const key = getCacheKey(`/jobs/${jobId}/tasks/${taskId}/logs`, options); return debounceRequest(key, async () => { const params = new URLSearchParams(); if (options.stepName) params.append('step_name', options.stepName); if (options.logLevel) params.append('log_level', options.logLevel); if (options.limit) params.append('limit', options.limit.toString()); if (options.sinceId) params.append('since_id', options.sinceId.toString()); const query = params.toString(); const result = await api.get(`/jobs/${jobId}/tasks/${taskId}/logs${query ? '?' + query : ''}`); // Handle both old format (array) and new format (object with logs, last_id, limit) if (Array.isArray(result)) { return { logs: result, last_id: result.length > 0 ? result[result.length - 1].id : 0, limit: options.limit || 100 }; } return result; }); }, async getTaskSteps(jobId, taskId) { return api.get(`/jobs/${jobId}/tasks/${taskId}/steps`); }, streamTaskLogsWebSocket(jobId, taskId, lastId = 0) { // Convert HTTP to WebSocket URL const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsHost = window.location.host; const url = `${wsProtocol}//${wsHost}${API_BASE}/jobs/${jobId}/tasks/${taskId}/logs/ws?last_id=${lastId}`; return new WebSocket(url); }, streamJobsWebSocket() { const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsHost = window.location.host; const url = `${wsProtocol}//${wsHost}${API_BASE}/jobs/ws`; return new WebSocket(url); }, streamJobWebSocket(jobId) { const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsHost = window.location.host; const url = `${wsProtocol}//${wsHost}${API_BASE}/jobs/${jobId}/ws`; return new WebSocket(url); }, async retryTask(jobId, taskId) { return api.post(`/jobs/${jobId}/tasks/${taskId}/retry`); }, async getMetadata(jobId) { return api.get(`/jobs/${jobId}/metadata`); }, async getTasks(jobId, options = {}) { const key = getCacheKey(`/jobs/${jobId}/tasks`, options); return debounceRequest(key, () => { const params = new URLSearchParams(); if (options.limit) params.append('limit', options.limit.toString()); if (options.offset) params.append('offset', options.offset.toString()); if (options.status) params.append('status', options.status); if (options.frameStart) params.append('frame_start', options.frameStart.toString()); if (options.frameEnd) params.append('frame_end', options.frameEnd.toString()); if (options.sort) params.append('sort', options.sort); const query = params.toString(); return api.get(`/jobs/${jobId}/tasks${query ? '?' + query : ''}`); }); }, async getTasksSummary(jobId, options = {}) { const key = getCacheKey(`/jobs/${jobId}/tasks/summary`, options); return debounceRequest(key, () => { const params = new URLSearchParams(); if (options.limit) params.append('limit', options.limit.toString()); if (options.offset) params.append('offset', options.offset.toString()); if (options.status) params.append('status', options.status); if (options.sort) params.append('sort', options.sort); const query = params.toString(); return api.get(`/jobs/${jobId}/tasks/summary${query ? '?' + query : ''}`); }); }, async batchGetJobs(jobIds) { // Sort jobIds for consistent cache key const sortedIds = [...jobIds].sort((a, b) => a - b); const key = getCacheKey('/jobs/batch', { job_ids: sortedIds.join(',') }); return debounceRequest(key, () => { return api.post('/jobs/batch', { job_ids: jobIds }); }); }, async batchGetTasks(jobId, taskIds) { // Sort taskIds for consistent cache key const sortedIds = [...taskIds].sort((a, b) => a - b); const key = getCacheKey(`/jobs/${jobId}/tasks/batch`, { task_ids: sortedIds.join(',') }); return debounceRequest(key, () => { return api.post(`/jobs/${jobId}/tasks/batch`, { task_ids: taskIds }); }); }, }; export const runners = { // Non-admin runner list removed - use admin.listRunners() instead }; export const admin = { async generateAPIKey(name, description, scope) { const data = { name, scope }; if (description) data.description = description; return api.post('/admin/runners/api-keys', data); }, async listAPIKeys() { return api.get('/admin/runners/api-keys'); }, async revokeAPIKey(keyId) { return api.patch(`/admin/runners/api-keys/${keyId}/revoke`); }, async deleteAPIKey(keyId) { return api.delete(`/admin/runners/api-keys/${keyId}`); }, async listRunners() { return api.get('/admin/runners'); }, async verifyRunner(runnerId) { return api.post(`/admin/runners/${runnerId}/verify`); }, async deleteRunner(runnerId) { return api.delete(`/admin/runners/${runnerId}`); }, async listUsers() { return api.get('/admin/users'); }, async getUserJobs(userId) { return api.get(`/admin/users/${userId}/jobs`); }, async setUserAdminStatus(userId, isAdmin) { return api.post(`/admin/users/${userId}/admin`, { is_admin: isAdmin }); }, async getRegistrationEnabled() { return api.get('/admin/settings/registration'); }, async setRegistrationEnabled(enabled) { return api.post('/admin/settings/registration', { enabled }); }, };