refactor: modularize directus.js into 8 focused submodules with backward-compatible facade
This commit is contained in:
255
js/services/directus/client.js
Normal file
255
js/services/directus/client.js
Normal file
@@ -0,0 +1,255 @@
|
||||
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 }
|
||||
Reference in New Issue
Block a user