Skip to content

Buffer & encodings — bytes, ArrayBuffer, TextDecoder

TL;DRBuffer est une sous-classe de Uint8Array (donc un view sur un ArrayBuffer) optimisée pour Node : pool partagé pour les petites allocations, méthodes d'encoding intégrées (utf8, base64, hex, etc.), parsing binaire (readInt32BE, …). Utilise toujours Buffer.alloc(n) (zéro-initialisé, safe) plutôt que Buffer.allocUnsafe(n) sauf si tu remplis immédiatement (perf hot path). Pour le code portable (Web/Workers/Bun), préfère Uint8Array + TextEncoder/TextDecoder. Blob est une référence opaque à des bytes (potentiellement disque), pas un view direct. Méfie-toi des encoding mismatches : 'utf-8' (avec tiret) marche dans TextDecoder mais pas dans Buffer.toString (qui veut 'utf8').

🧠 Mental model — ASCII + analogie

                  ┌──────────────────────────────────┐
                  │       ArrayBuffer (raw bytes)    │
                  │  ──────────────────────────────  │
                  │  pas accessible directement      │
                  └────────────┬─────────────────────┘

              ┌────────────────┼────────────────┐
              │                │                │
              ▼                ▼                ▼
       ┌───────────┐   ┌──────────────┐   ┌──────────┐
       │Uint8Array │   │ DataView     │   │ Int32Array│
       │ (1 octet) │   │ (multi-types │   │ etc.      │
       └─────┬─────┘   │  + endian)   │   └──────────┘
             │         └──────────────┘

       ┌──────────┐
       │ Buffer   │  ← Node-specific. Hérite de Uint8Array.
       │          │     Ajoute encodings, pool, helpers.
       └──────────┘

  Blob          : objet opaque (peut être backed par disque), .arrayBuffer() async
  TextEncoder   : string → Uint8Array (utf-8 uniquement, spec)
  TextDecoder   : Uint8Array → string (utf-8, utf-16, latin1, …)

Analogie : un ArrayBuffer est un bloc de mémoire brute, comme une page d'un cahier vierge. Tu ne peux pas écrire dessus directement — tu as besoin d'une vue (view). Uint8Array est une vue "un octet par case", Int32Array est une vue "quatre octets par case avec interprétation signée". Buffer est une Uint8Array augmentée d'outils Node (encoding, pool). Un Blob est plus comme un fichier scellé : tu sais qu'il y a des bytes dedans, tu peux les lire de manière asynchrone, mais tu n'as pas accès direct.

🛠️ Code minimal (ts/js)

Allocation et écriture

ts
// Allocation safe (zéro-initialisée)
const safe = Buffer.alloc(16); // tous à 0x00
console.log(safe); // <Buffer 00 00 00 00 ...>

// Allocation rapide mais non initialisée — DANGEREUX si tu ne remplis pas tout
const fast = Buffer.allocUnsafe(16);
// fast peut contenir des données aléatoires de mémoire libérée
fast.fill(0); // → équivalent à alloc(16)

// Depuis une string
const utf8 = Buffer.from('héllo', 'utf8');         // 6 bytes (é = 2 bytes)
const b64 = Buffer.from('aGVsbG8=', 'base64');      // 5 bytes ("hello")
const hex = Buffer.from('48656c6c6f', 'hex');       // 5 bytes ("Hello")

// Depuis un Uint8Array (zero-copy : partage le même backing buffer)
const arr = new Uint8Array([1, 2, 3, 4]);
const buf = Buffer.from(arr.buffer, arr.byteOffset, arr.byteLength);

Conversion entre encodings

ts
const buf = Buffer.from('hello world');
console.log(buf.toString('utf8'));       // "hello world"
console.log(buf.toString('base64'));     // "aGVsbG8gd29ybGQ="
console.log(buf.toString('base64url'));  // "aGVsbG8gd29ybGQ"  (sans padding, web-safe)
console.log(buf.toString('hex'));        // "68656c6c6f20776f726c64"
console.log(buf.toString('latin1'));     // chaque octet → 1 char (lossless dans [0, 255])

Parsing binaire (protocoles, fichiers, sockets)

ts
// Un header binaire : [magic:4][version:2][flags:2][length:4]
const buf = Buffer.alloc(12);
buf.writeUInt32BE(0xdeadbeef, 0);    // magic
buf.writeUInt16BE(1, 4);              // version
buf.writeUInt16BE(0b10101010, 6);    // flags
buf.writeUInt32BE(1024, 8);          // length

// Lecture
const magic = buf.readUInt32BE(0);     // 0xdeadbeef
const version = buf.readUInt16BE(4);   // 1
const length = buf.readUInt32BE(8);    // 1024

// LE/BE : Big Endian (réseau) vs Little Endian (x86)
buf.writeInt32LE(-1, 0);               // -1 en LE = 0xff 0xff 0xff 0xff
buf.readInt32LE(0);                    // -1

Interop avec Web (TextEncoder/TextDecoder)

ts
// String → bytes (toujours utf-8, par spec)
const encoder = new TextEncoder();
const bytes: Uint8Array = encoder.encode('héllo');
console.log(bytes); // Uint8Array(6) [104, 195, 169, 108, 108, 111]

// Bytes → string
const decoder = new TextDecoder('utf-8');
console.log(decoder.decode(bytes));     // "héllo"

