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.
Un Response tiene tres palancas, y hasta ahora hemos tocado sobre todo el cuerpo. Las otras dos son igual de expresivas. El código de estado es una sola cifra que declara qué pasó —200 todo bien, 201 creado, 404 no está, 429 frena—, entendible por cualquier programa sin leer el cuerpo. Las cabeceras describen y gobiernan la respuesta: su tipo, cuánto puede cachearse, quién puede consumirla. Y el cuerpo, además, no tiene por qué ser una cadena entera cargada en memoria: puede ser un flujo que emites por partes. Manejar las tres con precisión es la diferencia entre un endpoint que funciona y uno que se comporta como un buen ciudadano de la web.
- Construir un
Responsegobernando sus tres palancas:status,headersy cuerpo. - Elegir el código de estado correcto y componer cabeceras con el objeto
Headers. - Emitir una respuesta en streaming con un
ReadableStreamyTextEncoder. - Abrir un endpoint a otros orígenes con CORS y atender el 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.
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.
La cabecera Content-Type no describe el cuerpo, lo define para el cliente: le dice cómo interpretar los bytes. El mismo texto {"ok":true} es un objeto si lo anuncias como application/json y una cadena tonta si lo anuncias como text/plain. Un charset=utf-8 explícito evita que los acentos se rompan. Y para descargas, Content-Disposition: attachment; filename=datos.csv convierte una respuesta en un fichero que el navegador guarda en lugar de mostrar. Las cabeceras son la parte de la respuesta que programa el comportamiento del cliente.
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.
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.
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.
Una respuesta en streaming mantiene la conexión y el handler vivos hasta que llamas a close. Eso es su virtud —empujar en el tiempo— y su coste: cada flujo abierto consume una ranura de tu servidor o de tu función. Si el generador se cuelga o nunca cierra, esa conexión queda colgada. Cierra siempre el controller, contempla que el cliente se desconecte a mitad —escuchando la señal de abort del request— y no uses streaming para respuestas pequeñas que caben de sobra en una cadena: ahí solo añade complejidad sin ganancia.
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.
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.
Status
Una cifra que declara el resultado. 2xx exito, 4xx culpa del cliente, 5xx del servidor.
Headers
Describen y gobiernan: Content-Type, Cache-Control, Content-Disposition, cabeceras propias.
Streaming
Un ReadableStream como cuerpo: empujas trozos con enqueue y cierras con close.
CORS
Autoriza otros origenes con Allow-Origin y atiende el preflight con un handler OPTIONS.
sequenceDiagram participant N as navegador participant E as endpoint N->>E: OPTIONS preflight E-->>N: 204 con allow methods y headers N->>E: POST real con cuerpo E-->>N: 200 con allow origin Note over N,E: sin las cabeceras CORS el navegador bloquea la lectura
Es tentador ver el Response como un simple sobre donde metes datos, pero es un mensaje con tres capas de sentido, cada una dirigida a un lector distinto. El status habla a las máquinas en un lenguaje universal y minúsculo: una cifra que un balanceador, un CDN, un cliente HTTP o un buscador interpretan igual en todo el planeta, sin leer una sola letra del cuerpo. Las cabeceras hablan a la infraestructura: le dicen a la caché cuánto guardar, al navegador cómo interpretar y a quién dejar leer, gobernando el comportamiento de capas que ni escribiste ni controlas pero que respetan el protocolo. Y el cuerpo, con el streaming, deja de ser un valor para volverse un proceso en el tiempo: no una foto que entregas entera, sino una película que emites fotograma a fotograma, capaz de representar cosas que aún no han terminado de ocurrir. Cuando programas los tres con intención, dejas de “devolver datos” y empiezas a comunicarte con precisión en el protocolo que sostiene la web —diciendo la verdad sobre lo que pasó, instruyendo a la infraestructura sobre cómo tratar tu mensaje y modelando el tiempo en la propia forma de la respuesta—. Un endpoint mediocre ignora dos de las tres capas y grita 200 con una cadena; uno excelente usa las tres como un idioma, y por eso encaja sin fricción en cachés, proxys y clientes que nunca conocerá. Ese respeto por el protocolo no es pedantería: es lo que hace que tu código coopere con una web que es, toda ella, una conversación de peticiones y respuestas.
- Escribe un endpoint que devuelva
Response.jsoncon unCache-Controlexplícito y una cabecera propia; inspecciona ambas en las herramientas del navegador. - Crea un endpoint que fuerce descarga con
Content-Disposition: attachmenty un cuerpo CSV, y confirma que el navegador lo guarda en vez de mostrarlo. - Implementa un
GETen streaming conReadableStreamque emita varias líneas con retardo, y observa cómo llegan progresivamente. - Añade CORS a un endpoint: exporta
OPTIONSque responda204con las cabeceras de preflight y verifica una petición cruzada desde otro origen.