Todo el módulo hemos estado rodeados de Buffer sin mirarlos de frente. Cuando escribimos fs.readFile(FICHERO_EVENTOS, 'utf8') pedimos una traducción; cuando la omitimos —al copiar un cartel, al comprimir el histórico con zlib— lo que viajaba por el código era un Buffer puro. Los trozos de un stream binario son Buffer. La respuesta de una petición HTTP del Módulo 4 llegará en Buffer. Un hash del Módulo 8 es un Buffer.

Esta lección abre la caja: qué es exactamente un Buffer y por qué Node tuvo que inventarlo, cómo se crea sin dejar agujeros de seguridad, qué significa cada codificación, cómo se leen números binarios y por qué importa el orden de sus bytes. Verás también los dos errores clásicos de tratar bytes como si fueran caracteres: un subarray que comparte memoria con el original y un emoji partido por la mitad entre dos trozos de un stream. Y lo aplicaremos a Escena Viva: comprobar que el cartel que sube un organizador es de verdad un PNG —mirando sus primeros bytes, no su extensión— y generar la carga útil del código QR de una entrada en base64url, firmada y comparable en tiempo constante.

Contenido

  1. Qué es un Buffer y por qué existe
  2. Crear buffers: from, alloc y el peligro de allocUnsafe
  3. Codificaciones y para qué sirve cada una
  4. Números binarios y orden de bytes
  5. Operaciones: subarray, concat, copy, compare, indexOf
  6. Bytes frente a caracteres: la longitud engañosa
  7. StringDecoder: no partir un carácter en dos
  8. Escena Viva: validar el cartel por sus números mágicos
  9. Escena Viva: el QR de una entrada en base64url
  10. Comparar secretos en tiempo constante
  11. Buffer, TypedArray y ArrayBuffer

  1. Qué es un Buffer y por qué existe

JavaScript nació en el navegador para manipular texto y documentos. Hasta 2011 no tenía ningún tipo capaz de representar datos binarios: solo cadenas UTF-16, números en coma flotante y objetos. Node, en cambio, nació para leer ficheros y hablar por sockets, es decir, para mover bytes. Necesitaba un tipo que el lenguaje no le daba, y lo creó: Buffer.

Un Buffer es una secuencia de bytes de longitud fija: cada posición guarda un entero entre 0 y 255, y no puede crecer ni encogerse —para uno más grande hay que crear otro y copiar—. Dos rasgos lo distinguen de un array normal:

  • Es una Uint8Array. Literalmente: Buffer.prototype hereda de Uint8Array.prototype. Todo lo que funciona sobre una Uint8Array funciona sobre un Buffer, más los métodos que Node añade encima (toString, write, readUInt32BE…).
  • Su memoria vive fuera del montículo de V8. El objeto Buffer que manipulas sí está en el montículo, pero los bytes están en memoria reservada aparte. Eso permite que el sistema operativo escriba directamente en ella sin copiar, y que un proceso mueva cientos de megabytes sin presionar al recolector de basura. Es también la razón de que en la lección 03-04 heapUsed apenas se moviera mientras rss sí crecía.
const buf = Buffer.from('Teatro Almendra', 'utf8');
console.log(buf instanceof Uint8Array, buf.length, buf[0]);  // true 15 84  <-- bytes, no caracteres
console.log(buf.toString('hex'));        // 5465617472...
console.log(buf);                        // <Buffer 54 65 61 74 72 6f 20 ...>

Fíjate en cómo lo imprime la consola: <Buffer ...> seguido de los bytes en hexadecimal. Si ves eso en tus registros donde esperabas texto, la causa es casi siempre la misma: olvidaste la codificación al leer.

  1. Crear buffers: from, alloc y el peligro de allocUnsafe

Forma Qué hace Cuándo usarla
Buffer.from(cadena, cod) Codifica el texto a bytes Convertir texto a binario
Buffer.from([1, 2, 3]) Un byte por elemento (& 255) Firmas y datos literales
Buffer.from(otroBuffer) Copia el contenido Aislar una copia independiente
Buffer.from(arrayBuffer) Comparte memoria, no copia Interoperar con TypedArray
Buffer.alloc(n) n bytes puestos a cero Por defecto, siempre
Buffer.allocUnsafe(n) n bytes sin inicializar Solo si vas a sobrescribirlo entero
Buffer.concat([a, b]) Une varios en uno nuevo Reconstruir un stream acumulado

