@umituz/web-localization 1.1.6 → 1.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Google Translate Service
3
- * @description Main translation service using Google Translate API
2
+ * Google Translate Service with Performance Optimizations
3
+ * @description Main translation service using Google Translate API with caching and pooling
4
4
  */
5
5
 
6
6
  import type {
@@ -27,10 +27,53 @@ import {
27
27
  TRANSLATION_CONCURRENCY_LIMIT,
28
28
  } from "../constants/index.js";
29
29
 
30
+ /**
31
+ * Translation cache entry with TTL support
32
+ */
33
+ interface CacheEntry {
34
+ translation: string;
35
+ timestamp: number;
36
+ accessCount: number;
37
+ }
38
+
39
+ /**
40
+ * Object pool for reusing Map instances
41
+ */
42
+ class MapPool {
43
+ private pool: Map<string, string>[] = [];
44
+ private readonly maxPoolSize = 10;
45
+
46
+ acquire(): Map<string, string> {
47
+ return this.pool.pop() || new Map<string, string>();
48
+ }
49
+
50
+ release(map: Map<string, string>): void {
51
+ if (map.size === 0 && this.pool.length < this.maxPoolSize) {
52
+ this.pool.push(map);
53
+ }
54
+ }
55
+
56
+ clear(): void {
57
+ this.pool.length = 0;
58
+ }
59
+ }
60
+
30
61
  class GoogleTranslateService implements ITranslationService {
31
62
  private config: TranslationServiceConfig | null = null;
32
63
  private _rateLimiter: RateLimiter | null = null;
33
64
 
65
+ // Translation cache with LRU-style eviction
66
+ private translationCache = new Map<string, CacheEntry>();
67
+ private readonly maxCacheSize = 1000;
68
+ private readonly cacheTTL = 1000 * 60 * 60; // 1 hour
69
+
70
+ // Object pools
71
+ private readonly mapPool = new MapPool();
72
+
73
+ // Performance tracking
74
+ private activeRequests = 0;
75
+ private readonly maxConcurrentRequests = 20;
76
+
34
77
  initialize(config: TranslationServiceConfig): void {
35
78
  this.config = {
36
79
  minDelay: DEFAULT_MIN_DELAY,
@@ -59,6 +102,82 @@ class GoogleTranslateService implements ITranslationService {
59
102
  }
60
103
  }
61
104
 
105
+ /**
106
+ * Generate cache key for translation
107
+ */
108
+ private getCacheKey(text: string, targetLang: string, sourceLang: string): string {
109
+ return `${sourceLang}|${targetLang}|${text}`;
110
+ }
111
+
112
+ /**
113
+ * Get translation from cache
114
+ */
115
+ private getFromCache(text: string, targetLang: string, sourceLang: string): string | null {
116
+ const key = this.getCacheKey(text, targetLang, sourceLang);
117
+ const entry = this.translationCache.get(key);
118
+
119
+ if (!entry) return null;
120
+
121
+ // Check if entry has expired
122
+ const now = Date.now();
123
+ if (now - entry.timestamp > this.cacheTTL) {
124
+ this.translationCache.delete(key);
125
+ return null;
126
+ }
127
+
128
+ // Update access count and timestamp for LRU
129
+ entry.accessCount++;
130
+ entry.timestamp = now;
131
+
132
+ return entry.translation;
133
+ }
134
+
135
+ /**
136
+ * Store translation in cache with automatic eviction
137
+ */
138
+ private storeInCache(text: string, targetLang: string, sourceLang: string, translation: string): void {
139
+ // If cache is full, remove least recently used entries
140
+ if (this.translationCache.size >= this.maxCacheSize) {
141
+ let oldestKey: string | null = null;
142
+ let oldestTime = Infinity;
143
+
144
+ for (const [key, entry] of this.translationCache) {
145
+ if (entry.timestamp < oldestTime) {
146
+ oldestTime = entry.timestamp;
147
+ oldestKey = key;
148
+ }
149
+ }
150
+
151
+ if (oldestKey) {
152
+ this.translationCache.delete(oldestKey);
153
+ }
154
+ }
155
+
156
+ const key = this.getCacheKey(text, targetLang, sourceLang);
157
+ this.translationCache.set(key, {
158
+ translation,
159
+ timestamp: Date.now(),
160
+ accessCount: 1,
161
+ });
162
+ }
163
+
164
+ /**
165
+ * Clear translation cache
166
+ */
167
+ clearCache(): void {
168
+ this.translationCache.clear();
169
+ }
170
+
171
+ /**
172
+ * Get cache statistics
173
+ */
174
+ getCacheStats(): { size: number; maxSize: number } {
175
+ return {
176
+ size: this.translationCache.size,
177
+ maxSize: this.maxCacheSize,
178
+ };
179
+ }
180
+
62
181
  async translate(request: TranslationRequest): Promise<TranslationResponse> {
63
182
  this.ensureInitialized();
64
183
 
@@ -85,14 +204,36 @@ class GoogleTranslateService implements ITranslationService {
85
204
  };
86
205
  }
87
206
 
88
- await this.rateLimiter.waitForSlot();
207
+ // Check cache first
208
+ const cachedTranslation = this.getFromCache(text, targetLanguage, sourceLanguage);
209
+ if (cachedTranslation !== null) {
210
+ return {
211
+ originalText: text,
212
+ translatedText: cachedTranslation,
213
+ sourceLanguage,
214
+ targetLanguage,
215
+ success: true,
216
+ cached: true,
217
+ };
218
+ }
219
+
220
+ // Wait for rate limit slot with normal priority
221
+ await this.rateLimiter.waitForSlot(5);
89
222
 
90
223
  try {
224
+ const startTime = Date.now();
91
225
  const translatedText = await this.callTranslateAPI(
92
226
  text,
93
227
  targetLanguage,
94
228
  sourceLanguage
95
229
  );
230
+ const responseTime = Date.now() - startTime;
231
+
232
+ // Record response time for dynamic rate adjustment
233
+ this.rateLimiter.recordResponseTime(responseTime);
234
+
235
+ // Store in cache
236
+ this.storeInCache(text, targetLanguage, sourceLanguage, translatedText);
96
237
 
97
238
  return {
98
239
  originalText: text,
@@ -128,43 +269,88 @@ class GoogleTranslateService implements ITranslationService {
128
269
  return stats;
129
270
  }
130
271
 
131
- // Process requests concurrently with controlled parallelism
132
- const chunks: TranslationRequest[][] = [];
272
+ // Filter out requests that can be served from cache
273
+ const uncachedRequests: TranslationRequest[] = [];
274
+ const cacheIndexMap = new Map<number, string>(); // Maps original index to cached translation
275
+
276
+ for (let i = 0; i < requests.length; i++) {
277
+ const request = requests[i];
278
+ const cachedTranslation = this.getFromCache(
279
+ request.text,
280
+ request.targetLanguage,
281
+ request.sourceLanguage || "en"
282
+ );
133
283
 
134
- for (let i = 0; i < requests.length; i += TRANSLATION_CONCURRENCY_LIMIT) {
135
- chunks.push(requests.slice(i, i + TRANSLATION_CONCURRENCY_LIMIT));
284
+ if (cachedTranslation !== null) {
285
+ // Serve from cache
286
+ cacheIndexMap.set(i, cachedTranslation);
287
+ stats.successCount++;
288
+ stats.translatedKeys.push({
289
+ key: request.text,
290
+ from: request.text,
291
+ to: cachedTranslation,
292
+ });
293
+ } else {
294
+ uncachedRequests.push(request);
295
+ }
136
296
  }
137
297
 
138
- for (const chunk of chunks) {
139
- const results = await Promise.allSettled(
140
- chunk.map(async (request) => {
141
- await this.rateLimiter.waitForSlot();
142
- return this.callTranslateAPI(
143
- request.text,
144
- request.targetLanguage,
145
- request.sourceLanguage || "en"
146
- );
147
- })
148
- );
298
+ stats.skippedCount = cacheIndexMap.size;
149
299
 
150
- for (let i = 0; i < chunk.length; i++) {
151
- const request = chunk[i];
152
- const result = results[i];
153
-
154
- if (result.status === "fulfilled") {
155
- const translatedText = result.value;
156
- if (translatedText && translatedText !== request.text) {
157
- stats.successCount++;
158
- stats.translatedKeys.push({
159
- key: request.text,
160
- from: request.text,
161
- to: translatedText,
162
- });
300
+ // Process uncached requests with controlled parallelism
301
+ if (uncachedRequests.length > 0) {
302
+ const chunks: TranslationRequest[][] = [];
303
+
304
+ for (let i = 0; i < uncachedRequests.length; i += TRANSLATION_CONCURRENCY_LIMIT) {
305
+ chunks.push(uncachedRequests.slice(i, i + TRANSLATION_CONCURRENCY_LIMIT));
306
+ }
307
+
308
+ for (const chunk of chunks) {
309
+ const results = await Promise.allSettled(
310
+ chunk.map(async (request) => {
311
+ await this.rateLimiter.waitForSlot(5);
312
+ const startTime = Date.now();
313
+ const result = await this.callTranslateAPI(
314
+ request.text,
315
+ request.targetLanguage,
316
+ request.sourceLanguage || "en"
317
+ );
318
+ const responseTime = Date.now() - startTime;
319
+
320
+ // Record response time for dynamic rate adjustment
321
+ this.rateLimiter.recordResponseTime(responseTime);
322
+
323
+ return result;
324
+ })
325
+ );
326
+
327
+ for (let i = 0; i < chunk.length; i++) {
328
+ const request = chunk[i];
329
+ const result = results[i];
330
+
331
+ if (result.status === "fulfilled") {
332
+ const translatedText = result.value;
333
+ if (translatedText && translatedText !== request.text) {
334
+ stats.successCount++;
335
+ stats.translatedKeys.push({
336
+ key: request.text,
337
+ from: request.text,
338
+ to: translatedText,
339
+ });
340
+
341
+ // Cache the successful translation
342
+ this.storeInCache(
343
+ request.text,
344
+ request.targetLanguage,
345
+ request.sourceLanguage || "en",
346
+ translatedText
347
+ );
348
+ } else {
349
+ stats.skippedCount++;
350
+ }
163
351
  } else {
164
- stats.skippedCount++;
352
+ stats.failureCount++;
165
353
  }
166
- } else {
167
- stats.failureCount++;
168
354
  }
169
355
  }
170
356
  }
@@ -191,64 +377,96 @@ class GoogleTranslateService implements ITranslationService {
191
377
  if (!targetObject || typeof targetObject !== "object") return;
192
378
  if (!targetLanguage || targetLanguage.trim().length === 0) return;
193
379
 
380
+ // First pass: collect all texts to translate (flattens nested structure)
381
+ const textsToTranslate: Array<{
382
+ key: string;
383
+ enValue: string;
384
+ currentPath: string;
385
+ }> = [];
386
+
387
+ const nestedObjects: Array<{
388
+ key: string;
389
+ sourceObj: Record<string, unknown>;
390
+ targetObj: Record<string, unknown>;
391
+ currentPath: string;
392
+ }> = [];
393
+
394
+ // Collect texts and nested objects
194
395
  const keys = Object.keys(sourceObject);
195
- const textsToTranslate: Array<{key: string; enValue: string; currentPath: string}> = [];
196
-
197
396
  for (const key of keys) {
198
397
  const enValue = sourceObject[key];
199
398
  const targetValue = targetObject[key];
200
399
  const currentPath = path ? `${path}.${key}` : key;
201
400
 
202
401
  if (typeof enValue === "object" && enValue !== null) {
402
+ // Prepare nested object for processing
203
403
  if (!targetObject[key] || typeof targetObject[key] !== "object") {
204
404
  targetObject[key] = {};
205
405
  }
206
- await this.translateObject(
207
- enValue as Record<string, unknown>,
208
- targetObject[key] as Record<string, unknown>,
209
- targetLanguage,
406
+ nestedObjects.push({
407
+ key,
408
+ sourceObj: enValue as Record<string, unknown>,
409
+ targetObj: targetObject[key] as Record<string, unknown>,
210
410
  currentPath,
211
- stats,
212
- onTranslate,
213
- force
214
- );
411
+ });
215
412
  } else if (typeof enValue === "string") {
216
413
  stats.totalCount++;
217
414
  if (force || needsTranslation(targetValue)) {
218
- textsToTranslate.push({key, enValue, currentPath});
415
+ textsToTranslate.push({ key, enValue, currentPath });
219
416
  } else {
220
417
  stats.skippedCount++;
221
418
  }
222
419
  }
223
420
  }
224
421
 
422
+ // Process nested objects recursively
423
+ for (const nested of nestedObjects) {
424
+ await this.translateObject(
425
+ nested.sourceObj,
426
+ nested.targetObj,
427
+ targetLanguage,
428
+ nested.currentPath,
429
+ stats,
430
+ onTranslate,
431
+ force
432
+ );
433
+ }
434
+
435
+ // Process texts in batches
225
436
  if (textsToTranslate.length > 0) {
226
437
  for (let i = 0; i < textsToTranslate.length; i += TRANSLATION_BATCH_SIZE) {
227
438
  const batch = textsToTranslate.slice(i, i + TRANSLATION_BATCH_SIZE);
228
439
  const results = await this.translateBatch(
229
- batch.map(item => ({
440
+ batch.map((item) => ({
230
441
  text: item.enValue,
231
442
  targetLanguage,
232
443
  }))
233
444
  );
234
445
 
235
- // Create a map for quick lookup of translations
236
- const translationMap = new Map<string, string>();
237
- for (const item of results.translatedKeys) {
238
- translationMap.set(item.from, item.to);
239
- }
446
+ // Use object pool for map
447
+ const translationMap = this.mapPool.acquire();
448
+ try {
449
+ // Create a map for quick lookup of translations
450
+ for (const item of results.translatedKeys) {
451
+ translationMap.set(item.from, item.to);
452
+ }
240
453
 
241
- for (let j = 0; j < batch.length; j++) {
242
- const {key, enValue, currentPath} = batch[j];
243
- const translatedText = translationMap.get(enValue);
454
+ for (let j = 0; j < batch.length; j++) {
455
+ const { key, enValue, currentPath } = batch[j];
456
+ const translatedText = translationMap.get(enValue);
244
457
 
245
- if (translatedText && translatedText !== enValue) {
246
- targetObject[key] = translatedText;
247
- stats.successCount++;
248
- if (onTranslate) onTranslate(currentPath, enValue, translatedText);
249
- } else {
250
- stats.failureCount++;
458
+ if (translatedText && translatedText !== enValue) {
459
+ targetObject[key] = translatedText;
460
+ stats.successCount++;
461
+ if (onTranslate) onTranslate(currentPath, enValue, translatedText);
462
+ } else {
463
+ stats.failureCount++;
464
+ }
251
465
  }
466
+ } finally {
467
+ // Clear and release map back to pool
468
+ translationMap.clear();
469
+ this.mapPool.release(translationMap);
252
470
  }
253
471
  }
254
472
  }
@@ -261,80 +479,125 @@ class GoogleTranslateService implements ITranslationService {
261
479
  retries = DEFAULT_MAX_RETRIES,
262
480
  backoffMs = 2000
263
481
  ): Promise<string> {
264
- // 1. Variable Protection (Extract {{variables}})
265
- const varMap = new Map<string, string>();
266
- let counter = 0;
267
-
268
- // Find all {{something}} patterns
269
- const safeText = text.replace(/\{\{([^}]+)\}\}/g, (match) => {
270
- const placeholder = `_VAR${counter}_`; // Using a simple token less likely to be split
271
- varMap.set(placeholder, match);
272
- counter++;
273
- return placeholder;
274
- });
482
+ // Increment active requests counter
483
+ this.activeRequests++;
275
484
 
276
- const timeout = this.config?.timeout || DEFAULT_TIMEOUT;
277
- const encodedText = encodeURIComponent(safeText);
278
- const url = `${GOOGLE_TRANSLATE_API_URL}?client=gtx&sl=${sourceLanguage}&tl=${targetLanguage}&dt=t&q=${encodedText}`;
279
-
280
- for (let attempt = 0; attempt < retries; attempt++) {
281
- const controller = new AbortController();
282
- const timeoutId = setTimeout(() => controller.abort(), timeout);
485
+ try {
486
+ // 1. Variable Protection (Extract {{variables}})
487
+ // Use object pool for map to reduce GC pressure
488
+ const varMap = this.mapPool.acquire();
489
+ let counter = 0;
283
490
 
284
491
  try {
285
- const response = await fetch(url, {
286
- signal: controller.signal,
492
+ // Find all {{something}} patterns
493
+ // Use more specific pattern to avoid false matches
494
+ const safeText = text.replace(/\{\{([^}]+)\}\}/g, (match) => {
495
+ const placeholder = `__VAR${counter}__`;
496
+ varMap.set(placeholder, match);
497
+ counter++;
498
+ return placeholder;
287
499
  });
288
500
 
289
- if (!response.ok) {
290
- if (response.status === 429 || response.status >= 500) {
291
- if (attempt < retries - 1) {
292
- clearTimeout(timeoutId);
293
- // Exponential backoff
294
- const delay = backoffMs * Math.pow(2, attempt);
295
- await new Promise(resolve => setTimeout(resolve, delay));
296
- continue;
501
+ const timeout = this.config?.timeout || DEFAULT_TIMEOUT;
502
+ const encodedText = encodeURIComponent(safeText);
503
+ const url = `${GOOGLE_TRANSLATE_API_URL}?client=gtx&sl=${sourceLanguage}&tl=${targetLanguage}&dt=t&q=${encodedText}`;
504
+
505
+ for (let attempt = 0; attempt < retries; attempt++) {
506
+ const controller = new AbortController();
507
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
508
+
509
+ try {
510
+ const response = await fetch(url, {
511
+ signal: controller.signal,
512
+ });
513
+
514
+ if (!response.ok) {
515
+ if (response.status === 429 || response.status >= 500) {
516
+ if (attempt < retries - 1) {
517
+ clearTimeout(timeoutId);
518
+ // Exponential backoff
519
+ const delay = backoffMs * Math.pow(2, attempt);
520
+ await this.sleep(delay);
521
+ continue;
522
+ }
523
+ }
524
+ throw new Error(`API request failed: ${response.status}`);
297
525
  }
298
- }
299
- throw new Error(`API request failed: ${response.status}`);
300
- }
301
526
 
302
- const data = await response.json();
303
-
304
- let translatedStr = safeText;
305
- if (
306
- Array.isArray(data) &&
307
- data.length > 0 &&
308
- Array.isArray(data[0]) &&
309
- data[0].length > 0 &&
310
- typeof data[0][0][0] === "string"
311
- ) {
312
- translatedStr = data[0].map((item: unknown[]) => item[0] as string).join('');
313
- }
527
+ const data = await response.json();
528
+
529
+ let translatedStr = safeText;
530
+ if (
531
+ Array.isArray(data) &&
532
+ data.length > 0 &&
533
+ Array.isArray(data[0]) &&
534
+ data[0].length > 0 &&
535
+ typeof data[0][0][0] === "string"
536
+ ) {
537
+ translatedStr = data[0].map((item: unknown[]) => item[0] as string).join('');
538
+ }
539
+
540
+ // 2. Re-inject Variables
541
+ if (varMap.size > 0) {
542
+ // Sometimes Google adds spaces, like __VAR0__ -> __ VAR0 __
543
+ // Use pre-compiled regex patterns for better performance
544
+ for (const [placeholder, originalVar] of varMap.entries()) {
545
+ // Escape special regex characters in placeholder
546
+ const escapedPlaceholder = placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
547
+ // Allow optional spaces between each character
548
+ const regex = new RegExp(escapedPlaceholder.split('').join('\\s*'), 'g');
549
+ translatedStr = translatedStr.replace(regex, originalVar);
550
+ }
551
+ }
314
552
 
315
- // 2. Re-inject Variables
316
- if (varMap.size > 0) {
317
- // Sometimes Google adds spaces, like _VAR0_ -> _ VAR0 _
318
- for (const [placeholder, originalVar] of varMap.entries()) {
319
- const regex = new RegExp(placeholder.split('').join('\\s*'), 'g');
320
- translatedStr = translatedStr.replace(regex, originalVar);
553
+ return translatedStr;
554
+ } catch (error) {
555
+ clearTimeout(timeoutId);
556
+ if (attempt === retries - 1) {
557
+ throw error;
558
+ }
559
+ const delay = backoffMs * Math.pow(2, attempt);
560
+ await this.sleep(delay);
561
+ } finally {
562
+ clearTimeout(timeoutId);
321
563
  }
322
564
  }
323
565
 
324
- return translatedStr;
325
- } catch (error) {
326
- clearTimeout(timeoutId);
327
- if (attempt === retries - 1) {
328
- throw error;
329
- }
330
- const delay = backoffMs * Math.pow(2, attempt);
331
- await new Promise(resolve => setTimeout(resolve, delay));
566
+ return text;
332
567
  } finally {
333
- clearTimeout(timeoutId);
568
+ // Clear and release map back to pool
569
+ varMap.clear();
570
+ this.mapPool.release(varMap);
334
571
  }
572
+ } finally {
573
+ // Decrement active requests counter
574
+ this.activeRequests--;
575
+ }
576
+ }
577
+
578
+ /**
579
+ * Optimized sleep function
580
+ */
581
+ private sleep(ms: number): Promise<void> {
582
+ return new Promise<void>((resolve) => setTimeout(resolve, ms));
583
+ }
584
+
585
+ /**
586
+ * Get number of active requests
587
+ */
588
+ getActiveRequestCount(): number {
589
+ return this.activeRequests;
590
+ }
591
+
592
+ /**
593
+ * Clean up resources
594
+ */
595
+ dispose(): void {
596
+ this.clearCache();
597
+ this.mapPool.clear();
598
+ if (this._rateLimiter) {
599
+ this._rateLimiter.clear();
335
600
  }
336
-
337
- return text;
338
601
  }
339
602
  }
340
603