Tot el mòdul hem estat envoltats de Buffer sense mirar-los de cara. Quan escrivim fs.readFile(FITXER_ESDEVENIMENTS, 'utf8') demanem una traducció; quan l'ometem —en copiar un cartell, en comprimir l'històric amb zlib— el que viatjava pel codi era un Buffer pur. Els trossos d'un stream binari són Buffer. La resposta d'una petició HTTP del Mòdul 4 arribarà en Buffer. Un hash del Mòdul 8 és un Buffer.

Aquesta lliçó obre la caixa: què és exactament un Buffer i per què Node el va haver d'inventar, com es crea sense deixar forats de seguretat, què significa cada codificació, com es llegeixen números binaris i per què importa l'ordre dels seus bytes. Veuràs també els dos errors clàssics de tractar bytes com si fossin caràcters: un subarray que comparteix memòria amb l'original i un emoji partit per la meitat entre dos trossos d'un stream. I ho aplicarem a Escena Viva: comprovar que el cartell que puja un organitzador és de debò un PNG —mirant els seus primers bytes, no la seva extensió— i generar la càrrega útil del codi QR d'una entrada en base64url, signada i comparable en temps constant.

Contingut

  1. Què és un Buffer i per què existeix
  2. Crear buffers: from, alloc i el perill d'allocUnsafe
  3. Codificacions i per a què serveix cadascuna
  4. Números binaris i ordre de bytes
  5. Operacions: subarray, concat, copy, compare, indexOf
  6. Bytes davant de caràcters: la longitud enganyosa
  7. StringDecoder: no partir un caràcter en dos
  8. Escena Viva: validar el cartell pels seus nombres màgics
  9. Escena Viva: el QR d'una entrada en base64url
  10. Comparar secrets en temps constant
  11. Buffer, TypedArray i ArrayBuffer

  1. Què és un Buffer i per què existeix

JavaScript va néixer al navegador per manipular text i documents. Fins al 2011 no tenia cap tipus capaç de representar dades binàries: només cadenes UTF-16, números en coma flotant i objectes. Node, en canvi, va néixer per llegir fitxers i parlar per sòcols, és a dir, per moure bytes. Necessitava un tipus que el llenguatge no li donava, i el va crear: Buffer.

Un Buffer és una seqüència de bytes de longitud fixa: cada posició guarda un enter entre 0 i 255, i no pot créixer ni encongir-se —per tenir-ne un de més gran cal crear-ne un altre i copiar—. Dos trets el distingeixen d'un array normal:

  • És una Uint8Array. Literalment: Buffer.prototype hereta d'Uint8Array.prototype. Tot el que funciona sobre una Uint8Array funciona sobre un Buffer, més els mètodes que Node hi afegeix a sobre (toString, write, readUInt32BE…).
  • La seva memòria viu fora del munt de V8. L'objecte Buffer que manipules sí que és al munt, però els bytes són en memòria reservada a part. Això permet que el sistema operatiu hi escrigui directament sense copiar, i que un procés mogui centenars de megabytes sense pressionar el recol·lector d'escombraries. És també la raó que a la lliçó 03-04 heapUsed amb prou feines es mogués mentre que rss sí que creixia.
const buf = Buffer.from('Teatro Almendra', 'utf8');
console.log(buf instanceof Uint8Array, buf.length, buf[0]);  // true 15 84  <-- bytes, no caracters
console.log(buf.toString('hex'));        // 5465617472...
console.log(buf);                        // <Buffer 54 65 61 74 72 6f 20 ...>

Fixa't en com ho imprimeix la consola: <Buffer ...> seguit dels bytes en hexadecimal. Si veus això als teus registres on esperaves text, la causa és gairebé sempre la mateixa: has oblidat la codificació en llegir.

  1. Crear buffers: from, alloc i el perill d'allocUnsafe

Forma Què fa Quan fer-la servir
Buffer.from(cadena, cod) Codifica el text a bytes Convertir text a binari
Buffer.from([1, 2, 3]) Un byte per element (& 255) Signatures i dades literals
Buffer.from(altreBuffer) Copia el contingut Aïllar una còpia independent
Buffer.from(arrayBuffer) Comparteix memòria, no copia Interoperar amb TypedArray
Buffer.alloc(n) n bytes posats a zero Per defecte, sempre
Buffer.allocUnsafe(n) n bytes sense inicialitzar Només si el sobreescriuràs sencer
Buffer.concat([a, b]) Uneix uns quants en un de nou Reconstruir un stream acumulat

