Curso HTML – Módulo 13: APIs web y funcionalidades avanzadas

Curso HTML – Módulo 13: APIs web y funcionalidades avanzadas

Las APIs web permiten que una página haga cosas como solicitar la ubicación del usuario, recordar una preferencia o recibir avisos sin recargarse. Estas funciones las ofrece el navegador y puedes utilizarlas desde JavaScript.

En el módulo anterior utilizamos la API de Canvas para dibujar y animar gráficos. Ahora aplicaremos la misma idea a otros problemas. Aprenderás a guardar datos, mover elementos, delegar cálculos y comunicar la página con un servidor.

Curso de HTML de TecnoPS
Módulo anterior: 12. Gráficos en HTML con SVG y Canvas.
Módulo actual: 13. APIs web y funcionalidades avanzadas.
Próximo módulo: 14. Elementos misceláneos en HTML.

Objetivos del módulo

  • Entender qué ofrece una API del navegador.
  • Solicitar la ubicación y gestionar permisos y errores.
  • Añadir arrastrar y soltar con una alternativa mediante botones.
  • Guardar preferencias con localStorage y distinguirlo de sessionStorage.
  • Ejecutar un cálculo en segundo plano con un Web Worker.
  • Enviar y recibir mensajes con WebSocket.
  • Recibir eventos del servidor con Server-Sent Events.

1. Qué son las APIs web

Una API es una interfaz que permite utilizar una funcionalidad mediante operaciones definidas. Por ejemplo, navigator.geolocation.getCurrentPosition() pide al navegador que intente obtener una posición. Tu código recibe el resultado o un error y decide cómo mostrarlo.

En una aplicación web suelen convivir varias interfaces. El DOM te permite cambiar el contenido de la página. Web Storage guarda datos en el navegador. Una API HTTP de tu servidor puede proporcionar datos compartidos por varios usuarios.

2. Cómo preparar los ejemplos

En los primeros ejercicios puedes colocar cada fragmento en el <body> de un documento HTML. El código JavaScript está situado después de los elementos que utiliza. Para servir la carpeta por HTTP, puedes usar Python si ya lo tienes instalado.

python -m http.server 8000 --bind 127.0.0.1

Abre http://localhost:8000/ en el mismo ordenador. Para el laboratorio final usarás un servidor independiente en el puerto 3000. Los bloques de esta entrada muestran código. Copia cada ejemplo a sus archivos para ejecutarlo.

3. Geolocalización con permiso del usuario

La Geolocation API puede proporcionar una estimación de la latitud y la longitud. El dispositivo puede obtenerla mediante fuentes como el GPS, redes inalámbricas u otros mecanismos disponibles. El valor accuracy indica la precisión estimada en metros.

La API requiere un contexto seguro. En un sitio público, usa HTTPS. Para desarrollo, localhost es un contexto que el navegador considera de confianza. Si abres la página desde otro dispositivo mediante una dirección IP de tu red y HTTP, el resultado puede ser distinto.

<button id="ubicar" type="button">Consultar mi ubicación</button>
<p id="ubicacion" role="status"></p>

<script>
  const boton = document.querySelector("#ubicar");
  const salida = document.querySelector("#ubicacion");

  boton.addEventListener("click", () => {
    if (!window.isSecureContext || !navigator.geolocation) {
      salida.textContent = "La geolocalización no está disponible en esta página.";
      return;
    }

    boton.disabled = true;
    salida.textContent = "Solicitando ubicación…";

    navigator.geolocation.getCurrentPosition(
      ({ coords }) => {
        const latitud = coords.latitude.toFixed(4);
        const longitud = coords.longitude.toFixed(4);
        const precision = Math.round(coords.accuracy);

        salida.textContent =
          `Latitud ${latitud}, longitud ${longitud}. Precisión estimada: ${precision} m.`;

        boton.disabled = false;
      },
      (error) => {
        const mensajes = {
          1: "El permiso de ubicación se ha denegado.",
          2: "El dispositivo no ha podido determinar su ubicación.",
          3: "Se ha agotado el tiempo de espera."
        };

        salida.textContent = mensajes[error.code] ?? "No se pudo obtener la ubicación.";
        boton.disabled = false;
      },
      {
        enableHighAccuracy: false,
        timeout: 10000,
        maximumAge: 30000
      }
    );
  });
