Un panel web que muestra cada luz con un interruptor, y que se actualiza al instante cuando la luz cambia, necesita una conexión en tiempo real. Con AWS IoT Core, el navegador puede conectarse directamente al broker por MQTT sobre WebSocket, sin ningún servidor en medio.
En esta parte conectamos una app React a AWS IoT Core, mostramos el estado del Device Shadow y vemos cómo hacer que la interfaz nunca muestre un estado falso.
El stack
- React + Vite + TypeScript.
- mqtt.js para MQTT sobre WebSocket.
- SDK v3 de AWS para obtener credenciales de Cognito y firmar la URL.
- AWS IoT Core con los tópicos reservados del Device Shadow (parte 5).
Cómo se autentica el navegador
El navegador no tiene un certificado X.509 como el ESP32. Las opciones son:
| Opción | Cómo funciona |
|---|---|
| URL firmada con SigV4 | El navegador obtiene credenciales temporales de AWS (con Cognito Identity Pool) y firma la URL del WebSocket |
| URL firmada en el servidor | Una Lambda verifica el login y devuelve la URL ya firmada |
| Custom authorizer | AWS IoT llama a una Lambda tuya para validar un token propio |
La primera es la más directa y la que usamos aquí. La segunda da más control (por ejemplo, permisos que cambian por usuario) a cambio de una Lambda más.
Paso 1: credenciales temporales con Cognito
Después del login con el User Pool (parte 11), la Identity Pool cambia el token del usuario por credenciales temporales de AWS:
import { fromCognitoIdentityPool } from "@aws-sdk/credential-providers";
const credenciales = fromCognitoIdentityPool({
clientConfig: { region: REGION },
identityPoolId: IDENTITY_POOL_ID,
logins: { [`cognito-idp.${REGION}.amazonaws.com/${USER_POOL_ID}`]: idToken },
});
El rol autenticado de la Identity Pool necesita permisos IAM de IoT: iot:Connect, iot:Subscribe, iot:Receive e iot:Publish, limitados a los recursos que use la app.
La política de IoT que suele faltar
Con identidades autenticadas de Cognito, AWS IoT exige dos permisos: el del rol IAM y una política de IoT adjunta a la identidad de Cognito (AttachPolicy con el identity ID como target). Si falta la segunda, el WebSocket abre pero la conexión MQTT se cierra sin CONNACK, y no es obvio por qué.
aws iot attach-policy --policy-name panel-web-policy \
--target "us-east-1:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" # identity ID del usuario
Como hay que hacerlo por cada usuario, normalmente lo hace una Lambda la primera vez que el usuario inicia sesión. Esta es una de las razones por las que muchos proyectos prefieren firmar la URL en el servidor.
Paso 2: firmar la URL
La firma se hace contra el servicio iotdevicegateway. Un detalle propio de AWS IoT: el token de sesión no entra en la firma; se agrega a la URL después de firmar.
import { SignatureV4 } from "@smithy/signature-v4";
import { HttpRequest } from "@smithy/protocol-http";
import { formatUrl } from "@aws-sdk/util-format-url";
import { Sha256 } from "@aws-crypto/sha256-js";
async function urlFirmada() {
const { accessKeyId, secretAccessKey, sessionToken } = await credenciales();
const signer = new SignatureV4({
service: "iotdevicegateway",
region: REGION,
credentials: { accessKeyId, secretAccessKey }, // sin sessionToken
sha256: Sha256,
});
const firmada = await signer.presign(
new HttpRequest({ method: "GET", protocol: "wss:", hostname: IOT_ENDPOINT, path: "/mqtt", headers: { host: IOT_ENDPOINT } }),
{ expiresIn: 3600 }
);
return `${formatUrl(firmada)}&X-Amz-Security-Token=${encodeURIComponent(sessionToken!)}`;
}
Si incluyes el token en la firma, el WebSocket suele cerrarse de inmediato con el código 1006.
Paso 3: conectar y pedir el estado
import mqtt from "mqtt";
const base = `$aws/things/${THING}/shadow`;
const cliente = mqtt.connect(await urlFirmada(), {
clientId: `panel-${crypto.randomUUID()}`,
protocolVersion: 4, // MQTT 3.1.1
reconnectPeriod: 5000,
connectTimeout: 10000,
});
cliente.on("connect", () => {
// Primero suscribirse; pedir el estado solo cuando AWS confirmó la suscripción
cliente.subscribe([`${base}/get/accepted`, `${base}/update/accepted`], () => {
cliente.publish(`${base}/get`, "");
});
});
Esperar la confirmación de la suscripción (SUBACK) antes de publicar en get evita una carrera: si la respuesta llega antes de que la suscripción esté activa, se pierde y la interfaz arranca con un estado vacío.
El client ID tiene que estar permitido en la política (iot:Connect sobre client/panel-*, por ejemplo) y debe ser único: si dos pestañas usan el mismo, AWS desconecta la anterior.
Paso 4: mostrar solo lo confirmado
cliente.on("message", (_topic, buffer) => {
let doc: { state?: { reported?: Record<string, string> } };
try {
doc = JSON.parse(buffer.toString());
} catch {
return;
}
const reportado = doc.state?.reported; // nunca desired
if (reportado) setEstado((prev) => ({ ...prev, ...reportado }));
});
function alternar(salida: string, encender: boolean) {
setPendiente((prev) => ({ ...prev, [salida]: true }));
cliente.publish(`${base}/update`, JSON.stringify({ state: { desired: { [salida]: encender ? "on" : "off" } } }));
}
La regla es la misma que en el dispositivo: el interruptor solo cambia con reported. Si lo movieras con desired, se vería encendido aunque el ESP32 estuviera desconectado.
Para que el usuario sepa que su toque se registró, un estado pendiente ayuda:
- al tocar, el interruptor se marca como pendiente y se deshabilita;
- cuando llega un
reportedcon el valor pedido, vuelve a la normalidad; - si no hay confirmación en unos segundos, se desbloquea y muestra el último estado real.
Si el dispositivo responde rápido, el estado pendiente apenas se ve. Si está desconectado, el usuario lo nota sin un mensaje de error. Para mostrar explícitamente "desconectado", está la parte 19.
Paso 5: desconectarse cuando nadie mira
Mantener el WebSocket abierto con el celular bloqueado gasta batería y datos. Con el evento visibilitychange puedes cerrar la conexión cuando la página lleva unos segundos oculta y abrir una nueva al volver:
let temporizador: ReturnType<typeof setTimeout> | null = null;
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
temporizador = setTimeout(() => cliente.end(), 10_000);
} else {
if (temporizador) clearTimeout(temporizador);
if (!cliente.connected) reconectar(); // URL nueva, suscribirse y pedir el estado
}
});
Al reconectar, pide una URL nueva: las credenciales temporales y la firma vencen. Si el token de Cognito también venció, renueva la sesión antes.
Errores comunes
| Síntoma | Causa probable |
|---|---|
| WebSocket cerrado con 1006 al abrir | Firma inválida: región, servicio o token de sesión dentro de la firma |
WebSocket abre, MQTT se cierra sin CONNACK | Falta la política de IoT en la identidad de Cognito, o el client ID no está permitido |
| Conecta pero no llegan mensajes | Falta iot:Subscribe (sobre topicfilter/...) o iot:Receive (sobre topic/...) |
| Una pestaña desconecta a la otra | Mismo client ID en las dos |
| Funciona una hora y luego falla | Venció la URL o las credenciales: genera una nueva al reconectar |
Preguntas frecuentes
¿Por qué no usar AWS Amplify?
Amplify resuelve la autenticación y la conexión con menos código, pero agrega peso y esconde lo que pasa por debajo. Con el SDK directo ves cada pieza, lo que ayuda mucho a depurar.
¿Cuánto dura la URL firmada?
Lo que indiques en expiresIn, como máximo lo que duren las credenciales temporales. Una hora es un valor habitual.
¿Cuántas conexiones MQTT abre la app?
Una por pestaña. AWS IoT Core cobra por minuto de conexión, a precios de fracciones de centavo; el motivo real para desconectar en segundo plano es la batería del celular.