// Décoder du UTF-16LE (rare mais utile pour fichiers Windows)
const utf16 = new TextDecoder('utf-16le');
const buf16 = Buffer.from('h\x00i\x00');
console.log(utf16.decode(buf16));        // "hi"

// streaming decode (utile pour chunks UTF-8 coupés en plein milieu d'un caractère multi-octet)
const streamingDecoder = new TextDecoder('utf-8', { fatal: false });
let out = '';
out += streamingDecoder.decode(Buffer.from([195]), { stream: true });   // début de "é"
out += streamingDecoder.decode(Buffer.from([169]), { stream: true });   // fin de "é"
out += streamingDecoder.decode();                                        // flush
console.log(out); // "é"

Blob (Node 18+)

ts
// Un Blob est immutable, taillé pour les transferts (fetch body, fichiers)
const blob = new Blob(['hello ', 'world'], { type: 'text/plain' });
console.log(blob.size); // 11

// Lecture
console.log(await blob.text());            // "hello world"
console.log(await blob.arrayBuffer());     // ArrayBuffer
const stream = blob.stream();              // Web ReadableStream

🎯 Patterns courants — 6

1. Concatenation efficace

ts
// Mauvais : O(n²) en mémoire et CPU
let result = Buffer.alloc(0);
for (const chunk of chunks) {
  result = Buffer.concat([result, chunk]); // ré-alloue chaque fois
}

// Bon : O(n)
const result = Buffer.concat(chunks); // accepte un tableau, alloue 1 fois

// Encore mieux si tu connais la taille totale (skip un scan)
const total = chunks.reduce((s, c) => s + c.length, 0);
const result = Buffer.concat(chunks, total);

2. Comparaison à temps constant pour secrets (HMAC, tokens)

ts
import { timingSafeEqual } from 'node:crypto';

function compareTokens(received: string, expected: string): boolean {
  const a = Buffer.from(received);
  const b = Buffer.from(expected);
  if (a.length !== b.length) return false; // OK, longueur n'est pas secrète
  return timingSafeEqual(a, b);            // pas de leak via timing
}

buf.equals(other) ou === font une comparaison classique, vulnérable aux timing attacks.

3. Slicing sans copie : subarray

ts
const big = Buffer.alloc(1024 * 1024);
// subarray() : view sur le même ArrayBuffer (zero-copy)
const head = big.subarray(0, 16);

// slice() est un alias historique de subarray() dans Buffer.
// ⚠️ Sur Uint8Array standard, .slice() COPIE. Différence subtile.

💡 Comme head partage le buffer, le modifier modifie big. C'est très utile pour parser des frames sans réallouer, piège si tu n'es pas conscient.

4. Pool de petits buffers — pourquoi allocUnsafe peut leak

ts
// Buffer.allocUnsafe(size) puise dans un pool partagé de 8 KB par défaut
// (Buffer.poolSize). Plusieurs petits buffers partagent le même backing.
// Si un seul d'entre eux persiste (référence longue durée), le pool entier
// reste en mémoire.

// → Si tu gardes longtemps un petit Buffer issu d'allocUnsafe, copie-le :
const small = Buffer.allocUnsafe(4); small.fill(0xff);
// Mauvais : retient potentiellement 8 KB
keepForever(small);
// Bon : copie isolée
keepForever(Buffer.from(small)); // ou Buffer.alloc(4) + .copy

5. Détecter et corriger des encoding mismatches

ts
// Symptôme classique : "Mojibake"
const fromLatin1AsUtf8 = Buffer.from('café', 'latin1').toString('utf8');
console.log(fromLatin1AsUtf8); // "caf�" — caractère é (0xe9) invalide en utf-8 single byte

// Toujours connaître l'encoding source.
// Si tu reçois du legacy Windows-1252 :
const decoder = new TextDecoder('windows-1252');
const fixed = decoder.decode(Buffer.from([0x63, 0x61, 0x66, 0xe9]));
console.log(fixed); // "café"

6. Base64 URL-safe pour JWT et URL paths

ts
// JWT, signed URLs, etc. utilisent base64url (sans + / =)
const payload = Buffer.from(JSON.stringify({ user: 42 }));
const token = payload.toString('base64url');     // "eyJ1c2VyIjo0Mn0"

// Réversion
const back = Buffer.from('eyJ1c2VyIjo0Mn0', 'base64url').toString('utf8');
console.log(JSON.parse(back)); // { user: 42 }

🔄 Versions — Node 18 / 20 / 22 / 24

  • Node 18Buffer.from / Buffer.alloc / allocUnsafe stables. Blob global stable. Buffer.concat accepte un tableau de Uint8Array (pas seulement Buffer).

  • Node 20File class (sous-classe de Blob avec name et lastModified) stable. Buffer.copyBytesFrom (Node 19+) pour copier d'un Uint8Array vers un nouveau Buffer indépendant — utile pour briser le partage de pool.

  • Node 22 — Optimisations internes V8 sur Buffer.from(string) (utf8 path plus rapide). Améliorations TextDecoder (streaming plus efficace).

  • Node 24Buffer reste rétro-compatible. La doc officielle pousse de plus en plus vers Uint8Array + TextEncoder/Decoder pour la portabilité (Workers, Edge), mais Buffer reste le plus rapide sur Node pour les conversions d'encoding non-utf8 (latin1, hex, base64).