Compte amb les files tercera i quarta, que semblen intercanviables i no ho són: Buffer.from(altreBuffer) copia, mentre que Buffer.from(arrayBuffer) comparteix memòria, així que modificar-ne un afectaria l'altre només en el segon cas.

El nom allocUnsafe no és cap exageració de la documentació. Buffer.alloc(n) recorre els n bytes posant-los a zero; allocUnsafe(n) els lliura tal com estaven a la memòria reservada, que pot contenir restes de dades anteriors del mateix procés: fragments d'un JSON llegit fa un segon, trossos d'una contrasenya, mitja capçalera HTTP.

Buffer.alloc(64).fill('token-de-sessio-abc123');                      // embrutem memoria i la deixem anar
console.log(Buffer.alloc(64).toString('hex').slice(0, 24));           // 000000000000000000000000
console.log(Buffer.allocUnsafe(64).toString('latin1').slice(0, 40));  // brossa, de vegades recognoscible

El 2018 aquesta diferència va produir una família sencera de fuites d'informació en paquets d'npm que reservaven un buffer amb allocUnsafe i l'enviaven per xarxa sense omplir-lo del tot: els bytes sobrants viatjaven amb el que hi hagués abans. La regla pràctica és simple: fes servir sempre Buffer.alloc; allocUnsafe només quan la línia següent hagi de sobreescriure el buffer complet i el rendiment estigui mesurat i justificat.

  1. Codificacions i per a què serveix cadascuna

Una codificació és un contracte de traducció entre bytes i caràcters. Node admet aquestes:

Codificació Bytes per caràcter Alfabet de sortida Ús típic
utf8 1 a 4 Tot Unicode Per defecte per a text
utf16le 2 o 4 Tot Unicode Interoperar amb Windows/API natives
latin1 Sempre 1 0–255 Capçaleres binàries, byte↔caràcter 1:1
ascii 1 (descarta el bit alt) 0–127 Gairebé mai: corromp accents
hex 2 caràcters per byte 0-9a-f Hashes, bolcats, depuració
base64 ~1,33 caràcters per byte A-Za-z0-9+/= Binari dins de JSON o correu
base64url ~1,33 caràcters per byte A-Za-z0-9-_ Binari dins d'URL (sense farciment)
const text = 'Sessio al Teatro Almendra';
const buf = Buffer.from(text, 'utf8');

console.log(buf.toString('base64'));            // U2Vzc2lvIGFsIFRlYXRybyBBbG1lbmRyYQ==
console.log(buf.toString('base64url'));         // U2Vzc2lvIGFsIFRlYXRybyBBbG1lbmRyYQ
console.log(buf.toString('hex').slice(0, 8));   // 53657373

Dues precisions importants:

  • base64 no és xifratge. És una representació reversible sense cap secret. Serveix per ficar bytes on només caben caràcters imprimibles, no per protegir res.
  • base64url és la variant segura per a URL: substitueix + per -, / per _ i elimina el farciment =. És exactament el que fan servir els JWT del Mòdul 8 i el que farem servir per al QR de les entrades, perquè un + dins d'una URL s'interpreta com un espai i una / parteix la ruta.
  • Una codificació desconeguda no falla en silenci: Buffer.from(x, 'utf-9') llança TypeError: Unknown encoding, i pots consultar la llista amb Buffer.isEncoding('base64url').

  1. Números binaris i ordre de bytes

Un format binari no guarda «1189» com a text: guarda el número en un nombre fix de bytes. Llegir-lo requereix saber quants bytes ocupa, si té signe i en quin ordre estan els seus bytes. Aquest últim punt és l'ordre de bytes (endianness):

  • BE (big endian): el byte més significatiu primer. És l'ordre de les xarxes i de la majoria de formats de fitxer (PNG, JPEG).
  • LE (little endian): el menys significatiu primer. És l'ordre natiu dels processadors x86 i ARM habituals.
const buf = Buffer.alloc(4);
buf.writeUInt32BE(1189, 0);          // les entrades lliures de la llavor
console.log(buf);                    // <Buffer 00 00 04 a5>
console.log(buf.readUInt32BE(0));    // 1189
console.log(buf.readUInt32LE(0));    // 2768994304  <-- mateixos bytes, altre ordre
console.log(require('node:os').endianness());  // 'LE' a la majoria de maquines

