wandres.dev
SOURCES IV · Source maps y overrides

Anatomía de un source map: VLQ, mappings y sourcesContent

El formato por dentro, cómo se codifican los mappings en base64 VLQ con deltas, y un decodificador que funciona pegado en la consola.

⏱ 19 min

Un source map es un fichero JSON con una cadena aparentemente ininteligible dentro. Esa cadena es el noventa y cinco por ciento del fichero y contiene, comprimida, la correspondencia entre cada posición del código que se ejecuta y cada posición del código que escribiste. Entender cómo está codificada no es un ejercicio académico: es lo que permite diagnosticar por qué a veces la correspondencia está desplazada, por qué otras veces el nombre de una variable no aparece, y por qué un mapa de tres megas puede estar completo y uno de trescientos kilobytes estar roto.

🎯 Al terminar esta lección sabrás
  • Enumerar los campos de un source map y explicar la función de cada uno.
  • Decodificar manualmente un segmento VLQ en base64.
  • Explicar por qué los valores son deltas y cuáles se reinician en cada línea.
  • Escribir un decodificador de mappings y usarlo para verificar un mapa.

Los campos del JSON

Un source map es un objeto con esta forma.

{
  "version": 3,
  "file": "app.min.js",
  "sourceRoot": "",
  "sources": ["src/main.js", "src/utils.js"],
  "sourcesContent": ["export function main() {\n  ...", "export const suma = ..."],
  "names": ["main", "suma", "total"],
  "mappings": "AAAA,SAASA,IAAT;AACA,SAAS,GAAT",
  "ignoreList": [1]
}

version es siempre 3. Las versiones anteriores son historia.

file es el nombre del fichero generado al que este mapa corresponde. Es informativo y las herramientas no dependen de él.

sources es el array de rutas de los ficheros originales. Las rutas se resuelven relativas a sourceRoot y a la ubicación del propio mapa. Los empaquetadores suelen usar esquemas propios como webpack:// para expresar rutas que no existen en el servidor.

sourcesContent es un array paralelo con el contenido completo de cada fichero original. Es opcional y es lo que decide si el depurador puede enseñarte tu código o no: si está, lo lee de ahí; si no está, intenta descargar el fichero de la ruta de sources, y si esa descarga falla, ves un editor vacío.

names es el array de identificadores originales. Sirve para que el depurador pueda mostrar el nombre real de una variable minificada.

mappings es la cadena con toda la correspondencia. Es el corazón del formato.

ignoreList es el array de índices de sources que corresponden a código de terceros, visto en el nivel anterior.

ℹ️
Nota

El navegador encuentra el mapa por un comentario al final del fichero generado con la forma //# sourceMappingURL=app.min.js.map, o por una cabecera HTTP SourceMap en la respuesta. La cabecera tiene la ventaja de que permite servir los mapas solo a quien los pida con las credenciales adecuadas, sin publicarlos abiertamente.

La estructura de mappings

La cadena tiene tres niveles de separadores.

El punto y coma separa líneas del fichero generado. Dos puntos y comas seguidos significan una línea sin ninguna correspondencia.

La coma separa segmentos dentro de una línea. Cada segmento describe una posición.

Cada segmento es una secuencia de valores VLQ en base64, sin separador entre ellos: el propio formato de cada número dice dónde acaba.

Un segmento tiene uno, cuatro o cinco campos.

Campos Significado
1 Columna del fichero generado. Sin correspondencia con ningún original
4 Columna generada, índice en sources, línea original, columna original
5 Lo anterior más el índice en names

Y aquí está la parte que hace el formato compacto: todos los valores son deltas respecto al valor anterior del mismo campo, no absolutos. Con una excepción crítica: la columna generada se reinicia a cero en cada línea nueva, mientras que el índice de fuente, la línea original, la columna original y el índice de nombre son acumulativos a lo largo de todo el fichero.

Esa asimetría es la fuente de la mitad de los errores en implementaciones caseras de generadores de mapas.

Cómo se codifica un número

El esquema VLQ en base64 codifica un entero con signo en uno o más caracteres del alfabeto base64.

El alfabeto es ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/, donde cada carácter representa seis bits.

De esos seis bits, el más significativo es el bit de continuación: si vale uno, hay otro carácter después que aporta más bits del mismo número. Los cinco restantes son datos.

En el primer carácter de un número, el bit menos significativo de los datos es el signo: uno significa negativo.

El resultado es que un número pequeño ocupa un carácter y uno grande ocupa varios, con los grupos de cinco bits ordenados de menos a más significativo.

Ejemplos que conviene comprobar a mano.

A es el índice 0. Sin continuación, datos cero, signo cero. Vale 0.

C es el índice 2, binario 000010. Sin continuación. Datos 00010, es decir 2. El bit de signo es cero, y el valor es 2 desplazado un bit a la derecha. Vale 1.

D es el índice 3, binario 000011. Datos 3, signo uno. Vale menos 1.

E es el índice 4. Datos 4, signo cero, valor 2. Vale 2.