Ojo con las filas tercera y cuarta, que parecen intercambiables y no lo son: Buffer.from(otroBuffer) copia, mientras que Buffer.from(arrayBuffer) comparte memoria, así que modificar uno afectaría al otro solo en el segundo caso.

El nombre allocUnsafe no es una exageración de la documentación. Buffer.alloc(n) recorre los n bytes poniéndolos a cero; allocUnsafe(n) los entrega tal como estaban en la memoria reservada, que puede contener restos de datos anteriores del mismo proceso: fragmentos de un JSON leído hace un segundo, trozos de una contraseña, media cabecera HTTP.

Buffer.alloc(64).fill('token-de-sesion-abc123');                      // ensuciamos memoria y la soltamos
console.log(Buffer.alloc(64).toString('hex').slice(0, 24));           // 000000000000000000000000
console.log(Buffer.allocUnsafe(64).toString('latin1').slice(0, 40));  // basura, a veces reconocible

En 2018 esta diferencia produjo una familia entera de fugas de información en paquetes de npm que reservaban un buffer con allocUnsafe y lo enviaban por red sin llenarlo del todo: los bytes sobrantes viajaban con lo que hubiera antes. La regla práctica es simple: usa siempre Buffer.alloc; allocUnsafe solo cuando la línea siguiente vaya a sobrescribir el buffer completo y el rendimiento esté medido y justificado.

  1. Codificaciones y para qué sirve cada una

Una codificación es un contrato de traducción entre bytes y caracteres. Node soporta estas:

Codificación Bytes por carácter Alfabeto de salida Uso típico
utf8 1 a 4 Todo Unicode Por defecto para texto
utf16le 2 o 4 Todo Unicode Interoperar con Windows/APIs nativas
latin1 Siempre 1 0–255 Cabeceras binarias, byte↔carácter 1:1
ascii 1 (descarta el bit alto) 0–127 Casi nunca: corrompe acentos
hex 2 caracteres por byte 0-9a-f Hashes, volcados, depuración
base64 ~1,33 caracteres por byte A-Za-z0-9+/= Binario dentro de JSON o correo
base64url ~1,33 caracteres por byte A-Za-z0-9-_ Binario dentro de URL (sin relleno)
const texto = 'Sesion en el Teatro Almendra';
const buf = Buffer.from(texto, 'utf8');

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

Dos precisiones importantes:

  • base64 no es cifrado. Es una representación reversible sin secreto ninguno. Sirve para meter bytes donde solo caben caracteres imprimibles, no para proteger nada.
  • base64url es la variante segura para URLs: sustituye + por -, / por _ y elimina el relleno =. Es exactamente lo que usan los JWT del Módulo 8 y lo que usaremos para el QR de las entradas, porque un + dentro de una URL se interpreta como espacio y un / parte la ruta.
  • Una codificación desconocida no falla en silencio: Buffer.from(x, 'utf-9') lanza TypeError: Unknown encoding, y puedes consultar la lista con Buffer.isEncoding('base64url').

  1. Números binarios y orden de bytes

Un formato binario no guarda "1189" como texto: guarda el número en un número fijo de bytes. Leerlo requiere saber cuántos bytes ocupa, si tiene signo y en qué orden están sus bytes. Ese último punto es el orden de bytes (endianness):

  • BE (big endian): el byte más significativo primero. Es el orden de las redes y de la mayoría de formatos de fichero (PNG, JPEG).
  • LE (little endian): el menos significativo primero. Es el orden nativo de los procesadores x86 y ARM habituales.
const buf = Buffer.alloc(4);
buf.writeUInt32BE(1189, 0);          // las entradas libres de la semilla
console.log(buf);                    // <Buffer 00 00 04 a5>
console.log(buf.readUInt32BE(0));    // 1189
console.log(buf.readUInt32LE(0));    // 2768994304  <-- mismos bytes, otro orden
console.log(require('node:os').endianness());  // 'LE' en la mayoria de maquinas