Els mateixos quatre bytes valen 1189 o 2768994304 segons com els interpretis. Per això l'ordre no s'endevina: el fixa el format i cal llegir-ne l'especificació. Els mètodes segueixen tots el mateix patró: read/write + U si no té signe + Int + mida en bits + BE/LE. Per a 64 bits existeixen readBigUInt64BE i companyia, que tornen BigInt. Escriure un valor fora de rang o en un desplaçament que se surt del buffer llança ERR_OUT_OF_RANGE: és un error sorollós, no una corrupció silenciosa.

  1. Operacions: subarray, concat, copy, compare, indexOf

Operació Què fa Trampa
buf.subarray(ini, fi) Vista sobre el mateix buffer Comparteix memòria
buf.slice(ini, fi) Àlies de subarray (obsolet) No copia, al contrari que als arrays
Buffer.concat([a, b], n) Buffer nou amb tot Copia: costa memòria
origen.copy(desti, dOff) Copia bytes a un altre buffer La destinació ha de tenir lloc
a.equals(b) / a.compare(b) Igualtat / ordre compare torna -1, 0 o 1
buf.indexOf('EV-') Cerca bytes o text Torna posició en bytes
buf.fill(valor) Omple tot el buffer Modifica al lloc

L'error clàssic és a la primera fila. En un array, slice torna una còpia; en un Buffer, slice i subarray tornen una finestra sobre la mateixa memòria:

const original = Buffer.from('EV-2026-000123', 'utf8');
const any = original.subarray(3, 7);    // 2026
any.write('1999');                      // sembla inofensiu...
console.log(original.toString());       // EV-1999-000123   <-- l'original ha canviat

Si necessites una còpia de debò, demana-la explícitament: Buffer.from(original.subarray(3, 7)) o Buffer.copyBytesFrom(...). Aquest detall és la causa d'una fallada molt difícil de trobar: guardar trossos d'un stream amb trossos.push(dades) sense copiar-los, quan l'stream reutilitza el mateix buffer intern entre lectures; els trossos acumulats acaben contenint tots el mateix, l'últim. Quant a concat, és la manera correcta de reconstruir un contingut acumulat —Buffer.concat(trossos) després d'un for await sobre un stream— i accepta un tercer argument amb la longitud total, que evita recalcular-la si ja la coneixes. Aquest patró és exactament el que fa readFile per dins, amb la mateixa conseqüència de memòria que vam mesurar a 03-04: fes-lo servir només quan sàpigues que el contingut és petit.

  1. Bytes davant de caràcters: la longitud enganyosa

String.length compta unitats UTF-16. Buffer.length compta bytes. I Buffer.byteLength(cadena) et diu quants bytes ocuparà un text abans de convertir-lo. Els tres números poden ser diferents:

for (const text of ['Almendra', 'Boveda', 'Bóveda', 'Monólogos', '🎭']) {
  console.log(text.padEnd(10),
    `String.length=${text.length}`,
    `bytes=${Buffer.byteLength(text, 'utf8')}`,
    `caracters=${[...text].length}`);
}
Almendra    String.length=8   bytes=8    caracters=8
Boveda      String.length=6   bytes=6    caracters=6
Bóveda      String.length=6   bytes=7    caracters=6
Monólogos   String.length=9   bytes=10   caracters=9
🎭          String.length=2   bytes=4    caracters=1

Tres lliçons d'aquesta taula. Primera: una ó ocupa dos bytes en UTF-8, així que reservar un buffer amb Buffer.alloc(text.length) per guardar text accentuat el trunca. Segona: un emoji ocupa dues unitats a String.length i una en iterar-lo amb [...text], perquè JavaScript representa els caràcters fora del pla bàsic com una parella de valors substituts. Tercera, i la que trenca programes de debò: tallar un buffer per una posició arbitrària pot partir un caràcter en dos.

const sala = Buffer.from('Sala Bóveda', 'utf8');    // 12 bytes
const part1 = sala.subarray(0, 7);                  // talla dins de la 'ó'
const part2 = sala.subarray(7);
console.log(part1.toString('utf8'));                // Sala B�   <-- caracter de reemplacament
console.log(part1.toString() + part2.toString());   // Sala B��veda   <-- irrecuperable
console.log(Buffer.concat([part1, part2]).toString()); // Sala Bóveda   <-- correcte