🔮 Tendance 2026 : Buffer ne disparaît pas, mais le code "portable" (Cloudflare Workers, Deno, Bun, Vercel Edge) utilise Uint8Array + TextEncoder/Decoder + Blob + Web Streams. Sur Node pur, garde Buffer pour les hot paths binaires.

⚠️ Pitfalls — 13

  1. Buffer.allocUnsafe(n) non initialisé — peut contenir des données sensibles d'opérations précédentes (passwords, clés…). Ne jamais l'envoyer sur le réseau sans .fill() complet, ne jamais l'utiliser pour des assertions de sécurité.

  2. 'utf-8' vs 'utf8' — Buffer accepte 'utf8', 'utf-8', 'unicode-1-1-utf-8' (toutes les variantes IANA). Mais des vieux code paths peuvent ne reconnaître que 'utf8'. TextDecoder est plus strict : 'utf-8' est la forme canonique.

  3. Buffer.byteLengthstring.length'héllo'.length === 5 (caractères), Buffer.byteLength('héllo', 'utf8') === 6 (bytes). Pour limiter une taille en bytes (DB column, header HTTP), utilise byteLength.

  4. Truncation au milieu d'un caractère multi-byte — couper un Buffer à un offset arbitraire peut casser un caractère UTF-8. buf.subarray(0, 100).toString('utf8') peut produire un (replacement char) à la fin. Utilise TextDecoder en mode streaming.

  5. Buffer.from(arrayBuffer) vs Buffer.from(arrayBuffer, byteOffset, length) — la première forme partage le backing, la seconde aussi mais avec un slice. Ne pas confondre avec new Uint8Array(arrayBuffer) qui partage aussi (donc modifier l'un modifie l'autre).

  6. JSON.stringify d'un Buffer — donne {"type":"Buffer","data":[104,101,108,108,111]}, pas "hello". Si tu veux du JSON propre, convertis explicitement : buf.toString('base64').

  7. Comparaison naïve de bytesbuf1 === buf2 compare les références, pas le contenu. Utilise buf1.equals(buf2) ou Buffer.compare(buf1, buf2).

  8. Buffer.poolSize invisible — un Buffer issu de allocUnsafe peut retenir jusqu'à 8 KB en mémoire (le pool entier) même si tu n'en utilises que 16 octets. Pour des Buffers longue durée, fais une copie.

  9. Blob n'est pas itérable directement — pas de for...of ni de .read(). Tu dois passer par .text(), .arrayBuffer(), ou .stream().

  10. TextEncoder ne supporte que UTF-8 par spec web — si tu veux encoder en latin1 ou utf-16le, tu dois utiliser Buffer.from(str, encoding). Pas de symétrie avec TextDecoder qui, lui, supporte plein d'encodings.

  11. Buffer mutation invisible quand zero-copyBuffer.from(uint8arr) partage le backing. Modifier le Buffer modifie l'Uint8Array d'origine. Si tu veux vraiment copier, utilise Buffer.from(uint8arr.slice()) ou Buffer.copyBytesFrom(uint8arr).

  12. ascii est lossy au-delà de 0x7FBuffer.from('café', 'ascii') masque le bit de poids fort (0xE9 & 0x7F = 0x69) → é devient i. L'encoding 'ascii' n'est jamais un bon défaut : utilise 'utf8' (ou 'latin1' si tu veux un round-trip lossless sur [0,255]). Cf. exercice 1.

  13. Buffer.alloc(n) avec n non fiable = DoS — si n provient d'un length-prefix réseau non validé, un attaquant demande alloc(4 GB) → OOM. Toujours clamper n contre un maximum métier avant d'allouer, et valider que le payload annoncé est cohérent avec ce qui est réellement reçu.

🧪 Testing — node --test

ts
// buffer.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';

test('Buffer.alloc est zéro-initialisé', () => {
  const buf = Buffer.alloc(16);
  for (let i = 0; i < 16; i++) assert.equal(buf[i], 0);
});

test('byteLength diffère de string.length pour utf8', () => {
  assert.equal('héllo'.length, 5);
  assert.equal(Buffer.byteLength('héllo', 'utf8'), 6);
});

test('base64url est compatible URL', () => {
  const data = Buffer.from([0xff, 0xfe, 0xfd]);
  const encoded = data.toString('base64url');
  assert.ok(!encoded.includes('+'));
  assert.ok(!encoded.includes('/'));
  assert.ok(!encoded.includes('='));
  const back = Buffer.from(encoded, 'base64url');
  assert.deepEqual([...back], [0xff, 0xfe, 0xfd]);
});

test('TextDecoder streaming gère les chunks coupés', () => {
  const dec = new TextDecoder('utf-8');
  // 'é' = 0xc3 0xa9 (2 bytes utf-8)
  let out = '';
  out += dec.decode(new Uint8Array([0xc3]), { stream: true });
  out += dec.decode(new Uint8Array([0xa9]), { stream: true });
  out += dec.decode();
  assert.equal(out, 'é');
});

test('readInt32BE round-trip', () => {
  const buf = Buffer.alloc(4);
  buf.writeInt32BE(-12345, 0);
  assert.equal(buf.readInt32BE(0), -12345);
});

test('timingSafeEqual rejette tailles différentes', async () => {
  const { timingSafeEqual } = await import('node:crypto');
  assert.throws(
    () => timingSafeEqual(Buffer.from('abc'), Buffer.from('abcd')),
    /input buffers must have the same byte length/i,
  );
});