Con eso, el segmento AAAA significa: columna generada más cero, fuente más cero, línea original más cero, columna original más cero. Es decir, el principio del primer fichero. Es el segmento con el que empieza prácticamente cualquier mapa.

Un decodificador que funciona

Pegado tal cual en la consola de cualquier página, esto decodifica una cadena de mappings completa.

const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';

function decodificarVLQ(cadena) {
  const salida = [];
  let valor = 0, desplazamiento = 0;
  for (const ch of cadena) {
    const digito = B64.indexOf(ch);
    if (digito < 0) throw new Error('caracter no valido: ' + ch);
    valor += (digito & 31) << desplazamiento;
    if (digito & 32) {
      desplazamiento += 5;
    } else {
      const negativo = valor & 1;
      valor >>= 1;
      salida.push(negativo ? -valor : valor);
      valor = 0;
      desplazamiento = 0;
    }
  }
  return salida;
}

function decodificarMappings(mappings) {
  const segmentos = [];
  let fuente = 0, lineaOrig = 0, columnaOrig = 0, nombre = 0;
  mappings.split(';').forEach((linea, lineaGen) => {
    let columnaGen = 0;
    if (!linea) return;
    for (const trozo of linea.split(',')) {
      const campos = decodificarVLQ(trozo);
      columnaGen += campos[0];
      const s = { lineaGen, columnaGen };
      if (campos.length >= 4) {
        fuente += campos[1];
        lineaOrig += campos[2];
        columnaOrig += campos[3];
        Object.assign(s, { fuente, lineaOrig, columnaOrig });
        if (campos.length === 5) { nombre += campos[4]; s.nombre = nombre; }
      }
      segmentos.push(s);
    }
  });
  return segmentos;
}

// Prueba
console.table(decodificarMappings('AAAA,SAASA,IAAT;AACA').slice(0, 10));

Y la herramienta de verificación completa, que descarga un mapa real y lo audita.

async function auditarMapa(url) {
  const mapa = await (await fetch(url)).json();
  const segmentos = decodificarMappings(mapa.mappings);
  const conNombre = segmentos.filter(s => s.nombre !== undefined).length;
  console.table([{
    version: mapa.version,
    fuentes: mapa.sources.length,
    tieneContenido: Array.isArray(mapa.sourcesContent) && mapa.sourcesContent.every(Boolean),
    nombres: (mapa.names || []).length,
    segmentos: segmentos.length,
    segmentosConNombre: conNombre,
    ignorados: (mapa.ignoreList || []).length,
    lineasGeneradas: mapa.mappings.split(';').length
  }]);
  const sinContenido = (mapa.sources || []).filter((_, i) => !mapa.sourcesContent?.[i]);
  if (sinContenido.length) console.warn('fuentes sin sourcesContent:', sinContenido);
  return { mapa, segmentos };
}

Ejecutar eso sobre el mapa de tu bundle responde en un segundo a las preguntas que de otro modo requieren adivinar: si el contenido está incrustado, cuántas posiciones hay realmente mapeadas, y si los nombres se conservaron.

La densidad de mappings es lo que decide si la depuración va a ser cómoda, y casi nadie la mira

Hay un número en la salida de esa auditoría que merece más atención de la que recibe: la proporción entre segmentos y líneas generadas. Un mapa puede ser perfectamente válido, pasar cualquier validación, y ser malísimo para depurar, y la causa es la granularidad. Los generadores de mapas ofrecen varios niveles de detalle, y la diferencia entre ellos es de un orden de magnitud en tamaño y de otro tanto en utilidad. En el extremo barato, un mapa de granularidad de línea registra solo el principio de cada línea original: los breakpoints funcionan a nivel de línea, el paso a paso avanza línea a línea, y los nombres de variables no se recuperan. En el extremo caro, un mapa con granularidad de expresión registra cada token, permite poner breakpoints dentro de una cadena de llamadas, muestra los nombres originales al pasar el cursor sobre una variable minificada, y hace que el panel de scope enseñe identificadores legibles en vez de letras. La diferencia práctica es enorme y se decide en una línea de configuración del empaquetador que casi nadie revisa, normalmente eligiendo la opción rápida porque acorta el tiempo de compilación. El criterio correcto es distinto según el entorno y merece pensarse: en desarrollo, prioriza la velocidad de recompilación, porque recompilas cien veces al día y depuras con el código fuente disponible de todas formas; en producción, prioriza la calidad del mapa, porque solo se genera una vez por despliegue y es lo único que tendrás cuando llegue un informe de error de un usuario a las tres de la mañana. Y hay un tercer factor que decide más que ninguno: si sourcesContent está presente. Un mapa sin él depende de que el depurador pueda descargar los ficheros originales, cosa que en producción casi nunca ocurre, y el resultado es un panel de Sources que muestra los nombres de tus ficheros y el contenido vacío. Incrustar el contenido multiplica el tamaño del mapa por tres o por cuatro, y como el mapa solo se descarga cuando alguien abre las DevTools, ese coste no lo paga ningún usuario.