HTTP
apiFetch, le client HTTP des pages d'admin et des pages publiques, et la lecture de l'adresse IP du client derrière un reverse proxy.
Une page d'admin parle à l'API du core et des modules sous /za/api/*. apiFetch est le client unique de ces appels, côté navigateur : chaque module portait sa propre fonction api(), et les copies ne s'accordaient ni sur les en-têtes envoyés ni sur l'erreur levée. Côté serveur, le même sous-chemin donne l'adresse IP du client, sans jamais faire confiance à un en-tête que le client peut forger.
Import
import { apiFetch, getIpFromRequest, getIpFromHeaders, truncateIp } from '@fraza/core/shared/http';Sous-chemin neutre : le seul fichier qu'il tire en plus est le catalogue neutre des erreurs d'API, core/api/errors.js, sans aucun import, et check-bundle le vérifie à chaque build du core. apiFetch s'importe donc dans un fichier 'use client' sans entraîner de code serveur. Les lecteurs d'IP n'ont de sens que côté serveur, là où une requête a des en-têtes.
API
| Signature | Retour | Rôle |
|---|---|---|
apiFetch(path, { method, body, signal, headers }?) | Promise<*> | Appelle une route et retourne sa charge JSON. |
getIpFromRequest(request) | string | Adresse IP du client d'une route API. |
getIpFromHeaders(headersList) | string | Adresse IP du client d'une server action. |
truncateIp(ip, { v4, v6 }?) | string | null | Réseau de l'adresse, en notation CIDR. |
apiFetch(path, { method, body, signal, headers }?)
'use client';
import { apiFetch } from '@fraza/core/shared/http';
const API = '/za/api/booking';
export async function saveAppointment(id, data) {
const { appointment } = await apiFetch(`${API}/appointments/${id}`, { method: 'PATCH', body: data });
return appointment;
}
// Dans la page : l'erreur porte déjà un message en français, prêt pour le toast.
// try { await saveAppointment(id, data); } catch (error) { toast.error(error.message); }| Option | Défaut | Rôle |
|---|---|---|
method | POST avec un corps, GET sans | Méthode HTTP. |
body | aucun | Un objet ou un tableau est envoyé en JSON. Un FormData, un Blob, un URLSearchParams, un flux ou une chaîne passe tel quel. |
signal | aucun | AbortSignal d'un AbortController, pour annuler l'appel. |
headers | aucun | En-têtes ajoutés à l'appel. Ils sont tous gardés. |
Le comportement, dans l'ordre :
- Un corps objet ou tableau est sérialisé, et
Content-Type: application/jsonest posé seulement si l'appelant n'en a pas fourni. UnFormDatagarde leContent-Typequefetchcalcule avec sa frontière multipart, qu'un en-tête écrit à la main perdrait. Accept: application/jsonest posé s'il manque, etcredentials: 'include'toujours : le cookie de session doit voyager pour que le routeur du core authentifie l'appel et vérifie son origine.- Une réponse vide (
204, ou un corps vide) retournenull. Une réponse qui n'est pas du JSON, comme la page d'erreur d'un proxy, revient en texte. - Une réponse hors
2xxlève uneError. Son message est lemessagede la réponse, celui qu'apiError()écrit pour l'utilisateur, ou à défaut le message du catalogue des erreurs pour ce statut, celui que le serveur aurait envoyé : utile quand un proxy répond à la place de l'application. Un5xxabsent du catalogue prend le message du500. L'erreur portestatus,code, lecodede la charge ounull, etpayload, la charge reçue. - Une panne réseau lève une
Errordestatus0, avec l'erreur d'origine danscause. Une annulation parsignalest relancée telle quelle : l'appelant reconnaît sonAbortErroret ne l'affiche pas.
useEffect(() => {
const controller = new AbortController();
apiFetch(`${API}/slots?date=${date}`, { signal: controller.signal })
.then(setSlots)
.catch((error) => {
if (error.name !== 'AbortError') setError(error.message);
});
return () => controller.abort();
}, [date]);Adresse IP du client
getIpFromHeaders(headersList) et getIpFromRequest(request)
Extraient l'adresse IP du client : getIpFromHeaders depuis le headers() d'une server action, getIpFromRequest depuis la Request d'une route API. Elles ne lisent X-Forwarded-For et X-Real-IP que lorsque FRAZA_TRUST_PROXY déclare un proxy : sans cette précaution, n'importe quel client pourrait forger l'en-tête et contourner la limitation en variant la valeur. La valeur lue est en plus validée comme adresse IPv4 ou IPv6, groupes et :: compris : une chaîne arbitraire, ou une graphie IPv6 malformée, ne devient jamais une clé de compteur.
FRAZA_TRUST_PROXY porte le nombre de reverse proxys devant l'application, true valant 1. X-Forwarded-For est lu par la droite : chaque proxy ajoute en fin d'en-tête l'adresse qui vient de le joindre, donc avec N proxys déclarés, l'adresse du client est la N-ième en partant de la fin. Ce qui se trouve à sa gauche a voyagé avec la requête et reste falsifiable : la lecture ne part donc jamais de la première entrée.
| En-tête reçu | FRAZA_TRUST_PROXY | Adresse retenue |
|---|---|---|
203.0.113.7 | true | 203.0.113.7 |
1.2.3.4, 203.0.113.7, dont 1.2.3.4 forgé par le client | true | 203.0.113.7 |
1.2.3.4, 203.0.113.7, 172.70.0.1, derrière Cloudflare puis nginx | 2 | 203.0.113.7 |
Quand l'en-tête compte moins d'entrées que de proxys déclarés, la requête n'a pas traversé toute la chaîne : la première entrée est retenue, faute de mieux. X-Real-IP n'est lu que si X-Forwarded-For ne donne aucune adresse valide. Il ne porte qu'une valeur, et n'est donc fiable que si le proxy le plus proche de l'application l'écrase.
Sans proxy, laisser la variable à false. Le choix de la valeur est détaillé dans Déploiement.
Quand rien n'est exploitable, le retour dépend de l'environnement : 127.0.0.1 en développement, pour que la limitation se comporte en local comme pour un vrai client, et 'unknown' sinon. Le limiteur range alors l'appelant dans le compteur commun ip:unknown, et groupe une adresse IPv6 par son /64.
truncateIp(ip, { v4, v6 }?)
Rend le réseau d'une adresse, en notation CIDR : ce qu'il faut garder pour dire d'où vient une requête sans nommer l'abonné. Les longueurs par défaut, /24 en IPv4 et /48 en IPv6, sont la part qu'un fournisseur attribue à tout un réseau plutôt qu'à un foyer.
import { getIpFromRequest, truncateIp } from '@fraza/core/shared/http';
truncateIp('203.0.113.7'); // '203.0.113.0/24'
truncateIp('2001:db8:85a3:8d3:1319:8a2e:370:7348'); // '2001:db8:85a3::/48'
truncateIp('::ffff:203.0.113.7'); // '203.0.113.0/24'
truncateIp('203.0.113.7', { v4: 16 }); // '203.0.0.0/16'
truncateIp(getIpFromRequest(request)); // null pour 'unknown'Une adresse IPv4 portée par une adresse IPv6 (::ffff:…) est lue comme l'IPv4 qu'elle porte. Une valeur déjà en CIDR est coupée à nouveau, jamais élargie : appliquer la fonction deux fois donne le même résultat, ce qui permet à une migration de repasser sur des valeurs déjà réduites. Tout ce qui n'est pas une adresse, 'unknown' compris, rend null : rien de ce que la requête a écrit n'est gardé tel quel. L'IPv6 s'écrit sous sa forme courte, la plus longue suite de groupes nuls remplacée par ::.
Variables d'environnement
| Variable | Rôle |
|---|---|
FRAZA_TRUST_PROXY | Nombre de reverse proxys de confiance devant l'application. Sans elle, les en-têtes de proxy sont ignorés. |
Notes
path est passé tel quel à fetch. Dans le navigateur, un chemin absolu comme /za/api/drive/nodes vise l'origine de la page. Côté serveur, fetch exige une URL complète : un composant serveur lit la base directement plutôt que de s'appeler lui-même par HTTP.
apiFetch ne réessaie pas. Une requête mutante rejouée en silence peut s'appliquer deux fois. C'est à l'appelant de décider, et le status de l'erreur lui donne de quoi le faire.