Files
kashilo/js/services/directus/client.js

256 lines
7.5 KiB
JavaScript

const DIRECTUS_URL = 'https://api.dgray.io'
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 = localStorage.getItem('dgray_auth')
if (stored) {
try {
const { accessToken, refreshToken, expiry } = JSON.parse(stored)
this.accessToken = accessToken
this.refreshToken = refreshToken
this.tokenExpiry = expiry
this.scheduleTokenRefresh()
} catch (e) {
this.clearTokens()
}
}
}
saveTokens(accessToken, refreshToken, expiresIn) {
this.accessToken = accessToken
this.refreshToken = refreshToken
this.tokenExpiry = Date.now() + (expiresIn * 1000)
localStorage.setItem('dgray_auth', JSON.stringify({
accessToken: this.accessToken,
refreshToken: this.refreshToken,
expiry: this.tokenExpiry
}))
this.scheduleTokenRefresh()
}
clearTokens() {
this.accessToken = null
this.refreshToken = null
this.tokenExpiry = null
localStorage.removeItem('dgray_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 }