@unireq/http
@unireq/http fournit le transport HTTP(S) standard basé sur undici, ainsi que tous les serializers, parsers, policies et helpers spécifiques au protocole.
Installation
pnpm add @unireq/httpPanorama des exports
| Catégorie | Symbols | Rôle |
|---|---|---|
| Transport & connecteurs | http, UndiciConnector, UndiciConnectorOptions | Transport HTTP configurable (keep-alive, proxy, TLS, sockets). |
| Sérialiseurs de corps | body.json, body.form, body.text, body.binary, body.multipart, body.auto | Encodent la requête et posent Content-Type. |
| Parseurs de réponse | parse.json, parse.text, parse.blob, parse.stream, parse.sse, parse.raw | Décodent la réponse et gèrent Accept. |
| Policies | headers, query, timeout, redirectPolicy | En-têtes dynamiques, query params, timeouts, suivi des redirections. |
| Cache conditionnel | conditional, etag, lastModified, ETagPolicyOptions, LastModifiedPolicyOptions | Alimente If-None-Match / If-Modified-Since et stocke les nouvelles valeurs. |
| Range & reprise | range, resume, parseContentRange, supportsRange, RangeOptions, ResumeState | Gère les téléchargements partiels et leur reprise. |
| Rate limiting | rateLimitDelay, parseRetryAfter, RateLimitDelayOptions | Applique Retry-After avant un retry. |
| Prédicat HTTP | httpRetryPredicate, HttpRetryPredicateOptions | Conditionne retry() avec les bonnes pratiques HTTP. |
| Intercepteurs | interceptRequest/Response/Error, combine*, RequestInterceptor, ResponseInterceptor | Instrumentation légère sans écrire de policy complète. |
| Héritage | multipart (legacy), accept/json/text/raw (préférez parse.*). |
Transport & UndiciConnector
import { client } from '@unireq/core';
import { http, UndiciConnector, headers } from '@unireq/http';
const connector = new UndiciConnector({
keepAliveTimeout: 30_000,
connectTimeout: 5_000,
tls: { rejectUnauthorized: true },
});
const api = client(
http('https://api.example.com', connector),
headers({ 'user-agent': 'MyApp/1.0' }),
);http(baseUrl?, connector?)expose un transport inspectable avec capabilityhttp.- Le connecteur vous laisse configurer les pools TCP, proxies, timeouts, DNS, SNI, etc.
- Passez
undefinedpourbaseUrlsi vous fournissez des URLs absolues par requête.
Support de l'URL de base
Le factory http() accepte une URL de base optionnelle qui se combine avec les chemins relatifs des requêtes :
import { client } from '@unireq/core';
import { http, json } from '@unireq/http';
// Créer un client avec URL de base
const api = client(http('https://api.example.com'), json());
// Ces URLs relatives sont automatiquement résolues :
await api.get('/users'); // → https://api.example.com/users
await api.get('/users/123'); // → https://api.example.com/users/123
await api.post('/users', body); // → https://api.example.com/users
// Les URLs absolues contournent l'URL de base :
await api.get('https://other.api.com/data'); // → https://other.api.com/dataRègles de résolution des URLs
- Chemins relatifs (commençant par
/) sont combinés avec l'URL de base - URLs absolues (contenant
://) sont utilisées telles quelles - Sans URL de base – les URLs doivent être absolues
// Sans URL de base - chaque requête nécessite l'URL complète
const api = client(http(), json());
await api.get('https://api.example.com/users');Ordre d'exécution des policies
Les policies s'exécutent selon un pattern middleware/oignon où :
- La requête traverse les policies de gauche à droite
- La réponse revient de droite à gauche à travers les mêmes policies
Requête: client.get() → [policy1] → [policy2] → [transport] → Serveur
Réponse: client.get() ← [policy1] ← [policy2] ← [transport] ← ServeurOrdre recommandé des policies
Pour un comportement optimal, composez les policies dans cet ordre :
import { client, retry, backoff } from '@unireq/core';
import { http, accept, headers, timeout, redirectPolicy, json } from '@unireq/http';
const api = client(
http('https://api.example.com'),
// 1. EXTERNE : Retry (encapsule tout, capture toutes les erreurs)
retry(predicate, [backoff()], { tries: 3 }),
// 2. HEADERS : Définir accept/content-type
accept(['application/json']),
headers({ 'X-API-Key': 'secret' }),
// 3. TIMEOUT : Timeout de requête (doit être à l'intérieur du retry)
timeout(5000),
// 4. REDIRECTS : Suivre les redirections
redirectPolicy({ allow: [307, 308] }),
// 5. INTERNE : Parsing de la réponse
json(),
);Cela garantit :
- Retry encapsule les timeouts, donc les timeouts déclenchent des retries
- Headers sont définis avant l'envoi de la requête
- Timeout s'applique à chaque tentative, pas au total
- Parsing s'exécute en dernier sur la réponse
Sérialiseurs body.*
body.json({ query: 'unireq' });
body.form({ page: 2, sort: 'asc' });
body.text('hello world', 'text/plain; charset=utf-8');
body.binary(arrayBuffer, 'application/octet-stream');
body.multipart(
{ name: 'file', part: body.binary(fileBuffer, 'application/pdf'), filename: 'quote.pdf' },
{ name: 'meta', part: body.json({ id: 42 }) },
{
maxFileSize: 25 * 1024 * 1024,
allowedMimeTypes: ['application/pdf'],
sanitizeFilenames: true,
},
);Ces descripteurs sont compris par serializationPolicy() de @unireq/core (aucune configuration supplémentaire nécessaire).
Auto-détection avec body.auto()
Pour plus de commodité, body.auto() détecte automatiquement le bon sérialiseur :
body.auto({ name: 'value' }); // → body.json() pour les objets
body.auto('texte brut'); // → body.text() pour les strings
body.auto(new FormData()); // → multipart/form-data
body.auto(new URLSearchParams()); // → application/x-www-form-urlencoded
body.auto(new Blob([data])); // → binaire avec le type du blob
body.auto(new ArrayBuffer(8)); // → application/octet-stream
body.auto(null); // → body videPriorité de détection :
null/undefined→ body videstring→body.text()FormData→ multipart/form-data (passthrough)URLSearchParams→ application/x-www-form-urlencodedBlob→ binaire avec le type MIME du blobArrayBuffer→ application/octet-streamReadableStream→ streaming body- Objets/tableaux →
body.json()
Note : Le XML ne peut pas être auto-détecté (les objets ressemblent au JSON). Utilisez xmlBody() de @unireq/xml explicitement.
Parseurs parse.* & streaming
import { parse } from '@unireq/http';
const asJson = parse.json();
const asText = parse.text();
const asBlob = parse.blob();
const asStream = parse.stream();
const asSSE = parse.sse();- Chaque parser définit
Acceptautomatiquement. parse.stream()renvoie unReadableStream<Uint8Array>utilisable avecfor awaitougetReader().parse.sse()convertittext/event-streamenAsyncIterable<SSEEvent>(gestion des champsid,event,retry).
Policies principales
headers(init): ajoute ou calcule des en-têtes (peut recevoir une fonction(ctx) => record).query(init): fusionne des paramètres dans l'URL.timeout(ms | options): annule la requête (lèveTimeoutError). Supporte les timeouts par phase.redirectPolicy({ allow, follow303, max }): choisit quelles redirections suivre et limite la profondeur.
redirectPolicy — comportements de sécurité par défaut
redirectPolicy applique deux protections par défaut :
allowDowngrade (défaut : false) — bloque toute redirection qui dégrade le schéma de HTTPS vers HTTP. Passez true uniquement si vous devez explicitement suivre ce type de redirection (déconseillé en production).
Suppression des en-têtes sensibles lors d'un changement d'origine — quand une redirection change d'hôte ou de schéma, les en-têtes Authorization, Cookie et Proxy-Authorization sont automatiquement supprimés de la requête redirigée pour éviter toute fuite d'identifiants vers un serveur tiers.
import { redirectPolicy } from '@unireq/http';
// Défauts : allowDowngrade=false, en-têtes sensibles supprimés si changement d'origine
const api = client(
http('https://api.example.com'),
redirectPolicy({ allow: [301, 302, 307, 308] }),
);
// Autoriser explicitement le passage HTTPS→HTTP (rare, à éviter en production)
const legacyApi = client(
http('https://legacy.example.com'),
redirectPolicy({
allow: [301, 302],
allowDowngrade: true,
}),
);Configuration des Timeouts
La policy timeout supporte une configuration simple ou par phase :
import { timeout } from '@unireq/http';
// Timeout simple (5 secondes au total)
timeout(5000);
// Timeouts par phase
timeout({
request: 5000, // 5s pour connexion + TTFB (jusqu'à réception des headers)
body: 30000, // 30s pour télécharger le body après les headers
total: 60000, // 60s limite totale de sécurité
});
// Combinaison avec un signal utilisateur
const controller = new AbortController();
const api = client(
http('https://api.example.com'),
timeout(5000),
);
// Le signal utilisateur est automatiquement combiné via AbortSignal.any()
await api.get('/data', { signal: controller.signal });Diagramme des phases de timeout
gantt
title Timeline d'une requête HTTP avec timeouts par phase
dateFormat X
axisFormat %s
section Phase Request
DNS + TCP + TLS :req1, 0, 2
Envoi requête :req2, after req1, 1
Attente headers :req3, after req2, 2
section Phase Body
Téléchargement :body1, after req3, 6
section Timeouts
timeout request (5s) :crit, timeout_req, 0, 5
timeout body (30s) :crit, timeout_body, 5, 35
timeout total (60s) :milestone, timeout_total, 0, 60┌─────────────────────────────────────────────────────────────────────────────┐
│ Timeout Total (60s) │
├───────────────────────────────────┬─────────────────────────────────────────┤
│ Phase Request (5s) │ Phase Body (30s) │
├───────────────────────────────────┼─────────────────────────────────────────┤
│ DNS → TCP → TLS → Envoi → Headers │ Téléchargement du body (streaming) │
│ (utilise AbortSignal) │ (utilise reader.cancel() pour │
│ │ interruption réelle mid-download) │
└───────────────────────────────────┴─────────────────────────────────────────┘
↑
Headers reçus
(transition de phase)Timeouts par phase :
request: Temps pour connexion + envoi requête + réception headers (TTFB)body: Temps alloué pour télécharger le body après réception des headerstotal: Timeout global (filet de sécurité qui prévaut sur les phases)
Notes d'implémentation :
- Utilise
AbortSignal.timeout()natif pour une gestion efficace des timers - Les signaux multiples sont combinés via
AbortSignal.any()(avec fallback pour Node < 20) - Le timeout body utilise
ReadableStream.getReader().cancel()pour une vraie interruption mid-download - Le cleanup est géré automatiquement pour éviter les memory leaks
Exemples rapides par verbe HTTP
- Un seul client global, des policies ponctuelles par appel.
- Les snippets ci-dessous supposent
parse.json()mais gardez la liberté de changer de parser selon la ressource.
import { client } from '@unireq/core';
import { body, headers, http, parse } from '@unireq/http';
const api = client(
http('https://api.example.com'),
headers({ 'user-agent': 'docs-example/1.0' }),
);GET – Lecture
const user = await api.get('/users/42', parse.json());- Ajoutez
query({ include: 'profile' })ponctuellement pour enrichir la réponse. - Méthode idempotente : parfaite avec
retry(httpRetryPredicate()).
HEAD – Inspecter les en-têtes
const head = await api.head('/files/report.pdf', parse.raw());
const size = Number(head.headers['content-length'] ?? 0);
if (size > 10 * 1024 * 1024) {
console.log('Préparer un téléchargement chunké avant le GET');
}- HEAD ne retourne que les en-têtes : exploitez-le pour vérifier
Content-Length,ETagouLast-Modifiedavant d'exécuter unGETcoûteux. - Combinez-le avec
get()+If-None-Matchpour vos stratégies de cache conditionnelles.
POST – Création
const payload = { email: 'jane@example.com', name: 'Jane' };
const created = await api.post('/users', body.json(payload), parse.json());body.json()encode et définitContent-Typeautomatiquement.- Pensez à
headers({ 'x-idempotency-key': crypto.randomUUID() })lorsque l'API supporte les replays.
PUT – Remplacement complet
await api.put('/users/42', body.json({ id: 42, name: 'Jane Updated' }), parse.json());- Combinez avec
etag()/lastModified()pour éviter d'écraser des modifications concurrentes. - Idempotent → compatible avec les retries automatiques.
PATCH – Mise à jour partielle
await api.patch('/users/42', body.json({ name: 'Jane v2' }), parse.json());- Ajustez
Content-Type(application/merge-patch+json,json-patch) selon les conventions du serveur. - Utilisez
either()pour router vers différents formats de patch.
DELETE – Suppression
await api.delete('/users/42', parse.raw());- Les réponses
204n'ont pas de corps : ajoutezparse.raw()ou aucun parser si nécessaire. - Ajoutez un en-tête applicatif (
x-confirm-delete) ou un token CSRF pour éviter les suppressions accidentelles.
OPTIONS – Prévol / capacités
const preflight = await api.options('/users', headers({ Origin: 'https://app.example.com' }), parse.raw());
console.log(preflight.headers['access-control-allow-methods']);- Utile pour interroger dynamiquement les méthodes supportées (CORS, WebDAV, versionnement REST).
- Mettre en cache la réponse avec
conditional()oucachePolicyévite de répéter le prévol.
Cache conditionnel
import { conditional, etag, lastModified } from '@unireq/http';
const cache = client(
http('https://api.example.com'),
conditional(),
);
const etagAware = client(
http('https://api.example.com'),
etag({ get: cacheStore.getEtag, set: cacheStore.setEtag }),
);conditional()combine automatiquement ETag + Last-Modified.etag/lastModifiedacceptent vos stores (sync/async) pour lire/écrire les valeurs des headers.
Cache — Vary, confidentialité et sensibilité aux identifiants
La couche de cache respecte la sémantique HTTP au-delà du simple matching ETag :
Support du header Vary — quand une réponse inclut un header Vary, le cache stocke des entrées distinctes pour chaque combinaison des headers de requête indiqués (ex. Vary: Accept-Encoding, Accept-Language). Deux requêtes qui diffèrent sur un header listé par Vary ne partagent jamais la même entrée de cache.
Cache-Control: private — les réponses portant cette directive ne sont jamais écrites dans le cache partagé et sont retournées directement à l'appelant.
Clés de cache isolées pour les requêtes authentifiées — les requêtes portant un header Authorization ou Cookie obtiennent une clé de cache dérivée d'un hash de la valeur de l'identifiant. L'identifiant lui-même n'est jamais écrit dans le store.
import { conditional, etag } from '@unireq/http';
const cache = new Map<string, string>();
const api = client(
http('https://api.example.com'),
// Le store etag reçoit automatiquement des clés Vary-aware et auth-aware
etag({
get: (key) => cache.get(key),
set: (key, value) => cache.set(key, value),
}),
);
// Réponses avec Cache-Control: private → contournent le store
// Réponses avec Vary: Accept-Language → entrées séparées par langue
// Requêtes authentifiées → clés hashées, jamais l'identifiant brutRange & reprise de téléchargements
import { range, resume, supportsRange, parseContentRange } from '@unireq/http';
const chunked = client(http('https://files.example.com'), range({ start: 0, end: 1023 }));
const resumed = client(http(), resume({ downloaded: bytes déjà reçus }));supportsRange(response)vérifieAccept-Ranges.parseContentRange(header)aide à calculer la taille totale restante.resume({ downloaded })continue un téléchargement partiel en envoyantRange: bytes={downloaded}-.
Rate limiting & Retry-After
parseRetryAfter(headers)renvoie une date ou un délai en ms.rateLimitDelay({ maxWait })s'utilise comme première stratégie dansretry()pour respecterRetry-After.
import { retry } from '@unireq/core';
import { httpRetryPredicate, rateLimitDelay } from '@unireq/http';
const smartRetry = retry(
httpRetryPredicate({ statusCodes: [429, 503] }),
[rateLimitDelay({ maxWait: 60_000 })],
);Proxy
La policy proxy() route toutes les requêtes via un proxy HTTP ou HTTPS en utilisant le ProxyAgent d'undici. Les identifiants du proxy sont stockés exclusivement dans ctx.proxy et ne sont jamais transmis au serveur cible sous forme d'en-têtes.
import { proxy } from '@unireq/http';
const api = client(
http('https://api.example.com'),
proxy({
url: 'https://proxy.corp.example.com:8080',
// Les identifiants vont dans ctx.proxy — jamais exposés à la cible
username: process.env.PROXY_USER,
password: process.env.PROXY_PASS,
}),
);
// Toutes les requêtes de ce client transitent par le ProxyAgent.
// L'en-tête Authorization envoyé à la cible reste inchangé.
await api.get('/data', parse.json());- Le
ProxyAgentest créé une seule fois et réutilisé pour toutes les requêtes (connection pooling). - Les identifiants dans
ctx.proxyne sont pas sérialisés dans les en-têtes sortants, empêchant toute exposition accidentelle au serveur d'origine. - La vérification TLS s'applique à la fois au tunnel proxy et à la connexion de bout en bout, sauf configuration contraire dans
UndiciConnector.
Prédicat HTTP pour retry()
httpRetryPredicate({ methods, statusCodes, maxBodySize }) encapsule les bonnes pratiques : ne réessayer que les méthodes idempotentes (GET/HEAD/PUT/DELETE par défaut), ignorer les réponses hors liste, éviter de rejouer les payloads volumineux. Branchez-le directement sur le retry() générique de @unireq/core.
Intercepteurs
import { interceptRequest, interceptResponse } from '@unireq/http';
const instrumented = client(
http('https://api.example.com'),
interceptRequest((ctx) => {
console.log('→', ctx.method, ctx.url);
return ctx;
}),
interceptResponse((res, ctx) => {
console.log('←', res.status, ctx.url);
return res;
}),
);combineRequestInterceptors/combineResponseInterceptorsfacilitent la composition.interceptErrorconvertit une erreur réseau en une valeur métier ou déclenche des métriques spécifiques.
Exports hérités
multipart(legacy) reste disponible mais préférezbody.multipart().accept,json,text,rawsont conservés pour compatibilité maisparse.*est la voie recommandée.