Convertir cada tros a text per separat destrueix el caràcter partit: el � (U+FFFD) ja no es pot desfer. Unir primer els bytes i descodificar després funciona, però exigeix tenir tot el contingut a la memòria, que és justament el que un stream evita.

  1. StringDecoder: no partir un caràcter en dos

El problema anterior no és teòric: és exactament el que passa quan un stream lliura trossos de 64 KB i el caràcter número 65.536 cau a cavall entre dos. La solució és a node:string_decoder, un descodificador amb estat que reté els bytes incomplets del final d'un tros fins que arriben els que falten.

const { StringDecoder } = require('node:string_decoder');

const descodificador = new StringDecoder('utf8');
const sala = Buffer.from('Sala Bóveda', 'utf8');
console.log(JSON.stringify(descodificador.write(sala.subarray(0, 7))));  // "Sala B"
console.log(JSON.stringify(descodificador.write(sala.subarray(7))));     // "óveda"
console.log(JSON.stringify(descodificador.end()));                       // ""

El primer write torna "Sala B" sense la ó: el descodificador ha vist el primer byte del caràcter i se l'ha guardat. El segon write el completa i emet óveda. L'end() final torna el que quedés pendent —si el flux acaba amb un caràcter incomplet, allà apareix el �, senyal que l'entrada estava truncada—. Quan passes { encoding: 'utf8' } a createReadStream o crides setEncoding('utf8') sobre un stream, Node fa servir internament un StringDecoder, i per això els trossos ja arriben com a text correcte. Necessites fer-lo servir a mà només quan treballes amb els Buffer crus: en desxifrar, en descomprimir per trossos o en implementar un protocol propi.

  1. Escena Viva: validar el cartell pels seus nombres màgics

Els organitzadors pugen el cartell del seu esdeveniment. L'extensió del fitxer no demostra res: reanomenar virus.exe a cartell.png costa un segon. El que sí que identifica un format són els seus primers bytes, l'anomenada signatura o nombre màgic.

Format Bytes inicials (hex) Interpretació
PNG 89 50 4E 47 0D 0A 1A 0A .PNG + salts de control
JPEG FF D8 FF Marcador d'inici d'imatge
PDF 25 50 44 46 2D %PDF-
WEBP 52 49 46 46 … 57 45 42 50 RIFF + WEBP al byte 8
GZIP 1F 8B El .gz que vam generar a 03-05

Aprofitem l'API FileHandle de la lliçó 03-02 per llegir només els primers bytes, sense carregar una imatge de deu megues per mirar-ne vuit:

// src/utils/tipus-fitxer.js
// Detecta el format real d'un fitxer pels seus primers bytes (nombre magic),
// mai per la seva extensio: l'extensio la tria qui puja el fitxer.
const fs = require('node:fs/promises');

const BYTES_CAPCALERA = 16;
const SIGNATURES = [
  { tipus: 'png', extensio: '.png', desplacament: 0, signatura: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) },
  { tipus: 'jpeg', extensio: '.jpg', desplacament: 0, signatura: Buffer.from([0xff, 0xd8, 0xff]) },
  { tipus: 'pdf', extensio: '.pdf', desplacament: 0, signatura: Buffer.from('%PDF-', 'latin1') },
  { tipus: 'gzip', extensio: '.gz', desplacament: 0, signatura: Buffer.from([0x1f, 0x8b]) },
  { tipus: 'webp', extensio: '.webp', desplacament: 8, signatura: Buffer.from('WEBP', 'latin1') }
];

// Llegeix els primers bytes del fitxer i torna nomes els que existeixin de veritat.
async function llegirCapcalera(ruta, bytes = BYTES_CAPCALERA) {
  const gestor = await fs.open(ruta, 'r');
  try {
    const desti = Buffer.alloc(bytes);
    const { bytesRead } = await gestor.read(desti, 0, bytes, 0);
    return Buffer.from(desti.subarray(0, bytesRead));
  } finally {
    await gestor.close();
  }
}

function detectarTipus(capcalera) {
  for (const { tipus, extensio, desplacament, signatura } of SIGNATURES) {
    if (capcalera.subarray(desplacament, desplacament + signatura.length).equals(signatura)) {
      return { tipus, extensio };
    }
  }
  return null;
}