Los mismos cuatro bytes valen 1189 o 2768994304 según cómo los interpretes. Por eso el orden no se adivina: lo fija el formato y hay que leer su especificación. Los métodos siguen todos el mismo patrón: read/write + U si no tiene signo + Int + tamaño en bits + BE/LE. Para 64 bits existen readBigUInt64BE y compañía, que devuelven BigInt. Escribir un valor fuera de rango o en un desplazamiento que se sale del buffer lanza ERR_OUT_OF_RANGE: es un error ruidoso, no una corrupción silenciosa.

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

Operación Qué hace Trampa
buf.subarray(ini, fin) Vista sobre el mismo buffer Comparte memoria
buf.slice(ini, fin) Alias de subarray (obsoleto) No copia, al contrario que en los arrays
Buffer.concat([a, b], n) Nuevo buffer con todo Copia: cuesta memoria
origen.copy(destino, dOff) Copia bytes a otro buffer El destino debe tener sitio
a.equals(b) / a.compare(b) Igualdad / orden compare devuelve -1, 0 o 1
buf.indexOf('EV-') Busca bytes o texto Devuelve posición en bytes
buf.fill(valor) Rellena todo el buffer Modifica en el sitio

El error clásico está en la primera fila. En un array, slice devuelve una copia; en un Buffer, slice y subarray devuelven una ventana sobre la misma memoria:

const original = Buffer.from('EV-2026-000123', 'utf8');
const anio = original.subarray(3, 7);   // 2026
anio.write('1999');                     // parece inofensivo...
console.log(original.toString());       // EV-1999-000123   <-- el original ha cambiado

Si necesitas una copia de verdad, pídela explícitamente: Buffer.from(original.subarray(3, 7)) o Buffer.copyBytesFrom(...). Este detalle es la causa de un fallo muy difícil de encontrar: guardar trozos de un stream con trozos.push(datos) sin copiarlos, cuando el stream reutiliza el mismo buffer interno entre lecturas; los trozos acumulados acaban conteniendo todos lo mismo, el último. En cuanto a concat, es la forma correcta de reconstruir un contenido acumulado —Buffer.concat(trozos) tras un for await sobre un stream— y acepta un tercer argumento con la longitud total, que evita recalcularla si ya la conoces. Ese patrón es exactamente lo que hace readFile por dentro, con la misma consecuencia de memoria que medimos en 03-04: úsalo solo cuando sepas que el contenido es pequeño.

  1. Bytes frente a caracteres: la longitud engañosa

String.length cuenta unidades UTF-16. Buffer.length cuenta bytes. Y Buffer.byteLength(cadena) te dice cuántos bytes ocupará un texto antes de convertirlo. Los tres números pueden ser distintos:

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

Tres lecciones de esta tabla. Primera: una ó ocupa dos bytes en UTF-8, así que reservar un buffer con Buffer.alloc(texto.length) para guardar texto acentuado lo trunca. Segunda: un emoji ocupa dos unidades en String.length y una al iterarlo con [...texto], porque JavaScript representa los caracteres fuera del plano básico como un par de valores sustitutos. Tercera, y la que rompe programas de verdad: cortar un buffer por una posición arbitraria puede partir un carácter en dos.

const sala = Buffer.from('Sala Bóveda', 'utf8');   // 12 bytes
const parte1 = sala.subarray(0, 7);                 // corta dentro de la 'ó'
const parte2 = sala.subarray(7);
console.log(parte1.toString('utf8'));               // Sala B�   <-- caracter de reemplazo
console.log(parte1.toString() + parte2.toString()); // Sala B��veda   <-- irrecuperable
console.log(Buffer.concat([parte1, parte2]).toString()); // Sala Bóveda   <-- correcto

Convertir cada trozo a texto por separado destruye el carácter partido: el � (U+FFFD) ya no se puede deshacer. Unir primero los bytes y decodificar después funciona, pero exige tener todo el contenido en memoria, que es justo lo que un stream evita.

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