🎬 Cas d'usage concrets

Scénario 1 — Parsing PDF banque KYC

Banque en ligne traitant des pièces d'identité scannées (CNI, passeport) pour KYC. Les fichiers arrivent en multipart depuis le mobile, jusqu'à 8 MB par pièce. La détection précoce du format évite de pousser un faux PDF (parfois zip déguisé) dans le pipeline OCR coûteux.

L'API lit les 8 premiers octets en Buffer direct sans décoder en string (binaire) : pdf commence par %PDF- (0x25 0x50 0x44 0x46 0x2D), jpg par 0xFF 0xD8 0xFF, png par 0x89 0x50 0x4E 0x47. Le check magic-bytes prend < 1 µs. Le Content-Type HTTP est ignoré (un mobile peut envoyer application/octet-stream).

Ensuite, parsing de la trailer PDF pour extraire /Encrypt : on cherche le pattern dans les derniers 8 KB du buffer (les PDF placent le xref en queue). buffer.lastIndexOf(Buffer.from("/Encrypt")) est zéro-copie et 40 × plus rapide que de convertir en string puis chercher.

Tout reste en Buffer jusqu'à la zone OCR — pas de transcodage UTF-8 d'un blob qui n'est pas du texte.

Scénario 2 — Ingestion images e-commerce

Plateforme e-commerce : 30 000 uploads vendeur/jour, validation amont des images avant resize. Les vendeurs envoient parfois du SVG (XSS via <script>), parfois du WebP > 30 MB déguisé en JPG, parfois carrément des .exe renommés .jpg.

Pipeline : pre-buffer les 32 premiers octets, valider la signature, extraire dimensions depuis le header sans décoder. Pour JPEG, on parcourt les segments SOF avec buffer.readUInt16BE(offset) ; pour PNG, dimensions à offset 16 (readUInt32BE). Si > 5000 × 5000 px ou non reconnu, rejet HTTP 422 avant de passer à sharp/libvips qui aurait alloué 1 GB pour décoder.

Bénéfice : 3 % des uploads rejetés en amont, économie CPU/RAM substantielle, et zéro Buffer.allocUnsafe non-validé (toute allocation > 4 KB est explicite et auditée).

Scénario 3 — OCR cabinet juridique buffer

Cabinet ingérant des actes notariés scannés (TIFF multi-pages, parfois 200 MB par dossier). L'OCR (Tesseract via tesseract.js ou service externe) attend du raw image data, pas du TIFF.

L'extraction pages utilise Buffer.subarray(offset, end) (vue, pas copie) pour isoler chaque IFD TIFF puis envoie chaque page à OCR. Le subarray partage le même ArrayBuffer — économie mémoire critique sur des fichiers de 200 MB. À la fin de la passe, on libère explicitement la référence (buffer = null) pour permettre au GC de récupérer.

Conversion des résultats OCR (UTF-8 reçu du service) en Buffer via Buffer.from(text, "utf8") pour stockage dans une colonne bytea Postgres avec hash SHA-256 (crypto.createHash("sha256").update(buf).digest("hex")) pour audit RGPD : la trace de l'OCR est immuable et vérifiable.

🛠️ Exemple end-to-end

Validation magic-bytes + extraction dimensions sans décoder l'image, parsing trailer PDF KYC, tout en zero-copy.

ts
import { createHash } from "node:crypto";

type Detected =
  | { kind: "pdf"; encrypted: boolean; size: number; sha256: string }
  | { kind: "png"; width: number; height: number; size: number; sha256: string }
  | { kind: "jpeg"; width: number; height: number; size: number; sha256: string }
  | { kind: "unknown" };

const PDF_MAGIC = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2d]); // %PDF-
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const JPEG_SOI = Buffer.from([0xff, 0xd8, 0xff]);
const ENCRYPT_TAG = Buffer.from("/Encrypt");

export function detect(buf: Buffer): Detected {
  if (buf.length < 16) return { kind: "unknown" };
  const sha256 = createHash("sha256").update(buf).digest("hex");
  const size = buf.length;

  if (buf.subarray(0, 5).equals(PDF_MAGIC)) {
    // PDF trailer is in the last 8 KiB
    const tail = buf.subarray(Math.max(0, buf.length - 8192));
    const encrypted = tail.lastIndexOf(ENCRYPT_TAG) !== -1;
    return { kind: "pdf", encrypted, size, sha256 };
  }

  if (buf.subarray(0, 8).equals(PNG_MAGIC)) {
    // IHDR chunk: width @ offset 16, height @ offset 20 (big-endian uint32)
    const width = buf.readUInt32BE(16);
    const height = buf.readUInt32BE(20);
    return { kind: "png", width, height, size, sha256 };
  }

  if (buf.subarray(0, 3).equals(JPEG_SOI)) {
    // walk segments to find SOF0/SOF2 (0xFFC0 or 0xFFC2)
    let off = 2;
    while (off < buf.length - 8) {
      if (buf[off] !== 0xff) break;
      const marker = buf[off + 1];
      const segLen = buf.readUInt16BE(off + 2);
      if (marker === 0xc0 || marker === 0xc2) {
        const height = buf.readUInt16BE(off + 5);
        const width = buf.readUInt16BE(off + 7);
        return { kind: "jpeg", width, height, size, sha256 };
      }
      off += 2 + segLen;
    }
    return { kind: "unknown" };
  }

  return { kind: "unknown" };
}

