wandres.dev
REQUEST Y RESPONSE · streaming en el edge

Construir respuestas: new Response, status, headers, CORS y Response.json

Un Worker contesta construyendo una Response, nunca mutando la petición. Dominar su segundo argumento —status y headers—, el atajo Response.json, el papel del Content-Type y las cabeceras de CORS es controlar el sobre entero del mensaje, no solo su contenido.

⏱ 16 min

En un Worker no se responde modificando lo que llega: se construye una Response nueva y se devuelve. Esa respuesta es un sobre con tres partes —un status, unas cabeceras y un cuerpo—, y casi todo el significado de una contestación HTTP vive en las dos primeras, no en la tercera. El Content-Type le dice al cliente cómo leer los bytes; las cabeceras de CORS le dicen al navegador si le está permitido leerlos siquiera. Controlar la Response es controlar el sobre entero, y de eso trata esta lección.

🎯 Al terminar esta lección sabrás
  • Construir una Response con new Response(body, init) y controlar status y headers.
  • Usar Response.json() como atajo que serializa y fija el Content-Type por ti.
  • Fijar el Content-Type correcto y entender por qué el cliente se guía por la cabecera.
  • Habilitar CORS con las cabeceras Access-Control-Allow y responder al preflight OPTIONS.

El objeto Response y su init

El constructor new Response(body, init) toma un cuerpo y un objeto de inicialización. El cuerpo puede ser una cadena, un ArrayBuffer, un ReadableStream, un FormData o null para respuestas sin contenido. El init es donde fijas lo que de verdad dirige el intercambio:

🔢

status

El código HTTP: 200, 201, 404, 500. Por defecto 200. Acompáñalo de statusText si quieres un texto asociado.

🏷️

headers

Los metadatos: Content-Type, Cache-Control, CORS, cookies. Un objeto plano, o un Headers cuando hay varias entradas.

📦

body

El contenido: una cadena, un ArrayBuffer, un ReadableStream, un FormData o null para un 204 sin cuerpo.

Para casos sencillos basta un objeto literal de cabeceras. Cuando necesitas varias entradas del mismo nombre —varias cookies, por ejemplo— o construir las cabeceras poco a poco, usa la clase Headers, que expone set, append, get y delete:

const headers = new Headers();
headers.set("Content-Type", "text/html; charset=utf-8");
headers.append("Set-Cookie", "sesion=abc; HttpOnly; Secure");
headers.set("Cache-Control", "public, max-age=3600");
return new Response(cuerpoHtml, { status: 200, headers });

Hay un detalle que atrapa a todos: las cabeceras de una Response que recibes de fetch son inmutables. Si proxias una respuesta de un origen y quieres tocar una cabecera, no la mutes; construye una nueva a partir de ella, que sí es mutable:

const original = await fetch("https://origen.example.com/pagina");
const respuesta = new Response(original.body, original);
respuesta.headers.set("X-Servido-Por", "mi-worker");
return respuesta;

Response.json y el atajo de la cabecera

Devolver JSON es tan común que el estándar añadió un atajo estático. Response.json(dato, init) serializa el dato con JSON.stringify y fija por ti el Content-Type a application/json. Compara las dos formas:

// A mano: serializas y declaras el tipo tu mismo.
return new Response(JSON.stringify({ ok: true }), {
  status: 201,
  headers: { "Content-Type": "application/json" },
});

// Con el atajo: el mismo resultado, sin olvidos.
return Response.json({ ok: true }, { status: 201 });

El valor del atajo no es escribir menos, sino no olvidar el Content-Type. Un JSON servido sin esa cabecera es una fuente inagotable de bugs sutiles: el cliente recibe bytes correctos pero no sabe que son JSON, y response.json() en el otro extremo puede negarse a parsearlos. El atajo cierra esa puerta.

Content-Type: el cliente lee la cabecera

Aquí está la idea que reordena todo: el cliente decide qué es una respuesta por su Content-Type, no por su contenido. Los mismos bytes <h1>Hola</h1> servidos como text/plain se muestran como texto literal, con las etiquetas visibles; servidos como text/html los interpreta el navegador y renderiza un encabezado. El cuerpo no ha cambiado; ha cambiado la instrucción de cómo leerlo.

const bytes = "<h1>Hola</h1>";
// Se ve el texto crudo, etiquetas incluidas:
new Response(bytes, { headers: { "Content-Type": "text/plain" } });
// El navegador lo renderiza como HTML:
new Response(bytes, { headers: { "Content-Type": "text/html; charset=utf-8" } });