</script>

maximumAge admite una posición reciente de hasta 30 segundos. timeout limita la espera para obtener la posición. Con enableHighAccuracy puedes solicitar mayor precisión cuando la tarea lo justifique, aunque el dispositivo decide cómo obtenerla.

Para seguir cambios de posición existe watchPosition(). Guarda el identificador que devuelve y llama a clearWatch(id) al terminar. Solicita la ubicación cuando la tarea la necesite y explica su propósito. En este ejemplo, las coordenadas solo se muestran en la página. Especificación de geolocalización del W3C.

4. Arrastrar y soltar con HTML Drag and Drop

El atributo draggable=»true» permite iniciar el arrastre de un elemento. En dragstart guardas el dato que viaja en DataTransfer. El destino cancela el comportamiento por defecto de dragover para admitir la suelta y lee el dato en drop.

El siguiente ejercicio permite mover una tarea entre dos listas. Los botones ofrecen la misma operación a quien usa teclado o un dispositivo donde el arrastre resulte incómodo.

<h2>Pendientes</h2>
<ul id="pendientes" style="min-height: 3rem; border: 1px solid;">
  <li id="tarea-html" draggable="true">Practicar HTML</li>
</ul>

<h2>Completadas</h2>
<ul id="completadas" style="min-height: 3rem; border: 1px solid;"></ul>

<button id="completar" type="button">Marcar como completada</button>
<button id="devolver" type="button">Volver a pendientes</button>
<p id="estado-tarea" role="status"></p>

<script>
  const tarea = document.querySelector("#tarea-html");
  const pendientes = document.querySelector("#pendientes");
  const completadas = document.querySelector("#completadas");
  const estado = document.querySelector("#estado-tarea");
  const tipoDato = "application/x-tarea-curso";

  function mover(destino) {
    destino.append(tarea);
    estado.textContent = destino === completadas
      ? "Tarea completada."
      : "Tarea pendiente.";
  }

  tarea.addEventListener("dragstart", (evento) => {
    evento.dataTransfer.setData(tipoDato, tarea.id);
    evento.dataTransfer.effectAllowed = "move";
  });

  for (const lista of [pendientes, completadas]) {
    lista.addEventListener("dragover", (evento) => {
      if (evento.dataTransfer.types.includes(tipoDato)) {
        evento.preventDefault();
        evento.dataTransfer.dropEffect = "move";
      }
    });

    lista.addEventListener("drop", (evento) => {
      evento.preventDefault();

      if (evento.dataTransfer.getData(tipoDato) === tarea.id) {
        mover(lista);
      }
    });
  }

  document.querySelector("#completar").addEventListener("click", () => {
    mover(completadas);
  });

  document.querySelector("#devolver").addEventListener("click", () => {
    mover(pendientes);
  });
</script>

append() mueve el nodo existente a la lista elegida. Los dos modos de interacción llaman a mover(), así que ambos actualizan la tarea y el mensaje de estado de la misma forma.

Para recibir archivos arrastrados puedes leer dataTransfer.files durante la suelta y ofrecer también un <input type=»file»>. Para gestos personalizados con ratón, lápiz o pantalla táctil puedes estudiar Pointer Events. Drag and Drop en el estándar HTML.

5. Guardar preferencias con Web Storage

API Alcance Ejemplo de uso
localStorage Se conserva entre sesiones y suele compartirse entre pestañas del mismo origen. Preferencia de tema o tamaño de letra.
sessionStorage Se separa por origen y por contexto de navegación de nivel superior. Sobrevive a una recarga de la pestaña. Conservar temporalmente el paso de un asistente.

