locai 2.1.0

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.
Files changed (72) hide show
  1. package/README.md +928 -0
  2. package/dist/cli.js +718 -0
  3. package/dist/commands/review.js +357 -0
  4. package/dist/commands/translator.js +29 -0
  5. package/dist/config/index.js +52 -0
  6. package/dist/core/adapters/base-adapter.js +1 -0
  7. package/dist/core/adapters/factory.js +44 -0
  8. package/dist/core/adapters/json-adapter.js +20 -0
  9. package/dist/core/adapters/po-adapter.js +50 -0
  10. package/dist/core/adapters/properties-adapter.js +31 -0
  11. package/dist/core/adapters/yaml-adapter.js +24 -0
  12. package/dist/core/context-processor.js +247 -0
  13. package/dist/core/detection/framework-detector.js +56 -0
  14. package/dist/core/detection/structure-detector.js +30 -0
  15. package/dist/core/fallback-provider.js +442 -0
  16. package/dist/core/orchestrator.js +438 -0
  17. package/dist/core/pipeline/Pipeline.js +48 -0
  18. package/dist/core/pipeline/PipelineStep.js +13 -0
  19. package/dist/core/pipeline/context.js +21 -0
  20. package/dist/core/pipeline/steps/CacheReadStep.js +80 -0
  21. package/dist/core/pipeline/steps/CacheWriteStep.js +32 -0
  22. package/dist/core/pipeline/steps/ConfidenceCheckStep.js +67 -0
  23. package/dist/core/pipeline/steps/GlossaryPostStep.js +17 -0
  24. package/dist/core/pipeline/steps/GlossaryPreStep.js +20 -0
  25. package/dist/core/pipeline/steps/InputValidationStep.js +26 -0
  26. package/dist/core/pipeline/steps/QualityCheckStep.js +48 -0
  27. package/dist/core/pipeline/steps/ToneCheckStep.js +58 -0
  28. package/dist/core/pipeline/steps/TranslationStep.js +65 -0
  29. package/dist/core/pipeline/steps/VectorCacheReadStep.js +84 -0
  30. package/dist/core/pipeline/steps/VectorCacheWriteStep.js +42 -0
  31. package/dist/core/pipeline/steps/__tests__/ToneCheckStep.test.js +100 -0
  32. package/dist/core/provider-factory.js +139 -0
  33. package/dist/providers/base-provider.js +230 -0
  34. package/dist/providers/dashscope.js +128 -0
  35. package/dist/providers/deepseek.js +111 -0
  36. package/dist/providers/gemini.js +135 -0
  37. package/dist/providers/openai.js +203 -0
  38. package/dist/providers/xai.js +111 -0
  39. package/dist/services/embedding-provider.js +50 -0
  40. package/dist/services/translation-service.js +609 -0
  41. package/dist/services/vector-store.js +79 -0
  42. package/dist/utils/ai-context-analyzer.js +460 -0
  43. package/dist/utils/confidence-scorer.js +277 -0
  44. package/dist/utils/error-helper.js +386 -0
  45. package/dist/utils/file-manager.js +357 -0
  46. package/dist/utils/glossary-manager.js +194 -0
  47. package/dist/utils/graceful-shutdown.js +118 -0
  48. package/dist/utils/input-validator.js +663 -0
  49. package/dist/utils/logger.js +374 -0
  50. package/dist/utils/object-transformer.js +177 -0
  51. package/dist/utils/progress-tracker.js +277 -0
  52. package/dist/utils/prompt-templates.js +415 -0
  53. package/dist/utils/quality/__tests__/tone-verifier.test.js +91 -0
  54. package/dist/utils/quality/base-checker.js +73 -0
  55. package/dist/utils/quality/code-block-checker.js +214 -0
  56. package/dist/utils/quality/html-tag-checker.js +64 -0
  57. package/dist/utils/quality/index.js +220 -0
  58. package/dist/utils/quality/length-checker.js +140 -0
  59. package/dist/utils/quality/markdown-checker.js +220 -0
  60. package/dist/utils/quality/placeholder-checker.js +142 -0
  61. package/dist/utils/quality/punctuation-checker.js +50 -0
  62. package/dist/utils/quality/quote-balance-checker.js +143 -0
  63. package/dist/utils/quality/special-characters-checker.js +229 -0
  64. package/dist/utils/quality/style-guide-checker.js +192 -0
  65. package/dist/utils/quality/text-sanitizer.js +120 -0
  66. package/dist/utils/quality/tone-verifier.js +91 -0
  67. package/dist/utils/rate-limiter.js +398 -0
  68. package/dist/utils/retry-helper.js +166 -0
  69. package/dist/utils/state-manager.js +162 -0
  70. package/dist/utils/statistics-manager.js +51 -0
  71. package/dist/utils/ui-manager.js +98 -0
  72. package/package.json +80 -0
