Saltar al contenido

Cabeceras, status, streaming y CORS

Dominar el objeto Response en sus tres palancas: el código de estado que declara el resultado, las cabeceras que describen y gobiernan la respuesta, y el cuerpo, que no tiene por qué ser una cadena entera en memoria. Cómo construir Headers, elegir el status correcto, emitir una respuesta en streaming con un ReadableStream para volcados grandes o eventos en vivo, y abrir un endpoint a otros orígenes con CORS, incluyendo la petición de preflight con OPTIONS.

Las tres palancas del Response

El segundo argumento del constructor Response es un objeto de inicialización con status, statusText y headers. Las cabeceras admiten un objeto plano, pero el tipo Headers de la plataforma es más honesto: normaliza los nombres, permite append para valores repetidos y se pasa tal cual a fetch. Un endpoint cuidadoso las construye de forma explícita.

TypeScript
import type { APIRoute } from 'astro';export const GET: APIRoute = () => {  const headers = new Headers();  headers.set('Content-Type', 'application/json; charset=utf-8');  headers.set('Cache-Control', 'public, max-age=3600, s-maxage=86400');  headers.set('X-Generado-Por', 'nvim-dios');  return new Response(JSON.stringify({ ok: true }), {    status: 200,    statusText: 'OK',    headers,  });};

El código de estado es vocabulario, no decoración. Un 2xx dice éxito, un 3xx redirección, un 4xx culpa del cliente —400 petición mal formada, 401 sin autenticar, 403 prohibido, 404 no existe, 429 demasiadas peticiones—, un 5xx culpa del servidor. Responder siempre 200 con el error escondido en el cuerpo es la forma más común de API maleducada: obliga a cada cliente a parsear y adivinar en lugar de mirar una cifra. La cabecera Cache-Control, por su parte, gobierna la caché del navegador y de los CDN intermedios; max-age es para el cliente y s-maxage para el borde, y elegirlas bien es lo que convierte un endpoint bajo demanda en uno barato.

Streaming: un cuerpo que no cabe entero

Hasta aquí el cuerpo era una cadena completa: la construyes en memoria y la devuelves. Pero el cuerpo de un Response acepta también un ReadableStream, y eso cambia el modelo: en vez de tener toda la respuesta lista antes de enviar el primer byte, la emites por trozos a medida que están. Sirve para volcados enormes que no quieres cargar de golpe, para respuestas que se generan poco a poco, o para eventos en vivo que empujas al cliente conforme ocurren.

TypeScript
export const prerender = false;export const GET: APIRoute = () => {  const encoder = new TextEncoder();  const stream = new ReadableStream({    async start(controller) {      for (let i = 1; i <= 5; i++) {        controller.enqueue(encoder.encode(`linea ${i}\n`));        await new Promise((r) => setTimeout(r, 300));      }      controller.close();    },  });  return new Response(stream, {    headers: { 'Content-Type': 'text/plain; charset=utf-8' },  });};

El controller es el mando del flujo: enqueue empuja un trozo —bytes, por eso el TextEncoder—, y close cierra cuando no queda más. El cliente recibe linea 1, luego linea 2, sin esperar a que las cinco estén listas. Cambiando el tipo a text/event-stream y el formato de cada trozo tienes Server-Sent Events: un canal por el que el servidor empuja actualizaciones en vivo —progreso de una tarea, tokens de un modelo, notificaciones— sobre una sola conexión HTTP. El streaming solo tiene sentido bajo demanda: un fichero horneado ya está entero.

Para SSE el patrón es idéntico salvo el formato de cada trozo y las cabeceras: cada evento es una línea data: cerrada con un doble salto de línea.

TypeScript
export const prerender = false;export const GET: APIRoute = () => {  const encoder = new TextEncoder();  const stream = new ReadableStream({    async start(controller) {      for (let i = 0; i < 3; i++) {        controller.enqueue(encoder.encode(`data: progreso ${i}\n\n`));        await new Promise((r) => setTimeout(r, 500));      }      controller.close();    },  });  return new Response(stream, {    headers: {      'Content-Type': 'text/event-stream',      'Cache-Control': 'no-cache',      Connection: 'keep-alive',    },  });};

El navegador lo consume con un EventSource, que además reconecta solo si la conexión se cae. Es el canal más simple para empujar progreso o notificaciones sin recurrir a WebSockets, y encaja de maravilla con tareas largas cuyo avance quieres mostrar en tiempo real.

CORS: quién puede consumir tu endpoint

Por seguridad, el navegador prohíbe que una página en un origen lea la respuesta de un endpoint en otro origen, salvo que ese endpoint lo autorice con cabeceras CORS. Si tu API la va a consumir JavaScript servido desde otro dominio, tienes que abrir esa puerta explícitamente con Access-Control-Allow-Origin.

TypeScript
export const prerender = false;const cors = {  'Access-Control-Allow-Origin': 'https://app.ejemplo.com',  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',  'Access-Control-Allow-Headers': 'Content-Type, Authorization',};export const OPTIONS: APIRoute = () =>  new Response(null, { status: 204, headers: cors });export const POST: APIRoute = async ({ request }) => {  const datos = await request.json();  return Response.json({ recibido: datos }, { headers: cors });};

Para peticiones “no simples” —un POST con cuerpo JSON, cabeceras de autenticación— el navegador manda antes una petición de preflight con el método OPTIONS, preguntando si le permites el verbo y las cabeceras que pretende usar. Debes atenderla: exportas un handler OPTIONS que responde 204 con las cabeceras Allow-Methods y Allow-Headers. Solo si esa respuesta autoriza lo que quiere, el navegador envía la petición real. Poner Access-Control-Allow-Origin: * abre a cualquier origen; cómodo para datos públicos, imprudente para nada que dependa de credenciales.