Skip to content

Commit 68716c1

Browse files
committed
feat: websocket for dev widget
1 parent d1e444d commit 68716c1

5 files changed

Lines changed: 256 additions & 5 deletions

File tree

cmp/compiler/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@
149149
"@types/proper-lockfile": "^4.1.4",
150150
"@types/react": "^18.3.26",
151151
"@types/react-dom": "^19.2.3",
152+
"@types/ws": "^8.18.1",
152153
"next": "^16.0.3",
153154
"tsdown": "^0.16.5",
154155
"tsx": "^4.19.2",
@@ -176,7 +177,8 @@
176177
"lingo.dev": "^0.117.0",
177178
"lodash": "^4.17.21",
178179
"ollama-ai-provider": "^1.2.0",
179-
"proper-lockfile": "^4.1.2"
180+
"proper-lockfile": "^4.1.2",
181+
"ws": "^8.18.3"
180182
},
181183
"peerDependencies": {
182184
"next": "^15.0.0 || ^16.0.4",

cmp/compiler/src/react/shared/TranslationContext.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,8 @@ function TranslationProvider__Dev({
517517
pendingCount: pendingHashesRef.current.size,
518518
position: devWidget?.position || "bottom-left",
519519
} satisfies LingoDevState;
520+
// Set WebSocket URL for widget to connect to translation server
521+
window.__LINGO_DEV_WS_URL__ = serverUrl;
520522
// Trigger widget update
521523
window.__LINGO_DEV_UPDATE__?.();
522524
}

cmp/compiler/src/translation-server/translation-server.ts

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import http from "http";
1515
import type { Socket } from "net";
1616
import { URL } from "url";
17+
import { WebSocketServer, WebSocket } from "ws";
1718
import type { MetadataSchema, TranslationMiddlewareConfig } from "../types";
1819
import { getLogger } from "./logger";
1920
import {
@@ -22,6 +23,8 @@ import {
2223
TranslationService,
2324
} from "../translators";
2425
import { createEmptyMetadata, loadMetadata } from "../metadata/manager";
26+
import type { TranslationServerEvent } from "./ws-events";
27+
import { createEvent } from "./ws-events";
2528

2629
export interface TranslationServerOptions {
2730
/**
@@ -57,6 +60,8 @@ export class TranslationServer {
5760
private translationService: TranslationService | null = null;
5861
private metadata: MetadataSchema | null = null;
5962
private connections: Set<Socket> = new Set();
63+
private wss: WebSocketServer | null = null;
64+
private wsClients: Set<WebSocket> = new Set();
6065

6166
constructor(options: TranslationServerOptions) {
6267
this.config = options.config;
@@ -142,12 +147,77 @@ export class TranslationServer {
142147
this.server.listen(port, "127.0.0.1", () => {
143148
this.url = `http://127.0.0.1:${port}`;
144149
this.logger.info(`Translation server listening on ${this.url}`);
150+
151+
// Initialize WebSocket server on the same port
152+
this.initializeWebSocket();
153+
145154
this.onReadyCallback?.(port);
146155
resolve(port);
147156
});
148157
});
149158
}
150159

160+
/**
161+
* Initialize WebSocket server for real-time dev widget updates
162+
*/
163+
private initializeWebSocket(): void {
164+
if (!this.server) {
165+
throw new Error("HTTP server must be started before WebSocket");
166+
}
167+
168+
this.wss = new WebSocketServer({ server: this.server });
169+
170+
this.wss.on("connection", (ws: WebSocket) => {
171+
this.wsClients.add(ws);
172+
this.logger.debug(
173+
`WebSocket client connected. Total clients: ${this.wsClients.size}`,
174+
);
175+
176+
// Send initial connected event
177+
this.sendToClient(ws, createEvent("connected"));
178+
179+
ws.on("close", () => {
180+
this.wsClients.delete(ws);
181+
this.logger.debug(
182+
`WebSocket client disconnected. Total clients: ${this.wsClients.size}`,
183+
);
184+
});
185+
186+
ws.on("error", (error) => {
187+
this.logger.error(`WebSocket client error:`, error);
188+
this.wsClients.delete(ws);
189+
});
190+
});
191+
192+
this.wss.on("error", (error) => {
193+
this.logger.error(`WebSocket server error:`, error);
194+
this.onErrorCallback?.(error);
195+
});
196+
197+
this.logger.info(`WebSocket server initialized`);
198+
}
199+
200+
/**
201+
* Send event to a specific WebSocket client
202+
*/
203+
private sendToClient(ws: WebSocket, event: TranslationServerEvent): void {
204+
if (ws.readyState === WebSocket.OPEN) {
205+
ws.send(JSON.stringify(event));
206+
}
207+
}
208+
209+
/**
210+
* Broadcast event to all connected WebSocket clients
211+
*/
212+
private broadcast(event: TranslationServerEvent): void {
213+
const message = JSON.stringify(event);
214+
for (const client of this.wsClients) {
215+
if (client.readyState === WebSocket.OPEN) {
216+
client.send(message);
217+
}
218+
}
219+
}
220+
151221
/**
152222
* Stop the server
153223
*/
@@ -157,7 +227,19 @@ export class TranslationServer {
157227
return;
158228
}
159229

160-
// Destroy all active connections to prevent hanging
230+
// Close all WebSocket connections
231+
for (const client of this.wsClients) {
232+
client.close();
233+
}
234+
this.wsClients.clear();
235+
236+
// Close WebSocket server
237+
if (this.wss) {
238+
this.wss.close();
239+
this.wss = null;
240+
}
241+
242+
// Destroy all active HTTP connections to prevent hanging
161243
for (const socket of this.connections) {
162244
socket.destroy();
163245
}
@@ -293,11 +375,35 @@ export class TranslationServer {
293375
`Translating all ${allHashes.length} entries to ${locale}`,
294376
);
295377

296-
return await this.translationService.translate(
378+
// Broadcast batch start event
379+
const startTime = Date.now();
380+
this.broadcast(
381+
createEvent("batch:start", {
382+
locale,
383+
total: allHashes.length,
384+
hashes: allHashes,
385+
}),
386+
);
387+
388+
const result = await this.translationService.translate(
297389
locale,
298390
this.metadata,
299391
allHashes,
300392
);
393+
394+
// Broadcast batch complete event
395+
const duration = Date.now() - startTime;
396+
this.broadcast(
397+
createEvent("batch:complete", {
398+
locale,
399+
total: allHashes.length,
400+
successful: Object.keys(result.translations).length,
401+
failed: result.errors.length,
402+
duration,
403+
}),
404+
);
405+
406+
return result;
301407
}
302408

303409
/**

cmp/compiler/src/widget/lingo-dev-widget.ts

Lines changed: 134 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ import type { LingoDevState, WidgetPosition } from "./types";
1818
class LingoDevWidget extends HTMLElement {
1919
private shadow: ShadowRoot;
2020
private state: LingoDevState | null = null;
21+
private ws: WebSocket | null = null;
22+
private reconnectAttempts = 0;
23+
private maxReconnectAttempts = 5;
2124

2225
constructor() {
2326
super();
@@ -36,13 +39,137 @@ class LingoDevWidget extends HTMLElement {
3639
this.state = window.__LINGO_DEV_STATE__;
3740
this.render();
3841
}
42+
43+
// Connect to WebSocket for real-time server updates
44+
this.connectWebSocket();
3945
}
4046

4147
disconnectedCallback() {
4248
// Cleanup
4349
if (window.__LINGO_DEV_UPDATE__) {
4450
delete window.__LINGO_DEV_UPDATE__;
4551
}
52+
53+
// Close WebSocket connection
54+
if (this.ws) {
55+
this.ws.close();
56+
this.ws = null;
57+
}
58+
}
59+
60+
private connectWebSocket() {
61+
const wsUrl = window.__LINGO_DEV_WS_URL__;
62+
if (!wsUrl) {
63+
console.warn(
64+
"[Lingo.dev] WebSocket URL not available, real-time updates disabled",
65+
);
66+
return;
67+
}
68+
69+
try {
70+
// Convert HTTP URL to WS URL
71+
const url = new URL(wsUrl);
72+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
73+
74+
this.ws = new WebSocket(url.toString());
75+
76+
this.ws.onopen = () => {
77+
console.log("[Lingo.dev] WebSocket connected");
78+
this.reconnectAttempts = 0; // Reset on successful connection
79+
};
80+
81+
this.ws.onmessage = (event) => {
82+
try {
83+
const data = JSON.parse(event.data);
84+
this.handleServerEvent(data);
85+
} catch (error) {
86+
console.error(
87+
"[Lingo.dev] Failed to parse WebSocket message:",
88+
error,
89+
);
90+
}
91+
};
92+
93+
this.ws.onerror = (error) => {
94+
console.error("[Lingo.dev] WebSocket error:", error);
95+
};
96+
97+
this.ws.onclose = () => {
98+
console.log("[Lingo.dev] WebSocket disconnected");
99+
this.ws = null;
100+
101+
// Attempt to reconnect with exponential backoff
102+
if (this.reconnectAttempts < this.maxReconnectAttempts) {
103+
const delay = Math.min(
104+
1000 * Math.pow(2, this.reconnectAttempts),
105+
10000,
106+
);
107+
this.reconnectAttempts++;
108+
console.log(
109+
`[Lingo.dev] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`,
110+
);
111+
setTimeout(() => this.connectWebSocket(), delay);
112+
}
113+
};
114+
} catch (error) {
115+
console.error(
116+
"[Lingo.dev] Failed to create WebSocket connection:",
117+
error,
118+
);
119+
}
120+
}
121+
122+
private handleServerEvent(event: any) {
123+
switch (event.type) {
124+
case "connected":
125+
console.log(
126+
`[Lingo.dev] Connected to translation server: ${event.serverUrl}`,
127+
);
128+
break;
129+
130+
case "batch:start":
131+
// Update state to show server translation is in progress
132+
if (this.state) {
133+
this.state.serverProgress = {
134+
locale: event.locale,
135+
total: event.total,
136+
completed: 0,
137+
status: "in-progress",
138+
};
139+
this.render();
140+
}
141+
break;
142+
143+
case "batch:progress":
144+
// Update progress
145+
if (this.state && this.state.serverProgress) {
146+
this.state.serverProgress.completed = event.completed;
147+
this.render();
148+
}
149+
break;
150+
151+
case "batch:complete":
152+
// Clear server progress after a delay
153+
if (this.state && this.state.serverProgress) {
154+
this.state.serverProgress.status = "complete";
155+
this.render();
156+
157+
setTimeout(() => {
158+
if (this.state) {
159+
this.state.serverProgress = undefined;
160+
this.render();
161+
}
162+
}, 2000);
163+
}
164+
break;
165+
166+
case "batch:error":
167+
if (this.state && this.state.serverProgress) {
168+
this.state.serverProgress.status = "error";
169+
this.render();
170+
}
171+
break;
172+
}
46173
}
47174

48175
private render() {
@@ -52,14 +179,19 @@ class LingoDevWidget extends HTMLElement {
52179
return;
53180
}
54181

55-
const { isLoading, locale, pendingCount, position } = this.state;
182+
const { isLoading, locale, pendingCount, position, serverProgress } =
183+
this.state;
184+
185+
// Show loader if either client or server translations are in progress
186+
const showLoader =
187+
isLoading || (serverProgress && serverProgress.status === "in-progress");
56188

57189
this.shadow.innerHTML = `
58190
<style>
59191
${this.getStyles(position)}
60192
</style>
61193
<div class="container">
62-
${isLoading ? this.renderLoader() : this.renderLogo()}
194+
${showLoader ? this.renderLoader() : this.renderLogo()}
63195
<svg height="26" viewBox="0 0 650 171" fill="none" xmlns="http://www.w3.org/2000/svg">
64196
<path d="M391.372 137.084H373.458V119.326H391.372V137.084Z" fill="white"/>
65197
<path d="M420.766 133.46C415.204 130 410.808 125.121 407.566 118.842C404.329 112.562 402.685 105.422 402.685 97.4631C402.685 89.5033 404.306 82.4043 407.566 76.1645C410.808 69.925 415.227 65.0853 420.766 61.6257C426.327 58.1659 432.506 56.446 439.326 56.446C449.283 56.446 457.245 60.2258 463.222 67.7652H464.001V27.0283H479.56V137.08H466.204L464.624 127.02H463.845C457.764 134.78 449.583 138.66 439.326 138.66C432.506 138.66 426.327 136.94 420.766 133.48V133.46ZM452.606 120.881C456.102 118.681 458.883 115.542 460.943 111.442C462.985 107.362 464.001 102.683 464.001 97.4429C464.001 92.2033 462.985 87.5637 460.943 83.5241C458.906 79.4843 456.126 76.3645 452.606 74.1647C449.087 71.9649 445.285 70.865 441.206 70.865C437.127 70.865 433.187 71.9649 429.726 74.1647C426.264 76.3645 423.506 79.4843 421.464 83.5241C419.427 87.5637 418.406 92.2033 418.406 97.4429C418.406 102.683 419.427 107.482 421.464 111.522C423.506 115.562 426.264 118.681 429.726 120.881C433.187 123.081 437.006 124.181 441.206 124.181C445.406 124.181 449.087 123.081 452.606 120.881Z" fill="white"/>

cmp/compiler/src/widget/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,26 @@ export type WidgetPosition =
88
| "top-left"
99
| "top-right";
1010

11+
export interface ServerTranslationProgress {
12+
locale: string;
13+
total: number;
14+
completed: number;
15+
status: "in-progress" | "complete" | "error";
16+
}
17+
1118
export interface LingoDevState {
1219
isLoading: boolean;
1320
locale: string;
1421
sourceLocale: string;
1522
pendingCount: number;
1623
position: WidgetPosition;
24+
serverProgress?: ServerTranslationProgress;
1725
}
1826

1927
declare global {
2028
interface Window {
2129
__LINGO_DEV_STATE__?: LingoDevState;
2230
__LINGO_DEV_UPDATE__?: () => void;
31+
__LINGO_DEV_WS_URL__?: string;
2332
}
2433
}

0 commit comments

Comments
 (0)