@@ -0,0 +1,357 @@
1
+ import { promises as fs } from "fs";
2
+ import path from "path";
3
+ import { FormatFactory } from "../core/adapters/factory.js";
4
+ /**
5
+ * FileManager - Modern asynchronous file operations
6
+ * Handles multiple file formats via Adapter pattern.
7
+ */
8
+ class FileManager {
9
+ /**
10
+ * Default options for file operations
11
+ */
12
+ static defaultOptions = {
13
+ atomic: true,
14
+ createMissingDirs: true,
15
+ backupFiles: true,
16
+ backupDir: "./backups",
17
+ encoding: "utf8",
18
+ jsonIndent: 4,
19
+ };
20
+ static options = null;
21
+ /**
22
+ * Configure global options for file operations
23
+ * @param options - File operation options
24
+ */
25
+ static configure(options) {
26
+ if (!options)
27
+ return;
28
+ this.options = {
29
+ ...this.defaultOptions,
30
+ ...options,
31
+ };
32
+ }
33
+ /**
34
+ * Get current configuration
35
+ * @returns Current configuration
36
+ */
37
+ static getConfig() {
38
+ return this.options || this.defaultOptions;
39
+ }
40
+ /**
41
+ * Find locale files in the specified directory
42
+ * @param localesDir - Directory containing locale files
43
+ * @param sourceLang - Source language code
44
+ * @returns Array of file paths
45
+ */
46
+ static async findLocaleFiles(localesDir, sourceLang) {
47
+ try {
48
+ // Find any file with the source language name (ignoring extension)
49
+ const files = await fs.readdir(localesDir);
50
+ const sourceFiles = files
51
+ .filter((file) => file.startsWith(`${sourceLang}.`))
52
+ .map((file) => path.join(localesDir, file));
53
+ if (sourceFiles.length === 0) {
54
+ throw new Error(`Source language file not found for '${sourceLang}' in ${localesDir}`);
55
+ }
56
+ // Return the first match, or maybe prioritize JSON?
57
+ // For now, return all matches, but usually there's only one.
58
+ return sourceFiles;
59
+ }
60
+ catch (err) {
61
+ if (err.code === "ENOENT") {
62
+ throw new Error(`Locales directory not found: ${localesDir}`);
63
+ }
64
+ throw new Error(`Source language file search error: ${err.message}`);
65
+ }
66
+ }
67
+ /**
68
+ * Read structured data file asynchronously (JSON, YAML, etc.)
69
+ * @param filePath - Path to the file
70
+ * @param options - Options for reading
71
+ * @returns Parsed data
72
+ */
73
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
74
+ static async readFile(filePath, options = {}) {
75
+ const config = { ...this.getConfig(), ...options };
76
+ try {
77
+ const content = await fs.readFile(filePath, config.encoding);
78
+ const adapter = options.format && options.format !== "auto"
79
+ ? FormatFactory.getAdapterByFormat(options.format)
80
+ : FormatFactory.getAdapter(filePath);
81
+ return await adapter.parse(content);
82
+ }
83
+ catch (err) {
84
+ throw new Error(`File read error (${filePath}): ${err.message}`);
85
+ }
86
+ }
87
+ /**
88
+ * Write data to file asynchronously
89
+ * @param filePath - Path to write the file
90
+ * @param data - Data to write
91
+ * @param options - Options for writing
92
+ * @returns Success status
93
+ */
94
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
95
+ static async writeFile(filePath, data, options = {}) {
96
+ const config = { ...this.getConfig(), ...options };
97
+ try {
98
+ // Create target directory if it doesn't exist
99
+ const dir = path.dirname(filePath);
100
+ if (config.createMissingDirs) {
101
+ await this.ensureDir(dir);
102
+ }
103
+ // Create backup directory if needed
104
+ if (config.backupFiles && config.backupDir) {
105
+ await this.ensureDir(config.backupDir);
106
+ }
107
+ // Create backup of existing file
108
+ if (config.backupFiles && config.backupDir) {
109
+ try {
110
+ await fs.access(filePath);
111
+ const backupPath = path.join(config.backupDir, `${path.basename(filePath)}.${Date.now()}.bak`);
112
+ await fs.copyFile(filePath, backupPath);
113
+ }
114
+ catch (err) {
115
+ // Ignore if file doesn't exist
116
+ if (err.code !== "ENOENT") {
117
+ console.error(`Backup failed (${filePath}): ${err.message}`);
118
+ }
119
+ }
120
+ }
121
+ // Serialize content
122
+ const adapter = options.format && options.format !== "auto"
123
+ ? FormatFactory.getAdapterByFormat(options.format)
124
+ : FormatFactory.getAdapter(filePath);
125
+ const content = await adapter.serialize(data, {
126
+ indent: options.indent || config.jsonIndent,
127
+ compact: config.compact,
128
+ });
129
+ // Use atomic write if configured
130
+ if (config.atomic) {
131
+ const tempFile = this._generateTempFilePath(filePath);
132
+ let tempFileCreated = false;
133
+ try {
134
+ await fs.writeFile(tempFile, content, config.encoding);
135
+ tempFileCreated = true;
136
+ await fs.rename(tempFile, filePath);
137
+ tempFileCreated = false;
138
+ }
139
+ catch (renameError) {
140
+ if (tempFileCreated) {
141
+ try {
142
+ await fs.unlink(tempFile);
143
+ }
144
+ catch {
145
+ /* ignore cleanup error */
146
+ }
147
+ }
148
+ throw new Error(`Atomic write failed (${filePath}): ${renameError.message}`);
149
+ }
150
+ }
151
+ else {
152
+ await fs.writeFile(filePath, content, config.encoding);
153
+ }
154
+ return true;
155
+ }
156
+ catch (err) {
157
+ throw new Error(`File write error (${filePath}): ${err.message}`);
158
+ }
159
+ }
160
+ // Legacy method aliases for backward compatibility
161
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
162
+ static async readJSON(filePath, options = {}) {
163
+ return this.readFile(filePath, { ...options, format: "json" });
164
+ }
165
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
166
+ static async writeJSON(filePath, data, options = {}) {
167
+ return this.writeFile(filePath, data, { ...options, format: "json" });
168
+ }
169
+ /**
170
+ * Generate a unique temporary file path
171
+ */
172
+ static _generateTempFilePath(filePath) {
173
+ const timestamp = Date.now();
174
+ const random = Math.random().toString(36).substring(2, 8);
175
+ return `${filePath}.tmp.${timestamp}.${random}`;
176
+ }
177
+ /**
178
+ * Ensure directory exists
179
+ */
180
+ static async ensureDir(dir) {
181
+ try {
182
+ await fs.mkdir(dir, { recursive: true });
183
+ }
184
+ catch (err) {
185
+ if (err.code !== "EEXIST") {
186
+ throw err;
187
+ }
188
+ }
189
+ }
190
+ /**
191
+ * Scan for locale files
192
+ */
193
+ static async scanLocaleFiles(localesDir, pattern = /.*/) {
194
+ try {
195
+ const files = await fs.readdir(localesDir);
196
+ return files
197
+ .filter((file) => pattern.test(file))
198
+ .map((file) => path.join(localesDir, file));
199
+ }
200
+ catch (err) {
201
+ throw new Error(`Error scanning locale directory: ${err.message}`);
202
+ }
203
+ }
204
+ /**
205
+ * Get file modification time
206
+ */
207
+ static async getModifiedTime(filePath) {
208
+ try {
209
+ const stats = await fs.stat(filePath);
210
+ return stats.mtime;
211
+ }
212
+ catch (err) {
213
+ throw new Error(`Error getting file stats: ${err.message}`);
214
+ }
215
+ }
216
+ /**
217
+ * Check if file exists
218
+ */
219
+ static async exists(filePath) {
220
+ try {
221
+ await fs.access(filePath);
222
+ return true;
223
+ }
224
+ catch {
225
+ return false;
226
+ }
227
+ }
228
+ /**
229
+ * Delete file
230
+ * @param filePath - Path to the file
231
+ * @param options - Options for deletion
232
+ * @returns Success status
233
+ */
234
+ static async deleteFile(filePath, options = {}) {
235
+ const config = { ...this.getConfig(), ...options };
236
+ try {
237
+ // Create backup before deletion if backups are enabled
238
+ if (config.backupFiles && config.backupDir) {
239
+ try {
240
+ await fs.access(filePath);
241
+ await this.ensureDir(config.backupDir);
242
+ const backupPath = path.join(config.backupDir, `${path.basename(filePath)}.deleted.${Date.now()}.bak`);
243
+ await fs.copyFile(filePath, backupPath);
244
+ }
245
+ catch (err) {
246
+ console.error(`Backup failed (${filePath}): ${err.message}`);
247
+ }
248
+ }
249
+ await fs.unlink(filePath);
250
+ return true;
251
+ }
252
+ catch (err) {
253
+ throw new Error(`File deletion error (${filePath}): ${err.message}`);
254
+ }
255
+ }
256
+ /**
257
+ * Validate JSON file structure
258
+ * @param filePath - Path to the JSON file
259
+ * @returns Validation result
260
+ */
261
+ static async validateJSONFile(filePath) {
262
+ try {
263
+ const content = await fs.readFile(filePath, "utf8");
264
+ JSON.parse(content); // Throws if invalid
265
+ return {
266
+ valid: true,
267
+ filePath,
268
+ };
269
+ }
270
+ catch (error) {
271
+ return {
272
+ valid: false,
273
+ filePath,
274
+ error: error.message,
275
+ position: this._extractErrorPosition(error),
276
+ };
277
+ }
278
+ }
279
+ /**
280
+ * Extract error position from JSON parse error
281
+ * @param error - JSON parse error
282
+ * @returns Error position details
283
+ */
284
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
285
+ static _extractErrorPosition(error) {
286
+ const message = error.message;
287
+ const positionMatch = message.match(/position (\d+)/);
288
+ const lineMatch = message.match(/line (\d+)/);
289
+ if (positionMatch || lineMatch) {
290
+ return {
291
+ position: positionMatch ? parseInt(positionMatch[1]) : null,
292
+ line: lineMatch ? parseInt(lineMatch[1]) : null,
293
+ };
294
+ }
295
+ return null;
296
+ }
297
+ /**
298
+ * Validate translation value can be safely added to JSON
299
+ * @param key - Translation key
300
+ * @param value - Translation value
301
+ * @returns Validation result
302
+ */
303
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
304
+ static validateTranslationValue(key, value) {
305
+ try {
306
+ // Test if value can be part of valid JSON
307
+ const testObject = { [key]: value };
308
+ JSON.stringify(testObject);
309
+ JSON.parse(JSON.stringify(testObject)); // Round-trip test
310
+ return {
311
+ valid: true,
312
+ key,
313
+ value,
314
+ };
315
+ }
316
+ catch (error) {
317
+ return {
318
+ valid: false,
319
+ key,
320
+ value,
321
+ error: `JSON validation failed: ${error.message}`,
322
+ };
323
+ }
324
+ }
325
+ /**
326
+ * List files in a directory
327
+ * @param dirPath - Directory path
328
+ * @returns Array of file paths
329
+ */
330
+ static async listFiles(dirPath) {
331
+ try {
332
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
333
+ const files = entries
334
+ .filter((entry) => entry.isFile())
335
+ .map((entry) => path.join(dirPath, entry.name));
336
+ return files;
337
+ }
338
+ catch (err) {
339
+ throw new Error(`Directory read error (${dirPath}): ${err.message}`);
340
+ }
341
+ }
342
+ /**
343
+ * Ensure directory exists, create if it doesn't
344
+ * @param dirPath - Directory path
345
+ * @returns True if directory exists or was created
346
+ */
347
+ static async ensureDirectoryExists(dirPath) {
348
+ try {
349
+ await fs.mkdir(dirPath, { recursive: true });
350
+ return true;
351
+ }
352
+ catch (err) {
353
+ throw new Error(`Failed to create directory (${dirPath}): ${err.message}`);
354
+ }
355
+ }
356
+ }
357
+ export { FileManager };
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Glossary Manager
3
+ * Manages brand terminology and ensures consistent translations
4
+ */
5
+ class GlossaryManager {
6
+ glossary;
7
+ enabled;
8
+ caseSensitive;
9
+ preserveFormatting;
10
+ constructor(config = {}) {
11
+ this.glossary = config.glossary || {};
12
+ this.enabled = config.enabled !== false;
13
+ this.caseSensitive = config.caseSensitive !== false;
14
+ this.preserveFormatting = config.preserveFormatting !== false;
15
+ }
16
+ /**
17
+ * Apply glossary terms to source text before translation
18
+ * Replaces terms with protected tokens to preserve them
19
+ */
20
+ protectTerms(text, sourceLang, targetLang) {
21
+ if (!this.enabled || !text || typeof text !== "string") {
22
+ return { protectedText: text, termMap: new Map() };
23
+ }
24
+ const termMap = new Map();
25
+ let protectedText = text;
26
+ let tokenIndex = 0;
27
+ // Get applicable glossary entries
28
+ const entries = this.getApplicableEntries(sourceLang, targetLang);
29
+ // Sort by length (longest first) to avoid partial matches
30
+ const sortedEntries = entries.sort((a, b) => b.term.length - a.term.length);
31
+ for (const entry of sortedEntries) {
32
+ const { term, translation, caseSensitive } = entry;
33
+ const isCaseSensitive = caseSensitive ?? this.caseSensitive;
34
+ // Create regex for finding the term
35
+ const flags = isCaseSensitive ? "g" : "gi";
36
+ const escapedTerm = this.escapeRegex(term);
37
+ const regex = new RegExp(`\\b${escapedTerm}\\b`, flags);
38
+ // Find all matches
39
+ const matches = protectedText.match(regex);
40
+ if (matches) {
41
+ matches.forEach((match) => {
42
+ const token = `__GLOSSARY_TOKEN_${tokenIndex}__`;
43
+ tokenIndex++;
44
+ // Preserve original formatting if needed
45
+ const preservedTranslation = this.preserveFormatting
46
+ ? this.matchFormatting(match, translation)
47
+ : translation;
48
+ termMap.set(token, {
49
+ original: match,
50
+ translation: preservedTranslation,
51
+ term: term,
52
+ });
53
+ // Replace with token
54
+ protectedText = protectedText.replace(match, token);
55
+ });
56
+ }
57
+ }
58
+ return { protectedText, termMap };
59
+ }
60
+ /**
61
+ * Restore protected terms in translated text
62
+ */
63
+ restoreTerms(translatedText, termMap) {
64
+ if (!this.enabled || !termMap || termMap.size === 0) {
65
+ return translatedText;
66
+ }
67
+ let restoredText = translatedText;
68
+ // Replace tokens with translations
69
+ for (const [token, data] of termMap.entries()) {
70
+ restoredText = restoredText.replace(new RegExp(token, "g"), data.translation);
71
+ }
72
+ return restoredText;
73
+ }
74
+ /**
75
+ * Get applicable glossary entries for language pair
76
+ */
77
+ getApplicableEntries(sourceLang, targetLang) {
78
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
79
+ const entries = [];
80
+ for (const [term, config] of Object.entries(this.glossary)) {
81
+ // Simple format: { "API": "API" }
82
+ if (typeof config === "string") {
83
+ entries.push({
84
+ term,
85
+ translation: config,
86
+ caseSensitive: this.caseSensitive,
87
+ });
88
+ }
89
+ // Advanced format: { "API": { translation: "API", caseSensitive: true } }
90
+ else if (typeof config === "object" && config.translation) {
91
+ entries.push({
92
+ term,
93
+ translation: config.translation,
94
+ caseSensitive: config.caseSensitive ?? this.caseSensitive,
95
+ languages: config.languages,
96
+ });
97
+ }
98
+ // Language-specific format
99
+ else if (typeof config === "object" && config[targetLang]) {
100
+ entries.push({
101
+ term,
102
+ translation: config[targetLang],
103
+ caseSensitive: this.caseSensitive,
104
+ });
105
+ }
106
+ }
107
+ return entries;
108
+ }
109
+ /**
110
+ * Match formatting (capitalization) from original to translation
111
+ */
112
+ matchFormatting(original, translation) {
113
+ // All caps
114
+ if (original === original.toUpperCase()) {
115
+ return translation.toUpperCase();
116
+ }
117
+ // Title case (first letter uppercase)
118
+ if (original[0] === original[0].toUpperCase() &&
119
+ original.slice(1) === original.slice(1).toLowerCase()) {
120
+ return translation.charAt(0).toUpperCase() + translation.slice(1).toLowerCase();
121
+ }
122
+ // First char uppercase
123
+ if (original[0] === original[0].toUpperCase()) {
124
+ return translation.charAt(0).toUpperCase() + translation.slice(1);
125
+ }
126
+ return translation;
127
+ }
128
+ /**
129
+ * Escape special regex characters
130
+ */
131
+ escapeRegex(string) {
132
+ return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
133
+ }
134
+ /**
135
+ * Validate glossary configuration
136
+ */
137
+ validate() {
138
+ const errors = [];
139
+ const warnings = [];
140
+ if (!this.glossary || typeof this.glossary !== "object") {
141
+ errors.push("Glossary must be an object");
142
+ return { valid: false, errors, warnings };
143
+ }
144
+ for (const [term, config] of Object.entries(this.glossary)) {
145
+ if (!term || typeof term !== "string") {
146
+ errors.push(`Invalid term: ${term}`);
147
+ continue;
148
+ }
149
+ // Validate simple format
150
+ if (typeof config === "string" && !config) {
151
+ warnings.push(`Empty translation for term: ${term}`);
152
+ }
153
+ // Validate object format
154
+ if (typeof config === "object") {
155
+ if (!config.translation && !Object.keys(config).some((key) => key.length === 2)) {
156
+ errors.push(`Invalid glossary entry for term: ${term}`);
157
+ }
158
+ }
159
+ }
160
+ return {
161
+ valid: errors.length === 0,
162
+ errors,
163
+ warnings,
164
+ };
165
+ }
166
+ /**
167
+ * Get statistics about glossary
168
+ */
169
+ getStats() {
170
+ const terms = Object.keys(this.glossary);
171
+ return {
172
+ totalTerms: terms.length,
173
+ enabled: this.enabled,
174
+ caseSensitive: this.caseSensitive,
175
+ preserveFormatting: this.preserveFormatting,
176
+ terms: terms,
177
+ };
178
+ }
179
+ /**
180
+ * Load glossary from file
181
+ */
182
+ static async loadFromFile(filePath) {
183
+ try {
184
+ const { readFile } = await import("fs/promises");
185
+ const content = await readFile(filePath, "utf8");
186
+ const glossary = JSON.parse(content);
187
+ return new GlossaryManager({ glossary, enabled: true });
188
+ }
189
+ catch (error) {
190
+ throw new Error(`Failed to load glossary from ${filePath}: ${error.message}`);
191
+ }
192
+ }
193
+ }
194
+ export default GlossaryManager;
@@ -0,0 +1,118 @@
1
+ import rateLimiter from "./rate-limiter.js";
2
+ class GracefulShutdown {
3
+ options;
4
+ isShuttingDown;
5
+ shutdownCallbacks;
6
+ logger;
7
+ constructor(options = {}) {
8
+ this.options = {
9
+ shutdownTimeout: 5000,
10
+ waitForPending: true,
11
+ ...options,
12
+ };
13
+ this.isShuttingDown = false;
14
+ this.shutdownCallbacks = [];
15
+ this.logger = options.logger || console;
16
+ }
17
+ registerCallback(callback) {
18
+ if (typeof callback === "function") {
19
+ this.shutdownCallbacks.push(callback);
20
+ }
21
+ }
22
+ unregisterCallback(callback) {
23
+ if (typeof callback === "function") {
24
+ const index = this.shutdownCallbacks.indexOf(callback);
25
+ if (index > -1) {
26
+ this.shutdownCallbacks.splice(index, 1);
27
+ }
28
+ }
29
+ }
30
+ init() {
31
+ process.on("SIGTERM", () => this.handleSignal("SIGTERM"));
32
+ process.on("SIGINT", () => this.handleSignal("SIGINT"));
33
+ process.on("uncaughtException", (err) => {
34
+ this.logger.error("Uncaught Exception:", err);
35
+ this.shutdown(1);
36
+ });
37
+ process.on("unhandledRejection", (reason, promise) => {
38
+ this.logger.error("Unhandled Rejection at:", promise, "reason:", reason);
39
+ this.shutdown(1);
40
+ });
41
+ this.logger.info("Graceful shutdown handlers initialized");
42
+ }
43
+ async handleSignal(signal) {
44
+ this.logger.info(`Received ${signal}, initiating graceful shutdown...`);
45
+ await this.shutdown(0);
46
+ }
47
+ async shutdown(exitCode = 0) {
48
+ if (this.isShuttingDown) {
49
+ return;
50
+ }
51
+ this.isShuttingDown = true;
52
+ this.logger.info("Starting graceful shutdown...");
53
+ const timeoutPromise = new Promise((resolve) => {
54
+ setTimeout(() => {
55
+ this.logger.warn(`Shutdown timeout (${this.options.shutdownTimeout}ms) exceeded, forcing exit`);
56
+ resolve();
57
+ }, this.options.shutdownTimeout);
58
+ });
59
+ const shutdownProcess = this.performShutdown();
60
+ try {
61
+ await Promise.race([shutdownProcess, timeoutPromise]);
62
+ }
63
+ catch (error) {
64
+ this.logger.error("Error during shutdown:", error);
65
+ }
66
+ finally {
67
+ process.exit(exitCode);
68
+ }
69
+ }
70
+ async performShutdown() {
71
+ const startTime = Date.now();
72
+ try {
73
+ this.logger.info("Executing shutdown callbacks...");
74
+ for (const callback of this.shutdownCallbacks) {
75
+ try {
76
+ await callback();
77
+ }
78
+ catch (error) {
79
+ this.logger.warn("Error in shutdown callback:", error);
80
+ }
81
+ }
82
+ if (this.options.waitForPending) {
83
+ this.logger.info("Waiting for pending operations...");
84
+ await this.waitForPendingOperations();
85
+ }
86
+ const duration = Date.now() - startTime;
87
+ this.logger.info(`Graceful shutdown completed in ${duration}ms`);
88
+ }
89
+ catch (error) {
90
+ this.logger.error("Error during shutdown process:", error);
91
+ }
92
+ }
93
+ async waitForPendingOperations() {
94
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
95
+ const providerStatus = rateLimiter.getStatus();
96
+ let hasPending = false;
97
+ for (const [provider, status] of Object.entries(providerStatus)) {
98
+ if (status.queueSize > 0 || status.processing > 0) {
99
+ hasPending = true;
100
+ this.logger.info(`Waiting for ${status.queueSize} queued and ${status.processing} processing operations for ${provider}`);
101
+ }
102
+ }
103
+ if (hasPending) {
104
+ await new Promise((resolve) => setTimeout(resolve, 2000));
105
+ try {
106
+ rateLimiter.clearAllQueues();
107
+ this.logger.info("Forced queue cleanup completed");
108
+ }
109
+ catch (error) {
110
+ this.logger.warn("Error during forced queue cleanup:", error.message);
111
+ }
112
+ }
113
+ }
114
+ }
115
+ const gracefulShutdown = new GracefulShutdown();
116
+ // Can't auto-init if we want to test or configure it, but existing code did this.
117
+ gracefulShutdown.init();
118
+ export default gracefulShutdown;