|
14 | 14 | import http from "http"; |
15 | 15 | import type { Socket } from "net"; |
16 | 16 | import { URL } from "url"; |
17 | | -import { WebSocketServer, WebSocket } from "ws"; |
| 17 | +import { WebSocket, WebSocketServer } from "ws"; |
18 | 18 | import type { MetadataSchema, TranslationMiddlewareConfig } from "../types"; |
19 | 19 | import { getLogger } from "./logger"; |
20 | 20 | import { |
@@ -63,6 +63,12 @@ export class TranslationServer { |
63 | 63 | private wss: WebSocketServer | null = null; |
64 | 64 | private wsClients: Set<WebSocket> = new Set(); |
65 | 65 |
|
| 66 | + // Translation activity tracking for "busy" notifications |
| 67 | + private activeTranslations = 0; |
| 68 | + private isBusy = false; |
| 69 | + private busyTimeout: NodeJS.Timeout | null = null; |
| 70 | + private readonly BUSY_DEBOUNCE_MS = 500; // Time after last translation to send "idle" event |
| 71 | + |
66 | 72 | constructor(options: TranslationServerOptions) { |
67 | 73 | this.config = options.config; |
68 | 74 | this.startPort = options.startPort || 60000; |
@@ -227,6 +233,12 @@ export class TranslationServer { |
227 | 233 | return; |
228 | 234 | } |
229 | 235 |
|
| 236 | + // Clear any pending busy timeout |
| 237 | + if (this.busyTimeout) { |
| 238 | + clearTimeout(this.busyTimeout); |
| 239 | + this.busyTimeout = null; |
| 240 | + } |
| 241 | + |
230 | 242 | // Close all WebSocket connections |
231 | 243 | for (const client of this.wsClients) { |
232 | 244 | client.close(); |
@@ -448,6 +460,57 @@ export class TranslationServer { |
448 | 460 | }); |
449 | 461 | } |
450 | 462 |
|
| 463 | + /** |
| 464 | + * Mark translation activity start - emits busy event if not already busy |
| 465 | + */ |
| 466 | + private startTranslationActivity(): void { |
| 467 | + this.activeTranslations++; |
| 468 | + |
| 469 | + // Clear any pending idle timeout |
| 470 | + if (this.busyTimeout) { |
| 471 | + clearTimeout(this.busyTimeout); |
| 472 | + this.busyTimeout = null; |
| 473 | + } |
| 474 | + |
| 475 | + // Emit busy event if this is the first active translation |
| 476 | + if (!this.isBusy && this.activeTranslations > 0) { |
| 477 | + this.isBusy = true; |
| 478 | + this.broadcast( |
| 479 | + createEvent("server:busy", { |
| 480 | + activeTranslations: this.activeTranslations, |
| 481 | + }), |
| 482 | + ); |
| 483 | + this.logger.debug( |
| 484 | + `[BUSY] Server is now busy (${this.activeTranslations} active)`, |
| 485 | + ); |
| 486 | + } |
| 487 | + } |
| 488 | + |
| 489 | + /** |
| 490 | + * Mark translation activity end - emits idle event after debounce period |
| 491 | + */ |
| 492 | + private endTranslationActivity(): void { |
| 493 | + this.activeTranslations = Math.max(0, this.activeTranslations - 1); |
| 494 | + |
| 495 | + // If no more active translations, schedule idle notification |
| 496 | + if (this.activeTranslations === 0 && this.isBusy) { |
| 497 | + // Clear any existing timeout |
| 498 | + if (this.busyTimeout) { |
| 499 | + clearTimeout(this.busyTimeout); |
| 500 | + } |
| 501 | + |
| 502 | + // Wait for debounce period before sending idle event |
| 503 | + // This prevents rapid busy->idle->busy cycles when translations come in quick succession |
| 504 | + this.busyTimeout = setTimeout(() => { |
| 505 | + if (this.activeTranslations === 0) { |
| 506 | + this.isBusy = false; |
| 507 | + this.broadcast(createEvent("server:idle", {})); |
| 508 | + this.logger.debug("[IDLE] Server is now idle"); |
| 509 | + } |
| 510 | + }, this.BUSY_DEBOUNCE_MS); |
| 511 | + } |
| 512 | + } |
| 513 | + |
451 | 514 | /** |
452 | 515 | * Check if a given URL is running our translation server by calling the health endpoint |
453 | 516 | */ |
@@ -619,25 +682,33 @@ export class TranslationServer { |
619 | 682 | this.logger.info(`🔄 Translating ${hashes.length} hashes to ${locale}`); |
620 | 683 | this.logger.debug(`🔄 Hashes: ${hashes.join(", ")}`); |
621 | 684 |
|
622 | | - // Translate using the stored service |
623 | | - const result = await this.translationService.translate( |
624 | | - locale, |
625 | | - this.metadata, |
626 | | - hashes, |
627 | | - ); |
| 685 | + // Mark translation activity start |
| 686 | + this.startTranslationActivity(); |
628 | 687 |
|
629 | | - // Return successful response |
630 | | - res.writeHead(200, { |
631 | | - "Content-Type": "application/json", |
632 | | - "Cache-Control": "no-cache", |
633 | | - }); |
634 | | - res.end( |
635 | | - JSON.stringify({ |
| 688 | + try { |
| 689 | + // Translate using the stored service |
| 690 | + const result = await this.translationService.translate( |
636 | 691 | locale, |
637 | | - translations: result.translations, |
638 | | - errors: result.errors, |
639 | | - }), |
640 | | - ); |
| 692 | + this.metadata, |
| 693 | + hashes, |
| 694 | + ); |
| 695 | + |
| 696 | + // Return successful response |
| 697 | + res.writeHead(200, { |
| 698 | + "Content-Type": "application/json", |
| 699 | + "Cache-Control": "no-cache", |
| 700 | + }); |
| 701 | + res.end( |
| 702 | + JSON.stringify({ |
| 703 | + locale, |
| 704 | + translations: result.translations, |
| 705 | + errors: result.errors, |
| 706 | + }), |
| 707 | + ); |
| 708 | + } finally { |
| 709 | + // Mark translation activity end |
| 710 | + this.endTranslationActivity(); |
| 711 | + } |
641 | 712 | } catch (error) { |
642 | 713 | this.logger.error( |
643 | 714 | `Error getting batch translations for ${locale}:`, |
|
0 commit comments