Un origen se identifica mediante protocolo, nombre de host y puerto. Por ejemplo, http://localhost:8000 y http://localhost:3000 tienen almacenamientos distintos. Una ventana abierta desde otra puede recibir una copia inicial del sessionStorage de la ventana de origen. Los cambios posteriores en ambas copias son independientes.

Las operaciones son síncronas y guardan cadenas de texto. Para una preferencia simple basta con almacenar una palabra.

<label for="tema">Tema de la página</label>
<select id="tema">
  <option value="claro">Claro</option>
  <option value="oscuro">Oscuro</option>
</select>
<p id="estado-tema" role="status"></p>

<script>
  const selector = document.querySelector("#tema");
  const estado = document.querySelector("#estado-tema");
  const clave = "curso-html:tema";

  function aplicarTema(tema) {
    document.documentElement.style.colorScheme =
      tema === "oscuro" ? "dark" : "light";

    document.body.style.backgroundColor = "Canvas";
    document.body.style.color = "CanvasText";
  }

  try {
    const guardado = localStorage.getItem(clave);

    if (guardado === "claro" || guardado === "oscuro") {
      selector.value = guardado;
    }
  } catch {
    estado.textContent = "El navegador ha bloqueado el acceso al almacenamiento.";
  }

  aplicarTema(selector.value);

  selector.addEventListener("change", () => {
    aplicarTema(selector.value);

    try {
      localStorage.setItem(clave, selector.value);
      estado.textContent = "Preferencia guardada.";
    } catch {
      estado.textContent = "Tema aplicado para esta visita.";
    }
  });
</script>

Para guardar una estructura usa JSON.stringify() y, al recuperarla, JSON.parse(). Gestiona valores ausentes y JSON mal formado. Puedes eliminar una preferencia con removeItem(clave).

El almacenamiento puede fallar debido a restricciones del navegador o falta de espacio. Úsalo para datos pequeños y prescindibles. Los scripts del mismo origen pueden leer esos valores, así que evita guardar contraseñas y otros secretos. Para colecciones de datos estructurados estudiaremos IndexedDB en el módulo 14. Web Storage en el estándar HTML.

6. Ejecutar cálculos con Web Workers

Un cálculo largo en el hilo principal puede retrasar el dibujo de la página y la respuesta a los botones. Un Web Worker dedicado ejecuta JavaScript en un contexto separado. La página envía un mensaje con postMessage() y el worker responde mediante otro mensaje.

En este ejemplo, el worker cuenta cuántos números primos hay hasta 500.000. Crea dos archivos en la carpeta que estás sirviendo por HTTP.

Archivo primos.html

<!doctype html>
<html lang="es">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Cálculo con Web Workers</title>
</head>
<body>
  <h1>Contar números primos</h1>
  <button id="calcular" type="button">Calcular</button>
  <button id="cancelar" type="button" disabled>Cancelar</button>
  <p id="resultado" role="status"></p>

  <script>
    const calcular = document.querySelector("#calcular");
    const cancelar = document.querySelector("#cancelar");
    const resultado = document.querySelector("#resultado");
    let worker = null;

    function terminar(mensaje) {
      worker?.terminate();
      worker = null;
      calcular.disabled = false;
      cancelar.disabled = true;
      resultado.textContent = mensaje;
    }

    calcular.addEventListener("click", () => {
      try {
        worker = new Worker("./primos-worker.js");
      } catch {
        terminar("No se pudo iniciar el worker.");
        return;
      }

      calcular.disabled = true;
      cancelar.disabled = false;
      resultado.textContent = "Calculando…";

      worker.addEventListener("message", ({ data }) => {
        terminar(`Hay ${data} números primos hasta 500.000.`);
      });

      worker.addEventListener("error", () => {
        terminar("El worker no pudo completar el cálculo.");
      });

      worker.postMessage(500000);
    });

    cancelar.addEventListener("click", () => {
      terminar("Cálculo cancelado.");
    });
  </script>
</body>
</html>

Archivo primos-worker.js