async function validarCartell(ruta, tipusAcceptats = ['png', 'jpeg']) {
  const capcalera = await llegirCapcalera(ruta);
  const detectat = detectarTipus(capcalera);

  if (detectat === null || !tipusAcceptats.includes(detectat.tipus)) {
    const error = new Error(`El cartell ${ruta} no es un fitxer ${tipusAcceptats.join(' ni ')}`);
    error.codi = 'FORMAT_NO_ACCEPTAT';
    error.capcalera = capcalera.subarray(0, 8).toString('hex');
    throw error;
  }
  return detectat;
}

module.exports = { llegirCapcalera, detectarTipus, validarCartell, SIGNATURES, BYTES_CAPCALERA };

Quatre decisions que mereixen comentari: Buffer.alloc i no allocUnsafe, perquè si el fitxer té menys de 16 bytes els sobrants serien brossa de memòria comparada contra signatures reals; Buffer.from(desti.subarray(...)), que torna una còpia independent en comptes d'una vista amb accés als bytes sobrants; equals en comptes de toString, perquè comparar bytes amb bytes evita qualsevol dubte de codificació —0x89 no és text vàlid en UTF-8—; i error.codi seguint la convenció de domini del curs, amb la capçalera en hex adjunta per diagnosticar què va pujar realment l'organitzador.

node -e "require('./src/utils/tipus-fitxer.js').validarCartell('dades/esdeveniments.json').catch((e) => console.error(e.codi, e.capcalera))"
# FORMAT_NO_ACCEPTAT 7b0a20202265   <-- 0x7b es '{': un JSON disfressat de cartell

La comprovació funciona. Compte amb l'abast d'aquesta tècnica: la signatura demostra el format, no que el contingut sigui inofensiu. Al Mòdul 4, en rebre pujades de debò, la combinarem amb un límit de mida i amb la ruta blindada de la lliçó 03-03.

  1. Escena Viva: el QR d'una entrada en base64url

Cada entrada d'Escena Viva porta un codi EV-2026-000123 i un QR que l'acomodador escaneja a la porta. El QR conté una URL, i dins d'aquesta URL viatja una càrrega útil: les dades de l'entrada més una signatura que impedeix fabricar-ne. Aquest contingut és binari i ha de cabre en una URL sense escapaments: el cas exacte de base64url.

// src/utils/qr-entrada.js
// Carrega util del codi QR d'una entrada: cos en base64url i signatura HMAC.
// El format es <cos>.<signatura>, el mateix esquema que veurem amb JWT.
const crypto = require('node:crypto');

const SECRET = process.env.ESCENA_VIVA_SECRET ?? 'secret-de-desenvolupament';
const signar = (cos) => crypto.createHmac('sha256', SECRET).update(cos).digest('base64url');

function fallar(codi, missatge) {
  const error = new Error(missatge);
  error.codi = codi;
  throw error;
}

function codificarQr({ codiEntrada, sessioId, emesaEl }) {
  const carrega = JSON.stringify({ codiEntrada, sessioId, emesaEl });
  const cos = Buffer.from(carrega, 'utf8').toString('base64url');
  return `${cos}.${signar(cos)}`;
}

function descodificarQr(text) {
  const [cos, signatura] = String(text).split('.');
  if (cos === undefined || signatura === undefined) fallar('QR_MALFORMAT', "El QR no te el format <cos>.<signatura>");
  if (!sonIguals(signatura, signar(cos))) fallar('QR_SIGNATURA_INVALIDA', 'La signatura del codi QR no es valida');

  return JSON.parse(Buffer.from(cos, 'base64url').toString('utf8'));
}

module.exports = { codificarQr, descodificarQr };
const qr = codificarQr({ codiEntrada: 'EV-2026-000123', sessioId: 'ses-001-1', emesaEl: '2026-08-14T19:30:00' });

console.log(qr);   // eyJjb2RpRW50cmFkYSI6IkVWLTIwMjYtMDAwMTIzIiwic2Vzc2lvSWQiOiJz...Yk9u
console.log(descodificarQr(qr).sessioId);   // ses-001-1
descodificarQr(qr.replace(/.$/, 'X'));      // llanca QR_SIGNATURA_INVALIDA

