Un servidor de verdad corre en una computadora que nunca se apaga. Al final de este programa el tuyo correrá en una de esas, con dirección propia en internet. Mientras aprendes, tu navegador hará de laboratorio — igual que los simulados del Programa 3: el código es el de verdad; solo la máquina es prestada.
' + 'El contrato tiene dos partes. Primera: escribes una función llamada ' + tag("manejador") + ' que recibe la petición. Tú nunca la llamas — el servidor la llama por ti, una vez por CADA petición que llega, y le entrega la petición como argumento. Ya conoces ese trato: es el mismo de ' + tag("addEventListener") + ' en el Programa 1 — tú escribes la función, otro la ejecuta cuando toca.
' + 'Segunda: tu función devuelve la respuesta, fabricada con ' + tag("new Response(...)") + '. La palabra ' + tag("new") + ' fabrica un objeto a partir de un molde que trae el navegador; ' + tag("Response") + ' es el molde de las respuestas. Lo que pongas entre paréntesis viaja como cuerpo de la respuesta:
' + 'Escríbelo — con TU mensaje, no el del ejemplo — y pruébalo en el probador de al lado: tú harás de cliente, como hacía tu app del Programa 3. Pídele distintas rutas (lo que escribes después del dominio, como ' + tag("/saludo") + '; las desarmamos en la próxima lección). Junto a cada respuesta verás un número y una etiqueta: son la manera en que las máquinas se entienden — te los presento en las lecciones 3 y 4.
', starter: function () { return ""; }, mission: [ { key: "arranca", label: "Tu servidor arranca: existe la función " + tag("manejador") + " y no hay errores.", check: async function (code) { var ctx = await servidorFresco(code); if (ctx.error) return { ok: false, msg: "Hay un error: " + tag(ctx.error) + "." }; var r = await enviar(ctx, "GET", "/"); if (r && r.sinManejador) return { ok: false, msg: "No encuentro una función llamada " + tag("manejador") + ". El contrato del servidor es: " + tag("function manejador(peticion) { ... }") + "." }; var p = problemaDe(ctx, r); if (p) return p; return { ok: true }; } }, { key: "responde", label: "Responde a una petición con TU mensaje (el cuerpo no viaja vacío).", check: async function (code) { var ctx = await servidorFresco(code); var r = await enviar(ctx, "GET", "/"); var p = problemaDe(ctx, r); if (p) return p; if (!r.texto || !r.texto.trim()) return { ok: false, msg: "Tu respuesta viaja con el cuerpo vacío. Ponle un mensaje: " + tag('new Response("tu mensaje")') + "." }; if (r.texto.trim() === "¡Mi servidor vive!") return { ok: false, msg: "Ese es el mensaje del ejemplo. Es TU servidor: que diga algo tuyo." }; return { ok: true }; } }, { key: "siempre", label: "Responde a CUALQUIER ruta que llegue (un servidor nunca se queda callado).", check: async function (code) { var ctx = await servidorFresco(code); var r = await enviar(ctx, "GET", "/" + marca("ruta")); var p = problemaDe(ctx, r); if (p) return p; if (!r.texto || !r.texto.trim()) return { ok: false, msg: "A una ruta desconocida tu servidor respondió el cuerpo vacío. Por ahora responde lo mismo a todo; en la próxima lección aprenderá a distinguir." }; return { ok: true }; } } ], coach: function (code) { return []; }, winMsg: "Tu primer servidor respondió. Pero fíjate en el probador: pide /saludo, /precios, /loquesea... y responde LO MISMO a todo. Un servidor de verdad distingue QUÉ le están pidiendo. Eso es lo siguiente." }, { id: "rutas", eyebrow: "Lección 2 · Qué me estás pidiendo", title: "La petición: método y ruta", teach: 'La ' + tag("peticion") + ' que recibe tu ' + tag("manejador") + ' no es humo: es un objeto (como los del Programa 3) cargado con la información de qué pidieron. Hoy usarás dos de sus datos.
' + '' + tag("peticion.method") + ' es el verbo: ' + tag('"GET"') + ' significa "dame". Todos los ' + tag("fetch") + ' que escribiste en el Programa 3 eran GET sin que lo supieras. Hay otros verbos — ' + tag('"POST"') + ' significa "te traigo datos" — y llegarán en unas lecciones.
' + '' + tag("peticion.url") + ' es la dirección completa, como texto — algo como ' + tag('"https://…/saludo"') + '. El dominio de la izquierda te lo presta el laboratorio (cambia en cada arranque, y al final del programa tendrás el tuyo de verdad); por eso no te fijas en él, sino en la ruta. En el Programa 3 ya desarmaste direcciones (¿recuerdas los parámetros de Open-Meteo?). Para desarmarla aquí, el navegador trae un molde: ' + tag("new URL(peticion.url)") + ' fabrica un objeto con las piezas separadas, y su pieza ' + tag(".pathname") + ' es la ruta: lo que va después del dominio, empezando con ' + tag("/") + '.
' + 'Con la ruta en la mano, decidir es cosa del ' + tag("if") + ' que conoces desde el Programa 1:
' + 'Fíjate en el último ' + tag("return") + ', el que vive FUERA de todo ' + tag("if") + ': es la respuesta general, la que atiende cualquier ruta que no manejaste. Un servidor nunca se queda callado. Y compara la ruta exacta, con ' + tag("===") + ': que ' + tag("/saludo-falso") + ' no se cuele por parecerse.
' + 'Tu misión: ' + tag("/saludo") + ' y ' + tag("/despedida") + ', cada una con su propio mensaje (tuyos, distintos entre sí), y la respuesta general para todo lo demás.
', starter: function (prev) { return prev || ""; }, mission: [ { key: "saludo", label: tag("/saludo") + " responde su propio mensaje.", check: async function (code) { var ctx = await servidorFresco(code); var r = await enviar(ctx, "GET", "/saludo"); var p = problemaDe(ctx, r); if (p) return p; if (!r.texto || !r.texto.trim()) return { ok: false, msg: tag("/saludo") + " respondió el cuerpo vacío. Dale su mensaje." }; if (r.texto.trim() === "¡Hola! Qué bueno verte.") return { ok: false, msg: "Ese saludo es el del ejemplo. Es TU servidor: salúdalo a tu manera." }; return { ok: true }; } }, { key: "despedida", label: tag("/despedida") + " responde otro mensaje, distinto.", check: async function (code) { var ctx = await servidorFresco(code); var rs = await enviar(ctx, "GET", "/saludo"); var p = problemaDe(ctx, rs); if (p) return p; var rd = await enviar(ctx, "GET", "/despedida"); p = problemaDe(ctx, rd); if (p) return p; if (!rd.texto || !rd.texto.trim()) return { ok: false, msg: tag("/despedida") + " respondió el cuerpo vacío. Dale su mensaje." }; if (rd.texto.trim() === rs.texto.trim()) return { ok: false, msg: tag("/saludo") + " y " + tag("/despedida") + " respondieron LO MISMO. Cada ruta merece su propia respuesta: revisa tus " + tag("if") + "." }; return { ok: true }; } }, { key: "general", label: "Cualquier otra ruta recibe tu respuesta general (comparación EXACTA).", check: async function (code) { var ctx = await servidorFresco(code); var rs = await enviar(ctx, "GET", "/saludo"); var p = problemaDe(ctx, rs); if (p) return p; var rd = await enviar(ctx, "GET", "/despedida"); p = problemaDe(ctx, rd); if (p) return p; var ra = await enviar(ctx, "GET", "/" + marca("ruta")); p = problemaDe(ctx, ra); if (p) return p; if (!ra.texto || !ra.texto.trim()) return { ok: false, msg: "A una ruta desconocida tu servidor respondió el cuerpo vacío. La respuesta general también lleva mensaje." }; if (ra.texto.trim() === rs.texto.trim() || ra.texto.trim() === rd.texto.trim()) return { ok: false, msg: "Pedí una ruta desconocida y tu servidor respondió como si fuera una de las conocidas. La respuesta general va en su propio " + tag("return") + ", fuera de los " + tag("if") + "." }; var rb = await enviar(ctx, "GET", "/" + marca("otra")); p = problemaDe(ctx, rb); if (p) return p; if (ra.texto.trim() !== rb.texto.trim()) return { ok: false, msg: "Pedí DOS rutas desconocidas distintas y me respondiste cosas distintas. La respuesta general es UNA sola — un solo " + tag("return") + " fuera de los " + tag("if") + ", no un eco de la ruta que pediste." }; var rf = await enviar(ctx, "GET", "/saludo-" + marca("falso")); p = problemaDe(ctx, rf); if (p) return p; if (rf.texto.trim() === rs.texto.trim()) return { ok: false, msg: "Pedí una ruta que solo se PARECE a " + tag("/saludo") + " y tu servidor la saludó igual. Compara la ruta exacta: " + tag('url.pathname === "/saludo"') + "." }; var rfd = await enviar(ctx, "GET", "/despedida-" + marca("falso")); p = problemaDe(ctx, rfd); if (p) return p; if (rfd.texto.trim() === rd.texto.trim()) return { ok: false, msg: "Pedí una ruta que solo se PARECE a " + tag("/despedida") + " y tu servidor la despidió igual. Compara la ruta exacta con " + tag("===") + ", también en " + tag("/despedida") + "." }; var re = await enviar(ctx, "GET", "/" + marca("cola") + "/saludo"); p = problemaDe(ctx, re); if (p) return p; if (re.texto.trim() === rs.texto.trim()) return { ok: false, msg: "Una ruta que TERMINA en " + tag("/saludo") + " pero no es " + tag("/saludo") + " recibió el saludo. La ruta se compara COMPLETA con " + tag("===") + ", no por cómo termina." }; return { ok: true }; } } ], coach: function (code) { return []; }, winMsg: "Tu servidor ya distingue qué le piden. Pero responde prosa — texto suelto. Las aplicaciones (la tuya del Programa 3 incluida) esperan DATOS con forma: JSON. Vamos a responder como los servidores de verdad." }, { id: "json", eyebrow: "Lección 3 · El idioma de los datos", title: "Responder JSON, como los servidores de verdad", teach: 'Cuando tu app del Programa 3 hacía ' + tag("respuesta.json()") + ', estaba abriendo un sobre que alguien armó del otro lado. Open-Meteo no te mandaba prosa: te mandaba ' + tag('{"current_weather":{"temperature":21.5}}') + '. Hoy el que arma el sobre eres tú.
' + 'Armar el sobre son dos pasos. Primero, traducir tu objeto a texto con ' + tag("JSON.stringify(...)") + ' — el MISMO traductor del Programa 3: allá lo usaste para guardar en el cajón; acá lo usas para que los datos viajen.
' + 'Segundo, avisar qué formato viaja. Las respuestas llevan, además del cuerpo, headers: etiquetas pegadas al sobre, datos SOBRE la respuesta que no van dentro de ella. La etiqueta ' + tag("Content-Type") + ' declara el formato del cuerpo, y el rótulo del JSON es ' + tag("application/json") + '. El ' + tag("fetch") + ' de tu Programa 3 leía esa etiqueta cada vez, sin que lo supieras.
' + 'Los headers van en el segundo argumento de ' + tag("new Response(...)") + ': un objeto normal — de los que armas desde el Programa 1 — usado aquí como lista de opciones:
' + 'Míralo en el probador: junto al número de la respuesta viaja la etiqueta. Sin el header dice ' + tag("text/plain") + '; en cuanto pongas el rótulo, cambia a ' + tag("application/json") + '. Esa es la prueba visual de que tu sobre va bien rotulado.
' + 'Tu misión: una ruta nueva ' + tag("/yo") + ' que responda un objeto JSON con al menos dos datos tuyos (tu nombre, lo que te guste, lo que quieras — es tu tarjeta de presentación en formato servidor). Y tus rutas de la lección 2 siguen vivas: aquí lo construido no se tira.
', starter: function (prev) { return prev || ""; }, mission: [ { key: "jsonok", label: tag("/yo") + " responde un objeto JSON con al menos dos datos TUYOS.", check: async function (code) { var ctx = await servidorFresco(code); var r = await enviar(ctx, "GET", "/yo"); var p = problemaDe(ctx, r); if (p) return p; if (r.json === null || typeof r.json !== "object" || Array.isArray(r.json)) return { ok: false, msg: "El cuerpo de " + tag("/yo") + " no es un objeto JSON válido. Arma un objeto y tradúcelo: " + tag("JSON.stringify(tuObjeto)") + "." }; var claves = Object.keys(r.json); var conValor = claves.filter(function (k) { return String(r.json[k]).trim() !== ""; }); // Rescate del novato que envuelve sus datos en un objeto (como el // {current_weather:{...}} del ejemplo de Open-Meteo): están anidados, // pero están. Contamos los de adentro en vez de rechazar la tarjeta. if (conValor.length === 1 && r.json[conValor[0]] && typeof r.json[conValor[0]] === "object" && !Array.isArray(r.json[conValor[0]])) { var interno = r.json[conValor[0]]; var internoConValor = Object.keys(interno).filter(function (k) { return String(interno[k]).trim() !== ""; }); if (internoConValor.length >= 2 && interno.nombre !== "Learn It") return { ok: true }; } if (conValor.length < 2) return { ok: false, msg: "Tu objeto de " + tag("/yo") + " necesita al menos DOS datos con valor. Es tu tarjeta de presentación: llénala (los dos datos van directos en el objeto)." }; if (r.json.nombre === "Learn It") return { ok: false, msg: "Esos son los datos del ejemplo. La tarjeta es TUYA: pon tus propios datos." }; return { ok: true }; } }, { key: "rotulo", label: "La respuesta lleva el rótulo " + tag("Content-Type: application/json") + ".", check: async function (code) { var ctx = await servidorFresco(code); var r = await enviar(ctx, "GET", "/yo"); var p = problemaDe(ctx, r); if (p) return p; if (String(r.contentType).toLowerCase().indexOf("application/json") === -1) return { ok: false, msg: "El sobre viaja sin etiqueta. Los headers van en el segundo argumento: " + tag('new Response(cuerpo, { headers: { "Content-Type": "application/json" } })') + "." }; return { ok: true }; } }, { key: "herencia", label: "Tus rutas de la lección 2 siguen vivas.", check: async function (code) { return rutasVivas(code); } } ], coach: function (code) { return []; }, winMsg: "Tu servidor ya habla el idioma de los datos. Ahora, una verdad incómoda: cuando le piden una ruta que no existe, tu servidor responde \"todo bien\" — el número 200 — aunque no tenía nada que dar. Los servidores honestos avisan con CÓDIGOS DE ESTADO. Eso sigue." }, { id: "estados", eyebrow: "Lección 4 · Di la verdad con números", title: "Códigos de estado", teach: 'Cuando tu app del Programa 3 preguntaba ' + tag("respuesta.ok") + ', ¿de dónde salía ese ok? De un NÚMERO que viaja con cada respuesta: el código de estado. De 200 a 299 significa "salió bien" y ' + tag("ok") + ' vale ' + tag("true") + '; 404 significa "eso no existe" y ' + tag("ok") + ' vale ' + tag("false") + '. Los clientes son programas: no leen tu prosa — leen ese número.
' + 'Y aquí la verdad incómoda de TU servidor: pídele una ruta inventada en el probador y mira el número. Responde su mensaje de "no la conozco"... con un 200: todo bien. Le dice a la máquina lo contrario de lo que le dice al humano.
' + 'El número se elige en el objeto de opciones — el segundo argumento que conociste en la lección pasada. ' + tag("status") + ' y ' + tag("headers") + ' viajan juntos, en el mismo objeto:
' + 'Por ahora te bastan dos: 200 — el que ' + tag("new Response") + ' pone en silencio cuando no dices nada — y 404. Hay más (el 201, "creado", llegará con el POST), pero un servidor honesto se construye código por código.
' + 'Tu misión: que tu respuesta general diga la verdad — 404, con su error en JSON y su rótulo — y que tus rutas reales conserven su 200. El probador te acompaña: pinta el número en verde o en rojo.
', starter: function (prev) { return prev || ""; }, mission: [ { key: "verdad404", label: "La respuesta general dice la verdad: " + tag("404") + " con su error en JSON.", check: async function (code) { var ctx = await servidorFresco(code); var r = await enviar(ctx, "GET", "/" + marca("ruta")); var p = problemaDe(ctx, r); if (p) return p; if (r.status === 200) return { ok: false, msg: "Pedí una ruta inventada y tu servidor respondió 200: todo bien. La respuesta general debe avisar con " + tag("status: 404") + " en el objeto de opciones." }; if (r.status !== 404) return { ok: false, msg: "Tu respuesta general respondió " + r.status + ". El código para \"eso no existe\" es " + tag("404") + "." }; if (r.json === null || typeof r.json !== "object" || Array.isArray(r.json)) return { ok: false, msg: "El error también viaja con forma: un objeto JSON, algo como " + tag('{ error: "tu mensaje" }') + " pasado por " + tag("JSON.stringify") + "." }; var valores = Object.keys(r.json).filter(function (k) { return String(r.json[k]).trim() !== ""; }); if (!valores.length) return { ok: false, msg: "Tu objeto de error viaja vacío. Dale al cliente un mensaje útil dentro del JSON." }; if (valores.some(function (k) { return String(r.json[k]).trim() === "Esa ruta no existe"; })) return { ok: false, msg: "Ese es el aviso del ejemplo. El mensaje de error es tuyo: dile al cliente algo con tus palabras." }; return { ok: true }; } }, { key: "rutas200", label: "Tus rutas reales conservan su " + tag("200") + " (y todo lo construido sigue vivo).", check: async function (code) { var ctx = await servidorFresco(code); var rs = await enviar(ctx, "GET", "/saludo"); var p = problemaDe(ctx, rs); if (p) return p; var rd = await enviar(ctx, "GET", "/despedida"); p = problemaDe(ctx, rd); if (p) return p; var ry = await enviar(ctx, "GET", "/yo"); p = problemaDe(ctx, ry); if (p) return p; if (rs.status !== 200 || rd.status !== 200 || ry.status !== 200) return { ok: false, msg: "Una de tus rutas reales dejó de responder 200. El 404 es SOLO para lo que no existe; lo que sí existe responde con su 200 de siempre." }; if (!rs.texto || !rd.texto || rs.texto === rd.texto) return { ok: false, msg: "Se rompió lo de la lección 2: " + tag("/saludo") + " y " + tag("/despedida") + " deben seguir con sus propios mensajes." }; if (ry.json === null || typeof ry.json !== "object" || Array.isArray(ry.json)) return { ok: false, msg: "Se rompió lo de la lección 3: " + tag("/yo") + " debe seguir respondiendo su objeto JSON." }; return { ok: true }; } }, { key: "rotulo404", label: "El error también lleva su rótulo " + tag("application/json") + ".", check: async function (code) { var ctx = await servidorFresco(code); var r = await enviar(ctx, "GET", "/" + marca("ruta")); var p = problemaDe(ctx, r); if (p) return p; if (String(r.contentType).toLowerCase().indexOf("application/json") === -1) return { ok: false, msg: "El sobre del error viaja sin etiqueta. " + tag("status") + " y " + tag("headers") + " van JUNTOS en el mismo objeto de opciones: " + tag('{ status: 404, headers: { "Content-Type": "application/json" } }') + "." }; return { ok: true }; } } ], coach: function (code) { return []; }, winMsg: "Tu servidor ya dice la verdad con números. Hasta ahora, eso sí, responde siempre lo MISMO en cada ruta: frases fijas. Los servidores de verdad responden distinto según lo que el cliente les pida. Eso se hace con parámetros — los mismos que le enviaste a Open-Meteo en el Programa 3, ahora vistos desde el lado que los recibe." }, { id: "parametros", eyebrow: "Lección 5 · Respuestas a la medida", title: "Parámetros: leer lo que el cliente pide", teach: 'En el Programa 3 le ENVIASTE parámetros a un servidor: la URL de Open-Meteo llevaba ' + tag("?latitude=...&longitude=...") + ' y el servidor respondía el clima de ESE lugar. Hoy cruzas también esa frontera: ahora tu servidor los recibe.
' + 'La anatomía ya la conoces de allá: después de la ruta, ' + tag("?") + ' abre la lista de parámetros; cada uno es ' + tag("nombre=valor") + ' y se separan con ' + tag("&") + '. Son las piezas que el cliente decide — por eso la misma ruta puede responder cosas distintas.
' + 'El objeto URL que fabricas desde la lección 2 los trae ya separados, en ' + tag("url.searchParams") + '. Su método ' + tag('.get("nombre")') + ' devuelve el valor como texto... o ' + tag("null") + ' — el "aquí no hay nada" que conociste en el Programa 3 — si el cliente no lo mandó. Ese null es tu responsabilidad: protégelo con un ' + tag("if") + ', porque saludar a null es de robots. (Por hoy ese caso responde un mensaje y ya; el CÓDIGO para "tu petición viene mal" — el ' + tag("400") + ' — llega en la lección 8.)
' + 'El patrón, con una ruta de juguete:
' + 'Tu misión: una ruta nueva ' + tag("/hola") + ' que lea el parámetro ' + tag("nombre") + ' y salude con ese nombre; y si el nombre no viene, que responda algo útil (sin romperse y sin saludar a null). Pruébala en el probador: ' + tag("/hola?nombre=Ana") + ' y luego ' + tag("/hola") + ' a secas.
', starter: function (prev) { return prev || ""; }, mission: [ { key: "connombre", label: tag("/hola?nombre=...") + " saluda CON el nombre que llegó.", check: async function (code) { var ctx = await servidorFresco(code); var tokA = marca("Ana"), tokB = marca("Luis"); var ra = await enviar(ctx, "GET", "/hola?nombre=" + tokA); var p = problemaDe(ctx, ra); if (p) return p; if (ra.status !== 200) return { ok: false, msg: tag("/hola") + " con un nombre debe responder 200: es una ruta que SÍ existe y atendiste. ¿Le falta su propio " + tag("if") + " de ruta, o se cayó al 404 general?" }; if (!ra.texto || ra.texto.toLowerCase().indexOf(tokA.toLowerCase()) === -1) return { ok: false, msg: "Saludé como \"" + tokA + "\" y tu servidor no me nombró. Lee el parámetro con " + tag('url.searchParams.get("nombre")') + " y úsalo en la respuesta." }; if (ra.texto.indexOf("nombre=") !== -1) return { ok: false, msg: "Me respondiste la dirección cruda en vez de leer el parámetro. Saca el valor con " + tag('url.searchParams.get("nombre")') + " y salúdalo — no repitas la URL entera." }; var rb = await enviar(ctx, "GET", "/hola?nombre=" + tokB); p = problemaDe(ctx, rb); if (p) return p; if (!rb.texto || rb.texto.toLowerCase().indexOf(tokB.toLowerCase()) === -1) return { ok: false, msg: "Con otro nombre, tu saludo no cambió. La gracia del parámetro es que la MISMA ruta responde distinto según lo que llegue." }; return { ok: true }; } }, { key: "sinnombre", label: "Sin el parámetro, responde algo útil (nada de saludar a null).", check: async function (code) { var ctx = await servidorFresco(code); var tok = marca("Ana"); var rc = await enviar(ctx, "GET", "/hola?nombre=" + tok); var p = problemaDe(ctx, rc); if (p) return p; var r = await enviar(ctx, "GET", "/hola"); p = problemaDe(ctx, r); if (p) return p; if (!r.texto || !r.texto.trim()) return { ok: false, msg: "Pedí " + tag("/hola") + " sin nombre y la respuesta viajó vacía. Cuando falta el parámetro, responde un mensaje útil que explique qué faltó." }; // El descuido clásico: la MISMA plantilla del saludo con el hueco // relleno feo (null/undefined/vacío) en vez de un mensaje que explique. // Se detecta por EFECTO, no buscando la palabra "null" — que un mensaje // honesto puede usar ("sin nombre saludaría a null, y eso es de robots"). var norm = function (s) { return String(s || "").replace(/\s+/g, " ").trim(); }; var base = norm(rc.texto), sin = norm(r.texto); if (base && (sin === norm(base.split(tok).join("null")) || sin === norm(base.split(tok).join("undefined")) || sin === norm(base.split(tok).join("")))) { return { ok: false, msg: "Sin el parámetro respondiste el mismo saludo con el hueco relleno (" + tag("null") + ", " + tag("undefined") + " o vacío) en vez de explicar qué faltó. Cuando " + tag(".get()") + " devuelve " + tag("null") + ", atrápalo con un " + tag("if") + " ANTES de armar el saludo." }; } return { ok: true }; } }, { key: "herencia", label: "Todo lo construido sigue vivo (rutas, JSON, 404 honesto).", check: async function (code) { return servidorVivo(code); } } ], coach: function (code) { return []; }, winMsg: "Tu servidor ya responde a la medida de lo que le piden. Pero fíjate: el cliente solo te ha PEDIDO cosas. Todavía no te ha ENTREGADO nada — ni un dato suyo para guardar. Para recibir, existe el otro verbo: POST. Y con él, tu servidor empezará a recordar." }, { id: "post", eyebrow: "Lección 6 · El sobre cargado", title: "POST: recibir datos del cliente", teach: 'Hasta hoy, tus clientes solo PIDEN. Pero desde la lección 2 sabes que existe otro verbo: POST, "te traigo datos". Con POST, la petición llega con el cuerpo cargado — el sobre ahora viaja lleno en las dos direcciones. Elige POST en el probador y míralo: aparece el campo del cuerpo.
' + '¿Y cómo se abre ese sobre? Con el gesto que ya tienes en las manos: en el Programa 3 abrías las respuestas con ' + tag("respuesta.json()") + '; aquí abres las peticiones con ' + tag("peticion.json()") + '. El mismo gesto, del otro lado. Y también es una promesa — internet tarda, los cuerpos también — así que lleva ' + tag("await") + '.
' + 'Aquí hay un peaje que ya pagaste una vez: la regla del Programa 3 — await solo vive dentro de una función async. Tu manejador entero se vuelve ' + tag("async function manejador(peticion)") + '. Es UNA palabra; todo lo que ya construiste sigue funcionando igual.
' + 'Un detalle de honestidad: los GET no traen cuerpo, así que distingue el verbo ANTES de abrir el sobre — para eso tienes ' + tag("peticion.method") + ' desde la lección 2. La misma ruta puede atender verbos distintos. El patrón, con un buzón de juguete:
' + 'Ese ' + tag("201") + ' es el código que te prometí en la lección 4: "creado" — el servidor aceptó algo nuevo. Tu misión: la ruta ' + tag("/mensajes") + ' en su versión POST — recibe un JSON con el campo ' + tag("texto") + ' y confirma con 201, incluyendo el texto que recibió (así el cliente ve que su mensaje llegó bien — devuélvele su ' + tag(".texto") + ', no el sobre entero). Pruébalo en el probador con el cuerpo ' + tag('{ "texto": "hola" }') + '.
', starter: function (prev) { return prev || ""; }, mission: [ { key: "recibe", label: "POST " + tag("/mensajes") + " abre el sobre y confirma INCLUYENDO el texto recibido.", check: async function (code) { var ctx = await servidorFresco(code); var tokA = marca("aviso"), tokC = marca("sobrante"); var ra = await postMensaje(ctx, JSON.stringify({ texto: tokA, extra: tokC })); var p = problemaDe(ctx, ra); if (p) return p; if (ra.texto && ra.texto.indexOf("object Promise") !== -1) return { ok: false, msg: "Abriste el sobre sin esperar: " + tag("peticion.json()") + " es una promesa. Llévala con " + tag("await") + " (y tu manejador con " + tag("async") + ")." }; if (!ra.texto || ra.texto.indexOf(tokA) === -1) return { ok: false, msg: "Te entregué \"" + tokA + "\" y tu confirmación no lo menciona. Abre el sobre con " + tag("await peticion.json()") + " y usa su campo " + tag(".texto") + " en la respuesta." }; if (ra.texto.indexOf(tokC) !== -1) return { ok: false, msg: "Tu confirmación devolvió el sobre entero (incluí un campo que no pediste). Abre el sobre con " + tag("await peticion.json()") + " y usa SOLO su campo " + tag(".texto") + ", no el cuerpo crudo." }; var tokB = marca("nota"); var rb = await postMensaje(ctx, JSON.stringify({ texto: tokB })); p = problemaDe(ctx, rb); if (p) return p; if (!rb.texto || rb.texto.indexOf(tokB) === -1) return { ok: false, msg: "Con otro texto, tu confirmación no cambió. Confirma con lo que LLEGÓ, no con una frase fija." }; var rr = await enviar(ctx, "POST", "/" + marca("otra"), JSON.stringify({ texto: marca("x") })); p = problemaDe(ctx, rr); if (p) return p; if (rr.status === 201) return { ok: false, msg: "Un POST a una ruta que NO es " + tag("/mensajes") + " respondió 201. Distingue también la RUTA, no solo el verbo: " + tag('peticion.method === "POST" && url.pathname === "/mensajes"') + "." }; var rg = await enviar(ctx, "GET", "/mensajes"); if (rg && rg.fallo) return { ok: false, msg: "Un GET a " + tag("/mensajes") + " rompió tu servidor: un GET no trae cuerpo que abrir. Distingue el verbo ANTES: " + tag('peticion.method === "POST"') + "." }; var pg = problemaDe(ctx, rg); if (pg) return pg; if (rg && rg.status === 201) return { ok: false, msg: "Tu ruta " + tag("/mensajes") + " atiende los GET como si fueran POST. Distingue el verbo con " + tag("peticion.method") + "." }; return { ok: true }; } }, { key: "creado", label: "La confirmación dice la verdad: " + tag("201") + ", creado.", check: async function (code) { var ctx = await servidorFresco(code); var r = await postMensaje(ctx, JSON.stringify({ texto: marca("dato") })); var p = problemaDe(ctx, r); if (p) return p; if (r.status !== 201) return { ok: false, msg: "Tu POST respondió " + r.status + ". Cuando el servidor acepta algo nuevo, el código honesto es " + tag("201") + " — va en el objeto de opciones, como el 404." }; return { ok: true }; } }, { key: "herencia", label: "Todo lo construido sigue vivo.", check: async function (code) { return servidorVivoConHola(code); } } ], coach: function (code) { return []; }, winMsg: "Tu servidor ya recibe. Pero hazte la pregunta incómoda: confirmaste \"recibido\"... ¿y DÓNDE quedó el mensaje? En ningún lado — lo confirmaste y lo dejaste caer. Tu servidor todavía no tiene memoria. Eso — la memoria que TODOS los clientes comparten — es la próxima lección, y es la diferencia entera entre este programa y el anterior." }, { id: "memoria", eyebrow: "Lección 7 · Lo que guardas, lo ven todos", title: "La memoria del servidor", teach: 'Tu app del Programa 3 guardaba en ' + tag("localStorage") + ' — el cajón del NAVEGADOR. En aquel cajón, cada navegador guardaba SU propia lista: lo tuyo no lo veía nadie más. Hoy cruzas esa frontera, la más importante del programa: la memoria del servidor es UNA, y es de todos.
' + 'La pieza es humilde: un array declarado FUERA del manejador. Ya sabes por qué fuera — es lo que viste en la lección 1: el manejador corre una vez por CADA petición, así que una variable declarada DENTRO nacería y moriría con cada una. Afuera, vive mientras el servidor corre... y como el servidor es UNO y los clientes MUCHOS, ese array los une: lo que Ana guarda con su POST, Luis lo ve con su GET. Sin hacer nada extra. Esa es toda la magia, y no es magia.
' + 'Dos honestidades. Primera: ¿y el cajón del Programa 3? En un servidor no existe — ' + tag("localStorage") + ' vive en el navegador de cada quien, justo lo que estamos dejando atrás. Si lo intentas aquí, tu laboratorio te lo dirá con todas sus letras.
' + 'Segunda: esta memoria vive mientras el servidor corre. Apágalo y enciéndelo — el botón ' + tag("⟳ Reiniciar servidor") + ' del probador — y el array vuelve a nacer vacío. ¿Te suena al revés de lo que pasaba en el Programa 3? Exacto: allá cerrabas la app, volvías a abrirla y tu lista seguía ahí (gracias al cajón); aquí reiniciar BORRA. Para sobrevivir reinicios, los servidores reales usan bases de datos — ese mundo existe y no te toca todavía. Y de ahí tu detalle profesional: un servidor recién nacido responde el array vacío con dignidad — ' + tag("[]") + ' con su 200 — porque vacío no es un error.
' + 'Tu misión: dale memoria a ' + tag("/mensajes") + '. El POST guarda antes de confirmar; el GET responde el array completo en JSON. Luego haz TÚ el experimento en el probador: POSTea dos mensajes, GETea y míralos... y reinicia, y mira el vacío.
', starter: function (prev) { return prev || ""; }, mission: [ { key: "comparte", label: "Lo que un cliente POSTea, OTRO lo ve: GET " + tag("/mensajes") + " responde el array con TODO.", check: async function (code) { var ctx = await servidorFresco(code); var tokA = marca("pan"), tokB = marca("luz"), tokC = marca("sobra"); var ra = await postMensaje(ctx, JSON.stringify({ texto: tokA, extra: tokC })); var p = problemaDe(ctx, ra); if (p) return p; var rb = await postMensaje(ctx, JSON.stringify({ texto: tokB })); p = problemaDe(ctx, rb); if (p) return p; var rg = await enviar(ctx, "GET", "/mensajes"); p = problemaDe(ctx, rg); if (p) return p; if (rg.json === null || !Array.isArray(rg.json)) return { ok: false, msg: "El GET de " + tag("/mensajes") + " debe responder el ARRAY completo en JSON: " + tag("JSON.stringify(mensajes)") + ", con su rótulo." }; var plano = JSON.stringify(rg.json); if (rg.json.length === 0) return { ok: false, msg: "Guardé dos mensajes y el array llegó vacío. ¿Tu array vive DENTRO del manejador? Ahí nace y muere con cada petición: sácalo AFUERA." }; if (rg.json.length === 1) return { ok: false, msg: "Guardé dos mensajes y el array solo trae uno. ¿Reemplazas en vez de agregar? La memoria crece con " + tag("push") + "." }; if (plano.indexOf(tokA) === -1 || plano.indexOf(tokB) === -1) return { ok: false, msg: "El array no trae lo que guardé (\"" + tokA + "\" y \"" + tokB + "\"). Guarda el " + tag(".texto") + " que llegó en el sobre, no otra cosa." }; if (plano.indexOf(tokC) !== -1) return { ok: false, msg: "Guardaste el sobre entero (incluí un campo que no pediste), no el " + tag(".texto") + ". Abre el sobre con " + tag("await peticion.json()") + " y guarda SOLO " + tag("datos.texto") + "." }; if (rg.json.length !== 2) return { ok: false, msg: "Guardé exactamente dos mensajes y el array trae " + rg.json.length + ". Cada POST guarda UNO." }; if (String(rg.contentType).toLowerCase().indexOf("application/json") === -1) return { ok: false, msg: "El array viaja sin su rótulo. Como en la lección 3, el GET lleva su etiqueta: " + tag('new Response(JSON.stringify(mensajes), { headers: { "Content-Type": "application/json" } })') + "." }; return { ok: true }; } }, { key: "limpio", label: "Un servidor recién encendido responde el array VACÍO con dignidad (200 y " + tag("[]") + ").", check: async function (code) { var ctx = await servidorFresco(code); var r = await enviar(ctx, "GET", "/mensajes"); var p = problemaDe(ctx, r); if (p) return p; if (r.status !== 200 || r.json === null || !Array.isArray(r.json)) return { ok: false, msg: "Recién encendido, sin mensajes, el GET debe responder " + tag("[]") + " con 200. Vacío no es un error: es la verdad de un servidor nuevo." }; if (r.json.length !== 0) return { ok: false, msg: "Un servidor recién encendido trae " + r.json.length + " mensajes de fábrica. La memoria empieza vacía: " + tag("const mensajes = [];") + " y nada más." }; return { ok: true }; } }, { key: "herencia", label: "El POST sigue confirmando con 201, y todo lo demás vive.", check: async function (code) { var ctx = await servidorFresco(code); var tok = marca("eco"); var r = await postMensaje(ctx, JSON.stringify({ texto: tok })); var p = problemaDe(ctx, r); if (p) return p; if (r.status !== 201 || !r.texto || r.texto.indexOf(tok) === -1) return { ok: false, msg: "Se rompió lo de la lección 6: el POST debe seguir confirmando con " + tag("201") + " y mencionando lo recibido." }; return servidorVivoConHola(code); } } ], coach: function (code) { return []; }, winMsg: "Cruzaste la frontera: tu servidor RECUERDA, y su memoria es de todos sus clientes. Haz el experimento del reinicio en el probador y siente la diferencia con el Programa 3. Lo que sigue es ponerle modales a la puerta: ¿qué pasa cuando el cliente te manda un sobre roto, o vacío? Hoy tu servidor confía en todos. Los servidores de verdad, no." }, { id: "validacion", eyebrow: "Lección 8 · La puerta con modales", title: "Validar: no todo sobre merece un sí", teach: 'En el Programa 3 aprendiste que internet falla y tu app debía avisar con dignidad. Hoy la moneda gira: ahora el que falla es el cliente. Te mandará sobres rotos, sobres vacíos, sobres sin lo prometido — no siempre por maldad: los programas ajenos se equivocan. Tu servidor hoy confía en todos, y eso se acaba en esta lección.
' + 'Dos peligros distintos, dos defensas. Primero, el sobre roto: si el cuerpo no es JSON válido, ' + tag("await peticion.json()") + ' lanza un error — y sin protección, la petición se cae (pruébalo en el probador: POST con cuerpo ' + tag("hola sin comillas") + ' y verás a tu servidor romperse). Un servidor de verdad, cuando se rompe por su cuenta, responde un feo 500: "el problema es mío, servidor". La defensa la conoces del Programa 3: ' + tag("try/catch") + ' — allá protegía tu carga de datos; acá protege tu puerta. Detalle de oficio: declara ' + tag("let datos;") + ' ANTES del ' + tag("try") + ' — lo declarado dentro de un bloque muere con el bloque, y tú necesitas los datos después.
' + 'Segundo, el sobre incompleto: JSON válido... sin el campo prometido. Un campo que no vino vale ' + tag("undefined") + ' — el primo de null: null es "aquí no hay nada" a propósito; undefined es "esto nunca existió". Y un detalle de oficio, primo del que cuidabas en el Programa 3 al guardar datos: el texto vacío ' + tag('""') + ' tampoco merece guardarse.
' + '¿Y qué responde una puerta educada cuando el sobre viene mal? La familia del 404 tiene otro miembro: 400, "tu petición está mal". Los códigos que empiezan en 4 significan "el problema es tuyo, cliente"; los que empiezan en 5, "el problema es mío, servidor". El patrón completo:
' + 'Fíjate en el orden: los ' + tag("return") + ' del 400 salen ANTES del ' + tag("push") + '. Rechazar después de guardar es no rechazar. Tu misión: blinda el POST de ' + tag("/mensajes") + ' con las dos defensas — y que los sobres buenos sigan pasando como siempre.
', starter: function (prev) { return prev || ""; }, mission: [ { key: "roto", label: "Un sobre roto recibe " + tag("400") + " con aviso — y tu servidor sigue de pie.", check: async function (code) { var ctx = await servidorFresco(code); var r = await postMensaje(ctx, "esto no es JSON {{{"); if (r && (r.fallo || ctx.error)) return { ok: false, msg: "El sobre roto tumbó tu servidor. Rodea el " + tag("await peticion.json()") + " con " + tag("try/catch") + " y responde el 400 en el " + tag("catch") + "." }; var p = problemaDe(ctx, r); if (p) return p; if (r.status !== 400) return { ok: false, msg: "Al sobre roto respondiste " + r.status + ". Cuando la culpa es del cliente, el código honesto es " + tag("400") + "." }; if (r.json === null || typeof r.json !== "object") return { ok: false, msg: "El aviso del 400 también viaja en JSON, como el del 404." }; var r2 = await postMensaje(ctx, JSON.stringify({ texto: marca("vivo") })); p = problemaDe(ctx, r2); if (p) return p; if (r2.status !== 201) return { ok: false, msg: "Tras un sobre roto, el siguiente sobre BUENO debe pasar normal (201). Tu servidor no puede quedar resentido." }; return { ok: true }; } }, { key: "incompleto", label: "Sobre sin " + tag("texto") + " (o vacío): " + tag("400") + ", y NADA se guarda.", check: async function (code) { var ctx = await servidorFresco(code); var r1 = await postMensaje(ctx, JSON.stringify({ otra: "cosa" })); var p = problemaDe(ctx, r1); if (p) return p; if (r1.status !== 400) return { ok: false, msg: "Acepté un sobre SIN el campo " + tag("texto") + " (respondiste " + r1.status + "). Revisa: " + tag("datos.texto === undefined") + " merece un 400." }; var r2 = await postMensaje(ctx, JSON.stringify({ texto: "" })); p = problemaDe(ctx, r2); if (p) return p; if (r2.status !== 400) return { ok: false, msg: "Un texto vacío tampoco merece guardarse — el detalle de oficio que cuidabas en el Programa 3. También es " + tag("400") + "." }; var rg = await enviar(ctx, "GET", "/mensajes"); p = problemaDe(ctx, rg); if (p) return p; if (!Array.isArray(rg.json) || rg.json.length !== 0) return { ok: false, msg: "Rechazaste el sobre... pero IGUAL lo guardaste. El " + tag("return") + " del 400 debe salir ANTES del " + tag("push") + "." }; return { ok: true }; } }, { key: "herencia", label: "Los sobres buenos siguen pasando, y todo lo demás vive.", check: async function (code) { var ctx = await servidorFresco(code); var tok = marca("sano"), tokX = marca("sobra"); var r = await postMensaje(ctx, JSON.stringify({ texto: tok, extra: tokX })); var p = problemaDe(ctx, r); if (p) return p; if (r.status !== 201 || !r.texto || r.texto.indexOf(tok) === -1) return { ok: false, msg: "Blindaste tanto la puerta que los sobres BUENOS ya no pasan: un texto válido debe seguir guardándose con su 201." }; var rg = await enviar(ctx, "GET", "/mensajes"); p = problemaDe(ctx, rg); if (p) return p; if (!Array.isArray(rg.json) || JSON.stringify(rg.json).indexOf(tok) === -1) return { ok: false, msg: "El sobre bueno respondió 201 pero no quedó en la memoria. Valida primero, guarda después — pero guarda." }; if (JSON.stringify(rg.json).indexOf(tokX) !== -1) return { ok: false, msg: "Se rompió lo de la lección 7: al blindar la puerta guardaste el sobre entero (con un campo que no pedí), no el " + tag(".texto") + "." }; return servidorVivoConHola(code); } } ], coach: function (code) { return []; }, winMsg: "Tu servidor ya tiene puerta con modales: rechaza lo roto con un 400 que explica, y deja pasar lo bueno. Queda la pregunta más grande de todas — la que responde cada \"inicia sesión\" del mundo: ¿cómo sabe un servidor QUIÉN le está hablando? Esa es la próxima lección." }, { id: "usuarios", eyebrow: "Lección 9 · ¿Quién eres tú?", title: "Usuarios: la ficha que te reconoce", teach: 'Tu servidor recuerda mensajes... pero no sabe DE QUIÉN son. Y no es un descuido tuyo: es la naturaleza de HTTP — cada petición llega sola, sin pasado, como un desconocido que toca la puerta. Todo "inicia sesión" que has visto en tu vida es la respuesta a una sola pregunta: ¿cómo reconoce un servidor a quien le habla?
' + 'La respuesta es el guardarropa. Cuando dejas tu abrigo, no te piden demostrar quién eres: te dan una ficha única, y quien trae la ficha, ese es el dueño. En los servidores la ficha se llama token (en inglés, como en las apps reales) — y así, ' + tag("token") + ', se llamará el campo que devuelvas en tu JSON, aunque aquí le digamos "ficha". Al registrarte, el servidor inventa una, la asocia con tu nombre en su memoria, y TE LA ENTREGA. Desde entonces, tus peticiones viajan con la ficha, y el servidor te reconoce.
' + 'Las piezas, todas conocidas. La ficha se inventa con ' + tag("crypto.randomUUID()") + ' — el generador de identificadores únicos e impredecibles que traen los navegadores y los servidores (nada de inventar fichas adivinables a mano). Las parejas ficha→nombre viven en un objeto usado como diccionario — y accedes con corchetes porque la clave vive en una variable, como aprendiste en el Programa 1. Y la ficha viaja en un header de la petición: ' + tag("Authorization") + '. ¿Headers? Los conoces desde la lección 3 — etiquetas del sobre. Allá las escribías en tus respuestas; acá las LEES de las peticiones: ' + tag('peticion.headers.get("Authorization")') + '. El espejo, otra vez. Y como el ' + tag(".get()") + ' de la lección 5, si el header no vino, ' + tag("headers.get") + ' devuelve ' + tag("null") + ' — por eso el guardián empieza comparando con ' + tag("null") + '. (El probador ya te muestra el campo para pegarla.)
' + 'Ese ' + tag("401") + ' es el tercer miembro de la familia que ya conoces: 404 "eso no existe", 400 "tu petición está mal", 401 "no sé quién eres".
' + 'Una honestidad antes de empezar: ¿y las contraseñas? Existen para probar que un nombre es TUYO al volver otro día. Hoy tu registro confía en el nombre — suficiente para aprender el mecanismo real. Las contraseñas de verdad JAMÁS se guardan tal cual (se guardan trituradas por una función irreversible), y hacer eso bien es un oficio entero, con décadas de errores ajenos aprendidos. Aquí no vamos a fabricar una versión de juguete que te enseñe malos hábitos: los servidores serios usan piezas ya probadas, y tú usarás las tuyas cuando te toque. Lo que te llevas hoy es el corazón de toda sesión: la ficha.
' + 'Tu misión: ' + tag("POST /registro") + ' (recibe ' + tag("nombre") + ', lo valida como ayer, inventa la ficha, la guarda y la ENTREGA con 201) y ' + tag("GET /quien-soy") + ' (con ficha conocida te nombra; sin ficha o con ficha falsa, 401).
', starter: function (prev) { return prev || ""; }, mission: [ { key: "registro", label: tag("POST /registro") + " entrega una ficha ÚNICA a cada quien (y valida el nombre).", check: async function (code) { var ctx = await servidorFresco(code); var nA = marca("Ana"), nB = marca("Luis"), nC = marca("Carla"); var ra = await enviar(ctx, "POST", "/registro", JSON.stringify({ nombre: nA })); var p = problemaDe(ctx, ra); if (p) return p; if (ra.status !== 201) return { ok: false, msg: "El registro crea algo nuevo: responde " + tag("201") + " (respondiste " + ra.status + ")." }; if (ra.json === null || typeof ra.json !== "object" || typeof ra.json.token !== "string" || ra.json.token.length < 8) return { ok: false, msg: "La respuesta del registro debe ENTREGAR la ficha: un campo " + tag("token") + " en el JSON. Sin ficha en mano, el cliente no puede volver." }; var rb = await enviar(ctx, "POST", "/registro", JSON.stringify({ nombre: nB })); p = problemaDe(ctx, rb); if (p) return p; var rc = await enviar(ctx, "POST", "/registro", JSON.stringify({ nombre: nC })); p = problemaDe(ctx, rc); if (p) return p; if (!rb.json || !rc.json || typeof rb.json.token !== "string" || typeof rc.json.token !== "string" || ra.json.token === rb.json.token || ra.json.token === rc.json.token || rb.json.token === rc.json.token) return { ok: false, msg: "Dos registros recibieron la MISMA ficha. Cada ficha nace única: " + tag("crypto.randomUUID()") + ", una por registro." }; // Fichas ADIVINABLES: comparten casi todo y solo cambia una cola de dígitos (contador, fecha...) — justo lo que la lección prohíbe. var secuencial = function (a, b) { var n = Math.min(a.length, b.length), i = 0; while (i < n && a.charAt(i) === b.charAt(i)) i++; return i >= Math.max(a.length, b.length) - 3 && /^[0-9]*$/.test(a.slice(i)) && /^[0-9]*$/.test(b.slice(i)); }; if (secuencial(ra.json.token, rb.json.token) || secuencial(rb.json.token, rc.json.token) || secuencial(ra.json.token, rc.json.token)) return { ok: false, msg: "Tus fichas son adivinables: una lleva a la siguiente (un contador, la fecha...). Cualquiera adivinaría la del vecino y se haría pasar por él. Invéntalas con " + tag("crypto.randomUUID()") + ", impredecibles." }; var rv = await enviar(ctx, "POST", "/registro", JSON.stringify({ otra: "cosa" })); p = problemaDe(ctx, rv); if (p) return p; if (rv.status !== 400) return { ok: false, msg: "Un registro sin " + tag("nombre") + " merece el " + tag("400") + " de la lección pasada. La validación no era de una sola ruta." }; return { ok: true }; } }, { key: "reconoce", label: tag("GET /quien-soy") + " con tu ficha en " + tag("Authorization") + " responde TU nombre.", check: async function (code) { var ctx = await servidorFresco(code); var nA = marca("Ana"), nB = marca("Luis"); var ra = await enviar(ctx, "POST", "/registro", JSON.stringify({ nombre: nA })); var p = problemaDe(ctx, ra); if (p) return p; var rb = await enviar(ctx, "POST", "/registro", JSON.stringify({ nombre: nB })); p = problemaDe(ctx, rb); if (p) return p; if (!ra.json || !rb.json || typeof ra.json.token !== "string" || typeof rb.json.token !== "string") return { ok: false, msg: "Primero necesito que el registro entregue las fichas (misión anterior)." }; var qa = await enviar(ctx, "GET", "/quien-soy", undefined, ra.json.token); p = problemaDe(ctx, qa); if (p) return p; if (qa.status !== 200 || !qa.texto || qa.texto.indexOf(nA) === -1) return { ok: false, msg: "Llegué con la ficha de \"" + nA + "\" y tu servidor no respondió su nombre. Lee " + tag('peticion.headers.get("Authorization")') + " y busca en tu objeto con corchetes." }; var qb = await enviar(ctx, "GET", "/quien-soy", undefined, rb.json.token); p = problemaDe(ctx, qb); if (p) return p; if (qb.status !== 200 || !qb.texto || qb.texto.indexOf(nB) === -1 || qb.texto.indexOf(nA) !== -1) return { ok: false, msg: "Con DOS fichas vivas, cada una debe responder SU nombre. ¿Guardaste solo el último? Las parejas van en el objeto: " + tag("usuarios[token] = nombre") + "." }; return { ok: true }; } }, { key: "rechaza", label: "Sin ficha, o con ficha falsa: " + tag("401") + ". Y todo lo demás vive.", check: async function (code) { var ctx = await servidorFresco(code); var r0 = await enviar(ctx, "GET", "/quien-soy"); var p = problemaDe(ctx, r0); if (p) return p; if (r0.status !== 401) return { ok: false, msg: "Sin ficha respondiste " + r0.status + ". El código para \"no sé quién eres\" es " + tag("401") + "." }; if (r0.json === null || typeof r0.json !== "object") return { ok: false, msg: "El aviso del 401 también viaja en JSON, como sus hermanos 400 y 404." }; var rf = await enviar(ctx, "GET", "/quien-soy", undefined, "ficha-falsa-" + marca("x")); p = problemaDe(ctx, rf); if (p) return p; if (rf.status !== 401) return { ok: false, msg: "Una ficha que tu servidor JAMÁS entregó respondió " + rf.status + ". Ficha desconocida = " + tag("401") + ": revisa " + tag("usuarios[ficha] === undefined") + "." }; var rroto = await postMensaje(ctx, "sobre roto {{{"); if (rroto && (rroto.fallo || ctx.error)) return { ok: false, msg: "Se rompió lo de la lección 8: el sobre roto volvió a tumbar tu servidor." }; if (rroto.status !== 400) return { ok: false, msg: "Se rompió lo de la lección 8: el sobre roto debe seguir recibiendo su 400." }; return servidorVivoConHola(code); } } ], coach: function (code) { return []; }, winMsg: "Tu servidor ya reconoce a su gente: ficha única, entregada al registrarse, leída en cada visita. Regístrate en el probador, pega tu ficha en el campo de Authorization y pregúntale /quien-soy — se siente distinto cuando el servidor te nombra. Lo que sigue es el PROYECTO: juntar todo — mensajes con dueño, tu API completa — y después, el mundo real." }, { id: "p-dueno", eyebrow: "Proyecto · Parte 1", title: "El muro: mensajes con dueño", teach: 'Empieza el proyecto. Tus rutas dejan de ser ejercicios y se vuelven UNA cosa: el muro — un espacio compartido donde la gente registrada escribe, todos leen, y cada quien borra lo suyo. Hoy: los mensajes ganan dueño.
' + 'Primero, la pregunta de seguridad más importante que vas a responder en este programa: ¿cómo sabe el servidor QUIÉN escribió? La opción ingenua es que el sobre traiga un campo con el nombre... y es un agujero: cualquiera puede escribir "soy Ana". La regla de oro de los servidores: nunca le preguntes al cliente quién es — pregúntaselo a la ficha. El nombre no viene del sobre: lo pone TU servidor, leyendo ' + tag("usuarios[ficha]") + '. A eso se le llama firmar. Y si el sobre trae un campo de autor... ni lo mires.
' + 'Los cambios de hoy en ' + tag("/mensajes") + ': escribir exige ficha (el guardián del 401 que ya escribiste para ' + tag("/quien-soy") + ' — mismo patrón, otra puerta), el mensaje se guarda como objeto con su texto y su autor firmado, y la confirmación responde el mensaje COMPLETO creado — el cliente ve exactamente lo que quedó:
' + 'Un orden con lógica: la puerta pregunta quién eres (401) ANTES de abrir tu sobre (400). ¿Y leer? El GET del muro queda público, sin ficha — en TU servidor decides tú qué es de todos, y un muro se lee sin registrarse. Pruébalo entero en el probador: escribe sin ficha (rechazo), regístrate, escribe con ficha, y lee tu muro firmado.
', starter: function (prev) { return prev || ""; }, mission: [ { key: "guardian", label: "Escribir exige ficha: sin ella o con ficha falsa, " + tag("401") + " y nada se guarda.", check: async function (code) { var ctx = await servidorFresco(code); var r1 = await enviar(ctx, "POST", "/mensajes", JSON.stringify({ texto: marca("colado") })); var p = problemaDe(ctx, r1); if (p) return p; if (r1.status !== 401) return { ok: false, msg: "Un POST SIN ficha respondió " + r1.status + ". Escribir en el muro exige identificarse: guardián del " + tag("401") + ", como en " + tag("/quien-soy") + "." }; var r2 = await enviar(ctx, "POST", "/mensajes", JSON.stringify({ texto: marca("colado") }), "ficha-falsa-" + marca("x")); p = problemaDe(ctx, r2); if (p) return p; if (r2.status !== 401) return { ok: false, msg: "Una ficha que tu servidor jamás entregó pudo escribir (respondiste " + r2.status + "). Ficha desconocida = " + tag("401") + "." }; var rroto = await enviar(ctx, "POST", "/mensajes", "sobre roto {{{"); p = problemaDe(ctx, rroto); if (p) return p; if (rroto.status !== 401) return { ok: false, msg: "Un sobre roto SIN ficha respondió " + rroto.status + ". La puerta pregunta quién eres (" + tag("401") + ") ANTES de abrir el sobre (" + tag("400") + "): comprueba la ficha primero." }; var rg = await enviar(ctx, "GET", "/mensajes"); p = problemaDe(ctx, rg); if (p) return p; if (!Array.isArray(rg.json)) return { ok: false, msg: "No pude LEER el muro para verificar: el GET de " + tag("/mensajes") + " es público — sin ficha — y responde el array." }; if (rg.json.length !== 0) return { ok: false, msg: "Rechazaste al colado... pero su mensaje quedó guardado. El " + tag("return") + " del 401 va ANTES del " + tag("push") + "." }; return { ok: true }; } }, { key: "firma", label: "Con ficha, el mensaje queda FIRMADO por el servidor — aunque el sobre mienta.", check: async function (code) { var ctx = await servidorFresco(code); var nA = marca("Ana"); var reg = await enviar(ctx, "POST", "/registro", JSON.stringify({ nombre: nA })); var p = problemaDe(ctx, reg); if (p) return p; if (!reg.json || typeof reg.json.token !== "string") return { ok: false, msg: "Necesito el registro de la lección 9 entregando su ficha para probar el muro." }; var tokTxt = marca("mensaje"), impostor = "Impostor-" + marca("x"); var rp = await enviar(ctx, "POST", "/mensajes", JSON.stringify({ texto: tokTxt, autor: impostor }), reg.json.token); p = problemaDe(ctx, rp); if (p) return p; if (rp.status !== 201) return { ok: false, msg: "Con ficha válida y texto válido, el muro debe aceptar con " + tag("201") + " (respondiste " + rp.status + ")." }; var rg = await enviar(ctx, "GET", "/mensajes"); p = problemaDe(ctx, rg); if (p) return p; if (!Array.isArray(rg.json) || rg.json.length !== 1) return { ok: false, msg: "Guardé un mensaje con ficha y el muro no lo trae. El " + tag("push") + " sigue siendo el guardado." }; var plano = JSON.stringify(rg.json); if (plano.indexOf(tokTxt) === -1) return { ok: false, msg: "El mensaje guardado no trae su texto. Guarda el objeto " + tag("{ texto, autor }") + "." }; if (plano.indexOf(impostor) !== -1) return { ok: false, msg: "El sobre dijo ser \"" + impostor + "\" y tu servidor LE CREYÓ. El autor no viene del sobre: lo firma el servidor con " + tag("usuarios[ficha]") + "." }; if (plano.indexOf(nA) === -1) return { ok: false, msg: "El mensaje quedó sin su autor. Firma con el nombre de la ficha: " + tag("autor: usuarios[ficha]") + "." }; return { ok: true }; } }, { key: "muro", label: "El muro es de todos: dos registrados escriben, cualquiera lo lee SIN ficha.", check: async function (code) { var ctx = await servidorFresco(code); var nA = marca("Ana"), nB = marca("Luis"), tA = marca("primero"), tB = marca("segundo"); var ra = await enviar(ctx, "POST", "/registro", JSON.stringify({ nombre: nA })); var rb = await enviar(ctx, "POST", "/registro", JSON.stringify({ nombre: nB })); var p = problemaDe(ctx, ra) || problemaDe(ctx, rb); if (p) return p; if (!ra.json || !rb.json || typeof ra.json.token !== "string" || typeof rb.json.token !== "string") return { ok: false, msg: "Necesito dos registros con sus fichas para probar el muro compartido." }; await enviar(ctx, "POST", "/mensajes", JSON.stringify({ texto: tA }), ra.json.token); await enviar(ctx, "POST", "/mensajes", JSON.stringify({ texto: tB }), rb.json.token); var rg = await enviar(ctx, "GET", "/mensajes"); p = problemaDe(ctx, rg); if (p) return p; if (rg.status !== 200 || !Array.isArray(rg.json)) return { ok: false, msg: "Leer el muro es público: el GET sin ficha responde el array con 200." }; var plano = JSON.stringify(rg.json); if (rg.json.length !== 2 || plano.indexOf(tA) === -1 || plano.indexOf(tB) === -1) return { ok: false, msg: "Dos registrados escribieron y el muro no trae los dos mensajes completos." }; if (plano.indexOf(nA) === -1 || plano.indexOf(nB) === -1) return { ok: false, msg: "El muro trae los textos pero no las firmas: cada mensaje viaja con su autor." }; return { ok: true }; } }, { key: "herencia", label: "La validación de ayer sigue en la puerta, y todo lo demás vive.", check: async function (code) { var ctx = await servidorFresco(code); var reg = await enviar(ctx, "POST", "/registro", JSON.stringify({ nombre: marca("Eva") })); var p = problemaDe(ctx, reg); if (p) return p; if (!reg.json || typeof reg.json.token !== "string") return { ok: false, msg: "Necesito el registro entregando su ficha." }; var t = reg.json.token; var rroto = await enviar(ctx, "POST", "/mensajes", "sobre roto {{{", t); if (rroto && (rroto.fallo || ctx.error)) return { ok: false, msg: "Con ficha válida, el sobre roto volvió a tumbar tu servidor: el " + tag("try/catch") + " de la lección 8 sigue de guardia." }; if (rroto.status !== 400) return { ok: false, msg: "Con ficha válida, el sobre roto debe seguir recibiendo su " + tag("400") + " (respondiste " + rroto.status + ")." }; var rvacio = await enviar(ctx, "POST", "/mensajes", JSON.stringify({ texto: "" }), t); p = problemaDe(ctx, rvacio); if (p) return p; if (rvacio.status !== 400) return { ok: false, msg: "El texto vacío debe seguir recibiendo su " + tag("400") + ", también con ficha." }; var rgv = await enviar(ctx, "GET", "/mensajes"); p = problemaDe(ctx, rgv); if (p) return p; if (!Array.isArray(rgv.json) || rgv.json.length !== 0) return { ok: false, msg: "Rechazaste el sobre roto y el vacío con 400... pero alguno quedó guardado. El " + tag("return") + " del 400 va ANTES del " + tag("push") + ", también con ficha válida." }; return servidorVivoConHola(code); } } ], coach: function (code) { return []; }, winMsg: "Tu muro ya tiene autores de verdad — firmados por el servidor, no por confianza. Pero un muro donde nada se puede borrar se llena de arrepentimientos. Falta el tercer verbo: DELETE. Y con él, la pregunta más fina del programa: ¿quién puede borrar QUÉ?" }, { id: "p-borrar", eyebrow: "Proyecto · Parte 2", title: "Borrar lo tuyo (y solo lo tuyo)", teach: 'Para borrar UN mensaje hay que poder señalarlo sin confusión — dos mensajes pueden decir exactamente lo mismo. La solución ya la tienes en las manos: cada mensaje nace con un id único, fabricado por la misma máquina de tus fichas: ' + tag("crypto.randomUUID()") + '. Un UUID no era solo para fichas: es un nombre único para CUALQUIER cosa.
' + 'Y llega el tercer verbo: DELETE — "esto debe dejar de existir". Se enruta igual que siempre (' + tag('peticion.method === "DELETE"') + '), y el id viaja como parámetro (lección 5): ' + tag("/mensajes?id=...") + '. El probador ya lo tiene en su lista de métodos.
' + 'Ahora la pregunta fina: ¿quién puede borrar qué? En tu muro, cada quien borra lo suyo. Eso se decide comparando la firma del mensaje con la ficha de quien pide borrar... y cuando la ficha es válida pero el mensaje es AJENO, la familia de los códigos te presta su cuarto miembro: 403 — "sé quién eres, y no puedes". Compáralo con sus hermanos: 401 es "no sé quién eres"; 403 es "te conozco, pero esto no es tuyo".
' + 'Los cuatro guardianes del DELETE, en orden: ¿quién eres? (' + tag("401") + ') → ¿qué id traes? (' + tag("400") + ' si falta) → ¿existe? (' + tag("404") + ') → ¿es tuyo? (' + tag("403") + '). Y para quitarlo del array, herramientas viejas: un bucle ' + tag("for") + ' del Programa 1 buscando el id, y el ' + tag("splice") + ' del Programa 3 — el mismo que borraba de tu lista, hoy del lado del servidor. Un detalle: ' + tag("splice") + ' te DEVUELVE un arreglo con lo que quitó, así que ' + tag("splice(i, 1)[0]") + ' saca de ahí tu único mensaje borrado:
' + 'Tu misión: el DELETE completo, con sus cuatro guardianes. Al terminar, haz el ciclo entero en el probador: regístrate, escribe, lee tu id en el muro, bórralo... e intenta borrar algo con otra ficha, para ver tu 403 en rojo.
', starter: function (prev) { return prev || ""; }, mission: [ { key: "borra", label: "Con tu ficha y el id de TU mensaje: borrado de verdad (200 y fuera del muro).", check: async function (code) { var ctx = await servidorFresco(code); var reg = await enviar(ctx, "POST", "/registro", JSON.stringify({ nombre: marca("Ana") })); var p = problemaDe(ctx, reg); if (p) return p; if (!reg.json || typeof reg.json.token !== "string") return { ok: false, msg: "Necesito el registro entregando su ficha." }; var t = reg.json.token, tUno = marca("uno"), tDos = marca("dos"); await enviar(ctx, "POST", "/mensajes", JSON.stringify({ texto: tUno }), t); await enviar(ctx, "POST", "/mensajes", JSON.stringify({ texto: tDos }), t); var rg = await enviar(ctx, "GET", "/mensajes"); p = problemaDe(ctx, rg); if (p) return p; if (!Array.isArray(rg.json) || rg.json.length !== 2) return { ok: false, msg: "Primero necesito el muro de la parte 1 funcionando (escribir con ficha y leer)." }; var id1 = rg.json[0] && rg.json[0].id, id2 = rg.json[1] && rg.json[1].id; if (typeof id1 !== "string" || id1.length < 8 || typeof id2 !== "string" || id2.length < 8) return { ok: false, msg: "Tus mensajes no nacen con su " + tag("id") + ". Cada mensaje lo necesita: " + tag("id: crypto.randomUUID()") + " al crearlo." }; if (id1 === id2) return { ok: false, msg: "Tus dos mensajes tienen el MISMO " + tag("id") + ". Cada mensaje nace con el suyo, único, para poder señalarlo sin confusión: un " + tag("crypto.randomUUID()") + " por mensaje." }; // Borra el SEGUNDO por su id; el PRIMERO debe sobrevivir. Así el id de verdad SEÑALA // (borrar mensajes[0] a ciegas, o un id constante, se cae aquí). var rd = await enviar(ctx, "DELETE", "/mensajes?id=" + id2, undefined, t); p = problemaDe(ctx, rd); if (p) return p; if (rd.status !== 200 && rd.status !== 204) return { ok: false, msg: "Borrar lo propio con id válido debe responder " + tag("200") + " (respondiste " + rd.status + ")." }; var rg2 = await enviar(ctx, "GET", "/mensajes"); p = problemaDe(ctx, rg2); if (p) return p; if (!Array.isArray(rg2.json)) return { ok: false, msg: "Tras el borrado, el muro debe seguir leyéndose en público." }; var plano2 = JSON.stringify(rg2.json); if (plano2.indexOf(tDos) !== -1) return { ok: false, msg: "Respondiste OK... pero el mensaje que pedí borrar SIGUE en el muro. El " + tag("splice") + " es el borrado de verdad." }; if (rg2.json.length !== 1 || plano2.indexOf(tUno) === -1) return { ok: false, msg: "Borraste el mensaje EQUIVOCADO: pedí borrar uno por su " + tag("id") + " y desapareció otro. Recorre el muro y borra el mensaje cuyo " + tag("id") + " coincide, no el primero de la lista." }; return { ok: true }; } }, { key: "ajeno", label: "Nadie borra lo ajeno: ficha de otro → " + tag("403") + " y el mensaje SIGUE.", check: async function (code) { var ctx = await servidorFresco(code); var ra = await enviar(ctx, "POST", "/registro", JSON.stringify({ nombre: marca("Ana") })); var rb = await enviar(ctx, "POST", "/registro", JSON.stringify({ nombre: marca("Luis") })); var p = problemaDe(ctx, ra) || problemaDe(ctx, rb); if (p) return p; if (!ra.json || !rb.json || typeof ra.json.token !== "string" || typeof rb.json.token !== "string") return { ok: false, msg: "Necesito dos registros con sus fichas." }; var tokTxt = marca("intocable"); await enviar(ctx, "POST", "/mensajes", JSON.stringify({ texto: tokTxt }), ra.json.token); var rg = await enviar(ctx, "GET", "/mensajes"); p = problemaDe(ctx, rg); if (p) return p; var id = rg.json && rg.json[0] && rg.json[0].id; if (typeof id !== "string") return { ok: false, msg: "Necesito los mensajes naciendo con su " + tag("id") + " (misión anterior)." }; var rd = await enviar(ctx, "DELETE", "/mensajes?id=" + id, undefined, rb.json.token); p = problemaDe(ctx, rd); if (p) return p; if (rd.status !== 403) return { ok: false, msg: "Luis intentó borrar lo de Ana y respondiste " + rd.status + ". \"Te conozco, pero esto no es tuyo\" tiene su código: " + tag("403") + "." }; var rg2 = await enviar(ctx, "GET", "/mensajes"); p = problemaDe(ctx, rg2); if (p) return p; if (!Array.isArray(rg2.json) || JSON.stringify(rg2.json).indexOf(tokTxt) === -1) return { ok: false, msg: "Respondiste 403... pero el mensaje desapareció igual. El 403 va ANTES del " + tag("splice") + "." }; return { ok: true }; } }, { key: "guardianes", label: "Los otros guardianes: sin ficha " + tag("401") + ", sin id " + tag("400") + ", id inexistente " + tag("404") + ".", check: async function (code) { var ctx = await servidorFresco(code); var reg = await enviar(ctx, "POST", "/registro", JSON.stringify({ nombre: marca("Eva") })); var p = problemaDe(ctx, reg); if (p) return p; if (!reg.json || typeof reg.json.token !== "string") return { ok: false, msg: "Necesito el registro entregando su ficha." }; var r1 = await enviar(ctx, "DELETE", "/mensajes?id=cualquiera"); p = problemaDe(ctx, r1); if (p) return p; if (r1.status !== 401) return { ok: false, msg: "Un DELETE sin ficha respondió " + r1.status + ". Borrar también exige saber quién eres: " + tag("401") + "." }; var rff = await enviar(ctx, "DELETE", "/mensajes?id=cualquiera", undefined, "ficha-falsa-" + marca("x")); p = problemaDe(ctx, rff); if (p) return p; if (rff.status !== 401) return { ok: false, msg: "Una ficha que tu servidor jamás entregó pudo intentar borrar (respondiste " + rff.status + "). Ficha desconocida también es " + tag("401") + ": revisa " + tag("usuarios[ficha] === undefined") + "." }; var rni = await enviar(ctx, "DELETE", "/mensajes"); p = problemaDe(ctx, rni); if (p) return p; if (rni.status !== 401) return { ok: false, msg: "Un DELETE sin ficha Y sin id respondió " + rni.status + ". El orden importa: pregunta quién eres (" + tag("401") + ") ANTES de exigir el id (" + tag("400") + "). Comprueba la ficha primero." }; var r2 = await enviar(ctx, "DELETE", "/mensajes", undefined, reg.json.token); p = problemaDe(ctx, r2); if (p) return p; if (r2.status !== 400) return { ok: false, msg: "Un DELETE sin " + tag("?id=") + " respondió " + r2.status + ". Petición incompleta: " + tag("400") + " (el " + tag("null") + " del parámetro, como en la lección 5)." }; var r3 = await enviar(ctx, "DELETE", "/mensajes?id=no-existe-" + marca("x"), undefined, reg.json.token); p = problemaDe(ctx, r3); if (p) return p; if (r3.status !== 404) return { ok: false, msg: "Un id que no existe en el muro respondió " + r3.status + ". \"Eso no existe\" ya tiene dueño: " + tag("404") + "." }; return { ok: true }; } }, { key: "herencia", label: "El muro entero sigue vivo: escribir firmado, leer público, y todo lo demás.", check: async function (code) { var ctx = await servidorFresco(code); var nA = marca("Ana"), tokTxt = marca("perdura"); var reg = await enviar(ctx, "POST", "/registro", JSON.stringify({ nombre: nA })); var p = problemaDe(ctx, reg); if (p) return p; if (!reg.json || typeof reg.json.token !== "string") return { ok: false, msg: "Necesito el registro entregando su ficha." }; var rp = await enviar(ctx, "POST", "/mensajes", JSON.stringify({ texto: tokTxt, autor: "Impostor" }), reg.json.token); p = problemaDe(ctx, rp); if (p) return p; if (rp.status !== 201) return { ok: false, msg: "Se rompió la parte 1: escribir con ficha válida debe seguir aceptando con 201." }; var rg = await enviar(ctx, "GET", "/mensajes"); p = problemaDe(ctx, rg); if (p) return p; var plano = JSON.stringify(rg.json || []); if (!Array.isArray(rg.json) || plano.indexOf(tokTxt) === -1 || plano.indexOf(nA) === -1 || plano.indexOf("Impostor") !== -1) return { ok: false, msg: "Se rompió la parte 1: el muro debe seguir firmando con la ficha (nunca con lo que diga el sobre) y leyéndose en público." }; var r401 = await enviar(ctx, "POST", "/mensajes", JSON.stringify({ texto: marca("colado") })); p = problemaDe(ctx, r401); if (p) return p; if (r401.status !== 401) return { ok: false, msg: "Se rompió la parte 1: escribir sin ficha debe seguir recibiendo su 401." }; var rroto = await enviar(ctx, "POST", "/mensajes", "sobre roto {{{", reg.json.token); if (rroto && (rroto.fallo || ctx.error)) return { ok: false, msg: "Se rompió lo de la lección 8: con ficha válida, un sobre roto volvió a tumbar tu servidor. El " + tag("try/catch") + " sigue de guardia — y ese código viaja a internet." }; if (rroto.status !== 400) return { ok: false, msg: "Se rompió lo de la lección 8: con ficha válida, un sobre roto debe seguir recibiendo su " + tag("400") + " (respondiste " + rroto.status + "). No lo sueltes rumbo a Cloudflare." }; return servidorVivoConHola(code); } } ], coach: function (code) { return []; }, winMsg: "Tu API está completa: registro con fichas, un muro que firma, lee en público y borra con permiso — cuatro guardianes honestos vigilando la puerta. Esto que construiste es, pieza por pieza, lo que hace el backend de las aplicaciones que usas todos los días. Queda mirarlo de frente una vez más (el repaso)... y luego sacarlo del laboratorio: tu servidor, en internet, de verdad." } ]; /* =================================================== UI */ var STORE_KEY = "pymeriq-servidor-v1"; var state = { current: 0, codeByLesson: {}, completed: [], reviewPassed: false }; try { var saved = JSON.parse(localStorage.getItem(STORE_KEY) || "null"); if (saved && typeof saved.current === "number") state = saved; } catch (e) {} if (state.current > LESSONS.length - 1) state.current = LESSONS.length - 1; function persist() { try { localStorage.setItem(STORE_KEY, JSON.stringify(state)); } catch (e) {} } var eyebrow = document.getElementById("eyebrow"); var lessonTitle = document.getElementById("lessonTitle"); var teachBody = document.getElementById("teachBody"); var checklist = document.getElementById("checklist"); var editor = document.getElementById("editor"); var checkBtn = document.getElementById("checkBtn"); var nextBtn = document.getElementById("nextBtn"); var prevBtn = document.getElementById("prevBtn"); var banner = document.getElementById("banner"); var tip = document.getElementById("tip"); var lessonCounter = document.getElementById("lessonCounter"); var dots = document.getElementById("dots"); var workCard = document.getElementById("workCard"); var servidorCtx = null; var srvEstado = document.getElementById("srvEstado"); var reqMetodo = document.getElementById("reqMetodo"); var reqRuta = document.getElementById("reqRuta"); var reqCuerpo = document.getElementById("reqCuerpo"); var reqEnviar = document.getElementById("reqEnviar"); var reqRespuesta = document.getElementById("reqRespuesta"); var onReview = false, onFinale = false; function lesson() { return LESSONS[state.current]; } /* --------------------- el servidor vivo del probador */ // Un Worker persistente: conserva su memoria entre peticiones hasta que el // código cambia o se pulsa Reiniciar. Como corre en otro hilo, un bucle // infinito del alumno YA NO congela la página: se termina y se avisa. var servidorListo = false; var bootSeq = 0; async function bootServidor() { var mine = ++bootSeq; if (servidorCtx) { cerrarServidor(servidorCtx); servidorCtx = null; } servidorListo = false; var code = editor.value; if (!code.trim()) { srvEstado.className = ""; srvEstado.textContent = "✍️ Escribe tu servidor en el editor y arrancará aquí."; return; } srvEstado.className = ""; srvEstado.textContent = "… arrancando tu servidor"; var ctx = await arrancarWorker(code); if (mine !== bootSeq) { cerrarServidor(ctx); return; } // otro arranque lo reemplazó servidorCtx = ctx; if (ctx.error) { servidorListo = false; srvEstado.className = "roto"; srvEstado.textContent = "💥 Tu servidor no arrancó: " + ctx.error; } else { servidorListo = true; srvEstado.className = "vivo"; srvEstado.textContent = "⚡ Tu servidor está corriendo (con tu último código)."; } } reqMetodo.addEventListener("change", function () { reqCuerpo.className = reqMetodo.value === "POST" ? "visible" : ""; }); reqEnviar.addEventListener("click", async function () { if (!servidorListo || !servidorCtx || servidorCtx.muerto) { reqRespuesta.innerHTML = "Tu servidor no está corriendo todavía. Escríbelo en el editor (o corrige su error) y vuelve a intentar."; return; } var metodo = reqMetodo.value; var ruta = reqRuta.value.trim() || "/"; if (ruta[0] !== "/") ruta = "/" + ruta; reqEnviar.disabled = true; reqRespuesta.innerHTML = "→ " + esc(metodo) + " " + esc(ruta) + "\nEsperando la respuesta…"; var fichaViaje = (document.getElementById("reqToken").value || "").trim() || undefined; var r = await enviar(servidorCtx, metodo, ruta, reqMetodo.value === "POST" ? reqCuerpo.value : undefined, fichaViaje); reqEnviar.disabled = false; if (servidorCtx.error) { reqRespuesta.innerHTML = "→ " + esc(metodo) + " " + esc(ruta) + "\n💥 Tu servidor tiene un error: " + esc(servidorCtx.error); return; } if (r && r.sinManejador) { reqRespuesta.innerHTML = "→ " + esc(metodo) + " " + esc(ruta) + "\nNo encuentro la función " + esc("manejador") + ". ¿Ya la escribiste?"; return; } if (r && r.tope) { servidorListo = false; srvEstado.className = "roto"; srvEstado.textContent = "⏸ Tu servidor no respondió (¿un bucle que nunca termina?). Lo apagué; corrige el código y arrancará solo."; reqRespuesta.innerHTML = "→ " + esc(metodo) + " " + esc(ruta) + "\n⏱ Tu servidor no respondió a tiempo (¿bucle o promesa sin cumplir?). Lo apagué: corrige y vuelve a probar."; return; } if (r && r.fallo) { reqRespuesta.innerHTML = "→ " + esc(metodo) + " " + esc(ruta) + "\n💥 Tu servidor se rompió atendiendo esta petición: " + esc(r.fallo); return; } if (r && r.sinReturn) { reqRespuesta.innerHTML = "→ " + esc(metodo) + " " + esc(ruta) + "\nTu manejador no devolvió nada (¿faltó el return?)."; return; } if (r && r.noEsResponse) { reqRespuesta.innerHTML = "→ " + esc(metodo) + " " + esc(ruta) + "\nTu manejador devolvió " + esc(r.tipo) + " suelto: fabrica la respuesta con new Response(...)."; return; } var stCls = r.status >= 200 && r.status < 400 ? "st-ok" : "st-bad"; var cuerpo = r.json !== null ? JSON.stringify(r.json, null, 2) : r.texto; var etiqueta = r.contentType ? " · " + esc(r.contentType) : ""; reqRespuesta.innerHTML = "→ " + esc(metodo) + " " + esc(ruta) + "\n← " + r.status + "" + etiqueta + "\n" + esc(cuerpo === "" ? "(cuerpo vacío)" : cuerpo) + ""; }); /* --------------------- render */ function renderDots() { var html = ""; for (var i = 0; i < LESSONS.length; i++) { var cls = state.completed.indexOf(LESSONS[i].id) !== -1 ? "done" : (i === state.current && !onReview && !onFinale ? "now" : ""); html += ''; } dots.innerHTML = html; } function initialCode(L, idx) { // Un editor borrado del todo no "mata" el viaje: el código arrastrado vuelve. if (state.codeByLesson[L.id] !== undefined && state.codeByLesson[L.id] !== "") return state.codeByLesson[L.id]; for (var i = idx - 1; i >= 0; i--) { var prev = LESSONS[i]; if (state.codeByLesson[prev.id] !== undefined) return L.starter(state.codeByLesson[prev.id]); } return L.starter(""); } function renderLesson() { onReview = false; onFinale = false; var L = lesson(); if (!document.getElementById("lessonTitle")) { document.getElementById("teachCard").innerHTML = '' + '' + (qi + 1) + ". " + item.q + "
" + opts + "Repaso · Asegura que entendiste
Antes de sacar tu servidor al mundo, unas preguntas. No es para castigarte: si lo pasas, cierras; si no, puedes seguir igual, pero te diré qué conviene repasar.
' + (state.reviewPassed ? 'Este repaso ya lo aprobaste. Repetirlo es práctica, no examen.
' : "") + '🖥️
Crea tu servidor · el cierre
' + "Todo lo que escribiste en este programa corre con el mismo modelo de los servidores de Cloudflare Workers — no aprendiste una imitación: aprendiste lo real en una máquina prestada. Hoy lo mudas a una de verdad, gratis (100.000 peticiones al día), desde el navegador y sin terminal.
' + 'Sobre el envoltorio que va al final de tu código: Cloudflare no busca una función suelta — busca algo exportado con ' + tag("export default") + ': un objeto con un método ' + tag("fetch") + '. Tu ' + tag("manejador") + ' ya ES ese método; el envoltorio solo lo presenta y lo exporta. Y hace algo más: le pega a cada respuesta el header ' + tag("Access-Control-Allow-Origin") + ' — el permiso de lectura. Los navegadores protegen a la gente: una página no puede leer las respuestas de un servidor ajeno salvo que ese servidor lo permita explícitamente. ¿Te suena? Open-Meteo daba ese permiso — por ESO tu app del Programa 3 podía leerle el clima. Ahora el que lo da eres tú.
' + '' + 'La prueba final: tu servidor, vivo en internet
' + '' + '' + (state.urlAlive ? "✓ Verificado antes. Puedes volver a probar cuando quieras." : "") + "
El cierre del arco: tu app, hablando con TU servidor
' + 'Tu ' + tag("mi-app.html") + ' del Programa 3 ya sabe pedir con fetch. Ábrela en tu editor y hazla leer de tu servidor — el patrón es el que ya dominas (recuerda: esos ' + tag("await") + ' viven dentro de una función ' + tag("async") + ', como en tu app):
' + '' + 'Con eso, el arco completo es tuyo: la página (P1), publicada (P2), vuelta aplicación (P3)... y hoy, conversando con un servidor que TÚ construiste. Escribir en el muro desde tu app también se puede — necesita un permiso extra (los navegadores \"preguntan antes\" con una petición OPTIONS cuando viaja la ficha); te queda como frontera para investigar.
Honestidad de la casa: tu Worker es un servidor real, pero su memoria sigue siendo la de la lección 7 — y en la nube los servidores duermen y despiertan solos, así que tu muro olvidará más seguido que en el laboratorio. Para recordar de verdad existen las bases de datos: esa es la siguiente frontera, y todavía no te toca. Lo que sí es tuyo desde hoy: el otro lado de internet.
' + '' + "¿Algo te confundió, te gustó o falta? Se envía tu mensaje y en qué lección estás — nada más, ni nombre ni correo. Si quieres respuesta, deja tu correo dentro del mensaje.