El problema anterior no es teórico: es exactamente lo que ocurre cuando un stream entrega trozos de 64 KB y el carácter número 65.536 cae a caballo entre dos. La solución está en node:string_decoder, un decodificador con estado que retiene los bytes incompletos del final de un trozo hasta que llegan los que faltan.

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

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

El primer write devuelve "Sala B" sin la ó: el decodificador ha visto el primer byte del carácter y se lo ha guardado. El segundo write lo completa y emite óveda. El end() final devuelve lo que quedara pendiente —si el flujo termina con un carácter incompleto, ahí aparece el �, señal de que la entrada estaba truncada—. Cuando pasas { encoding: 'utf8' } a createReadStream o llamas a setEncoding('utf8') sobre un stream, Node usa internamente un StringDecoder, y por eso los trozos ya llegan como texto correcto. Necesitas usarlo a mano solo cuando trabajas con los Buffer crudos: al descifrar, al descomprimir por trozos o al implementar un protocolo propio.

  1. Escena Viva: validar el cartel por sus números mágicos

Los organizadores suben el cartel de su evento. La extensión del fichero no demuestra nada: renombrar virus.exe a cartel.png cuesta un segundo. Lo que sí identifica un formato son sus primeros bytes, la llamada firma o número mágico.

Formato Bytes iniciales (hex) Interpretación
PNG 89 50 4E 47 0D 0A 1A 0A .PNG + saltos de control
JPEG FF D8 FF Marcador de inicio de imagen
PDF 25 50 44 46 2D %PDF-
WEBP 52 49 46 46 … 57 45 42 50 RIFF + WEBP en el byte 8
GZIP 1F 8B El .gz que generamos en 03-05

Aprovechamos la API FileHandle de la lección 03-02 para leer solo los primeros bytes, sin cargar una imagen de diez megas para mirar ocho:

// src/utiles/tipo-fichero.js
// Detecta el formato real de un fichero por sus primeros bytes (numero magico),
// nunca por su extension: la extension la elige quien sube el fichero.
const fs = require('node:fs/promises');

const BYTES_CABECERA = 16;
const FIRMAS = [
  { tipo: 'png', extension: '.png', desplazamiento: 0, firma: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) },
  { tipo: 'jpeg', extension: '.jpg', desplazamiento: 0, firma: Buffer.from([0xff, 0xd8, 0xff]) },
  { tipo: 'pdf', extension: '.pdf', desplazamiento: 0, firma: Buffer.from('%PDF-', 'latin1') },
  { tipo: 'gzip', extension: '.gz', desplazamiento: 0, firma: Buffer.from([0x1f, 0x8b]) },
  { tipo: 'webp', extension: '.webp', desplazamiento: 8, firma: Buffer.from('WEBP', 'latin1') }
];

// Lee los primeros bytes del fichero y devuelve solo los que existan de verdad.
async function leerCabecera(ruta, bytes = BYTES_CABECERA) {
  const manejador = await fs.open(ruta, 'r');
  try {
    const destino = Buffer.alloc(bytes);
    const { bytesRead } = await manejador.read(destino, 0, bytes, 0);
    return Buffer.from(destino.subarray(0, bytesRead));
  } finally {
    await manejador.close();
  }
}

function detectarTipo(cabecera) {
  for (const { tipo, extension, desplazamiento, firma } of FIRMAS) {
    if (cabecera.subarray(desplazamiento, desplazamiento + firma.length).equals(firma)) {
      return { tipo, extension };
    }
  }
  return null;
}

async function validarCartel(ruta, tiposAceptados = ['png', 'jpeg']) {
  const cabecera = await leerCabecera(ruta);
  const detectado = detectarTipo(cabecera);

  if (detectado === null || !tiposAceptados.includes(detectado.tipo)) {
    const error = new Error(`El cartel ${ruta} no es un fichero ${tiposAceptados.join(' ni ')}`);
    error.codigo = 'FORMATO_NO_ACEPTADO';
    error.cabecera = cabecera.subarray(0, 8).toString('hex');
    throw error;
  }
  return detectado;
}

module.exports = { leerCabecera, detectarTipo, validarCartel, FIRMAS, BYTES_CABECERA };

