Du hast eine Job-Gruppe erstellt. Irgendwo schaut ein Nutzer auf einen Spinner, und „Übersetzung in 14 Sprachen …“ ist zwar korrekt, aber völlig nutzlos – es bewegt sich einfach nichts. Du willst, dass der Zähler direkt vor ihren Augen hochgeht: 3 fertig, dann 4, dann eine Sprache mit Fehler, dann abgeschlossen.
Polling der Job-Gruppe bringt dich zwar ans Ziel, ist aber unnötig gesprächig, und bei jeder Abfrage bekommst du einen frischen Snapshot, den du erst mit dem letzten vergleichen musst, um zu sehen, was sich tatsächlich geändert hat. Der WebSocket dreht das um. Du verbindest dich einmal, und der Server pusht jedes Mal ein Event, wenn eine Sprache abgeschlossen ist – und jede Nachricht enthält den vollständigen Status der Gruppe. Du renderst also den Snapshot und musst nie Deltas zusammenführen. Verpasst du einen Frame, verbindest dich neu oder startest den Tab neu: Die nächste Nachricht liefert wieder die ganze Wahrheit.
GET /jobs/localization/groups/:groupId/wsNeu bei asynchroner Lokalisierung? Starte mit dem Überblick. Das groupId hier ist das, was du zurückbekommen hast, als du die Jobs erstellt hast.
Auf dieser Seite
- Nachrichtentypen
- Nachrichten-Payloads
- So bindest du es in deine UI ein
- API-Schlüssel serverseitig halten
Nachrichtentypen#
Vier Nachrichtentypen laufen über den Socket. Jeder zeigt dir, was gerade passiert ist, und liefert dir gleichzeitig den aktuellen Status der gesamten Gruppe.
| Typ | Wann | Wichtige Felder |
|---|---|---|
snapshot | Bei der ersten Verbindung | Vollständiger Gruppenstatus |
job.completed | Eine Sprache wird erfolgreich abgeschlossen | jobId, locale plus vollständiger Gruppenstatus |
job.failed | Eine Sprache schlägt fehl | jobId, locale, error plus vollständiger Gruppenstatus |
group.completed | Alle Jobs sind abgeschlossen | groupId, status plus vollständiger Gruppenstatus. Danach schließt der Server die Verbindung. |
Jede Nachricht enthält ein snapshot-Objekt mit dem aktuellen Status der Gruppe: totalJobs, completedJobs, completedWithWarningsJobs, failedJobs und eine jobs-Map mit Job-IDs als Schlüssel, jeweils inklusive locale und status. Diese Zähler sind dieselben, die auch der Job-Gruppen-Endpunkt meldet – ein Snapshot vom Socket und eine Abfrage über den REST-Endpunkt zeigen also denselben Fortschritt der Gruppe.
Snapshot rendern, nie abgleichen
Du musst nie nachhalten, welche Events du schon gesehen hast, verpasste Nachrichten erneut abspielen oder partielle Updates in deinen lokalen Status mergen. Lies bei jeder Nachricht snapshot und rendere deine UI daraus. Bei einer erneuten Verbindung wird zuerst snapshot noch einmal gesendet – so landen ein Client, der gerade erst dazukommt, und ein Client, der die ganze Zeit zugehört hat, im selben Status.
Nachrichten-Payloads#
Das sind die exakten Frames, die der Server sendet. Die IDs haben echte Formate (ljg_ für die Gruppe, ljb_ für jeden Job); snapshot wird nur dort mit "..." abgekürzt, wo sich die bereits gezeigte Struktur wiederholt.
Beim Verbindungsaufbau sendet der Server den aktuellen Status:
{
"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" }
}
}
}Sobald eine Sprache abgeschlossen ist, nennt das Event die Sprache, die sich geändert hat, und enthält den aktualisierten Snapshot:
{
"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" }
}
}
}Ein Fehler ist eine ganz normale Nachricht, keine abgebrochene Verbindung. job.failed enthält die Sprache und ein error – plus denselben vollständigen Snapshot. Die fehlgeschlagene Sprache zeigt status: "failed" in der jobs-Map, alle anderen Sprachen laufen weiter, und der Socket bleibt offen bis group.completed:
{
"type": "job.failed",
"jobId": "ljb_C3d4E5f6G7h8I9j0",
"locale": "ja",
"error": "Model timeout after 30 seconds",
"snapshot": { "...": "..." }
}Sobald alle Jobs abgeschlossen sind, sendet der Server ein letztes Event und schließt die Verbindung:
{
"type": "group.completed",
"groupId": "ljg_A1b2C3d4E5f6G7h8",
"status": "completed",
"snapshot": { "...": "..." }
}Das abschließende status ist completed, wenn jede Sprache erfolgreich war, completed_with_warnings, wenn jede Sprache ein Ergebnis geliefert hat, aber eine oder mehrere optionale pipeline-Stufen bei mindestens einer davon fehlgeschlagen sind, partial, wenn einige Sprachen erfolgreich waren und andere fehlgeschlagen sind, und failed, wenn alle fehlgeschlagen sind. Was diese Status jeweils für die Gruppe als Ganzes bedeuten, erfährst du unter Track a job group.
Bei allem Unbekannten aus dem Snapshot rendern
Verzweige auf die Nachrichtentypen, die du kennst, und falle bei allem, was du nicht erkennst, auf ein erneutes Rendern aus snapshot zurück. Jede Nachricht enthält einen vollständigen Snapshot – ein Client, der standardmäßig daraus rendert, bleibt also selbst bei einem Frame korrekt, für den er keinen eigenen Branch hat.
So bindest du es in deine UI ein#
Die Gruppe ist dein Fortschrittsmodell. Als du die Jobs erstellt hast, hast du mit dem 202 ein groupId und ein jobs-Array zurückbekommen – ein Eintrag pro Sprache. Initialisiere deinen Fortschrittsstatus mit dieser Antwort, und du hast genau die Struktur, die der Socket später auffüllt: die Gesamtzahl, auf die du hinarbeitest, und einen Zähler, der bei null startet.
const { groupId, jobs } = await response.json();
await db.translationProgress.create({
contentId: content.id,
groupId,
totalLanguages: jobs.length,
completedLanguages: 0,
});Öffne dann den Socket für dieses groupId, lies bei jeder Nachricht snapshot und rendere neu. Schau zu, wie der Zähler steigt, sobald Sprachen einlaufen, und stoppe, wenn group.completed ankommt:
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;
}
});Bei einer Gruppe mit drei Sprachen wird der Lauf dann live ausgegeben:
1/3 complete
fr ready (2/3)
ja failed: Model timeout after 30 seconds
All translations done: partialDer Zähler lief von selbst hoch, eine Sprache ist fehlgeschlagen, ohne den Stream zu unterbrechen, und partial hat dir gezeigt, wo der Lauf gelandet ist – genau das, was dein Spinner braucht, um zu einer echten Fortschrittsleiste zu werden. Wichtig ist: Die Schleife sammelt nie Status an. Jeder Branch liest aus dem snapshot der aktuellen Nachricht – deshalb funktioniert derselbe Code beim ersten Verbindungsaufbau, bei jedem Update und nach einer erneuten Verbindung.
API-Schlüssel serverseitig halten#
Der Socket authentifiziert sich mit deinem API-Schlüssel – demselben organisationsgebundenen Schlüssel, den auch die REST-Endpunkte verwenden. Deshalb ist der Browser der falsche Ort dafür: Ein API-Schlüssel in clientseitigem JavaScript öffnet für jeden mit Zugriff auf den Quelltext den Weg zu jeder Engine in deiner Organisation.
Vom Backend verbinden, nicht aus dem Browser
Öffne den WebSocket von deinem Server aus, wo der Schlüssel ohnehin schon liegt, und leite die Events dann über deinen eigenen Kanal an den Browser weiter – etwa per WebSocket oder über einen von dir kontrollierten Server-Sent-Events-Stream. Dein Frontend bekommt Live-Fortschritt, dein Schlüssel bleibt in deiner Infrastruktur.
Das entspricht dem Webhook-Modell: Die Verbindung zu Lingo.dev läuft serverseitig, und beim Nutzer kommt nur das an, was deine eigene App weiterleitet.
Wo das hineinpasst#
Der WebSocket ist die Live-Ansicht – er ist an genau eine Gruppe gebunden und schließt sich, sobald diese Gruppe fertig ist. Für eine robuste Server-zu-Server-Zustellung, die auch das Schließen eines Tabs oder ein Deployment übersteht, kombinierst du ihn mit Webhooks: Der Socket treibt die UI an, solange der Lauf auf dem Bildschirm ist, der Webhook zeichnet jedes Ergebnis in dem Moment auf, in dem es eintrifft. Verdrahte beides aus demselben Create-Call, und deine Nutzer sehen den Fortschritt live, während dein Backend die Ergebnisse behält – unabhängig davon, wer gerade zuschaut.