sonIguals és la funció de comparació segura de l'apartat següent, que viu en aquest mateix mòdul. El cos no està xifrat —qualsevol el pot descodificar amb Buffer.from(cos, 'base64url').toString()—, i això és acceptable: al QR d'una entrada no hi ha res secret. El que protegeix la signatura és la integritat: sense conèixer el secret no es pot fabricar una entrada vàlida ni canviar-li la sessió. La comparació d'aquesta signatura, però, no es pot fer amb ===, i aquest és l'últim apartat tècnic de la lliçó.

  1. Comparar secrets en temps constant

a === b sobre cadenes compara caràcter a caràcter i s'atura al primer que difereix. Aquesta optimització, inofensiva en qualsevol altre context, filtra informació quan el que es compara és un secret: un atacant que mesura el temps de resposta pot deduir quants caràcters inicials ha encertat i reconstruir la signatura byte a byte.

crypto.timingSafeEqual compara sempre tots els bytes, trigui el que trigui a trobar la primera diferència. Té una exigència: els dos buffers han de mesurar exactament el mateix, o llança ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH, i la longitud de la dada aliena no la controles. La solució estàndard és normalitzar la longitud amb un hash abans de comparar:

// Afegir a src/utils/qr-entrada.js
function sonIguals(a, b) {
  // El hash iguala les longituds (32 bytes sempre) sense filtrar informacio.
  const resumA = crypto.createHash('sha256').update(String(a)).digest();
  const resumB = crypto.createHash('sha256').update(String(b)).digest();
  return crypto.timingSafeEqual(resumA, resumB);
}

digest() sense argument torna un Buffer —amb 'hex' tornaria una cadena—, i dos SHA-256 mesuren 32 bytes cadascun passi el que passi. Reprendrem aquesta funció tal qual al Mòdul 8, on el mateix raonament s'aplica a claus d'API i a testimonis de sessió.

  1. Buffer, TypedArray i ArrayBuffer

Les tres peces encaixen així: un ArrayBuffer és un bloc de memòria en brut que no es pot llegir ni escriure directament; un TypedArray (Uint8Array, Int16Array, Float64Array…) és una vista que interpreta aquest bloc com a números de cert tipus, i diverses vistes poden mirar el mateix bloc; un Buffer és una Uint8Array amb mètodes afegits per Node.

const buf = Buffer.from('Ribera', 'utf8');

console.log(buf.byteOffset, buf.byteLength);      // p. ex. 88 6  <-- compte amb l'offset
console.log(new Uint8Array(buf.buffer).length);   // 8192  <-- NO son 6

Aquí hi ha la trampa més subtil del mòdul. Node reserva els buffers petits (menys de 4 KB) dins d'un pool compartit de 8 KB, així que buf.buffer no és la memòria del teu buffer: és la del pool sencer, i buf.byteOffset indica on comença la teva porció. Passar buf.buffer a una API que espera les dades exactes lliura 8 KB de memòria aliena. La manera correcta de convertir:

const vista = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);   // comparteix memoria
const altre = Buffer.from(vista.buffer, vista.byteOffset, vista.byteLength); // i la tornada

A la pràctica això apareix en interoperar amb API d'estàndard web dins de Node —fetch, crypto.subtle, WebSocket, els worker threads del Mòdul 10—, que parlen Uint8Array i ArrayBuffer, no Buffer.

Errors Comuns i Consells

  • Veure <Buffer 7b 0a ...> on esperaves text. Falta la codificació: readFile(ruta, 'utf8') o .toString('utf8'). No facis servir mai String(buf) amb l'esperança que faci el correcte amb dades binàries.
  • Fer servir allocUnsafe per costum. Si no omples el buffer sencer, publiques memòria antiga del procés. alloc per defecte, sempre.
  • Creure que slice copia. Comparteix memòria, al revés que als arrays. Còpia explícita amb Buffer.from(vista) si l'original ha de canviar o reutilitzar-se.
  • Reservar amb text.length en comptes de Buffer.byteLength(text). Amb un sol accent ja et falta un byte i el text surt truncat.
  • Concatenar trossos com a cadenes. tros.toString() per cada tros d'un stream parteix els caràcters multibyte: acumula Buffer i descodifica al final, o fes servir StringDecoder.
  • Comparar signatures amb ===. Filtra informació per temps. timingSafeEqual sobre buffers d'igual longitud, normalitzada amb un hash.
  • Consell: treballa amb Buffer fins a l'últim moment possible i converteix a text una sola vegada, a la frontera del teu sistema. Cada conversió d'anada i tornada costa CPU i és una oportunitat de corrompre dades.

Exercicis

Exercici 1: inventari de cartells