Cuatro decisiones que merecen comentario: Buffer.alloc y no allocUnsafe, porque si el fichero tiene menos de 16 bytes los sobrantes serían basura de memoria comparada contra firmas reales; Buffer.from(destino.subarray(...)), que devuelve una copia independiente en lugar de una vista con acceso a los bytes sobrantes; equals en lugar de toString, porque comparar bytes con bytes evita cualquier duda de codificación —0x89 no es texto válido en UTF-8—; y error.codigo siguiendo la convención de dominio del curso, con la cabecera en hex adjunta para diagnosticar qué subió realmente el organizador.

node -e "require('./src/utiles/tipo-fichero.js').validarCartel('datos/eventos.json').catch((e) => console.error(e.codigo, e.cabecera))"
# FORMATO_NO_ACEPTADO 7b0a20202265   <-- 0x7b es '{': un JSON disfrazado de cartel

La comprobación funciona. Ojo con el alcance de esta técnica: la firma demuestra el formato, no que el contenido sea inofensivo. En el Módulo 4, al recibir subidas de verdad, la combinaremos con un límite de tamaño y con la ruta blindada de la lección 03-03.

  1. Escena Viva: el QR de una entrada en base64url

Cada entrada de Escena Viva lleva un código EV-2026-000123 y un QR que el acomodador escanea en la puerta. El QR contiene una URL, y dentro de esa URL viaja una carga útil: los datos de la entrada más una firma que impide fabricarlas. Ese contenido es binario y tiene que caber en una URL sin escapes: el caso exacto de base64url.

// src/utiles/qr-entrada.js
// Carga util del codigo QR de una entrada: cuerpo en base64url y firma HMAC.
// El formato es <cuerpo>.<firma>, el mismo esquema que veremos con JWT.
const crypto = require('node:crypto');

const SECRETO = process.env.ESCENA_VIVA_SECRETO ?? 'secreto-de-desarrollo';
const firmar = (cuerpo) => crypto.createHmac('sha256', SECRETO).update(cuerpo).digest('base64url');

function fallar(codigo, mensaje) {
  const error = new Error(mensaje);
  error.codigo = codigo;
  throw error;
}

function codificarQr({ codigoEntrada, sesionId, emitidaEn }) {
  const carga = JSON.stringify({ codigoEntrada, sesionId, emitidaEn });
  const cuerpo = Buffer.from(carga, 'utf8').toString('base64url');
  return `${cuerpo}.${firmar(cuerpo)}`;
}

