Has creado un grupo de trabajos. En algún sitio, un usuario está mirando un spinner, y «traduciendo a 14 idiomas…» es cierto, pero no sirve de mucho: no avanza. Quieres que vea subir el contador delante de sus ojos: 3 listos, luego 4, luego un idioma que falla y, después, completado.
Consultar periódicamente el grupo de trabajos te lleva hasta ahí, pero genera mucho tráfico, y cada consulta te devuelve una instantánea nueva que tienes que comparar con la anterior para saber qué ha cambiado realmente. El WebSocket le da la vuelta a eso. Te conectas una vez y el servidor envía un evento cada vez que se resuelve un idioma; además, cada mensaje incluye el estado completo del grupo, así que renderizas la instantánea y nunca reconcilias un delta. Si se pierde un frame, te reconectas o reinicias la pestaña, el siguiente mensaje vuelve a contener toda la verdad.
GET /jobs/localization/groups/:groupId/ws¿Acabas de llegar a la localización asíncrona? Empieza por la Descripción general. El groupId de aquí es el que recibiste cuando creaste los trabajos.
En esta página
- Tipos de mensaje
- Carga útil de los mensajes
- Cómo integrarlo en tu interfaz
- Mantén tu clave de API en el servidor
Tipos de mensaje#
Hay cuatro tipos de mensaje que viajan por el socket. Cada uno te dice qué acaba de pasar y, al mismo tiempo, te entrega el estado actual del grupo completo.
| Tipo | Cuándo | Campos clave |
|---|---|---|
snapshot | Al conectar por primera vez | Estado completo del grupo |
job.completed | Un idioma termina correctamente | jobId, locale, además del estado completo del grupo |
job.failed | Un idioma falla | jobId, locale, error, además del estado completo del grupo |
group.completed | Todos los trabajos se han resuelto | groupId, status, además del estado completo del grupo. El servidor cierra la conexión después de este mensaje. |
Cada mensaje contiene un objeto snapshot con el estado actual del grupo: totalJobs, completedJobs, completedWithWarningsJobs, failedJobs y un mapa jobs indexado por ID de trabajo, cada uno con su locale y status. Esos recuentos son los mismos que devuelve el endpoint del grupo de trabajos, así que una instantánea recibida por el socket y una consulta al endpoint REST coinciden en cuánto ha avanzado el grupo.
renderiza la instantánea, nunca reconcilies
Nunca necesitas llevar la cuenta de qué eventos ya has visto, reproducir mensajes perdidos ni fusionar una actualización parcial con el estado local. Lee snapshot en cada mensaje y pinta tu interfaz a partir de ahí. Al reconectarte, primero se vuelve a enviar snapshot, así que un cliente que acaba de unirse y otro que lleva escuchando todo el tiempo convergen en el mismo estado.
Carga útil de los mensajes#
Estos son los frames exactos que envía el servidor. Los ID tienen formatos reales (ljg_ para el grupo, ljb_ para cada trabajo); snapshot se abrevia con "..." solo cuando repite la estructura ya mostrada.
Al conectarte, el servidor envía el estado actual:
{
"type": "snapshot",
"snapshot": {
"groupId": "ljg_A1b2C3d4E5f6G7h8",
"totalJobs": 3,
"completedJobs": 1,
"completedWithWarningsJobs": 0,
"failedJobs": 0,
"jobs": {
"ljb_A1b2C3d4E5f6G7h8": { "locale": "de", "status": "completed" },
"ljb_B2c3D4e5F6g7H8i9": { "locale": "fr", "status": "processing" },
"ljb_C3d4E5f6G7h8I9j0": { "locale": "ja", "status": "queued" }
}
}
}A medida que termina cada idioma, el evento indica qué idioma ha cambiado e incluye la instantánea actualizada:
{
"type": "job.completed",
"jobId": "ljb_B2c3D4e5F6g7H8i9",
"locale": "fr",
"snapshot": {
"groupId": "ljg_A1b2C3d4E5f6G7h8",
"totalJobs": 3,
"completedJobs": 2,
"completedWithWarningsJobs": 0,
"failedJobs": 0,
"jobs": {
"ljb_A1b2C3d4E5f6G7h8": { "locale": "de", "status": "completed" },
"ljb_B2c3D4e5F6g7H8i9": { "locale": "fr", "status": "completed" },
"ljb_C3d4E5f6G7h8I9j0": { "locale": "ja", "status": "processing" }
}
}
}Un fallo es un mensaje normal, no una conexión interrumpida. job.failed incluye el idioma y un error, además de la misma instantánea completa: el idioma fallido muestra status: "failed" en el mapa jobs, todos los demás idiomas siguen llegando y el socket continúa hasta group.completed:
{
"type": "job.failed",
"jobId": "ljb_C3d4E5f6G7h8I9j0",
"locale": "ja",
"error": "Model timeout after 30 seconds",
"snapshot": { "...": "..." }
}Cuando todos los trabajos se han resuelto, el servidor envía un evento final y cierra la conexión:
{
"type": "group.completed",
"groupId": "ljg_A1b2C3d4E5f6G7h8",
"status": "completed",
"snapshot": { "...": "..." }
}El status final es completed cuando todos los idiomas se completaron correctamente, completed_with_warnings cuando todos los idiomas generaron salida pero una o más fases opcionales del pipeline fallaron en al menos uno de ellos, partial cuando algunos idiomas se completaron correctamente y otros fallaron, y failed cuando fallaron todos. Para saber qué significa cada uno de esos estados para el grupo en su conjunto, consulta Track a job group.
Renderiza desde la instantánea ante cualquier cosa que no reconozcas
Haz un switch con los tipos de mensaje que conoces y, ante cualquier cosa que no reconozcas, vuelve a renderizar a partir de snapshot. Cada mensaje incluye una instantánea completa, así que un cliente que por defecto pinta desde ahí sigue siendo correcto incluso con un frame para el que no tiene una rama específica.
Cómo integrarlo en tu interfaz#
El grupo es tu modelo de progreso. Cuando creaste los trabajos, la respuesta 202 te devolvió un groupId y un array jobs: una entrada por idioma. Inicializa tu registro de progreso a partir de esa respuesta y ya tendrás la estructura que el socket irá completando: el total hacia el que contar y un contador que empieza en cero.
const { groupId, jobs } = await response.json();
await db.translationProgress.create({
contentId: content.id,
groupId,
totalLanguages: jobs.length,
completedLanguages: 0,
});Después, abre el socket con ese groupId y, en cada mensaje, lee snapshot y vuelve a pintar. Verás cómo sube el contador a medida que se completan los idiomas y podrás detenerte cuando llegue group.completed:
import WebSocket from "ws";
const groupId = "ljg_A1b2C3d4E5f6G7h8";
const ws = new WebSocket(
`wss://api.lingo.dev/jobs/localization/groups/${groupId}/ws`,
{ headers: { "X-API-Key": process.env.LINGO_API_KEY } }
);
ws.on("message", (raw) => {
const event = JSON.parse(raw);
const { snapshot } = event;
switch (event.type) {
case "snapshot":
console.log(`${snapshot.completedJobs}/${snapshot.totalJobs} complete`);
break;
case "job.completed":
console.log(`${event.locale} ready (${snapshot.completedJobs}/${snapshot.totalJobs})`);
break;
case "job.failed":
console.error(`${event.locale} failed: ${event.error}`);
break;
case "group.completed":
console.log(`All translations done: ${event.status}`);
ws.close();
break;
}
});Si lo ejecutas con un grupo de tres idiomas, imprimirá la ejecución a medida que ocurre:
1/3 complete
fr ready (2/3)
ja failed: Model timeout after 30 seconds
All translations done: partialEl contador avanzó por sí solo, un idioma falló sin tumbar el flujo y partial te dijo en qué punto terminó la ejecución: justo lo que necesita tu spinner para convertirse en una barra de progreso de verdad. Fíjate en que el bucle nunca acumula estado: cada rama lee del snapshot del mensaje que tiene delante, así que el mismo código es correcto al conectar por primera vez, en cada actualización y al reconectarse.
Mantén tu clave de API en el servidor#
El socket se autentica con tu clave de API, la misma clave con alcance de organización que usan los endpoints REST. Eso significa que el navegador es el lugar equivocado para abrirlo: una clave de API en el JavaScript del cliente da acceso a todos los motores de tu organización a cualquiera que vea el código fuente.
Conéctate desde tu backend, no desde el navegador
Abre el WebSocket desde tu servidor, donde la clave ya está, y luego distribuye los eventos al navegador por tu propio canal: un WebSocket o un flujo de eventos enviados por el servidor que controles tú. Tu frontend recibe el progreso en tiempo real; tu clave nunca sale de tu infraestructura.
Esto refleja el modelo de webhook: la conexión que toca Lingo.dev está del lado del servidor, y lo que le llega al usuario es lo que tu propia aplicación decida reenviar.
Dónde encaja esto#
El WebSocket es la vista en tiempo real: está vinculado a un grupo y se cierra cuando ese grupo termina. Para una entrega duradera, de servidor a servidor, que sobreviva al cierre de una pestaña o a un despliegue, combínalo con webhooks: el socket impulsa la interfaz mientras la ejecución está en pantalla y el webhook registra cada resultado en el momento en que llega. Conecta ambos desde la misma llamada de creación y tus usuarios verán el progreso a medida que sucede mientras tu backend conserva la salida independientemente de quién esté mirando.