Escriu src/laboratori/inventari-cartells.js que recorri un directori (per defecte dades/cartells/) i, per cada fitxer, imprimeixi en una taula el seu nom, la seva extensió declarada, el tipus real detectat amb detectarTipus i si coincideixen. Ha de marcar clarament els fitxers l'extensió dels quals menteix. Fes servir readdir amb withFileTypes de la lliçó 03-02 i les rutes de src/config/rutes.js.

Exercici 2: trossejador segur de text

Escriu una funció trossejarText(text, bytesMaxims) que torni un array de Buffer de com a màxim bytesMaxims bytes cadascun sense partir cap caràcter. Verifica amb 'Noche de Monólogos 🎭 en la Sala Bóveda' i bytesMaxims = 10 que en concatenar els trossos i descodificar recuperes el text original i que cap tros no produeix � per separat.

Exercici 3: capçalera binària de l'informe

Dissenya un format binari mínim per a l'informe diari d'ocupació: 4 bytes de signatura 'EVIV', 1 byte de versió, 2 bytes big endian amb el nombre de sessions, 4 bytes big endian amb les entrades venudes i la resta en JSON UTF-8. Escriu escriureInformeBinari(ruta, informe) i llegirInformeBinari(ruta), i comprova que el cicle complet torna les dades originals i que un fitxer amb signatura incorrecta llança un error amb codi: 'FORMAT_NO_ACCEPTAT'.

Solucions

Solució 1. La comparació entre extensió declarada i tipus real és el nucli:

const { readdir } = require('node:fs/promises');
const path = require('node:path');
const { llegirCapcalera, detectarTipus } = require('../utils/tipus-fitxer.js');

async function inventariar(directori) {
  const files = [];
  for (const entrada of await readdir(directori, { withFileTypes: true })) {
    if (!entrada.isFile()) continue;
    const detectat = detectarTipus(await llegirCapcalera(path.join(directori, entrada.name)));
    const declarada = path.extname(entrada.name).toLowerCase();
    const normalitzada = declarada === '.jpeg' ? '.jpg' : declarada;   // .jpeg i .jpg son legitimes
    files.push({ fitxer: entrada.name, declarada, real: detectat?.tipus ?? 'desconegut', coincideix: detectat?.extensio === normalitzada });
  }

  console.table(files);
  return files;
}

El cas .jpeg/.jpg recorda que un format pot tenir diverses extensions legítimes: la comprovació no ha de generar falses alarmes. Un desconegut no sempre és un atac —pot ser un format que no és a SIGNATURES—, però sí que és motiu per no acceptar-lo.

Solució 2. La clau és no tallar a cegues, sinó recular fins al principi d'un caràcter. En UTF-8, els bytes de continuació tenen la forma 10xxxxxx, és a dir, (byte & 0xc0) === 0x80:

function trossejarText(text, bytesMaxims) {
  const complet = Buffer.from(text, 'utf8');
  const trossos = [];
  for (let inici = 0; inici < complet.length; ) {
    let fi = Math.min(inici + bytesMaxims, complet.length);
    // Reculem mentre 'fi' caigui sobre un byte de continuacio.
    while (fi > inici + 1 && (complet[fi] & 0xc0) === 0x80) fi -= 1;
    trossos.push(Buffer.from(complet.subarray(inici, fi)));
    inici = fi;
  }
  return trossos;
}

console.log(trossejarText('Noche de Monólogos 🎭 en la Sala Bóveda', 10).map((t) => t.toString('utf8')));

El Buffer.from(...) al voltant del subarray és imprescindible: sense ell, els trossos serien vistes del mateix buffer i qualsevol modificació posterior els afectaria tots. Una alternativa més curta si només vols text: recórrer [...text] acumulant per Buffer.byteLength de cada caràcter.

Solució 3. El format s'escriu i es llegeix en el mateix ordre en què està definit:

const SIGNATURA = Buffer.from('EVIV', 'latin1');
const VERSIO = 1;

function serialitzar(informe) {
  const capcalera = Buffer.alloc(11);
  SIGNATURA.copy(capcalera, 0);
  capcalera.writeUInt8(VERSIO, 4);
  capcalera.writeUInt16BE(informe.sessions.length, 5);
  capcalera.writeUInt32BE(informe.entradesVenudes, 7);
  return Buffer.concat([capcalera, Buffer.from(JSON.stringify(informe), 'utf8')]);
}