function decodificarQr(texto) {
  const [cuerpo, firma] = String(texto).split('.');
  if (cuerpo === undefined || firma === undefined) fallar('QR_MALFORMADO', 'El QR no tiene el formato <cuerpo>.<firma>');
  if (!sonIguales(firma, firmar(cuerpo))) fallar('QR_FIRMA_INVALIDA', 'La firma del codigo QR no es valida');

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

module.exports = { codificarQr, decodificarQr };
const qr = codificarQr({ codigoEntrada: 'EV-2026-000123', sesionId: 'ses-001-1', emitidaEn: '2026-08-14T19:30:00' });

console.log(qr);   // eyJjb2RpZ29FbnRyYWRhIjoiRVYtMjAyNi0wMDAxMjMiLCJzZXNpb25JZCI6...Yk9u
console.log(decodificarQr(qr).sesionId);   // ses-001-1
decodificarQr(qr.replace(/.$/, 'X'));      // lanza QR_FIRMA_INVALIDA

sonIguales es la función de comparación segura del apartado siguiente, que vive en este mismo módulo. El cuerpo no está cifrado —cualquiera puede decodificarlo con Buffer.from(cuerpo, 'base64url').toString()—, y eso es aceptable: en el QR de una entrada no hay nada secreto. Lo que protege la firma es la integridad: sin conocer el secreto no se puede fabricar una entrada válida ni cambiarle la sesión. La comparación de esa firma, sin embargo, no puede hacerse con ===, y ese es el último apartado técnico de la lección.

  1. Comparar secretos en tiempo constante

a === b sobre cadenas compara carácter a carácter y se detiene en el primero que difiere. Esa optimización, inofensiva en cualquier otro contexto, filtra información cuando lo comparado es un secreto: un atacante que mide el tiempo de respuesta puede deducir cuántos caracteres iniciales ha acertado y reconstruir la firma byte a byte.

crypto.timingSafeEqual compara siempre todos los bytes, tarde lo que tarde en encontrar la primera diferencia. Tiene una exigencia: los dos buffers deben medir exactamente lo mismo, o lanza ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH, y la longitud del dato ajeno no la controlas. La solución estándar es normalizar la longitud con un hash antes de comparar:

// Anadir a src/utiles/qr-entrada.js
function sonIguales(a, b) {
  // El hash iguala las longitudes (32 bytes siempre) sin filtrar informacion.
  const resumenA = crypto.createHash('sha256').update(String(a)).digest();
  const resumenB = crypto.createHash('sha256').update(String(b)).digest();
  return crypto.timingSafeEqual(resumenA, resumenB);
}

digest() sin argumento devuelve un Buffer —con 'hex' devolvería una cadena—, y dos SHA-256 miden 32 bytes cada uno pase lo que pase. Retomaremos esta función tal cual en el Módulo 8, donde el mismo razonamiento se aplica a claves de API y a testigos de sesión.

  1. Buffer, TypedArray y ArrayBuffer

Las tres piezas encajan así: un ArrayBuffer es un bloque de memoria en bruto que no se puede leer ni escribir directamente; un TypedArray (Uint8Array, Int16Array, Float64Array…) es una vista que interpreta ese bloque como números de cierto tipo, y varias vistas pueden mirar el mismo bloque; un Buffer es una Uint8Array con métodos añadidos por Node.

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

console.log(buf.byteOffset, buf.byteLength);      // p. ej. 88 6  <-- ojo con el offset
console.log(new Uint8Array(buf.buffer).length);   // 8192  <-- NO son 6

Ahí está la trampa más sutil del módulo. Node reserva los buffers pequeños (menos de 4 KB) dentro de un pool compartido de 8 KB, así que buf.buffer no es la memoria de tu buffer: es la del pool entero, y buf.byteOffset indica dónde empieza tu porción. Pasar buf.buffer a una API que espera los datos exactos entrega 8 KB de memoria ajena. La forma correcta de convertir:

const vista = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);   // comparte memoria
const otro = Buffer.from(vista.buffer, vista.byteOffset, vista.byteLength); // y la vuelta

En la práctica esto aparece al interoperar con APIs de estándar web dentro de Node —fetch, crypto.subtle, WebSocket, los worker threads del Módulo 10—, que hablan Uint8Array y ArrayBuffer, no Buffer.

Errores Comunes y Consejos

  • Ver <Buffer 7b 0a ...> donde esperabas texto. Falta la codificación: readFile(ruta, 'utf8') o .toString('utf8'). Nunca uses String(buf) con la esperanza de que haga lo correcto con datos binarios.
  • Usar allocUnsafe por costumbre. Si no llenas el buffer entero, publicas memoria antigua del proceso. alloc por defecto, siempre.
  • Creer que slice copia. Comparte memoria, al revés que en los arrays. Copia explícita con Buffer.from(vista) si el original va a cambiar o a reutilizarse.
  • Reservar con texto.length en lugar de Buffer.byteLength(texto). Con una sola tilde ya te falta un byte y el texto sale truncado.
  • Concatenar trozos como cadenas. trozo.toString() por cada trozo de un stream parte los caracteres multibyte: acumula Buffer y decodifica al final, o usa StringDecoder.
  • Comparar firmas con ===. Filtra información por tiempo. timingSafeEqual sobre buffers de igual longitud, normalizada con un hash.
  • Consejo: trabaja con Buffer hasta el último momento posible y convierte a texto una sola vez, en la frontera de tu sistema. Cada conversión de ida y vuelta cuesta CPU y es una oportunidad de corromper datos.

Ejercicios

Ejercicio 1: inventario de carteles

Escribe src/laboratorio/inventario-carteles.js que recorra un directorio (por defecto datos/carteles/) y, por cada fichero, imprima en una tabla su nombre, su extensión declarada, el tipo real detectado con detectarTipo y si coinciden. Debe marcar claramente los ficheros cuya extensión miente. Usa readdir con withFileTypes de la lección 03-02 y las rutas de src/config/rutas.js.

