Files
parenthese/frontend/src/api/client.js
T
IliesAllaliandClaude Fable 5.2 4c80dea44a Nettoyage frontend : styles et fonctions sans appelant, drapeau ?layout=sugiyama, en-tête d'autorisation partagé
- CSS : règles des anciens écrans (assistant de création, gestionnaire de souvenirs, réglages, lecteur vidéo), keyframes et variable sans lecteur
- Fonctions sans appelant : mockData (3), treeApi (stats, audit), getMediaToken, drawPathLabel, NavButton
- Fichiers-barils navbar/ et search/ remplacés par des imports directs
- Galaxy : le placement par blocs est le seul chemin
- api/client.js : bearer(token) remplace 59 en-têtes écrits à la main ; import dynamique inutile de client.js retiré

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 12:51:56 +01:00

111 lines
4.1 KiB
JavaScript

import { t } from '../i18n/index.js'
const rawApiBaseUrl = import.meta.env.VITE_API_BASE_URL
const defaultApiBaseUrl = import.meta.env.DEV ? 'http://localhost:4000' : ''
const API_BASE_URL = (rawApiBaseUrl ?? defaultApiBaseUrl).replace(/\/$/, '')
class ApiError extends Error {
constructor(message, status, payload = null) {
super(message)
this.name = 'ApiError'
this.status = status
this.payload = payload
}
}
async function safeParseJson(text) {
if (!text) {
return null
}
try {
return JSON.parse(text)
} catch {
return null
}
}
// Sans limite, un réseau mobile qui décroche laisse l'arbre « en chargement » pour toujours.
// Un corps de plus de 100 Ko est un envoi de fichier : il a plus de temps sur une connexion lente.
const DEFAULT_TIMEOUT_MS = 30_000
const UPLOAD_TIMEOUT_MS = 5 * 60_000
const LARGE_BODY_CHARS = 100_000
function timeoutSignal(options) {
if (options.signal) return options.signal
if (typeof AbortSignal === 'undefined' || typeof AbortSignal.timeout !== 'function') return undefined
const isLargeBody = typeof options.body === 'string' && options.body.length > LARGE_BODY_CHARS
return AbortSignal.timeout(options.timeoutMs ?? (isLargeBody ? UPLOAD_TIMEOUT_MS : DEFAULT_TIMEOUT_MS))
}
// Envois sans doublon (backend/src/lib/idempotency.ts). Un envoi qui n'a pas abouti (délai dépassé, réseau coupé,
// erreur) garde sa clé, et le même envoi refait (même adresse, même contenu) la réutilise : si le premier avait en
// fait abouti, le serveur renvoie sa réponse au lieu de tout recréer. Une réussite libère la clé, et renvoyer le même
// contenu plus tard (un souvenir supprimé puis remis) est un nouvel envoi.
const unsettledKeys = new Map()
// Empreinte rapide du contenu (cyrb53) : crypto.subtle n'existe pas hors HTTPS (auto-hébergement en réseau local)
function fingerprint(text) {
let h1 = 0xdeadbeef
let h2 = 0x41c6ce57
for (let i = 0; i < text.length; i += 1) {
const ch = text.charCodeAt(i)
h1 = Math.imul(h1 ^ ch, 2654435761)
h2 = Math.imul(h2 ^ ch, 1597334677)
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909)
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909)
return `${text.length}:${(h2 >>> 0).toString(36)}${(h1 >>> 0).toString(36)}`
}
// getRandomValues existe aussi hors HTTPS, contrairement à crypto.randomUUID
function newIdempotencyKey() {
const bytes = new Uint8Array(16)
crypto.getRandomValues(bytes)
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
}
async function apiRequest(path, options = {}) {
const { timeoutMs: _timeoutMs, idempotent = false, ...fetchOptions } = options
const providedHeaders = options.headers || {}
const hasExplicitContentType = Object.keys(providedHeaders).some((key) => key.toLowerCase() === 'content-type')
const print = idempotent ? `${path}|${fingerprint(typeof options.body === 'string' ? options.body : '')}` : null
if (print && !unsettledKeys.has(print)) unsettledKeys.set(print, newIdempotencyKey())
const headers = {
...(options.body !== undefined && !hasExplicitContentType ? { 'Content-Type': 'application/json' } : {}),
...(print ? { 'Idempotency-Key': unsettledKeys.get(print) } : {}),
...providedHeaders,
}
let response
let text
try {
response = await fetch(`${API_BASE_URL}${path}`, {
...fetchOptions,
headers,
signal: timeoutSignal(options),
})
text = await response.text()
} catch (error) {
if (error?.name === 'TimeoutError') {
throw new ApiError(t('Le serveur ne répond pas. Vérifiez la connexion et réessayez.'), 0, { error: 'request_timeout' })
}
throw error
}
const payload = await safeParseJson(text)
if (!response.ok) {
throw new ApiError(payload?.error || `request_failed_${response.status}`, response.status, payload)
}
if (print) unsettledKeys.delete(print)
return payload
}
// En-tête d'un appel fait au nom d'un compte ou d'un accès partagé
const bearer = (token) => ({ Authorization: `Bearer ${token}` })
export { ApiError, API_BASE_URL, apiRequest, bearer }