const DIRECTUS_URL = 'https://api.kashilo.com' let _persist = false export function setPersist(value) { _persist = value } export function getPersist() { return _persist } function _storage() { return _persist ? localStorage : sessionStorage } class DirectusError extends Error { constructor(status, message, data = {}) { super(message) this.name = 'DirectusError' this.status = status this.data = data } isAuthError() { return this.status === 401 || this.status === 403 } isNotFound() { return this.status === 404 } isValidationError() { return this.status === 400 } } class DirectusClient { constructor() { this.baseUrl = DIRECTUS_URL this.accessToken = null this.refreshToken = null this.tokenExpiry = null this.refreshTimeout = null this._refreshPromise = null this.loadTokens() this.setupVisibilityRefresh() } // ── Token Management ── loadTokens() { const stored = sessionStorage.getItem('kashilo_auth') || localStorage.getItem('kashilo_auth') if (stored) { try { const { accessToken, refreshToken, expiry } = JSON.parse(stored) this.accessToken = accessToken this.refreshToken = refreshToken this.tokenExpiry = expiry if (localStorage.getItem('kashilo_auth')) { _persist = true } this.scheduleTokenRefresh() } catch (e) { this.clearTokens() } } } saveTokens(accessToken, refreshToken, expiresIn) { this.accessToken = accessToken this.refreshToken = refreshToken this.tokenExpiry = Date.now() + (expiresIn * 1000) _storage().setItem('kashilo_auth', JSON.stringify({ accessToken: this.accessToken, refreshToken: this.refreshToken, expiry: this.tokenExpiry })) this.scheduleTokenRefresh() } clearTokens() { this.accessToken = null this.refreshToken = null this.tokenExpiry = null sessionStorage.removeItem('kashilo_auth') localStorage.removeItem('kashilo_auth') if (this.refreshTimeout) { clearTimeout(this.refreshTimeout) this.refreshTimeout = null } } scheduleTokenRefresh() { if (this.refreshTimeout) { clearTimeout(this.refreshTimeout) } if (!this.tokenExpiry || !this.refreshToken) return const refreshIn = this.tokenExpiry - Date.now() - 60000 if (refreshIn > 0) { this.refreshTimeout = setTimeout(() => this.refreshSession(), refreshIn) } else if (this.refreshToken) { this.refreshSession() } } setupVisibilityRefresh() { document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && this.refreshToken) { const timeLeft = this.tokenExpiry - Date.now() if (timeLeft < 120000) { this.refreshSession() } this.scheduleTokenRefresh() } }) } isAuthenticated() { return !!this.accessToken && (!this.tokenExpiry || Date.now() < this.tokenExpiry) } async refreshSession(_retryCount = 0) { if (!this.refreshToken) return false try { const response = await fetch(`${this.baseUrl}/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: this.refreshToken, mode: 'json' }) }) if (!response.ok) throw new Error('Refresh failed') const result = await response.json() if (result.data) { this.saveTokens( result.data.access_token, result.data.refresh_token, result.data.expires ) return true } } catch (e) { if (_retryCount < 2) { await new Promise(r => setTimeout(r, 2000)) return this.refreshSession(_retryCount + 1) } this.clearTokens() } return false } // ── HTTP Methods ── async request(endpoint, options = {}, _retryCount = 0) { const url = `${this.baseUrl}${endpoint}` const headers = { 'Content-Type': 'application/json', ...options.headers } if (this.accessToken) { headers['Authorization'] = `Bearer ${this.accessToken}` } try { const response = await fetch(url, { ...options, headers }) if (response.status === 401 && this.refreshToken && !endpoint.startsWith('/auth/') && _retryCount < 1) { if (!this._refreshPromise) { this._refreshPromise = this.refreshSession().finally(() => { this._refreshPromise = null }) } const refreshed = await this._refreshPromise if (!refreshed) { this.clearTokens() } return this.request(endpoint, options, _retryCount + 1) } if (response.status === 429 && _retryCount < 3) { const retryAfterHeader = response.headers.get('Retry-After') || '1' let waitMs if (parseInt(retryAfterHeader, 10) > 100) { waitMs = parseInt(retryAfterHeader, 10) } else { waitMs = parseInt(retryAfterHeader, 10) * 1000 } await new Promise(r => setTimeout(r, waitMs)) return this.request(endpoint, options, _retryCount + 1) } if (!response.ok) { const error = await response.json().catch(() => ({})) throw new DirectusError(response.status, error.errors?.[0]?.message || 'Request failed', error) } if (response.status === 204) { return null } return await response.json() } catch (error) { if (error instanceof DirectusError) throw error throw new DirectusError(0, 'Network error', { originalError: error }) } } async get(endpoint, params = {}) { const queryString = this.buildQueryString(params) const url = queryString ? `${endpoint}?${queryString}` : endpoint return this.request(url, { method: 'GET' }) } async post(endpoint, data) { return this.request(endpoint, { method: 'POST', body: JSON.stringify(data) }) } async patch(endpoint, data) { return this.request(endpoint, { method: 'PATCH', body: JSON.stringify(data) }) } async delete(endpoint) { return this.request(endpoint, { method: 'DELETE' }) } buildQueryString(params) { const searchParams = new URLSearchParams() for (const [key, value] of Object.entries(params)) { if (value === undefined || value === null) continue if (key === 'sort' && Array.isArray(value)) { searchParams.set(key, value.join(',')) } else if (key === 'fields' && Array.isArray(value)) { searchParams.set(key, value.join(',')) } else if (typeof value === 'object') { searchParams.set(key, JSON.stringify(value)) } else { searchParams.set(key, value) } } return searchParams.toString() } } export const client = new DirectusClient() export { DirectusError }