Ejercicio 2: troceador seguro de texto

Escribe una función trocearTexto(texto, bytesMaximos) que devuelva un array de Buffer de como mucho bytesMaximos bytes cada uno sin partir ningún carácter. Verifica con 'Noche de Monólogos 🎭 en la Sala Bóveda' y bytesMaximos = 10 que al concatenar los trozos y decodificar recuperas el texto original y que ningún trozo produce � por separado.

Ejercicio 3: cabecera binaria del informe

Diseña un formato binario mínimo para el informe diario de ocupación: 4 bytes de firma 'EVIV', 1 byte de versión, 2 bytes big endian con el número de sesiones, 4 bytes big endian con las entradas vendidas y el resto en JSON UTF-8. Escribe escribirInformeBinario(ruta, informe) y leerInformeBinario(ruta), y comprueba que el ciclo completo devuelve los datos originales y que un fichero con firma incorrecta lanza un error con codigo: 'FORMATO_NO_ACEPTADO'.

Soluciones

Solución 1. La comparación entre extensión declarada y tipo real es el núcleo:

const { readdir } = require('node:fs/promises');
const path = require('node:path');
const { leerCabecera, detectarTipo } = require('../utiles/tipo-fichero.js');

async function inventariar(directorio) {
  const filas = [];
  for (const entrada of await readdir(directorio, { withFileTypes: true })) {
    if (!entrada.isFile()) continue;
    const detectado = detectarTipo(await leerCabecera(path.join(directorio, entrada.name)));
    const declarada = path.extname(entrada.name).toLowerCase();
    const normalizada = declarada === '.jpeg' ? '.jpg' : declarada;   // .jpeg y .jpg son legitimas
    filas.push({ fichero: entrada.name, declarada, real: detectado?.tipo ?? 'desconocido', coincide: detectado?.extension === normalizada });
  }

  console.table(filas);
  return filas;
}

El caso .jpeg/.jpg recuerda que un formato puede tener varias extensiones legítimas: la comprobación no debe generar falsas alarmas. Un desconocido no siempre es un ataque —puede ser un formato que no está en FIRMAS—, pero sí es motivo para no aceptarlo.

Solución 2. La clave es no cortar a ciegas, sino retroceder hasta el principio de un carácter. En UTF-8, los bytes de continuación tienen la forma 10xxxxxx, es decir, (byte & 0xc0) === 0x80:

function trocearTexto(texto, bytesMaximos) {
  const completo = Buffer.from(texto, 'utf8');
  const trozos = [];
  for (let inicio = 0; inicio < completo.length; ) {
    let fin = Math.min(inicio + bytesMaximos, completo.length);
    // Retrocedemos mientras 'fin' caiga sobre un byte de continuacion.
    while (fin > inicio + 1 && (completo[fin] & 0xc0) === 0x80) fin -= 1;
    trozos.push(Buffer.from(completo.subarray(inicio, fin)));
    inicio = fin;
  }
  return trozos;
}

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

El Buffer.from(...) alrededor del subarray es imprescindible: sin él, los trozos serían vistas del mismo buffer y cualquier modificación posterior los afectaría a todos. Una alternativa más corta si solo quieres texto: recorrer [...texto] acumulando por Buffer.byteLength de cada carácter.

Solución 3. El formato se escribe y se lee en el mismo orden en que está definido:

const FIRMA = Buffer.from('EVIV', 'latin1');
const VERSION = 1;

function serializar(informe) {
  const cabecera = Buffer.alloc(11);
  FIRMA.copy(cabecera, 0);
  cabecera.writeUInt8(VERSION, 4);
  cabecera.writeUInt16BE(informe.sesiones.length, 5);
  cabecera.writeUInt32BE(informe.entradasVendidas, 7);
  return Buffer.concat([cabecera, Buffer.from(JSON.stringify(informe), 'utf8')]);
}