function esPrimo(numero) {
  for (let divisor = 2; divisor * divisor <= numero; divisor++) {
    if (numero % divisor === 0) {
      return false;
    }
  }

  return true;
}

self.addEventListener("message", ({ data: limite }) => {
  let total = 0;

  for (let numero = 2; numero <= limite; numero++) {
    if (esPrimo(numero)) {
      total++;
    }
  }

  self.postMessage(total);
});

El resultado esperado es 41.538. El worker hace el cálculo y la página actualiza el DOM. El botón Cancelar utiliza terminate(), que detiene el worker incluso si está ocupado. En un equipo rápido puede que el cálculo termine antes de que puedas pulsarlo.

Los mensajes normalmente copian los datos mediante el algoritmo de clonación estructurada. Para datos binarios grandes puedes transferir un ArrayBuffer y ceder su propiedad al destinatario. Los Service Workers tienen otra finalidad, como gestionar peticiones y cachés. Web Workers en el estándar HTML.

7. Comunicación en tiempo real con WebSocket

WebSocket mantiene un canal por el que cliente y servidor pueden enviarse mensajes. Es útil cuando ambos extremos necesitan iniciar intercambios, como en un chat, una edición colaborativa o un juego multijugador.

La conexión pasa por estados como CONNECTING, OPEN y CLOSED. Espera al evento open antes de enviar datos. Recibe mensajes con message y actualiza la interfaz con close. La aplicación decide cuándo volver a conectar y qué hacer con los mensajes pendientes.

En el laboratorio usaremos ws:// en el equipo local. Para una aplicación servida por HTTPS se utiliza wss://. En el servidor conviene validar los mensajes y limitar su tamaño. Estándar WebSockets de WHATWG.

8. Avisos del servidor con Server-Sent Events

Server-Sent Events, o SSE, transmite eventos de texto desde el servidor al navegador sobre una respuesta HTTP que se mantiene abierta. Encaja en avisos, registros y actualizaciones de progreso. Si el cliente necesita enviar una acción, puede hacerlo con una petición HTTP aparte.

El navegador abre el canal mediante EventSource. El servidor responde con Content-Type: text/event-stream. Cada evento termina con una línea en blanco.

event: progreso
id: 17
data: {"porcentaje":60}

El campo event da nombre al evento. data lleva el contenido y id identifica el último evento recibido para una posible reanudación. EventSource suele intentar reconectar tras perder la conexión. Para recuperar mensajes perdidos, el servidor debe implementar la reanudación usando el identificador recibido. Llama a close() cuando quieras cerrar el canal desde la página. Server-Sent Events en el estándar HTML.

9. Laboratorio de APIs web: WebSocket y SSE en una misma página

El laboratorio muestra las dos direcciones de comunicación. Con WebSocket, escribes un mensaje y el servidor lo devuelve. Con SSE, el servidor envía su hora cada dos segundos. Necesitas Node.js y npm instalados.

mkdir laboratorio-apis-web
cd laboratorio-apis-web
npm init -y
npm install ws

Archivo servidor.cjs

const http = require("node:http");
const fs = require("node:fs");
const path = require("node:path");
const { WebSocketServer } = require("ws");

const pagina = fs.readFileSync(path.join(__dirname, "index.html"));

const servidor = http.createServer((peticion, respuesta) => {
  if (peticion.url === "/") {
    respuesta.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
    respuesta.end(pagina);
    return;
  }

  if (peticion.url === "/eventos") {
    respuesta.writeHead(200, {
      "Content-Type": "text/event-stream; charset=utf-8",
      "Cache-Control": "no-cache"
    });

    function enviarHora() {
      const datos = JSON.stringify({ hora: new Date().toISOString() });
      respuesta.write(`event: reloj\ndata: ${datos}\n\n`);
    }

    enviarHora();
    const intervalo = setInterval(enviarHora, 2000);
    respuesta.on("close", () => clearInterval(intervalo));
    return;
  }

  respuesta.writeHead(404);
  respuesta.end("Ruta no encontrada");
});

