Construir ~/tools/paper-revision fue la parte fácil. Lograr que se
comportara igual en producción que en localhost tomó mucho más tiempo, y la
mayoría de los bugs no estaban para nada en la parte de IA — estaban en la
plomería que la rodeaba. Esto es lo que realmente se rompió.
El 502 silencioso: las conexiones SSE necesitan un latido
Las reviews largas que usaban una key propia (BYOK) de Anthropic fallaban ocasionalmente después de ~100 segundos — sin mensaje de error, solo una conexión muerta. Los logs de Vercel mostraban un 502 limpio, sin nada que apuntara a la causa real.
El problema real: un modelo puede quedarse en silencio durante un rato en medio de la generación (un tramo largo de "pensamiento" interno antes del primer token visible), y si el stream de respuesta no envía bytes durante demasiado tiempo, algún intermediario entre el cliente y el modelo decide que la conexión está inactiva y la mata. La solución fue un latido — un carácter invisible escrito en el stream a intervalos fijos para que los bytes nunca dejen de fluir:
const HEARTBEAT_MS = 15_000
const HEARTBEAT_CHAR = '' // espacio de ancho cero, invisible en el markdown renderizado
const result = await Promise.race([pending, heartbeat])
if (result === 'heartbeat') {
controller.enqueue(encoder.encode(HEARTBEAT_CHAR))
continue
}Investigar esto también sacó a la luz un bug peor que se escondía detrás:
el manejador de abort estaba capturando cualquier excepción llamada
AbortError y tratándola como una cancelación deliberada del usuario —
incluidas las que venían de que la red simplemente muriera por su cuenta.
Los fallos reales se estaban tragando en silencio. Arreglar esto significó
rastrear si el usuario realmente había hecho clic en "abort" antes de
confiar en el nombre del error.
Trae tu propia key, sin dispararte en el pie
El plan gratuito corre sobre un modelo alojado con un límite de tasa; los usuarios de BYOK pagan con su propia key y se lo saltan por completo — salvo que la primera versión no se lo saltaba, y las solicitudes de BYOK también estaban consumiendo la cuota del plan gratuito. Un reporte de bug honesto ("pero usé mi propia key y aun así me aplicaron un límite de tasa") fue así como salió a la luz.
La otra lección de BYOK fue proactiva en lugar de reactiva: limitar la lista blanca de modelos a niveles de precio razonables para que una solicitud no pueda caer accidentalmente en el modelo más caro disponible, y limitar los tokens de salida sin importar el proveedor. Nada de esto evita que un usuario gaste mal su propio dinero, pero sí evita que nuestro código sea la razón por la que lo hace.
El favicon que Google Search se negaba a mostrar
El favicon del sitio se renderizaba perfectamente en cada pestaña del
navegador, pero nunca aparecía junto a la URL en los resultados de Google
Search. La causa era casi vergonzosamente indirecta: la convención
genérica de iconos de Next.js añade un hash de contenido a la URL del
archivo — /icon.png?d72260d... — y ese hash cambia cada vez que cambian
los bytes del archivo. Las propias directrices de Google son explícitas
sobre este modo de fallo exacto: "la URL del favicon debe ser estable: no
cambies la URL con frecuencia." Cada edición de ese icono estaba, sin
querer, reiniciando la confianza que Google había acumulado sobre la URL
antigua.
Next.js tiene un caso especial para exactamente un nombre de archivo —
favicon.ico se salta el hash de contenido por completo y obtiene una URL
permanentemente estable. Envolver la misma imagen en un contenedor ICO
mínimo y cambiar a ese nombre de archivo fue todo el arreglo. Valió la pena
revisar también la configuración de dominio en ese momento: Google trata
los hostnames distintos como "sitios" completamente separados para efectos
del favicon, así que tanto www. como el dominio por defecto
*.vercel.app recibieron redirecciones permanentes hacia el único hostname
canónico.
Cloudflare Turnstile solo se renderiza una vez
El modo de renderizado implícito de Turnstile escanea la página en busca
de elementos .cf-turnstile exactamente una vez, cuando su script se
carga por primera vez. Eso pasa desapercibido en un sitio tradicional de
múltiples páginas, donde cada navegación recarga el script desde cero —
pero esto es una SPA, y el estado del formulario de review sobrevive
deliberadamente a la navegación del lado del cliente para que una review
en curso no se abandone. Si navegas fuera y vuelves, el punto de montaje
del widget es un nodo del DOM completamente nuevo que el script, ya
cargado, nunca vuelve a escanear. El token se convierte silenciosamente en
undefined, lo que — después de agregar verificación del lado del
servidor al endpoint de compartir — significaba que cada intento de
compartir después de navegar fuera y volver fallaba sin ninguna
explicación visible.
La solución es la API de renderizado explícito de Turnstile: cargar el
script una sola vez, y luego llamar tú mismo a turnstile.render() en
cada montaje y a turnstile.remove() en cada desmontaje, en lugar de
depender de su escaneo automático de una sola vez.
Desarrollo en Windows, producción en Linux
Dos bugs distintos, la misma causa raíz: desarrollar en Windows y
desplegar en el runtime Linux de Vercel no es el mismo entorno, y las
librerías de PDF e imágenes son exactamente donde esa brecha se
manifiesta. pdf-parse fallaba con un error de DOMMatrix en producción
que nunca se reproducía en local — cambiar a una librería de PDF segura
para entornos serverless (unpdf) lo arregló de raíz. Por separado, la
generación de imágenes de next/og fallaba al cargar fuentes, y se
rastreó hasta una ruta de archivo de Windows que contenía espacios, algo
con lo que se atragantaba el parseo de URLs de la librería. Ninguno de los
dos bugs existía en lo que a npm run dev local respecta; ambos eran 100%
reproducibles en cada despliegue.
Lo que una revisión de seguridad realmente detecta
Una revisión completa de la base de código encontró un puñado de problemas reales, aunque discretos: un endpoint de compartir snapshots sin ninguna verificación de bots — técnicamente abierto para que cualquiera publicara texto arbitrario en una URL pública; un límite de tasa que se cobraba antes de comprobar si el archivo subido siquiera se podía parsear, así que una subida malformada consumía la cuota de un usuario para una review que nunca iba a suceder; un manejador de zip que descomprimía una entrada del archivo antes de comprobar su tamaño declarado, que es exactamente la forma de una vulnerabilidad de zip-bomb; y un botón de compartir que creaba en silencio un snapshot duplicado si navegabas fuera y volvías a hacer clic en él, porque el estado de "ya tenemos un link" vivía en un componente que acababa de ser destruido y recreado.
Ninguno de estos era exótico. Eran el tipo de bug que solo aparece cuando alguien realmente lee el código buscando qué podría salir mal, en lugar de limitarse a comprobar que el camino feliz funciona.
Reflexión final
Cada bug de esta lista se lanzó a producción, funcionó y parecía
terminado — hasta que una entrada específica, una navegación específica o
un entorno de hosting específico demostró lo contrario. La infraestructura
aburrida, revisada con cuidado, le gana a la infraestructura ingeniosa que
nunca se ha puesto a prueba contra producción. exit 0.