// HTTP usage: refuse upload before OCR/resize touches the file
export async function handleUpload(req: import("node:http").IncomingMessage) {
  const chunks: Buffer[] = [];
  let total = 0;
  for await (const c of req) {
    chunks.push(c as Buffer);
    total += (c as Buffer).length;
    if (total > 30 * 1024 * 1024) throw new Error("payload too large");
  }
  const buf = Buffer.concat(chunks, total);
  const result = detect(buf);
  if (result.kind === "unknown") throw new Error("unsupported format");
  if (result.kind === "pdf" && result.encrypted) throw new Error("encrypted pdf");
  if ((result.kind === "png" || result.kind === "jpeg") && (result.width > 5000 || result.height > 5000)) {
    throw new Error("image too large");
  }
  return { buf, result };
}

Points clés : Buffer.subarray zero-copy pour inspections locales, readUInt32BE directement sur le header sans allouer, hash SHA-256 calculé une fois pour audit, rejet précoce avant les pipelines coûteux (OCR, libvips, antivirus).

⚠️ Failure mode de cet exemple : handleUpload charge tout le payload en RAM avant de valider (Buffer.concat). Avec 30 000 uploads/jour et 30 MB max, un pic concurrent de 100 requêtes peut consommer 3 GB de RSS et OOM-kill le process. La version production-grade lit les 32 premiers octets via req (le premier chunk suffit souvent), valide le magic, puis soit stream vers le disque/S3 soit rejette — sans jamais bufferiser le corps entier. C'est l'exercice 4 ci-dessous.

🧠 Comment un staff engineer raisonne sur Buffer

Le réflexe junior est « j'ai des bytes → je les transforme en string pour travailler dessus ». Le réflexe senior est l'inverse : rester en bytes le plus longtemps possible et ne décoder qu'au dernier moment, sur le plus petit sous-range possible. Décoder = allouer une string UTF-16 (×2 la taille des bytes ASCII), valider l'UTF-8, et bloquer l'event loop.

Trois questions qu'un staff engineer pose systématiquement face à du code Buffer :

QuestionPourquoiMauvais signal
Cette allocation est-elle bornée par une entrée non fiable ?Buffer.alloc(n)n vient du réseau = vecteur DoS (CVE-2018-7160 famille).Buffer.alloc(headerLen) sans clamp.
Ce Buffer survit-il au handler courant ?Un subarray/allocUnsafe retenu garde tout son backing/pool vivant.cache.set(key, buf.subarray(...)).
Combien de copies entre le socket et la réponse ?Chaque toString/concat/Buffer.from(str) est une copie + une passe CPU.3+ transcodages sur le hot path.

Mental model de la copie : trace le chemin d'un byte. Socket → kernel buffer → libuv → Buffer (1 copie) → concat (2) → toString (3, +UTF-16) → JSON.parse (4, objets). Sur un proxy à 50 k req/s, chaque copie superflue est ~mesurable en p99. Le code « propre » naïf est souvent 3× plus de mémoire que nécessaire.

Tableau des modes de défaillance (failure modes)

ModeCause racineSymptôme prodMitigation
DoS allocationalloc(n) avec n attaquant-contrôléRSS spike, OOM killClamp n à un max, valider le length-prefix
Pool leakallocUnsafe court retenu longtempsexternal memory monte, ne redescend pasBuffer.from(small) / copyBytesFrom
Mojibakeencoding source ≠ encoding decodecaf�, é dans la DBFixer l'encoding à la frontière, TextDecoder strict
Replacement char chunk UTF-8 coupé mid-codepointtexte tronqué/corrompu en streamingTextDecoder({ stream: true })
Fuite d'infosallocUnsafe envoyé sans .fillbytes mémoire d'une autre requête sur le wireBuffer.alloc ou .fill(0)
Timing attack=== / .equals sur un secretcomparaison de token leak en timingcrypto.timingSafeEqual
Event loop blocktoString/JSON.parse sur gros Bufferp99 latence explose, healthchecks timeoutworker_threads ou stream-parse

🏋️ Exercices

Progression : implémenter → durcir pour la prod → casser puis réparer. Chaque exercice tient en un fichier node:test.

Exercice 1 — Round-trip d'encodings (implémenter)

Objectif : écrire roundtrip(input: Buffer, enc: BufferEncoding): boolean qui encode puis décode et vérifie l'égalité byte-à-byte, et démontrer pourquoi ascii et latin1 ne round-trippent pas au-delà de 0x7F / 0xFF.

Indice / Solution

Buffer.from(input.toString(enc), enc).equals(input). Pour hex/base64/base64url/utf8/latin1 ⊂ [0,255] le round-trip est lossless. Pour ascii, tout byte > 0x7F est masqué (& 0x7F) → 0xE9 → 0x69. Test : Buffer.from([0xe9]).toString('ascii') puis re-encode ≠ original. C'est la preuve concrète du pitfall #12.

Exercice 2 — Détecteur d'encoding heuristique (implémenter)

Objectif : écrire sniff(buf: Buffer): 'utf8' | 'utf16le' | 'latin1' | 'binary' à partir du BOM (EF BB BF, FF FE, FE FF), de la densité d'octets nuls (UTF-16 a beaucoup de 0x00), et d'une validation UTF-8.