const canal = new WebSocketServer({
  server: servidor,
  path: "/mensajes",
  maxPayload: 4096
});

canal.on("connection", (cliente) => {
  cliente.on("error", (error) => console.error(error.message));

  cliente.on("message", (datos, esBinario) => {
    if (esBinario) {
      cliente.close(1003, "Solo se admite texto");
      return;
    }

    cliente.send(`El servidor ha recibido: ${datos.toString()}`);
  });
});

servidor.listen(3000, "127.0.0.1", () => {
  console.log("Abre http://127.0.0.1:3000/");
});

Archivo index.html

<!doctype html>
<html lang="es">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Laboratorio de WebSocket y SSE</title>
</head>
<body>
  <h1>WebSocket y Server-Sent Events</h1>

  <h2>WebSocket</h2>
  <form id="formulario">
    <label for="mensaje">Mensaje para el servidor</label>
    <input id="mensaje" required maxlength="200">
    <button id="enviar" disabled>Enviar</button>
  </form>
  <p id="estado-ws" role="status">Conectando…</p>
  <p id="respuesta-ws" role="status"></p>

  <h2>Server-Sent Events</h2>
  <p id="estado-sse" role="status">Conectando…</p>
  <p>Hora del servidor: <output id="hora">—</output></p>
  <button id="detener" type="button">Detener avisos</button>

  <script>
    const enviar = document.querySelector("#enviar");
    const mensaje = document.querySelector("#mensaje");
    const estadoWs = document.querySelector("#estado-ws");
    const estadoSse = document.querySelector("#estado-sse");
    const detener = document.querySelector("#detener");
    const hora = document.querySelector("#hora");

    const protocolo = location.protocol === "https:" ? "wss:" : "ws:";
    const socket = new WebSocket(`${protocolo}//${location.host}/mensajes`);

    socket.addEventListener("open", () => {
      enviar.disabled = false;
      estadoWs.textContent = "Conectado.";
    });

    socket.addEventListener("message", ({ data }) => {
      document.querySelector("#respuesta-ws").textContent = data;
    });

    socket.addEventListener("close", () => {
      enviar.disabled = true;
      estadoWs.textContent = "Conexión cerrada. Recarga para volver a conectar.";
    });

    document.querySelector("#formulario").addEventListener("submit", (evento) => {
      evento.preventDefault();
      const texto = mensaje.value.trim();

      if (socket.readyState === WebSocket.OPEN && texto) {
        socket.send(texto);
        mensaje.value = "";
      }
    });

    const eventos = new EventSource("/eventos");

    eventos.addEventListener("open", () => {
      estadoSse.textContent = "Canal de avisos abierto.";
    });

    eventos.addEventListener("reloj", ({ data }) => {
      const datos = JSON.parse(data);
      hora.textContent = new Date(datos.hora).toLocaleTimeString("es-ES");
    });

    eventos.addEventListener("error", () => {
      estadoSse.textContent = eventos.readyState === EventSource.CLOSED
        ? "Canal de avisos cerrado."
        : "Canal interrumpido. Intentando volver a conectar…";
    });

    detener.addEventListener("click", () => {
      eventos.close();
      detener.disabled = true;
      estadoSse.textContent = "Avisos detenidos.";
    });
  </script>
</body>
</html>

Cómo probar el laboratorio

  1. Ejecuta node servidor.cjs en la carpeta del ejercicio.
  2. Abre http://127.0.0.1:3000/ y espera a que se activen las conexiones.
  3. Envía un mensaje. Debajo del formulario debe aparecer la respuesta del servidor.
  4. Observa que la hora cambia aunque no envíes mensajes.
  5. Pulsa Detener avisos. La hora deja de actualizarse y el formulario sigue funcionando.
  6. Recarga la página, detén el servidor con Ctrl+C y vuelve a iniciarlo. Observa que SSE intenta recuperar su conexión. WebSocket permanece cerrado hasta que recargas la página.

