@pz4l/tinyimg-core 0.0.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.
package/dist/index.mjs ADDED
@@ -0,0 +1,851 @@
1
+ import { mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
2
+ import path, { join } from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import os, { homedir } from "node:os";
5
+ import { Buffer } from "node:buffer";
6
+ import tinify from "tinify";
7
+ import https from "node:https";
8
+ import FormData from "form-data";
9
+ import pLimit from "p-limit";
10
+ import process from "node:process";
11
+ import fs from "node:fs";
12
+ //#region src/utils/logger.ts
13
+ function logWarning(message) {
14
+ console.warn(`⚠ ${message}`);
15
+ }
16
+ function logInfo(message) {
17
+ console.log(`ℹ ${message}`);
18
+ }
19
+ //#endregion
20
+ //#region src/cache/buffer-storage.ts
21
+ /**
22
+ * Cache storage for reading and writing compressed image data by hash.
23
+ *
24
+ * Uses atomic writes (temp file + rename) for concurrent safety.
25
+ * Handles corruption gracefully by returning null on read failures.
26
+ */
27
+ var BufferCacheStorage = class {
28
+ constructor(cacheDir) {
29
+ this.cacheDir = cacheDir;
30
+ }
31
+ /**
32
+ * Ensure cache directory exists.
33
+ */
34
+ async ensureDir() {
35
+ await mkdir(this.cacheDir, {
36
+ recursive: true,
37
+ mode: 493
38
+ });
39
+ }
40
+ /**
41
+ * Get the cache file path for an image hash.
42
+ *
43
+ * @param hash - MD5 hash of the image buffer
44
+ * @returns Path to cache file (MD5 hash as filename, no extension)
45
+ */
46
+ getCachePath(hash) {
47
+ return join(this.cacheDir, hash);
48
+ }
49
+ /**
50
+ * Read cached compressed image data by hash.
51
+ *
52
+ * @param hash - MD5 hash of the image buffer
53
+ * @returns Cached Buffer or null if not found/corrupted
54
+ */
55
+ async read(hash) {
56
+ try {
57
+ return await readFile(this.getCachePath(hash));
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+ /**
63
+ * Write compressed image data to cache by hash.
64
+ *
65
+ * Uses atomic write pattern: temp file + rename.
66
+ *
67
+ * @param hash - MD5 hash of the image buffer
68
+ * @param data - Compressed image data to cache
69
+ */
70
+ async write(hash, data) {
71
+ await this.ensureDir();
72
+ const cachePath = this.getCachePath(hash);
73
+ const tmpPath = `${cachePath}.tmp`;
74
+ await writeFile(tmpPath, data);
75
+ await rename(tmpPath, cachePath);
76
+ }
77
+ };
78
+ /**
79
+ * Read cached image data from multiple cache directories in priority order.
80
+ *
81
+ * @param hash - MD5 hash of the image buffer
82
+ * @param cacheDirs - Array of cache directories (priority order)
83
+ * @returns First successful Buffer read or null if all miss
84
+ */
85
+ async function readCacheByHash(hash, cacheDirs) {
86
+ for (const cacheDir of cacheDirs) {
87
+ const data = await new BufferCacheStorage(cacheDir).read(hash);
88
+ if (data !== null) {
89
+ logInfo(`ℹ Cache hit: ${hash.substring(0, 8)}`);
90
+ return data;
91
+ }
92
+ }
93
+ return null;
94
+ }
95
+ /**
96
+ * Write compressed image data to cache by hash.
97
+ *
98
+ * @param hash - MD5 hash of the image buffer
99
+ * @param data - Compressed image data to cache
100
+ * @param cacheDir - Cache directory to write to
101
+ */
102
+ async function writeCacheByHash(hash, data, cacheDir) {
103
+ await new BufferCacheStorage(cacheDir).write(hash, data);
104
+ logInfo(`ℹ Cached: ${hash.substring(0, 8)}`);
105
+ }
106
+ //#endregion
107
+ //#region src/cache/hash.ts
108
+ /**
109
+ * Calculate MD5 hash of a file's content.
110
+ *
111
+ * @param filePath - Absolute path to the file
112
+ * @returns MD5 hash as a 32-character hexadecimal string
113
+ *
114
+ * @example
115
+ * ```ts
116
+ * const hash = await calculateMD5('/path/to/image.png')
117
+ * console.log(hash) // 'a1b2c3d4e5f6...'
118
+ * ```
119
+ */
120
+ async function calculateMD5(filePath) {
121
+ const content = await readFile(filePath);
122
+ const hash = createHash("md5");
123
+ hash.update(content);
124
+ return hash.digest("hex");
125
+ }
126
+ /**
127
+ * Calculate MD5 hash of a buffer's content.
128
+ *
129
+ * @param buffer - Buffer to hash
130
+ * @returns MD5 hash as a 32-character hexadecimal string
131
+ *
132
+ * @example
133
+ * ```ts
134
+ * const hash = await calculateMD5FromBuffer(buffer)
135
+ * console.log(hash) // 'a1b2c3d4e5f6...'
136
+ * ```
137
+ */
138
+ async function calculateMD5FromBuffer(buffer) {
139
+ const hash = createHash("md5");
140
+ hash.update(buffer);
141
+ return hash.digest("hex");
142
+ }
143
+ //#endregion
144
+ //#region src/cache/paths.ts
145
+ /**
146
+ * Get the project-level cache directory path.
147
+ *
148
+ * @param projectRoot - Absolute path to the project root directory
149
+ * @returns Path to project cache directory: `node_modules/.tinyimg_cache/`
150
+ *
151
+ * @example
152
+ * ```ts
153
+ * const cachePath = getProjectCachePath('/Users/test/project')
154
+ * // Returns: '/Users/test/project/node_modules/.tinyimg_cache'
155
+ * ```
156
+ */
157
+ function getProjectCachePath(projectRoot) {
158
+ return join(projectRoot, "node_modules", ".tinyimg_cache");
159
+ }
160
+ /**
161
+ * Get the global cache directory path.
162
+ *
163
+ * @returns Path to global cache directory: `~/.tinyimg/cache/`
164
+ *
165
+ * @example
166
+ * ```ts
167
+ * const cachePath = getGlobalCachePath()
168
+ * // Returns: '/Users/username/.tinyimg/cache'
169
+ * ```
170
+ */
171
+ function getGlobalCachePath() {
172
+ return join(homedir(), ".tinyimg", "cache");
173
+ }
174
+ //#endregion
175
+ //#region src/cache/stats.ts
176
+ /**
177
+ * Format bytes to human-readable format.
178
+ *
179
+ * @param bytes - Number of bytes
180
+ * @returns Formatted string (e.g., "1.23 MB", "456 KB")
181
+ *
182
+ * @example
183
+ * ```ts
184
+ * formatBytes(0) // "0 B"
185
+ * formatBytes(512) // "512 B"
186
+ * formatBytes(1024) // "1.00 KB"
187
+ * formatBytes(1024 * 1024) // "1.00 MB"
188
+ * formatBytes(1024 * 1024 * 1024) // "1.00 GB"
189
+ * ```
190
+ */
191
+ function formatBytes(bytes) {
192
+ if (bytes === 0) return "0 B";
193
+ if (bytes < 1024) return `${bytes} B`;
194
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
195
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
196
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
197
+ }
198
+ /**
199
+ * Get cache statistics for a directory.
200
+ *
201
+ * @param cacheDir - Cache directory path
202
+ * @returns Cache statistics (count and size)
203
+ *
204
+ * @example
205
+ * ```ts
206
+ * const stats = await getCacheStats('/path/to/cache')
207
+ * console.log(`Files: ${stats.count}, Size: ${formatBytes(stats.size)}`)
208
+ * ```
209
+ */
210
+ async function getCacheStats(cacheDir) {
211
+ try {
212
+ const files = await readdir(cacheDir);
213
+ let count = 0;
214
+ let size = 0;
215
+ for (const file of files) {
216
+ const stats = await stat(`${cacheDir}/${file}`);
217
+ if (stats.isFile()) {
218
+ count++;
219
+ size += stats.size;
220
+ }
221
+ }
222
+ return {
223
+ count,
224
+ size
225
+ };
226
+ } catch {
227
+ return {
228
+ count: 0,
229
+ size: 0
230
+ };
231
+ }
232
+ }
233
+ /**
234
+ * Get cache statistics for both project and global cache.
235
+ *
236
+ * @param projectRoot - Optional project root directory
237
+ * @returns Object with project and global cache statistics
238
+ *
239
+ * @example
240
+ * ```ts
241
+ * // Get both project and global stats
242
+ * const stats = await getAllCacheStats('/project/path')
243
+ * console.log(`Project: ${stats.project?.count}, Global: ${stats.global.count}`)
244
+ *
245
+ * // Get only global stats
246
+ * const globalOnly = await getAllCacheStats()
247
+ * console.log(`Global: ${globalOnly.global.count}`)
248
+ * ```
249
+ */
250
+ async function getAllCacheStats(projectRoot) {
251
+ const global = await getCacheStats(getGlobalCachePath());
252
+ let project = null;
253
+ if (projectRoot) project = await getCacheStats(getProjectCachePath(projectRoot));
254
+ return {
255
+ project,
256
+ global
257
+ };
258
+ }
259
+ //#endregion
260
+ //#region src/cache/storage.ts
261
+ /**
262
+ * Cache storage for reading and writing compressed image data.
263
+ *
264
+ * Uses atomic writes (temp file + rename) for concurrent safety.
265
+ * Handles corruption gracefully by returning null on read failures.
266
+ */
267
+ var CacheStorage = class {
268
+ constructor(cacheDir) {
269
+ this.cacheDir = cacheDir;
270
+ }
271
+ /**
272
+ * Ensure cache directory exists.
273
+ */
274
+ async ensureDir() {
275
+ await mkdir(this.cacheDir, {
276
+ recursive: true,
277
+ mode: 493
278
+ });
279
+ }
280
+ /**
281
+ * Get the cache file path for an image.
282
+ *
283
+ * @param imagePath - Absolute path to the source image
284
+ * @returns Path to cache file (MD5 hash as filename, no extension)
285
+ */
286
+ async getCachePath(imagePath) {
287
+ const md5Hash = await calculateMD5(imagePath);
288
+ return join(this.cacheDir, md5Hash);
289
+ }
290
+ /**
291
+ * Read cached compressed image data.
292
+ *
293
+ * @param imagePath - Absolute path to the source image
294
+ * @returns Cached Buffer or null if not found/corrupted
295
+ */
296
+ async read(imagePath) {
297
+ try {
298
+ return await readFile(await this.getCachePath(imagePath));
299
+ } catch {
300
+ return null;
301
+ }
302
+ }
303
+ /**
304
+ * Write compressed image data to cache.
305
+ *
306
+ * Uses atomic write pattern: temp file + rename.
307
+ *
308
+ * @param imagePath - Absolute path to the source image
309
+ * @param data - Compressed image data to cache
310
+ */
311
+ async write(imagePath, data) {
312
+ await this.ensureDir();
313
+ const cachePath = await this.getCachePath(imagePath);
314
+ const tmpPath = `${cachePath}.tmp`;
315
+ await writeFile(tmpPath, data);
316
+ await rename(tmpPath, cachePath);
317
+ }
318
+ };
319
+ /**
320
+ * Read cached image data from multiple cache directories in priority order.
321
+ *
322
+ * @param imagePath - Absolute path to the source image
323
+ * @param cacheDirs - Array of cache directories (priority order)
324
+ * @returns First successful Buffer read or null if all miss
325
+ */
326
+ async function readCache(imagePath, cacheDirs) {
327
+ for (const cacheDir of cacheDirs) {
328
+ const data = await new CacheStorage(cacheDir).read(imagePath);
329
+ if (data !== null) {
330
+ logInfo(`ℹ️ cache hit: ${(await calculateMD5(imagePath)).substring(0, 8)}`);
331
+ return data;
332
+ }
333
+ }
334
+ return null;
335
+ }
336
+ /**
337
+ * Write compressed image data to cache.
338
+ *
339
+ * @param imagePath - Absolute path to the source image
340
+ * @param data - Compressed image data to cache
341
+ * @param cacheDir - Cache directory to write to
342
+ */
343
+ async function writeCache(imagePath, data, cacheDir) {
344
+ await new CacheStorage(cacheDir).write(imagePath, data);
345
+ logInfo(`ℹ️ cache miss: ${(await calculateMD5(imagePath)).substring(0, 8)}, compressed`);
346
+ }
347
+ //#endregion
348
+ //#region src/compress/retry.ts
349
+ var RetryManager = class {
350
+ failureCount = 0;
351
+ constructor(maxRetries = 8, baseDelay = 1e3) {
352
+ this.maxRetries = maxRetries;
353
+ this.baseDelay = baseDelay;
354
+ }
355
+ async execute(operation) {
356
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) try {
357
+ const result = await operation();
358
+ this.failureCount = 0;
359
+ return result;
360
+ } catch (error) {
361
+ this.failureCount++;
362
+ if (attempt === this.maxRetries || !this.shouldRetry(error)) throw error;
363
+ const delay = this.baseDelay * 2 ** attempt;
364
+ logWarning(`Retry ${attempt + 1}/${this.maxRetries} after ${delay}ms`);
365
+ await this.sleep(delay);
366
+ }
367
+ throw new Error("Max retries exceeded");
368
+ }
369
+ shouldRetry(error) {
370
+ if (error.code && [
371
+ "ECONNRESET",
372
+ "ETIMEDOUT",
373
+ "ENOTFOUND"
374
+ ].includes(error.code)) return true;
375
+ if (error.statusCode && error.statusCode >= 500 && error.statusCode < 600) return true;
376
+ return false;
377
+ }
378
+ sleep(ms) {
379
+ return new Promise((resolve) => setTimeout(resolve, ms));
380
+ }
381
+ getFailureCount() {
382
+ return this.failureCount;
383
+ }
384
+ reset() {
385
+ this.failureCount = 0;
386
+ }
387
+ };
388
+ //#endregion
389
+ //#region src/compress/web-compressor.ts
390
+ const TINYPNG_WEB_URL = "https://tinypng.com/backend/opt/shrink";
391
+ var TinyPngWebCompressor = class {
392
+ retryManager;
393
+ constructor(maxRetries = 8) {
394
+ this.retryManager = new RetryManager(maxRetries);
395
+ }
396
+ async compress(buffer) {
397
+ return this.retryManager.execute(async () => {
398
+ const uploadUrl = await this.uploadToTinyPngWeb(buffer);
399
+ const compressedBuffer = await this.downloadCompressedImage(uploadUrl);
400
+ const originalSize = buffer.byteLength;
401
+ const compressedSize = compressedBuffer.byteLength;
402
+ logInfo(`Compressed with [TinyPngWebCompressor]: ${originalSize} → ${compressedSize} (saved ${((1 - compressedSize / originalSize) * 100).toFixed(1)}%)`);
403
+ return compressedBuffer;
404
+ });
405
+ }
406
+ async uploadToTinyPngWeb(buffer) {
407
+ return new Promise((resolve, reject) => {
408
+ const form = new FormData();
409
+ form.append("file", buffer, { filename: "image.png" });
410
+ const req = https.request(TINYPNG_WEB_URL, {
411
+ method: "POST",
412
+ headers: form.getHeaders()
413
+ }, (res) => {
414
+ let data = "";
415
+ res.on("data", (chunk) => {
416
+ data += chunk;
417
+ });
418
+ res.on("end", () => {
419
+ if (res.statusCode !== 200) {
420
+ const error = /* @__PURE__ */ new Error(`HTTP ${res.statusCode}: ${data}`);
421
+ error.statusCode = res.statusCode;
422
+ return reject(error);
423
+ }
424
+ try {
425
+ const response = JSON.parse(data);
426
+ if (!response.output?.url) return reject(/* @__PURE__ */ new Error("No output URL in response"));
427
+ resolve(response.output.url);
428
+ } catch (error) {
429
+ reject(/* @__PURE__ */ new Error(`Failed to parse response: ${error.message}`));
430
+ }
431
+ });
432
+ });
433
+ req.on("error", (error) => {
434
+ reject(error);
435
+ });
436
+ form.pipe(req);
437
+ });
438
+ }
439
+ async downloadCompressedImage(url) {
440
+ return new Promise((resolve, reject) => {
441
+ https.get(url, (res) => {
442
+ if (res.statusCode !== 200) {
443
+ const error = /* @__PURE__ */ new Error(`HTTP ${res.statusCode} downloading compressed image`);
444
+ error.statusCode = res.statusCode;
445
+ return reject(error);
446
+ }
447
+ const chunks = [];
448
+ res.on("data", (chunk) => chunks.push(chunk));
449
+ res.on("end", () => resolve(Buffer.concat(chunks)));
450
+ res.on("error", reject);
451
+ }).on("error", reject);
452
+ });
453
+ }
454
+ getFailureCount() {
455
+ return this.retryManager.getFailureCount();
456
+ }
457
+ };
458
+ //#endregion
459
+ //#region src/compress/api-compressor.ts
460
+ const MAX_FILE_SIZE = 5 * 1024 * 1024;
461
+ var TinyPngApiCompressor = class {
462
+ retryManager;
463
+ currentKey = null;
464
+ constructor(keyPool, maxRetries = 8) {
465
+ this.keyPool = keyPool;
466
+ this.retryManager = new RetryManager(maxRetries);
467
+ }
468
+ async compress(buffer) {
469
+ if (buffer.byteLength > MAX_FILE_SIZE) {
470
+ logWarning(`File exceeds 5MB limit for API compressor (${(buffer.byteLength / 1024 / 1024).toFixed(2)}MB)`);
471
+ throw new Error("File size exceeds 5MB limit");
472
+ }
473
+ return this.retryManager.execute(async () => {
474
+ const key = await this.keyPool.selectKey();
475
+ if (this.currentKey !== key) try {
476
+ tinify.key = key;
477
+ this.currentKey = key;
478
+ } catch {}
479
+ const originalSize = buffer.byteLength;
480
+ const result = await tinify.fromBuffer(buffer).toBuffer();
481
+ const compressedSize = result.byteLength;
482
+ const saved = ((1 - compressedSize / originalSize) * 100).toFixed(1);
483
+ this.keyPool.decrementQuota();
484
+ logInfo(`Compressed with [TinyPngApiCompressor]: ${originalSize} → ${compressedSize} (saved ${saved}%)`);
485
+ return Buffer.from(result);
486
+ });
487
+ }
488
+ getFailureCount() {
489
+ return this.retryManager.getFailureCount();
490
+ }
491
+ };
492
+ //#endregion
493
+ //#region src/errors/types.ts
494
+ var AllKeysExhaustedError = class extends Error {
495
+ constructor(message = "All API keys have exhausted quota") {
496
+ super(message);
497
+ this.name = "AllKeysExhaustedError";
498
+ }
499
+ };
500
+ var NoValidKeysError = class extends Error {
501
+ constructor(message = "No valid API keys available") {
502
+ super(message);
503
+ this.name = "NoValidKeysError";
504
+ }
505
+ };
506
+ var AllCompressionFailedError = class extends Error {
507
+ constructor(message = "All compression methods failed") {
508
+ super(message);
509
+ this.name = "AllCompressionFailedError";
510
+ }
511
+ };
512
+ //#endregion
513
+ //#region src/compress/compose.ts
514
+ /**
515
+ * Compress buffer with automatic fallback through multiple compressors
516
+ *
517
+ * @param buffer - Original image data
518
+ * @param options - Compression options (mode, compressors, maxRetries)
519
+ * @returns Compressed image data
520
+ * @throws AllCompressionFailedError when all compressors fail
521
+ *
522
+ * @example
523
+ * ```ts
524
+ * try {
525
+ * const compressed = await compressWithFallback(buffer, { mode: 'auto' })
526
+ * } catch (error) {
527
+ * if (error instanceof AllCompressionFailedError) {
528
+ * // All compression methods failed
529
+ * }
530
+ * }
531
+ * ```
532
+ */
533
+ async function compressWithFallback(buffer, options = {}) {
534
+ const compressors = options.compressors ?? [];
535
+ for (const compressor of compressors) try {
536
+ logInfo(`Attempting compression with [${compressor.constructor.name}]`);
537
+ return await compressor.compress(buffer);
538
+ } catch (error) {
539
+ logWarning(`[${compressor.constructor.name}] failed: ${error.message}`);
540
+ if (error.name === "AllCompressionFailedError") throw error;
541
+ continue;
542
+ }
543
+ throw new AllCompressionFailedError("All compression methods failed");
544
+ }
545
+ /**
546
+ * Get default compressor types for a given mode
547
+ * This is a helper - actual compressor instances created in service layer
548
+ *
549
+ * @param mode - Compression mode
550
+ * @returns Compressor type names (not instances)
551
+ *
552
+ * @example
553
+ * ```ts
554
+ * const types = getCompressorTypesForMode('auto')
555
+ * // Returns: ['TinyPngApiCompressor', 'TinyPngWebCompressor']
556
+ * ```
557
+ */
558
+ function getCompressorTypesForMode(mode = "auto") {
559
+ switch (mode) {
560
+ case "api": return ["TinyPngApiCompressor"];
561
+ case "web": return ["TinyPngWebCompressor"];
562
+ default: return ["TinyPngApiCompressor", "TinyPngWebCompressor"];
563
+ }
564
+ }
565
+ //#endregion
566
+ //#region src/compress/concurrency.ts
567
+ /**
568
+ * Create a concurrency limiter for async operations
569
+ *
570
+ * @param concurrency - Max concurrent operations (default: 8)
571
+ * @returns Limit function that wraps async operations
572
+ *
573
+ * @example
574
+ * ```ts
575
+ * const limit = createConcurrencyLimiter(2)
576
+ * const task1 = limit(() => asyncOperation1())
577
+ * const task2 = limit(() => asyncOperation2())
578
+ * await Promise.all([task1, task2])
579
+ * ```
580
+ */
581
+ function createConcurrencyLimiter(concurrency = 8) {
582
+ return pLimit(concurrency);
583
+ }
584
+ /**
585
+ * Execute tasks with concurrency control
586
+ *
587
+ * @param tasks - Array of async functions to execute
588
+ * @param concurrency - Max concurrent tasks (default: 8)
589
+ * @returns Promise resolving to array of results
590
+ *
591
+ * @example
592
+ * ```ts
593
+ * const tasks = [
594
+ * () => compressImage(buffer1),
595
+ * () => compressImage(buffer2),
596
+ * () => compressImage(buffer3)
597
+ * ]
598
+ * const results = await executeWithConcurrency(tasks, 2)
599
+ * // Only 2 compressions run at a time
600
+ * ```
601
+ */
602
+ async function executeWithConcurrency(tasks, concurrency = 8) {
603
+ const limit = createConcurrencyLimiter(concurrency);
604
+ const limitedTasks = tasks.map((task) => limit(task));
605
+ return Promise.all(limitedTasks);
606
+ }
607
+ //#endregion
608
+ //#region src/config/storage.ts
609
+ const CONFIG_DIR = ".tinyimg";
610
+ const CONFIG_FILE = "keys.json";
611
+ function getConfigPath() {
612
+ const homeDir = os.homedir();
613
+ return path.join(homeDir, CONFIG_DIR, CONFIG_FILE);
614
+ }
615
+ function ensureConfigFile() {
616
+ const configPath = getConfigPath();
617
+ const configDir = path.dirname(configPath);
618
+ if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, {
619
+ recursive: true,
620
+ mode: 448
621
+ });
622
+ if (!fs.existsSync(configPath)) fs.writeFileSync(configPath, JSON.stringify({ keys: [] }, null, 2), { mode: 384 });
623
+ }
624
+ function readConfig() {
625
+ ensureConfigFile();
626
+ const configPath = getConfigPath();
627
+ const content = fs.readFileSync(configPath, "utf-8");
628
+ return JSON.parse(content);
629
+ }
630
+ function writeConfig(config) {
631
+ ensureConfigFile();
632
+ const configPath = getConfigPath();
633
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 384 });
634
+ }
635
+ //#endregion
636
+ //#region src/config/loader.ts
637
+ function loadKeys() {
638
+ const envKeys = process.env.TINYPNG_KEYS;
639
+ if (envKeys && envKeys.trim()) return envKeys.split(",").map((k) => k.trim()).filter((k) => k.length > 0).map((key) => ({ key }));
640
+ try {
641
+ return readConfig().keys.map((metadata) => ({
642
+ key: metadata.key,
643
+ valid: metadata.valid,
644
+ lastCheck: metadata.lastCheck
645
+ }));
646
+ } catch {
647
+ return [];
648
+ }
649
+ }
650
+ //#endregion
651
+ //#region src/keys/masker.ts
652
+ function maskKey(key) {
653
+ if (key.length < 8) return "****";
654
+ return `${key.substring(0, 4)}****${key.substring(key.length - 4)}`;
655
+ }
656
+ //#endregion
657
+ //#region src/keys/quota.ts
658
+ const MONTHLY_LIMIT = 500;
659
+ async function queryQuota(key) {
660
+ try {
661
+ tinify.key = key;
662
+ await tinify.validate();
663
+ const usedThisMonth = tinify.compressionCount ?? 0;
664
+ return Math.max(0, MONTHLY_LIMIT - usedThisMonth);
665
+ } catch (error) {
666
+ if (error?.message?.includes("credentials") || error?.message?.includes("Unauthorized") || error?.constructor?.name === "AccountError") return 0;
667
+ throw error;
668
+ }
669
+ }
670
+ function createQuotaTracker(key, remaining) {
671
+ return {
672
+ key,
673
+ remaining,
674
+ localCounter: remaining,
675
+ decrement() {
676
+ if (this.localCounter > 0) {
677
+ this.localCounter--;
678
+ if (this.localCounter === 0) console.warn(`⚠ Key ${maskKey(this.key)} quota exhausted, switching to next key`);
679
+ }
680
+ },
681
+ isZero() {
682
+ return this.localCounter === 0;
683
+ }
684
+ };
685
+ }
686
+ //#endregion
687
+ //#region src/keys/validator.ts
688
+ async function validateKey(key) {
689
+ try {
690
+ tinify.key = key;
691
+ await tinify.validate();
692
+ console.log(`✓ API key ${maskKey(key)} validated successfully`);
693
+ return true;
694
+ } catch (error) {
695
+ if (error?.message?.includes("credentials") || error?.message?.includes("Unauthorized") || error?.constructor?.name === "AccountError") {
696
+ console.warn(`⚠ Invalid API key ${maskKey(key)} marked and skipped`);
697
+ return false;
698
+ }
699
+ throw error;
700
+ }
701
+ }
702
+ //#endregion
703
+ //#region src/keys/selector.ts
704
+ var RandomSelector = class {
705
+ async select(keys) {
706
+ const available = await this.getAvailableKeys(keys);
707
+ if (available.length === 0) return null;
708
+ return available[Math.floor(Math.random() * available.length)];
709
+ }
710
+ async getAvailableKeys(keys) {
711
+ const available = [];
712
+ for (const key of keys) {
713
+ if (!await validateKey(key)) continue;
714
+ const remaining = await queryQuota(key);
715
+ if (remaining === 0) {
716
+ logWarning(`Key ${maskKey(key)} has no quota remaining`);
717
+ continue;
718
+ }
719
+ available.push({
720
+ key,
721
+ tracker: createQuotaTracker(key, remaining)
722
+ });
723
+ }
724
+ return available;
725
+ }
726
+ };
727
+ var RoundRobinSelector = class extends RandomSelector {
728
+ currentIndex = 0;
729
+ async select(keys) {
730
+ const available = await this.getAvailableKeys(keys);
731
+ if (available.length === 0) return null;
732
+ const selected = available[this.currentIndex % available.length];
733
+ this.currentIndex++;
734
+ return selected;
735
+ }
736
+ reset() {
737
+ this.currentIndex = 0;
738
+ }
739
+ };
740
+ var PrioritySelector = class extends RandomSelector {
741
+ async select(keys) {
742
+ const available = await this.getAvailableKeys(keys);
743
+ if (available.length === 0) return null;
744
+ return available[0];
745
+ }
746
+ };
747
+ //#endregion
748
+ //#region src/keys/pool.ts
749
+ var KeyPool = class {
750
+ keys;
751
+ selector;
752
+ currentSelection = null;
753
+ constructor(strategy = "random") {
754
+ this.keys = loadKeys().map((k) => k.key);
755
+ if (this.keys.length === 0) throw new NoValidKeysError("No API keys configured");
756
+ this.selector = this.createSelector(strategy);
757
+ }
758
+ createSelector(strategy) {
759
+ switch (strategy) {
760
+ case "random": return new RandomSelector();
761
+ case "round-robin": return new RoundRobinSelector();
762
+ case "priority": return new PrioritySelector();
763
+ default: return new RandomSelector();
764
+ }
765
+ }
766
+ async selectKey() {
767
+ if (this.currentSelection && !this.currentSelection.tracker.isZero()) return this.currentSelection.key;
768
+ const selection = await this.selector.select(this.keys);
769
+ if (!selection) throw new AllKeysExhaustedError();
770
+ this.currentSelection = selection;
771
+ return selection.key;
772
+ }
773
+ decrementQuota() {
774
+ if (this.currentSelection) this.currentSelection.tracker.decrement();
775
+ }
776
+ getCurrentKey() {
777
+ return this.currentSelection?.key ?? null;
778
+ }
779
+ };
780
+ //#endregion
781
+ //#region src/compress/service.ts
782
+ /**
783
+ * Compress a single image with cache integration and fallback
784
+ *
785
+ * @param buffer - Original image data
786
+ * @param options - Compression options
787
+ * @returns Compressed image data
788
+ */
789
+ async function compressImage(buffer, options = {}) {
790
+ const { cache = true, projectCacheOnly = false, mode = "auto", maxRetries = 8 } = options;
791
+ const hash = await calculateMD5FromBuffer(buffer);
792
+ const hashPrefix = hash.substring(0, 8);
793
+ if (cache) try {
794
+ const cached = await readCacheByHash(hash, [getProjectCachePath(process.cwd())]);
795
+ if (cached) {
796
+ logInfo(`ℹ Cache hit: ${hashPrefix}`);
797
+ return cached;
798
+ }
799
+ if (!projectCacheOnly) {
800
+ const globalCached = await readCacheByHash(hash, [getGlobalCachePath()]);
801
+ if (globalCached) {
802
+ logInfo(`ℹ Cache hit (global): ${hashPrefix}`);
803
+ return globalCached;
804
+ }
805
+ }
806
+ } catch (error) {
807
+ logWarning(`Cache read failed: ${error.message}`);
808
+ }
809
+ logInfo(`ℹ Cache miss: ${hashPrefix}, compressing...`);
810
+ const compressed = await compressWithFallback(buffer, {
811
+ mode,
812
+ maxRetries,
813
+ compressors: createCompressors(options)
814
+ });
815
+ if (cache) try {
816
+ await writeCacheByHash(hash, compressed, getProjectCachePath(process.cwd()));
817
+ logInfo(`ℹ Cached: ${hashPrefix}`);
818
+ } catch (error) {
819
+ logWarning(`Cache write failed: ${error.message}`);
820
+ }
821
+ return compressed;
822
+ }
823
+ /**
824
+ * Compress multiple images with concurrency control
825
+ *
826
+ * @param buffers - Array of image buffers
827
+ * @param options - Compression options
828
+ * @returns Array of compressed buffers
829
+ */
830
+ async function compressImages(buffers, options = {}) {
831
+ const { concurrency = 8 } = options;
832
+ const limit = createConcurrencyLimiter(concurrency);
833
+ const tasks = buffers.map((buffer) => limit(() => compressImage(buffer, options)));
834
+ return Promise.all(tasks);
835
+ }
836
+ /**
837
+ * Create compressor instances based on options
838
+ * Factory function to inject KeyPool for API compressor
839
+ */
840
+ function createCompressors(options) {
841
+ const { mode = "auto", maxRetries = 8, keyPool } = options;
842
+ const compressors = [];
843
+ const pool = keyPool || new KeyPool("random");
844
+ if (mode === "auto" || mode === "api") compressors.push(new TinyPngApiCompressor(pool, maxRetries));
845
+ if (mode === "auto" || mode === "web") compressors.push(new TinyPngWebCompressor(maxRetries));
846
+ return compressors;
847
+ }
848
+ //#endregion
849
+ export { AllCompressionFailedError, AllKeysExhaustedError, BufferCacheStorage, CacheStorage, KeyPool, NoValidKeysError, PrioritySelector, RandomSelector, RetryManager, RoundRobinSelector, TinyPngApiCompressor, TinyPngWebCompressor, calculateMD5, calculateMD5FromBuffer, compressImage, compressImages, compressWithFallback, createConcurrencyLimiter, createQuotaTracker, ensureConfigFile, executeWithConcurrency, formatBytes, getAllCacheStats, getCacheStats, getCompressorTypesForMode, getGlobalCachePath, getProjectCachePath, loadKeys, logInfo, logWarning, maskKey, queryQuota, readCache, readCacheByHash, readConfig, validateKey, writeCache, writeCacheByHash, writeConfig };
850
+
851
+ //# sourceMappingURL=index.mjs.map