Indice / Solution
  1. BOM d'abord (déterministe). 2) Si > ~25 % d'octets nuls aux positions paires/impaires → UTF-16 LE/BE. 3) Tenter new TextDecoder('utf-8', { fatal: true }).decode(buf) dans un try/catch : si ça throw → pas de l'UTF-8 valide → latin1/binary. 4) Si aucun octet imprimable hors plage texte → binary. Note de senior : un sniffer n'est jamais fiable à 100 % — toujours préférer un encoding déclaré à la frontière.

Exercice 3 — TLV parser zero-copy (production-grade)

Objectif : parser un flux Type-Length-Value ([type:u8][len:u16BE][value:len]…) en retournant des subarray (vues, pas copies), avec gestion d'un buffer incomplet (frame coupée entre deux chunks réseau) sans jamais lever d'exception sur une entrée partielle.

Indice / Solution

Maintenir un Buffer d'accumulation. À chaque chunk : acc = Buffer.concat([acc, chunk]), puis boucler tant que acc.length >= 3 + readUInt16BE(1). Émettre acc.subarray(off, off+len) (zero-copy), avancer l'offset, et à la fin acc = acc.subarray(off) pour ne garder que le reliquat. Piège : la vue émise partage le backing — si le consommateur la retient longtemps, copie-la (Buffer.from(view)), sinon tout acc reste vivant. Vérifier len contre un MAX_FRAME pour éviter le DoS allocation.

Exercice 4 — Validation d'upload sans bufferiser le corps (production-grade)

Objectif : réécrire handleUpload de l'exemple end-to-end pour qu'il valide le magic-byte sur le premier chunk et streame le reste vers une destination (ou un compteur), sans jamais matérialiser plus de quelques KB en RAM, même pour un upload de 30 MB.

Indice / Solution

Lire le premier chunk de l'async-iterator req, accumuler jusqu'à avoir ≥ 32 octets (un seul chunk suffit en général), appeler detect(prefix). Si rejet → req.destroy() / réponse 422 avant de drainer. Sinon, ré-injecter le préfixe puis pipeline(req, dest) (node:stream/promises) avec un --max via un Transform qui compte et throw au-delà du quota. Mesurer process.memoryUsage().rss avant/après : doit rester plat. C'est le fix du failure mode noté sous l'exemple.

Exercice 5 — Casser puis réparer un pool leak (break-then-fix)

Objectif : écrire un repro qui fait monter process.memoryUsage().external en gardant 10 000 petits allocUnsafe(8) dans une Map, observer que ~80 MB sont retenus (10 000 × 8 KB de pool), puis réparer pour retomber à ~80 KB.

Indice / Solution

Boucle : cache.set(i, Buffer.allocUnsafe(8)). Comme chaque vue de 8 octets pointe dans un pool de 8 KB et que poolSize/2 est le seuil, beaucoup partagent et retiennent des pools entiers ; mais des allocs entrelacées d'autres tailles fragmentent → l'external explose. Mesure via global.gc() (lancer avec --expose-gc) + process.memoryUsage(). Fix : cache.set(i, Buffer.from(Buffer.allocUnsafe(8))) ou Buffer.alloc(8) → chaque buffer possède son backing exact, le GC libère les pools. Bonus : remplacer par un seul Buffer.alloc(8 * N) + subarray indexés (un seul backing, contigu).

Exercice 6 — Streaming UTF-8 robuste sous fuzz (break-then-fix)

Objectif : prouver que buf.toString('utf8') appliqué chunk-par-chunk corrompt le texte quand un codepoint multi-octet est coupé, puis montrer que TextDecoder({ stream: true }) ne le fait pas — sous un fuzzer qui découpe une string aléatoire à des offsets aléatoires.

Indice / Solution

Générer une string mêlant ASCII, accents et emoji ('café 🚀 日本語'), l'encoder, puis la re-découper à des frontières aléatoires. Concaténer les chunk.toString('utf8') → comparer à l'original : échoue (présence de ) dès qu'une coupe tombe mid-codepoint. Refaire avec un seul TextDecoder réutilisé et { stream: true } + flush final → toujours égal. Leçon : toString est stateless, TextDecoder est stateful (garde les octets pendants entre appels).

🎤 En entretien

Q : Buffer vs Uint8Array — quand choisir lequel, et pourquoi l'un hérite de l'autre ? R : Buffer extends Uint8Array pour rester un view sur ArrayBuffer (donc transférable, slicable, interopérable Web) tout en ajoutant le pool d'allocation et les encodings non-UTF-8 (hex/base64/latin1) implémentés en C++. Sur Node-only et hot path binaire → Buffer (plus rapide). Sur code portable (Workers/Edge/Deno/Bun) → Uint8Array + TextEncoder/Decoder.

Q : Pourquoi allocUnsafe est plus rapide, et quels sont ses deux dangers ? R : Il ne zéro-initialise pas et pioche dans un pool pré-alloué de 8 KB (Buffer.poolSize) pour les tailles < 4 KB → pas de memset, pas de syscall d'alloc. Dangers : (1) fuite d'infos — il peut contenir des bytes d'une opération précédente (clés, autre requête) qu'on enverrait par mégarde sur le wire ; (2) pool leak — un petit buffer retenu garde vivant tout son pool de 8 KB. Mitigation : alloc par défaut, et Buffer.from(buf)/copyBytesFrom si on doit retenir un allocUnsafe.