function deserializar(binario) {
  if (!binario.subarray(0, 4).equals(FIRMA)) throw Object.assign(new Error('No es un informe'), { codigo: 'FORMATO_NO_ACEPTADO' });
  return {
    version: binario.readUInt8(4),
    sesiones: binario.readUInt16BE(5),
    entradasVendidas: binario.readUInt32BE(7),
    detalle: JSON.parse(binario.subarray(11).toString('utf8'))
  };
}

Con los datos de la semilla, readUInt32BE(7) devuelve 1811 y readUInt16BE(5) devuelve 7. Fíjate en el byte de versión: es lo que permitirá leer ficheros antiguos cuando el formato cambie, y omitirlo es el error más frecuente al diseñar un formato binario propio. writeUInt16BE con más de 65.535 sesiones lanzaría ERR_OUT_OF_RANGE, un recordatorio de que cada campo binario tiene un techo que hay que elegir a conciencia.

Conclusión

Ya sabes qué hay dentro de la caja. Un Buffer es una Uint8Array sobre memoria ajena al montículo de V8, de longitud fija, que existe porque JavaScript no tenía tipo binario cuando Node lo necesitó. Lo creas con Buffer.from —copiando desde texto, array u otro buffer, compartiendo desde un ArrayBuffer— o con Buffer.alloc, nunca con allocUnsafe salvo que vayas a sobrescribirlo entero, porque su contenido inicial es memoria usada por el propio proceso.

Conoces las codificaciones y su reparto de papeles: utf8 para texto, latin1 para tratar bytes como caracteres uno a uno, hex para depurar y hashes, base64 para meter binario en JSON y base64url para meterlo en una URL. Sabes leer y escribir enteros con readUInt32BE y compañía, y que el orden de bytes lo fija el formato, no tu máquina. Dominas las operaciones y sus trampas: subarray comparte memoria —el error clásico—, concat copia, equals compara sin ambigüedad de codificación. Y tienes claro por qué String.length, Buffer.byteLength y [...texto].length dan tres números distintos, por qué cortar un buffer por una posición arbitraria produce un � irrecuperable y cómo lo evita el StringDecoder guardándose los bytes incompletos entre trozo y trozo.

Escena Viva se lleva dos módulos nuevos: src/utiles/tipo-fichero.js, que valida el cartel de un evento por su número mágico leyendo solo dieciséis bytes con FileHandle, y src/utiles/qr-entrada.js, que codifica la carga útil del QR en base64url con una firma HMAC comparada en tiempo constante con timingSafeEqual sobre resúmenes de igual longitud. Y sabes que buf.buffer no es tu buffer, sino el pool de 8 KB donde vive: byteOffset y byteLength no son opcionales. Con esto cierras el Módulo 3. Escena Viva ha pasado de tener los datos incrustados en el código a leer su catálogo del disco de forma asíncrona, organizar informes por mes, resolver rutas que funcionan desde cualquier directorio y en cualquier sistema, procesar un histórico de ventas con memoria constante mediante tuberías con pipeline, y entender los bytes que circulan por todo lo anterior. El proyecto sabe leer y escribir; lo que no sabe todavía es hablar con nadie. Eso empieza en el Módulo 4: HTTP, y verás enseguida que no es un territorio nuevo: un servidor HTTP de Node es un EventEmitter que emite request; la petición que recibes es un stream de lectura y la respuesta que devuelves es un stream de escritura; el cuerpo JSON de un POST llega en trozos de Buffer que hay que acumular con cuidado; y servir un fichero estático es exactamente createReadStream más la ruta blindada de la lección 03-03. Todo lo de este módulo vuelve, esta vez conectado a la red.

Curso de Node.js: De Principiante a Avanzado

Módulo 1: Introducción a Node.js

Módulo 2: Conceptos Básicos

Módulo 3: Sistema de Archivos y E/S

Módulo 4: HTTP y Servidores Web

Módulo 5: NPM y Gestión de Paquetes

Módulo 6: Framework Express.js

Módulo 7: Bases de Datos y ORMs

Módulo 8: Autenticación y Autorización

Módulo 9: Pruebas y Depuración

Módulo 10: Temas Avanzados

Módulo 11: Despliegue y DevOps

Módulo 12: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados