Buffer & encodings — bytes, ArrayBuffer, TextDecoder
TL;DR —
Bufferest une sous-classe deUint8Array(donc un view sur unArrayBuffer) 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 toujoursBuffer.alloc(n)(zéro-initialisé, safe) plutôt queBuffer.allocUnsafe(n)sauf si tu remplis immédiatement (perf hot path). Pour le code portable (Web/Workers/Bun), préfèreUint8Array+TextEncoder/TextDecoder.Blobest une référence opaque à des bytes (potentiellement disque), pas un view direct. Méfie-toi des encoding mismatches :'utf-8'(avec tiret) marche dansTextDecodermais pas dansBuffer.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
// 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
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)
// 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); // -1Interop avec Web (TextEncoder/TextDecoder)
// 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+)
// 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
// 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)
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
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
headpartage le buffer, le modifier modifiebig. 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
// 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) + .copy5. Détecter et corriger des encoding mismatches
// 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
// 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 18 —
Buffer.from/Buffer.alloc/allocUnsafestables.Blobglobal stable.Buffer.concataccepte un tableau deUint8Array(pas seulement Buffer).Node 20 —
Fileclass (sous-classe deBlobavecnameetlastModified) 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éliorationsTextDecoder(streaming plus efficace).Node 24 —
Bufferreste rétro-compatible. La doc officielle pousse de plus en plus versUint8Array+TextEncoder/Decoderpour la portabilité (Workers, Edge), maisBufferreste 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, gardeBufferpour les hot paths binaires.
⚠️ Pitfalls — 13
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é.'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.Buffer.byteLength≠string.length—'héllo'.length === 5(caractères),Buffer.byteLength('héllo', 'utf8') === 6(bytes). Pour limiter une taille en bytes (DB column, header HTTP), utilisebyteLength.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. UtiliseTextDecoderen mode streaming.Buffer.from(arrayBuffer)vsBuffer.from(arrayBuffer, byteOffset, length)— la première forme partage le backing, la seconde aussi mais avec un slice. Ne pas confondre avecnew Uint8Array(arrayBuffer)qui partage aussi (donc modifier l'un modifie l'autre).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').Comparaison naïve de bytes —
buf1 === buf2compare les références, pas le contenu. Utilisebuf1.equals(buf2)ouBuffer.compare(buf1, buf2).Buffer.poolSizeinvisible — un Buffer issu deallocUnsafepeut 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.Blobn'est pas itérable directement — pas defor...ofni de.read(). Tu dois passer par.text(),.arrayBuffer(), ou.stream().TextEncoderne supporte que UTF-8 par spec web — si tu veux encoder enlatin1ouutf-16le, tu dois utiliserBuffer.from(str, encoding). Pas de symétrie avec TextDecoder qui, lui, supporte plein d'encodings.Buffer mutation invisible quand zero-copy —
Buffer.from(uint8arr)partage le backing. Modifier le Buffer modifie l'Uint8Array d'origine. Si tu veux vraiment copier, utiliseBuffer.from(uint8arr.slice())ouBuffer.copyBytesFrom(uint8arr).asciiest lossy au-delà de 0x7F —Buffer.from('café', 'ascii')masque le bit de poids fort (0xE9 & 0x7F = 0x69) →édevienti. 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.Buffer.alloc(n)avecnnon fiable = DoS — sinprovient d'un length-prefix réseau non validé, un attaquant demandealloc(4 GB)→ OOM. Toujours clamperncontre 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
// 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.
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 :
handleUploadcharge 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 viareq(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 :
| Question | Pourquoi | Mauvais signal |
|---|---|---|
| Cette allocation est-elle bornée par une entrée non fiable ? | Buffer.alloc(n) où 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)
| Mode | Cause racine | Symptôme prod | Mitigation |
|---|---|---|---|
| DoS allocation | alloc(n) avec n attaquant-contrôlé | RSS spike, OOM kill | Clamp n à un max, valider le length-prefix |
| Pool leak | allocUnsafe court retenu longtemps | external memory monte, ne redescend pas | Buffer.from(small) / copyBytesFrom |
| Mojibake | encoding source ≠ encoding decode | caf�, é dans la DB | Fixer l'encoding à la frontière, TextDecoder strict |
Replacement char � | chunk UTF-8 coupé mid-codepoint | texte tronqué/corrompu en streaming | TextDecoder({ stream: true }) |
| Fuite d'infos | allocUnsafe envoyé sans .fill | bytes mémoire d'une autre requête sur le wire | Buffer.alloc ou .fill(0) |
| Timing attack | === / .equals sur un secret | comparaison de token leak en timing | crypto.timingSafeEqual |
| Event loop block | toString/JSON.parse sur gros Buffer | p99 latence explose, healthchecks timeout | worker_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
- 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 untry/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
| Outil | Utiliser quand | Éviter quand |
|---|---|---|
Buffer.alloc | Allocation par défaut, sécurité d'abord | Hot path où tu vas remplir tout le buffer immédiatement |
Buffer.allocUnsafe | Hot path (parsing réseau), tu remplis tout | Sans copie si stocké longtemps (pool leak) |
Buffer.from(str) | Conversion string → bytes en Node | Code portable Web (préfère TextEncoder) |
Uint8Array | Code portable (Workers, Edge, navigateur) | Operations encoding non-utf8 fréquentes |
Blob/File | Représenter des bodies opaques (fetch, upload) | Manipulation byte-à-byte (besoin d'un view direct) |
TextDecoder | Décodage robuste, streaming, encodings exotiques | Hot path utf8 simple (Buffer.toString plus rapide) |
DataView | Parsing endian-mixed, alignement libre | Quand 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 :
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.
const buf = Buffer.alloc(4);
buf.writeInt32BE(1, 0); // → 00 00 00 01
buf.writeInt32LE(1, 0); // → 01 00 00 00Rè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 :
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)etBuffer.from(string)allouent hors heap V8 (external memory). Tu les vois dansprocess.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
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 :
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
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 rempliePour du code portable, crypto.getRandomValues est standard (Web Crypto API). Pour du code Node-only, randomBytes est ergonomique.
🔗 Liens
- Doc Buffer : https://nodejs.org/api/buffer.html
- Encoding standard (WHATWG) : https://encoding.spec.whatwg.org/
TextEncoder/TextDecoder(MDN) : https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder- Blob / File standard : https://w3c.github.io/FileAPI/
crypto.timingSafeEqual: https://nodejs.org/api/crypto.html#cryptotimingsafeequala-b- Article "Buffer pool" Node internals : https://nodejs.org/api/buffer.html#class-buffer (section "Buffers and shared memory")
Buffer.copyBytesFrom: https://nodejs.org/api/buffer.html#static-method-buffercopybytesfromview-offset-length- ICU encodings supportés par TextDecoder : https://encoding.spec.whatwg.org/#names-and-labels
- Article "Don't use allocUnsafe" (security writeups) — voir CVE-2018-12116
Annexe — table comparative des encodings supportés par Buffer
| Encoding | Description | Cas d'usage |
|---|---|---|
utf8 / utf-8 | UTF-8 standard (1-4 bytes par char) | Texte par défaut |
utf16le / ucs2 / ucs-2 | UTF-16 little endian (2 ou 4 bytes) | Fichiers Windows, certains protocoles |
latin1 / binary | ISO-8859-1 (1 byte par char, lossless [0-255]) | Stocker du binaire dans une string, legacy |
ascii | 7-bit ASCII (1 byte, top bit ignoré) | Très rare, préfère utf8 |
base64 | Base64 RFC 4648 (avec padding =) | JSON, headers HTTP |
base64url | Base64 URL-safe (sans +/=) | JWT, signed URLs |
hex | Hexadécimal (2 chars par byte) | Hashes, debug, format readable |
⚠️
asciitruncate les bytes > 127 ! Si tu écrisBuffer.from('café', 'ascii'), leé(0xe9) devient0x69(i). Toujours préfèreutf8.
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).
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
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
- Caractères bizarres dans le texte → encoding mismatch (utf8 vs latin1 vs windows-1252).
- Taille inattendue → confondre
string.length(caractères) etBuffer.byteLength(bytes). - Données aléatoires →
allocUnsafenon rempli, ou un pool partagé qui leak. - Memory leak → références longues vers des Buffers issus de
allocUnsafe. - Comparaison qui ne match jamais →
===au lieu de.equals(). �à 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 msnew TextEncoder().encode(str): ~3 msBuffer.toString('utf8'): ~1 msnew TextDecoder().decode(uint8): ~2 msJSON.parsesur 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.