Q : Un service reçoit des chunks réseau et fait chunk.toString('utf8') puis concatène les strings. Bug en prod : caractères corrompus aléatoires. Diagnostic ? R : Les caractères multi-octets UTF-8 (accents, CJK, emoji) sont coupés à la frontière des chunks TCP ; toString décode chaque chunk isolément et insère un . Fix : décoder les bytes concaténés en une fois, ou mieux, un TextDecoder partagé avec { stream: true } qui conserve les octets pendants entre appels. Corollaire : ne jamais supposer qu'un chunk est une frontière de caractère.

Q : Comment comparer deux tokens d'auth en sécurité, et pourquoi === ou .equals() ne suffisent pas ? R : === compare les références ; .equals() compare le contenu mais court-circuite au premier octet différent → le temps de réponse fuit la position de la divergence (timing attack). Utiliser crypto.timingSafeEqual(a, b) qui parcourt toute la longueur en temps constant. La longueur, elle, n'est généralement pas secrète : on peut court-circuiter dessus avant d'appeler timingSafeEqual (qui throw sinon).


🔁 Quand utiliser / éviter

OutilUtiliser quandÉviter quand
Buffer.allocAllocation par défaut, sécurité d'abordHot path où tu vas remplir tout le buffer immédiatement
Buffer.allocUnsafeHot path (parsing réseau), tu remplis toutSans copie si stocké longtemps (pool leak)
Buffer.from(str)Conversion string → bytes en NodeCode portable Web (préfère TextEncoder)
Uint8ArrayCode portable (Workers, Edge, navigateur)Operations encoding non-utf8 fréquentes
Blob/FileReprésenter des bodies opaques (fetch, upload)Manipulation byte-à-byte (besoin d'un view direct)
TextDecoderDécodage robuste, streaming, encodings exotiquesHot path utf8 simple (Buffer.toString plus rapide)
DataViewParsing endian-mixed, alignement libreQuand un typed array (Int32Array, …) suffit

🧬 Aller plus loin

Buffer est-il vraiment un Uint8Array ?

Oui : Buffer.prototype hérite de Uint8Array.prototype. Tu peux donc :

ts
const buf = Buffer.from('hello');
console.log(buf instanceof Uint8Array); // true
console.log(buf instanceof Buffer);     // true

// API Uint8Array fonctionne :
buf.fill(0); buf.set([1,2,3], 0); buf.slice(0, 2);
// + API Buffer en plus :
buf.toString('hex'); buf.readUInt32BE(0); buf.write('x', 0);

L'inverse n'est pas vrai : un Uint8Array n'a pas .toString('hex'). Pour convertir : Buffer.from(uint8array.buffer, uint8array.byteOffset, uint8array.byteLength) (zero-copy).

Endianness — Big vs Little

  • Big Endian (BE) : octet de poids fort en premier. Le standard réseau (TCP, IP, beaucoup de protocoles binaires).
  • Little Endian (LE) : octet de poids faible en premier. Architecture x86/x64, ARM (par défaut), donc ton serveur.
ts
const buf = Buffer.alloc(4);
buf.writeInt32BE(1, 0);   // → 00 00 00 01
buf.writeInt32LE(1, 0);   // → 01 00 00 00

Règle : si tu parses un format de fichier ou un protocole défini par un RFC ou un spec, lis le spec. La plupart sont BE. La plupart des formats Windows (.exe, .bmp) et le ZIP sont LE.

Buffer.copyBytesFrom (Node 19+)

Crée un nouveau Buffer isolé depuis un Uint8Array / autre Buffer. Différent de Buffer.from(typedArray) qui partage le backing :

ts
const src = new Uint8Array([1, 2, 3, 4]);
const shared = Buffer.from(src.buffer);     // partage le memory
const isolated = Buffer.copyBytesFrom(src);  // copie indépendante

src[0] = 99;
console.log(shared[0]);    // 99 (partagé !)
console.log(isolated[0]);  // 1  (isolé)

Tu peux aussi passer byteOffset et length pour ne copier qu'un sous-range.

Allocation : où va la mémoire ?

  • Buffer.alloc(n) et Buffer.from(string) allouent hors heap V8 (external memory). Tu les vois dans process.memoryUsage().external.
  • new Uint8Array(n) alloue aussi hors heap V8.
  • Le GC les libère normalement, mais ils ne comptent pas dans --max-old-space-size.
  • Buffer.allocUnsafe(n) avec n < 4 KB pioche dans le pool partagé (8 KB par défaut). Quand le pool est plein, libuv en alloue un nouveau.

Buffer.byteLength — la taille réelle d'une string en bytes

ts
Buffer.byteLength('hello', 'utf8');       // 5
Buffer.byteLength('héllo', 'utf8');       // 6 (é = 2 bytes)
Buffer.byteLength('日本語', 'utf8');      // 9 (3 bytes par caractère)
Buffer.byteLength('aGVsbG8=', 'base64');   // 5 (décodé)

Indispensable pour calculer Content-Length, fixer une limite DB en bytes, etc.

Buffer non utilisé après parse — relâcher la mémoire

Si tu parses un gros JSON avec Buffer.toString('utf8') puis JSON.parse, le Buffer initial peut être retenu plus longtemps que nécessaire :

ts
function parseFile(path: string) {
  const buf = fs.readFileSync(path);     // 100 MB en memory
  const str = buf.toString('utf8');       // 200 MB (string UTF-16 interne)
  const obj = JSON.parse(str);            // + objets parsés
  return obj;
  // À la sortie, buf et str sont GC-able si l'appelant ne tient pas la référence
}

Pour des gros fichiers, stream-parser le JSON (stream-json, JSONStream) au lieu de tout charger.

Blob.slice() est zero-copy

Comme Buffer.subarray, Blob.slice(start, end) ne copie pas — il retourne une vue. Mais à la différence de Buffer, un Blob peut être backed par un fichier sur disque (new File(...)), auquel cas slicer une partie ne charge pas le fichier en RAM.

TextDecoder vs Buffer.toString — perf

Buffer.toString('utf8') est implémenté en C++ Node-spécifique, généralement plus rapide que new TextDecoder().decode(uint8array) sur Node. Mais TextDecoder est :

  • Portable (Web, Workers, Edge, Bun, Deno).
  • Capable de streaming (chunks coupés au milieu de caractères multi-byte).
  • Supporte plus d'encodings (latin1, utf-16be/le, windows-1252, gb18030, shift_jis…).

En 2026, pour du code Node-only où la perf compte, garde Buffer.toString. Pour du code portable, TextDecoder.

crypto.randomBytes vs crypto.getRandomValues

ts
import { randomBytes, getRandomValues } from 'node:crypto';

// Node-style, retourne Buffer
const a = randomBytes(16);          // Buffer
// Web-style, fill un typed array existant, retourne le même
const b = new Uint8Array(16);
getRandomValues(b);                 // Uint8Array remplie

Pour du code portable, crypto.getRandomValues est standard (Web Crypto API). Pour du code Node-only, randomBytes est ergonomique.

🔗 Liens

Annexe — table comparative des encodings supportés par Buffer

EncodingDescriptionCas d'usage
utf8 / utf-8UTF-8 standard (1-4 bytes par char)Texte par défaut
utf16le / ucs2 / ucs-2UTF-16 little endian (2 ou 4 bytes)Fichiers Windows, certains protocoles
latin1 / binaryISO-8859-1 (1 byte par char, lossless [0-255])Stocker du binaire dans une string, legacy
ascii7-bit ASCII (1 byte, top bit ignoré)Très rare, préfère utf8
base64Base64 RFC 4648 (avec padding =)JSON, headers HTTP
base64urlBase64 URL-safe (sans +/=)JWT, signed URLs
hexHexadécimal (2 chars par byte)Hashes, debug, format readable

⚠️ ascii truncate les bytes > 127 ! Si tu écris Buffer.from('café', 'ascii'), le é (0xe9) devient 0x69 (i). Toujours préfère utf8.

Quand utiliser DataView

DataView est utile quand :

  • Tu lis un format avec endianness mixte (parts en BE, parts en LE).
  • Tu as besoin d'accès non aligné (getFloat32 à un offset non multiple de 4).
  • Tu veux une API uniforme entre types (int8/int16/int32/uint/float).
ts
const buf = new ArrayBuffer(16);
const view = new DataView(buf);
view.setUint32(0, 0xdeadbeef, false); // BE
view.setFloat64(4, 3.14, true);        // LE
const flag = view.getUint8(12);

Avec Buffer, tu as buf.readUInt32BE, readFloat64LE, etc. C'est équivalent et souvent plus ergonomique sur Node.

Buffer et JSON — sérialisation custom

ts
const buf = Buffer.from('hello');

// Sérialisation par défaut : {"type":"Buffer","data":[104,101,108,108,111]}
JSON.stringify({ payload: buf });

// Mieux : encoder explicitement
JSON.stringify({ payload: buf.toString('base64') });

// Désérialiser
const obj = JSON.parse(json);
const restored = Buffer.from(obj.payload, 'base64');

Le format {type:"Buffer",data:[…]} est gourmand (chaque byte devient un entier décimal → 1 à 3 chars + virgule). Pour des Buffers > quelques KB, base64 économise ~30 % d'espace.

Récap mental — quand quelque chose ne va pas avec un Buffer

  1. Caractères bizarres dans le texte → encoding mismatch (utf8 vs latin1 vs windows-1252).
  2. Taille inattendue → confondre string.length (caractères) et Buffer.byteLength (bytes).
  3. Données aléatoiresallocUnsafe non rempli, ou un pool partagé qui leak.
  4. Memory leak → références longues vers des Buffers issus de allocUnsafe.
  5. Comparaison qui ne match jamais=== au lieu de .equals().
  6. à la fin du texte → caractère utf-8 multi-byte coupé. Utilise TextDecoder streaming.

Petit benchmark mental — convert 1 MB de texte

Sur Node 22, M2 (ordre de grandeur, indicatif) :

  • Buffer.from(str, 'utf8') : ~2 ms
  • new TextEncoder().encode(str) : ~3 ms
  • Buffer.toString('utf8') : ~1 ms
  • new TextDecoder().decode(uint8) : ~2 ms
  • JSON.parse sur 1 MB : ~6 ms

Toutes ces opérations sont synchrones et bloquent l'event loop. Sur 100 MB, ça devient critique. Délègue à un worker ou stream-parse.

Bibliothèque tech perso — Achref