Skip to content

@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

bash
pnpm add @unireq/http

Panorama des exports

CatégorieSymbolsRôle
Transport & connecteurshttp, UndiciConnector, UndiciConnectorOptionsTransport HTTP configurable (keep-alive, proxy, TLS, sockets).
Sérialiseurs de corpsbody.json, body.form, body.text, body.binary, body.multipart, body.autoEncodent la requête et posent Content-Type.
Parseurs de réponseparse.json, parse.text, parse.blob, parse.stream, parse.sse, parse.rawDécodent la réponse et gèrent Accept.
Policiesheaders, query, timeout, redirectPolicyEn-têtes dynamiques, query params, timeouts, suivi des redirections.
Cache conditionnelconditional, etag, lastModified, ETagPolicyOptions, LastModifiedPolicyOptionsAlimente If-None-Match / If-Modified-Since et stocke les nouvelles valeurs.
Range & repriserange, resume, parseContentRange, supportsRange, RangeOptions, ResumeStateGère les téléchargements partiels et leur reprise.
Rate limitingrateLimitDelay, parseRetryAfter, RateLimitDelayOptionsApplique Retry-After avant un retry.
Prédicat HTTPhttpRetryPredicate, HttpRetryPredicateOptionsConditionne retry() avec les bonnes pratiques HTTP.
IntercepteursinterceptRequest/Response/Error, combine*, RequestInterceptor, ResponseInterceptorInstrumentation légère sans écrire de policy complète.
Héritagemultipart (legacy), accept/json/text/raw (préférez parse.*).

Transport & UndiciConnector

typescript
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 capability http.
  • Le connecteur vous laisse configurer les pools TCP, proxies, timeouts, DNS, SNI, etc.
  • Passez undefined pour baseUrl si 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 :

typescript
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/data

Règles de résolution des URLs

  1. Chemins relatifs (commençant par /) sont combinés avec l'URL de base
  2. URLs absolues (contenant ://) sont utilisées telles quelles
  3. Sans URL de base – les URLs doivent être absolues
typescript
// 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] ← Serveur

Ordre recommandé des policies

Pour un comportement optimal, composez les policies dans cet ordre :

typescript
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.*

typescript
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 :

typescript
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 vide

Priorité de détection :

  1. null/undefined → body vide
  2. stringbody.text()
  3. FormData → multipart/form-data (passthrough)
  4. URLSearchParams → application/x-www-form-urlencoded
  5. Blob → binaire avec le type MIME du blob
  6. ArrayBuffer → application/octet-stream
  7. ReadableStream → streaming body
  8. 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

typescript
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 Accept automatiquement.
  • parse.stream() renvoie un ReadableStream<Uint8Array> utilisable avec for await ou getReader().
  • parse.sse() convertit text/event-stream en AsyncIterable<SSEEvent> (gestion des champs id, 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ève TimeoutError). 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.

typescript
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 :

typescript
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

mermaid
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 headers
  • total : 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.
typescript
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

typescript
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

typescript
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, ETag ou Last-Modified avant d'exécuter un GET coûteux.
  • Combinez-le avec get() + If-None-Match pour vos stratégies de cache conditionnelles.

POST – Création

typescript
const payload = { email: 'jane@example.com', name: 'Jane' };
const created = await api.post('/users', body.json(payload), parse.json());
  • body.json() encode et définit Content-Type automatiquement.
  • Pensez à headers({ 'x-idempotency-key': crypto.randomUUID() }) lorsque l'API supporte les replays.

PUT – Remplacement complet

typescript
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

typescript
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

typescript
await api.delete('/users/42', parse.raw());
  • Les réponses 204 n'ont pas de corps : ajoutez parse.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

typescript
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() ou cachePolicy évite de répéter le prévol.

Cache conditionnel

typescript
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 / lastModified acceptent 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.

typescript
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 brut

Range & reprise de téléchargements

typescript
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érifie Accept-Ranges.
  • parseContentRange(header) aide à calculer la taille totale restante.
  • resume({ downloaded }) continue un téléchargement partiel en envoyant Range: 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 dans retry() pour respecter Retry-After.
typescript
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.

typescript
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 ProxyAgent est créé une seule fois et réutilisé pour toutes les requêtes (connection pooling).
  • Les identifiants dans ctx.proxy ne 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

typescript
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 / combineResponseInterceptors facilitent la composition.
  • interceptError convertit 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érez body.multipart().
  • accept, json, text, raw sont conservés pour compatibilité mais parse.* est la voie recommandée.

← Core · HTTP/2 →

Released under the MIT License.