Optimización de Imágenes con Astro y Squidex

4 de junio, 2024

En el desarrollo de aplicaciones web modernas, la priorización del rendimiento y la flexibilidad del contenido se ha convertido en algo indispensable. Es por esto que usar un Sistema de Gestión de Contenidos headless "ya no es una opción, es una necesidad" .

Aunque la integración de Astro con la mayoría de los CMS es un proceso trivial, surgen "desafíos" como consecuencia de usar un servidor independiente para almacenar el contenido de nuestra web:

  • Carga lenta de imágenes
  • Solicitudes a diferentes dominios
  • Riesgo de sobrecargar al CMS

Propuesta de solución

Una posible solución es usar un endpoint de Astro que actúe como intermediario entre el navegador web y el CMS. Con esto, tendremos la oportunidad de realizar modificaciones y transformaciones que necesitemos a las URL de entrada y a las imágenes de salida del endpoint.

Nuestro endpoint recibe las solicitudes de imágenes, con los parámetros como id, ancho, alto y calidad. Pasamos de usar directamente el dominio del CMS a usar el mismo dominio de nuestra página web:

Si teníamos:

<Image src="https://cms.servidor.com/api/assets/imageId" />

Ahora tendríamos:

<Image src="https://nuestrapagina.com/api/image/imageId" />

Adicionalmente, se le abre la posibilidad de agregar parámetros para un ajuste perfecto de las dimensiones de la imagen y, si aplica, podemos bajar la calidad de la imagen para reducir el peso:

// "w" para el ancho
// "h" para el alto
// "q" para la calidad (en porcentaje)

<Image
	src="https://nuestrapagina.com/api/image/imageId?w=200&h=200&q=80"
	width="200"
	height="200"
/>

Ahora miremos el codigo

💡 Nota : En este artículo, utilizaremos el CMS Squidex como ejemplo de implementación. Sin embargo, se puede adaptar a cualquier CMS compatible con Astro .

Lo primero que debemos hacer es agregar el archivo [id].ts del endpoint dentro de nuestra carpeta pages. En mi caso, estoy usando TypeScript y voy a crear una carpeta api para organizar mejor mis endpoints:

src/pages/
    └── api/
        └── image/
            └── [id].ts // id será el paramentro identificador de la imagen

Astro nos permite definir verbos HTTP en nuestro endpoint. En este caso, vamos a usar el GET para recibir las peticiones:

import type { APIRoute } from 'astro';

export const GET: APIRoute = async ({ params, url }) => {
    const { id } = params;
    const width = url.searchParams.get("w");
    const heigth = url.searchParams.get("h");
    const quality = url.searchParams.get("q");

    const cmsImage = await getFromCms(id, width, heigth, quality);
    if (!cmsImage) {
        return new Response('Image not found', { status: 404 });
    }

    const imageBuffer = cmsImage.buffer;
    const contentType = cmsImage.type;
    const origin = cmsImage.origin ?? "server";
		
    // En mi caso, ajusto el cache a un año
    const cacheHeader = 'max-age=31536000, immutable';

    return new Response(buffer, {
        headers: {
            'Content-Type': contentType,
            'Cache-Control': cacheHeader, 
            'Astro-Origin': origin, // Este header es opcional, solo es informativo
        },
    });
};

Ahora definimos la función que obtendrá la imagen del CMS. En este caso, se realizó una implementación sencilla de almacenaje en caché del tipo clave/valor, usando el objeto Map. En caso de no existir la imagen en el caché, se arma la URL del CMS, se descarga la imagen y se guarda en el caché para evitar descargar la misma imagen en una siguiente oportunidad:

const rootCmsUrl = "https://cms.servidor.com";
const imageCache = new Map();

export type CmsImage = {
    id: string;
    buffer: ArrayBuffer;
    type: string;
    origin?: string;
}

async function getFromCms(id: string, width?: string, heigth?: string, quality?: string): Promise<CmsImage> | undefined {
    const type = "WEBP";
    const imageQuality = quality ?? "100";

    // Intentamos traer la imagen del cache
    const cacheKey = `${id}/w${width ?? ""}/h${heigth ?? ""}/q${quality ?? ""}`;
    const cachedData = imageCache.get(cacheKey);
    if (cachedData) {
        return {
            id: id,
            buffer: cachedData.buffer,
            type: cachedData.contentType,
            origin: "cache-cms",
        };
    }

    let imageUrl = `${rootCmsUrl}/api/assets/devcloud-landing/${id}?quality=${imageQuality}&format=${type}`;
    if (width && heigth) {
        imageUrl += `&width=${width}&height=${heigth}&mode=Max`;
    }
		
    const response = await fetch(imageUrl);
    if (!response.ok) {
        return null;
    }

    const contentType = response.headers.get('Content-Type') || 'image/jpeg';
    const imageBuffer = await response.arrayBuffer();

    // Se guarda la imagen en el cache
    imageCache.set(cacheKey, {
        buffer: imageBuffer,
        contentType: contentType
    });

    return {
        id: id,
        buffer: imageBuffer,
        type: contentType,
        origin: "server-cms",
    };
}

Esta implementación sencilla no solo resuelve los problemas identificados, sino que también nos permite crear ayudantes en el proyecto. Estos se encargarán de convertir automáticamente las URLs del CMS a URLs del endpoint, facilitando su uso en todas las secciones de nuestra página donde se muestren imágenes.

Prueba de concepto

Un ejemplo práctico de la implementación, es esta imagen almacenada en el CMS de esta página web:

Banner de Astro (directo del CMS)
Banner de Astro (directo del CMS)

Y ahora la misma imagen, pero usando el endpoint, ajustada a 768x402 y con una calidad del 90%, ya que para el ojo humano esa diferencia es casi imperceptible, pero el peso de la imagen sí se ve disminuido:

Banner de Astro (usando endpoint)
Banner de Astro (usando endpoint)

En este ejemplo, pasamos de un peso de 54kB en la imagen del CMS a tan solo 23kB en la imagen procesada a través del endpoint.
¿Puedes notar la diferencia entre ambas imágenes?

Conclusiones y Pasos a Seguir

La implementación de un endpoint de Astro como intermediario para la gestión de imágenes ofrece importantes mejoras en rendimiento y flexibilidad, como se evidencia en la reducción significativa del tamaño de las imágenes sin pérdida perceptible de calidad.

Para potenciar aún más esta solución, se recomiendan los siguientes pasos:

  • Implementar un sistema de caché más robusto, por ejemplo, utilizando Cloudflare Workers KV para un almacenamiento en caché distribuido y de alto rendimiento.
  • Expandir el soporte a múltiples fuentes de imágenes más allá del CMS, como permitir la optimización de imágenes almacenadas en buckets de Amazon S3 o Google Cloud Storage.
  • Integrar técnicas de optimización para formatos modernos como AVIF, que puede reducir aún más el tamaño de las imágenes manteniendo una alta calidad visual.