function deserialitzar(binari) {
  if (!binari.subarray(0, 4).equals(SIGNATURA)) throw Object.assign(new Error('No es un informe'), { codi: 'FORMAT_NO_ACCEPTAT' });
  return {
    versio: binari.readUInt8(4),
    sessions: binari.readUInt16BE(5),
    entradesVenudes: binari.readUInt32BE(7),
    detall: JSON.parse(binari.subarray(11).toString('utf8'))
  };
}

Amb les dades de la llavor, readUInt32BE(7) torna 1811 i readUInt16BE(5) torna 7. Fixa't en el byte de versió: és el que permetrà llegir fitxers antics quan el format canviï, i ometre'l és l'error més freqüent en dissenyar un format binari propi. writeUInt16BE amb més de 65.535 sessions llançaria ERR_OUT_OF_RANGE, un recordatori que cada camp binari té un sostre que cal triar a consciència.

Conclusió

Ja saps què hi ha dins de la caixa. Un Buffer és una Uint8Array sobre memòria aliena al munt de V8, de longitud fixa, que existeix perquè JavaScript no tenia tipus binari quan Node el va necessitar. El crees amb Buffer.from —copiant des de text, array o un altre buffer, compartint des d'un ArrayBuffer— o amb Buffer.alloc, mai amb allocUnsafe llevat que l'hagis de sobreescriure sencer, perquè el seu contingut inicial és memòria feta servir pel procés mateix.

Coneixes les codificacions i el seu repartiment de papers: utf8 per a text, latin1 per tractar bytes com a caràcters un a un, hex per depurar i hashes, base64 per ficar binari en JSON i base64url per ficar-lo en una URL. Saps llegir i escriure enters amb readUInt32BE i companyia, i que l'ordre de bytes el fixa el format, no la teva màquina. Domines les operacions i les seves trampes: subarray comparteix memòria —l'error clàssic—, concat copia, equals compara sense ambigüitat de codificació. I tens clar per què String.length, Buffer.byteLength i [...text].length donen tres números diferents, per què tallar un buffer per una posició arbitrària produeix un � irrecuperable i com ho evita el StringDecoder guardant-se els bytes incomplets entre tros i tros.

Escena Viva s'emporta dos mòduls nous: src/utils/tipus-fitxer.js, que valida el cartell d'un esdeveniment pel seu nombre màgic llegint només setze bytes amb FileHandle, i src/utils/qr-entrada.js, que codifica la càrrega útil del QR en base64url amb una signatura HMAC comparada en temps constant amb timingSafeEqual sobre resums d'igual longitud. I saps que buf.buffer no és el teu buffer, sinó el pool de 8 KB on viu: byteOffset i byteLength no són opcionals. Amb això tanques el Mòdul 3. Escena Viva ha passat de tenir les dades incrustades al codi a llegir el seu catàleg del disc de manera asíncrona, organitzar informes per mes, resoldre rutes que funcionen des de qualsevol directori i en qualsevol sistema, processar un històric de vendes amb memòria constant mitjançant canonades amb pipeline, i entendre els bytes que circulen per tot l'anterior. El projecte sap llegir i escriure; el que encara no sap és parlar amb ningú. Això comença al Mòdul 4: HTTP, i veuràs de seguida que no és cap territori nou: un servidor HTTP de Node és un EventEmitter que emet request; la petició que reps és un stream de lectura i la resposta que tornes és un stream d'escriptura; el cos JSON d'un POST arriba en trossos de Buffer que cal acumular amb cura; i servir un fitxer estàtic és exactament createReadStream més la ruta blindada de la lliçó 03-03. Tot el d'aquest mòdul torna, aquesta vegada connectat a la xarxa.

Curs de Node.js: De Principiant a Avançat

Mòdul 1: Introducció a Node.js

Mòdul 2: Conceptes Bàsics

Mòdul 3: Sistema de Fitxers i E/S

Mòdul 4: HTTP i Servidors Web

Mòdul 5: NPM i Gestió de Paquets

Mòdul 6: Framework Express.js

Mòdul 7: Bases de Dades i ORMs

Mòdul 8: Autenticació i Autorització

Mòdul 9: Proves i Depuració

Mòdul 10: Temes Avançats

Mòdul 11: Desplegament i DevOps

Mòdul 12: Projectes del Món Real

© Copyright 2026. Tots els drets reservats