En el panel Red de las herramientas de desarrollo puedes inspeccionar la conexión WebSocket y la petición /eventos. El laboratorio escucha únicamente en 127.0.0.1 y usa datos de ejemplo. En una aplicación pública, añade autenticación según los datos que compartas y controla las conexiones que permanecen abiertas.

10. Cómo elegir entre HTTP, SSE y WebSocket

Necesidad Punto de partida Ejemplo
Consultar datos cuando el usuario realiza una acción. fetch() con una petición HTTP. Buscar artículos al enviar un formulario.
Recibir novedades continuas desde el servidor. SSE con EventSource. Mostrar el progreso de un proceso remoto.
Intercambiar mensajes frecuentes en ambos sentidos. WebSocket. Enviar jugadas y recibir el estado de una partida.

11. Errores frecuentes y buenas prácticas

  • Ubicación bloqueada. Comprueba el contexto seguro, los permisos del sitio y las restricciones del sistema. Muestra un mensaje distinto para permiso denegado y posición no disponible.
  • La zona de destino rechaza la suelta. Revisa si cancelas dragover cuando reconoces el tipo de dato.
  • La preferencia desaparece al cambiar de puerto. Confirma que la página se abre desde el mismo origen.
  • El worker falla al abrir el HTML como archivo. Sirve la carpeta por HTTP y comprueba la ruta del script en el panel de red.
  • El formulario intenta enviar antes de conectar. Comprueba readyState y habilita el botón cuando llegue open.
  • Los avisos SSE llegan juntos y tarde. Si hay un proxy entre cliente y servidor, comprueba si acumula la respuesta. Revisa también el formato y la línea en blanco de cada evento.
  • Los mensajes cambian el marcado de la página. Utiliza textContent cuando quieras mostrar texto recibido de otros usuarios o del servidor.

12. Ejercicios prácticos

  1. Geolocalización. Prueba a conceder y denegar el permiso. Comprueba que el botón vuelve a estar disponible después del resultado.
  2. Lista de tareas. Mueve la tarea con los botones y con el puntero. Comprueba que el mensaje coincide con la lista de destino.
  3. Almacenamiento. Añade un botón que borre la preferencia de tema. Prueba después con sessionStorage y compara una recarga con una pestaña nueva abierta de forma independiente.
  4. Worker. Añade un campo numérico para elegir el límite. Valida que sea un entero positivo y establece un máximo para el ejercicio.
  5. SSE. Cambia el reloj por un contador de progreso. Haz que el navegador cierre el canal cuando reciba el 100 %.

13. Ejercicios avanzados

Chat local con dos pestañas

Modifica el servidor para enviar cada mensaje a todos los clientes conectados cuyo estado sea OPEN. Abre dos pestañas y comprueba que ambas reciben el mensaje. Cierra una y verifica que la otra sigue funcionando.

Gráfico que se actualiza con eventos

Reutiliza el gráfico de barras del módulo 12. Haz que el servidor envíe tres valores por SSE y que el navegador actualice tanto las barras como la tabla con los mismos datos.

Tablero con preferencias y acciones accesibles

Amplía el tablero a varias tareas, conserva su estado en localStorage y añade botones para mover cada una. Comprueba que puedes completar una tarea solo con el teclado y que su estado se conserva al recargar.

14. Conclusión

Una página puede combinar varias APIs web para resolver partes distintas de una tarea. En un tablero, el arrastre mueve una tarjeta, Web Storage recuerda una preferencia y WebSocket comunica los cambios a otros usuarios. Si el servidor solo necesita emitir avisos, SSE ofrece un canal dedicado a ese flujo.

En el módulo 14 ampliaremos el almacenamiento y la comunicación con IndexedDB, Web Messaging, CORS y WebRTC. También veremos cómo representar fórmulas y datos estructurados mediante MathML y microdatos.

Módulo anterior: 12. Gráficos en HTML con SVG y Canvas.
Siguiente módulo: 14. Elementos misceláneos en HTML.

Otra lectura del curso: Módulo 9. Medios en HTML.