Movimos nuestras imágenes a un CDN y el servidor de origen las siguió sirviendo
Nuestra documentación está cargada de capturas de pantalla. Cada función del portal, capturada en claro y en oscuro, en dos anchos, en diez idiomas, lo que suma bastante más de cien megabytes de imágenes. Todo eso estaba en el propio bundle estático de la aplicación, servido desde el origen, que es el lugar equivocado para cien megabytes de imágenes que nunca cambian. Así que las movimos a Cloudflare R2 y pusimos un CDN delante, uno por entorno: cdn.authagonal.io para producción y un host CDN equivalente para nuestro sitio de dev.
La aplicación elige el host CDN correcto en tiempo de ejecución mirando el nombre de host desde el que se sirve. Si la página está en authagonal.io, las imágenes vienen de cdn.authagonal.io. Sencillo, sin configuración en tiempo de build, una sola referencia de imagen que se resuelve correctamente en cualquiera de los dos entornos. Lo desplegamos, vimos al navegador tirar cada imagen limpiamente desde el CDN, y luego miramos el tráfico del origen. El origen seguía sirviendo las imágenes. Todas y cada una, en la primera carga, exactamente como antes.
La página que ve un rastreador no es la página que construye un navegador
Para explicar por qué, tengo que explicar qué son en realidad nuestras páginas de marketing. Son una aplicación de una sola página, pero una aplicación de una sola página no es más que un cascarón vacío hasta que se ejecuta el JavaScript, y un cascarón vacío es malo para los buscadores y malo para el tiempo que tarda en aparecer lo primero con sentido. Así que, en tiempo de build, prerrenderizamos: arrancamos el sitio, abrimos cada página en un navegador headless, dejamos que se renderice por completo y guardamos el HTML resultante. Ese HTML prerrenderizado es lo que servimos primero. Ya lleva el contenido dentro, el rastreador ve una página real, el usuario ve texto e imágenes de inmediato, y entonces el JavaScript toma el control.
Aquí está el problema escondido en esa descripción. El prerrenderizado ejecuta el sitio en 127.0.0.1, una dirección de loopback en la máquina de build. Así que cuando la aplicación pregunta «¿en qué nombre de host estoy, para poder elegir un CDN?», la respuesta durante el prerrenderizado es «una dirección IP local», que no coincide ni con authagonal.io ni con el host de dev. La deducción del host en tiempo de ejecución, la parte astuta, cae en silencio a su única opción restante: la ruta relativa al origen. Y esa ruta de origen es la que queda congelada en el HTML prerrenderizado.
Así que cada visitante recibía HTML con las URL de imagen del origen incrustadas, y el navegador hizo lo evidente: las pidió, al origen, en el primer pintado. Un instante después el JavaScript volvía a renderizar la página, volvía a deducir el nombre de host, esta vez obteniendo el real, y cambiaba las imágenes al CDN. Dos peticiones por cada imagen, la primera golpeando exactamente el servidor que intentábamos aliviar, y completamente invisible a menos que estuvieras mirando los logs del origen, porque la página se veía y funcionaba a la perfección.
El arreglo que empeoró las cosas durante once minutos
El primer arreglo era el tentador. Si el problema es que una URL equivocada queda congelada en el HTML prerrenderizado, entonces no emitas ninguna URL durante el prerrenderizado. Deja la fuente de la imagen en blanco en la instantánea, y deja que el JavaScript rellene la URL de CDN correcta una vez que se ejecute y conozca el host real. Ninguna URL equivocada, ninguna petición al origen.
Se desplegó, y estaba mal, y lo reemplazamos unos once minutos después. Dejar en blanco la fuente de la imagen en el HTML prerrenderizado echa por tierra toda la razón por la que prerrenderizamos. El rastreador que lee la página estática ahora no ve ninguna imagen, y el navegador no tiene nada que mostrar hasta que el JavaScript se haya ejecutado, que es justamente la vía lenta que construimos el HTML prerrenderizado para evitar. Habíamos eliminado la doble petición eliminando la imagen de la única versión de la página que existe antes del JavaScript. Eso cambia un problema de tráfico por un problema de búsqueda y de primer pintado, que es un peor cambio.
La lección de esos once minutos: el HTML prerrenderizado tiene que llevar una referencia de imagen real y funcional. El bug nunca fue que hubiera una URL presente. Fue que estaba presente la URL equivocada, y dejarla en blanco solo cambia un defecto por otro. Lo que de verdad necesitábamos era que la URL correcta pudiera conocerse en tiempo de prerrenderizado, y no puede, porque en tiempo de prerrenderizado nadie sabe qué entorno servirá la página.
Decidir en el último momento posible
La URL correcta depende del host de la petición, y el host de la petición no se conoce hasta que hay una petición. El prerrenderizado ocurre una vez, en el build, y produce un archivo que se sirve en ambos entornos. Así que la parte de la URL específica del entorno no puede decidirse en el build. Tiene que decidirse por petición, y lo único en la pila que ve cada petición y su host es el servidor web en el edge.
Así que el prerrenderizado ya no escribe una URL ni un vacío. Escribe un marcador de posición, una cadena centinela literal, https://cdn.authagonal.io, en lugar del host del CDN. El HTML prerrenderizado que sale del build dice https://cdn.authagonal.io/screenshots/whatever.webp, que no es ni una URL de origen ni una fuente vacía. Es una URL con un agujero dentro.
nginx rellena el agujero a la salida. Asigna el host de la petición a la base de CDN correcta, el host que no es de producción a su propio CDN de dev, authagonal.io a https://cdn.authagonal.io, cualquier otra cosa a vacío para que la URL vuelva a ser relativa al origen como un repliegue local seguro. Luego una sola directiva reescribe el centinela a ese valor mientras el HTML se transmite al cliente:
map $host $asset_cdn_base {
default "";
"~*example\.dev$" "https://cdn.example.dev";
"~*authagonal\.io$" "https://cdn.authagonal.io";
}
sub_filter 'https://cdn.authagonal.io' $asset_cdn_base;
El HTML estático ahora sale del origen llevando ya URL de CDN reales, correctas para el host que preguntó. El navegador va directo al CDN en el primer pintado. No hay ninguna primera petición equivocada que deshacer, porque no hay ninguna URL equivocada, y tampoco hubo nunca una vacía. El rastreador ve una página completa con enlaces de imagen reales. Un solo archivo sirve ambos entornos, y ninguno de los dos necesita un build aparte.
Hay una pequeña elegancia en qué partes de la respuesta se reescriben. La reescritura se limita solo al HTML, que es el comportamiento por defecto de nginx. Eso importa porque la misma cadena centinela aparece también en el bundle de JavaScript, como la rama de repliegue del código de deducción del host. En el cliente esa rama es código muerto, ya que el navegador tiene un nombre de host real y nunca emite el centinela, así que específicamente no queremos que nginx toque el bundle. Dejar la reescritura en su valor por defecto de solo HTML nos da eso gratis. Aprendimos, de paso, que escribir el valor por defecto de forma explícita es peor que dejarlo fuera, porque una declaración redundante hace que nginx avise de un duplicado.
Por qué no reescribir el archivo una sola vez al arrancar
La objeción evidente es que esto es mucho trabajo por petición para un valor que solo tiene dos posibilidades. ¿Por qué no reescribir el HTML una sola vez cuando arranca el contenedor, incrustar el host correcto y servir un archivo estático sin ningún filtro?
Porque el contenedor no puede escribir en sí mismo. El sitio estático corre en nginx como un usuario no root, con un sistema de archivos raíz de solo lectura y todas las capacidades de Linux retiradas, con un único y pequeño directorio de trabajo con permiso de escritura para los archivos temporales propios de nginx. Es una postura de endurecimiento deliberada: un servidor web que sirve tráfico no confiable no debería poder modificar los archivos que sirve, de modo que un atacante que encuentre un punto de apoyo no encuentre nada que reescribir. Una reescritura del HTML al arrancar necesitaría exactamente el acceso de escritura que quitamos a propósito. El sistema de archivos de solo lectura no es un obstáculo que sortear, es una propiedad que queremos, y descarta limpiamente la reescritura al arrancar. La reescritura por petición no toca ningún archivo en absoluto, y por eso es compatible con un servidor que no posee nada que pueda cambiar.
Tampoco quisimos incrustar el host del CDN en el build y producir dos bundles de imágenes distintos, uno por entorno, porque eso reintroduce el build específico del entorno del que acabábamos de deshacernos. El propósito de la deducción del host era un solo artefacto para ambos entornos. El centinela mantiene esa promesa: un build, un archivo, el entorno resuelto en el edge en el último momento posible.
El prerrenderizado congela los valores del entorno, así que resuélvelos en el edge
El prerrenderizado congela un instante. Todo lo que tu aplicación decide mirando su entorno queda decidido, en tiempo de prerrenderizado, frente al entorno del build, que es una dirección de loopback y el host real de nadie. Así que un valor que es correcto en tiempo de ejecución se vuelve incorrecto en el instante en que se toma la instantánea, y falla en silencio, porque la página prerrenderizada igual se renderiza, solo que apuntando al lugar equivocado.
El arreglo general es no resolver en absoluto los valores específicos del entorno durante el prerrenderizado. Emite un marcador de posición, llévalo intacto a través del artefacto estático, y resuélvelo en la capa que sí ve el entorno, que para un valor por petición es el edge. Es enlace tardío, aplicado a una cadena en un archivo HTML: deja el agujero abierto a través de cada etapa que no conoce la respuesta, y rellénalo en la única etapa que sí la conoce.
Si prefieres que la propia documentación y los recursos de tu proveedor de identidad ya se sirvan correctamente desde el edge, esa es una de las muchas pequeñas cosas que Authagonal ya ha resuelto para que puedas reservar el debate para tu propio producto.