Por eso el charset importa —utf-8 evita que los acentos se rompan— y por eso conviene la cabecera X-Content-Type-Options con valor nosniff, que le prohíbe al navegador adivinar el tipo por su cuenta cuando la cabecera falta o es dudosa. El tipo de medio es una promesa que le haces al cliente sobre cómo tratar los bytes; incumplirla es la raíz de bugs de renderizado y de agujeros de seguridad.

CORS: por qué el navegador exige permiso

Un navegador impide por defecto que el JavaScript de un origen lea la respuesta de otro origen distinto: es la política del mismo origen, una barrera de seguridad. Para que tu API pueda consumirse desde otro dominio, tu Response debe llevar las cabeceras que conceden ese permiso, empezando por Access-Control-Allow-Origin. Y para métodos o cabeceras no triviales, el navegador envía antes una petición OPTIONS de sondeo —el preflight— que también debes contestar.

const cors = {
  "Access-Control-Allow-Origin": "https://miapp.com",
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
  "Access-Control-Allow-Headers": "Content-Type, Authorization",
};

export default {
  async fetch(request: Request): Promise<Response> {
    if (request.method === "OPTIONS") {
      return new Response(null, { status: 204, headers: cors });
    }
    return Response.json({ ok: true }, { headers: cors });
  },
};
flowchart LR
N[Navegador] -->|1 OPTIONS preflight| W[Worker]
W -->|2 204 con cabeceras CORS| N
N -->|3 POST real| W
W -->|4 200 con Allow-Origin| N
⚠️
El comodín y las credenciales no se llevan bien

Es tentador poner Access-Control-Allow-Origin a * y olvidarse. Sirve para APIs públicas de solo lectura, pero en cuanto la petición lleva credenciales —cookies o cabecera Authorization— el navegador rechaza el comodín: exige un origen concreto y, además, Access-Control-Allow-Credentials en true. Refleja el origen permitido desde una lista blanca en vez de abrir a todo el mundo.

Una respuesta es casi toda metadato

Es fácil creer que el contenido de una respuesta es el cuerpo, y que las cabeceras son un envoltorio menor. La verdad es la contraria: los bytes del cuerpo son inertes, y casi todo el significado de una contestación HTTP vive en su metadato. El mismo cuerpo, byte por byte, es una página web o texto plano según su Content-Type; es cacheable durante una hora o nunca según su Cache-Control; es legible desde otro dominio o prohibido según sus cabeceras de CORS; existe como recurso nuevo o como error según su status. HTTP no es un protocolo de transporte de datos, es un protocolo de interpretación: el cuerpo dice qué, y las cabeceras dicen cómo, cuánto tiempo, para quién y con qué autoridad. El caso de CORS lo lleva al extremo y revela algo hermoso del modelo: la seguridad del mismo origen no la impone el servidor, la impone el navegador del cliente, y tu servidor no la vence, sino que declara un permiso que el navegador decide respetar. Tu Response no fuerza nada; ofrece un contrato —quién puede leerme, cómo debes tratarme, cuánto puedes guardarme— y confía en que el otro extremo lo honre. Por eso construir respuestas es la mitad seria del oficio en el edge: muchos frameworks te esconden las cabeceras tras helpers cómodos, y está bien hasta que algo se rompe y descubres que nunca controlaste el sobre, solo la carta. Un Worker te devuelve ese control crudo. Cuando internalizas que dominas el sobre entero —status, tipo, caché, permisos— y no solo el contenido, dejas de pelearte con comportamientos inexplicables del cliente y empiezas a diseñar el contrato exacto que quieres que el otro extremo obedezca.

⚔️ Controla el sobre entero
  1. Construye un 404 con cuerpo text/plain y, aparte, una variante con cuerpo JSON de error usando Response.json().
  2. Sirve la misma cadena <h1>Hola</h1> como text/plain y como text/html, y observa en el navegador la diferencia.
  3. Devuelve un JSON con Response.json() y otro a mano con JSON.stringify, e inspecciona el Content-Type que produce cada uno.
  4. Añade CORS a un endpoint y contesta el preflight OPTIONS con un 204; pruébalo desde una página de otro origen.
  5. Toma una respuesta de fetch y cámbiale una cabecera construyendo una Response nueva a partir de ella.