@pz4l/tinyimg-core 0.3.2 → 0.3.6

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.d.mts CHANGED
@@ -265,7 +265,7 @@ interface CompressServiceOptions extends CompressOptions {
265
265
  * @param options - Compression options
266
266
  * @returns Compressed image data
267
267
  */
268
- declare function compressImage(buffer: Buffer, options?: CompressServiceOptions): Promise<Buffer>;
268
+ declare function compressImage(buffer: Buffer, options?: CompressServiceOptions): Promise<CompressResult>;
269
269
  /**
270
270
  * Compress multiple images with concurrency control
271
271
  *
@@ -273,7 +273,7 @@ declare function compressImage(buffer: Buffer, options?: CompressServiceOptions)
273
273
  * @param options - Compression options
274
274
  * @returns Array of compressed buffers
275
275
  */
276
- declare function compressImages(buffers: Buffer[], options?: CompressServiceOptions): Promise<Buffer[]>;
276
+ declare function compressImages(buffers: Buffer[], options?: CompressServiceOptions): Promise<CompressResult[]>;
277
277
  //#endregion
278
278
  //#region src/compress/types.d.ts
279
279
  /**
@@ -333,11 +333,48 @@ interface CompressOptions {
333
333
  */
334
334
  maxRetries?: number;
335
335
  }
336
+ /**
337
+ * Compression metadata returned with compressed image
338
+ */
339
+ interface CompressionMeta {
340
+ /**
341
+ * Whether the result was served from cache
342
+ */
343
+ cached: boolean;
344
+ /**
345
+ * Name of the compressor that performed the compression
346
+ * Null when served from cache
347
+ */
348
+ compressorName: string | null;
349
+ /**
350
+ * Original image size in bytes
351
+ */
352
+ originalSize: number;
353
+ /**
354
+ * Compressed image size in bytes
355
+ */
356
+ compressedSize: number;
357
+ }
358
+ /**
359
+ * Result of a compression operation
360
+ * Contains both the compressed buffer and metadata
361
+ */
362
+ interface CompressResult {
363
+ /**
364
+ * Compressed image data
365
+ */
366
+ buffer: Buffer;
367
+ /**
368
+ * Compression metadata
369
+ */
370
+ meta: CompressionMeta;
371
+ }
336
372
  //#endregion
337
373
  //#region src/compress/web-compressor.d.ts
338
374
  declare class TinyPngWebCompressor implements ICompressor {
339
375
  private retryManager;
340
376
  private requestHeaders?;
377
+ private userAgentGenerator;
341
378
  constructor(maxRetries?: number);
342
379
  compress(buffer: Buffer): Promise<Buffer>;
343
380
  private getRandomHeaders;
@@ -352,7 +389,6 @@ declare class TinyPngWebCompressor implements ICompressor {
352
389
  declare class TinyPngApiCompressor implements ICompressor {
353
390
  private keyPool;
354
391
  private retryManager;
355
- private currentKey;
356
392
  constructor(keyPool: KeyPool, maxRetries?: number);
357
393
  compress(buffer: Buffer): Promise<Buffer>;
358
394
  getFailureCount(): number;
@@ -378,7 +414,10 @@ declare class TinyPngApiCompressor implements ICompressor {
378
414
  * }
379
415
  * ```
380
416
  */
381
- declare function compressWithFallback(buffer: Buffer, options?: CompressOptions): Promise<Buffer>;
417
+ declare function compressWithFallback(buffer: Buffer, options?: CompressOptions): Promise<{
418
+ buffer: Buffer;
419
+ compressorName: string;
420
+ }>;
382
421
  /**
383
422
  * Get default compressor types for a given mode
384
423
  * This is a helper - actual compressor instances created in service layer
@@ -547,9 +586,5 @@ declare class PrioritySelector extends RandomSelector {
547
586
  //#region src/keys/validator.d.ts
548
587
  declare function validateKey(key: string): Promise<boolean>;
549
588
  //#endregion
550
- //#region src/utils/logger.d.ts
551
- declare function logWarning(message: string): void;
552
- declare function logInfo(message: string): void;
553
- //#endregion
554
- export { AllCompressionFailedError, AllKeysExhaustedError, BufferCacheStorage, type CacheStats, CacheStorage, type CompressOptions, type CompressServiceOptions, type CompressionMode, type ConfigFile, type DetectOptions, type ICompressor, type KeyMetadata, KeyPool, type KeyStrategy, type LoadedKey, NoValidKeysError, PrioritySelector, RandomSelector, RetryManager, RoundRobinSelector, TinyPngApiCompressor, TinyPngWebCompressor, calculateMD5, calculateMD5FromBuffer, compressImage, compressImages, compressWithFallback, createConcurrencyLimiter, createQuotaTracker, detectAlpha, detectAlphas, ensureConfigFile, executeWithConcurrency, formatBytes, getAllCacheStats, getCacheStats, getCompressorTypesForMode, getGlobalCachePath, getProjectCachePath, loadKeys, logInfo, logWarning, maskKey, queryQuota, readCache, readCacheByHash, readConfig, validateKey, writeCache, writeCacheByHash, writeConfig };
589
+ export { AllCompressionFailedError, AllKeysExhaustedError, BufferCacheStorage, type CacheStats, CacheStorage, type CompressOptions, type CompressResult, type CompressServiceOptions, type CompressionMeta, type CompressionMode, type ConfigFile, type DetectOptions, type ICompressor, type KeyMetadata, KeyPool, type KeyStrategy, type LoadedKey, NoValidKeysError, PrioritySelector, RandomSelector, RetryManager, RoundRobinSelector, TinyPngApiCompressor, TinyPngWebCompressor, calculateMD5, calculateMD5FromBuffer, compressImage, compressImages, compressWithFallback, createConcurrencyLimiter, createQuotaTracker, detectAlpha, detectAlphas, ensureConfigFile, executeWithConcurrency, formatBytes, getAllCacheStats, getCacheStats, getCompressorTypesForMode, getGlobalCachePath, getProjectCachePath, loadKeys, maskKey, queryQuota, readCache, readCacheByHash, readConfig, validateKey, writeCache, writeCacheByHash, writeConfig };
555
590
  //# sourceMappingURL=index.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../src/cache/buffer-storage.ts","../src/cache/hash.ts","../src/cache/paths.ts","../src/cache/stats.ts","../src/cache/storage.ts","../src/keys/pool.ts","../src/compress/service.ts","../src/compress/types.ts","../src/compress/web-compressor.ts","../src/compress/api-compressor.ts","../src/compress/compose.ts","../src/compress/concurrency.ts","../src/compress/retry.ts","../src/config/loader.ts","../src/config/types.ts","../src/config/storage.ts","../src/detect/types.ts","../src/detect/service.ts","../src/errors/types.ts","../src/keys/masker.ts","../src/keys/quota.ts","../src/keys/selector.ts","../src/keys/validator.ts","../src/utils/logger.ts"],"mappings":"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"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/cache/buffer-storage.ts","../src/cache/hash.ts","../src/cache/paths.ts","../src/cache/stats.ts","../src/cache/storage.ts","../src/keys/pool.ts","../src/compress/service.ts","../src/compress/types.ts","../src/compress/web-compressor.ts","../src/compress/api-compressor.ts","../src/compress/compose.ts","../src/compress/concurrency.ts","../src/compress/retry.ts","../src/config/loader.ts","../src/config/types.ts","../src/config/storage.ts","../src/detect/types.ts","../src/detect/service.ts","../src/errors/types.ts","../src/keys/masker.ts","../src/keys/quota.ts","../src/keys/selector.ts","../src/keys/validator.ts"],"mappings":"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"}
package/dist/index.mjs CHANGED
@@ -1,22 +1,14 @@
1
1
  import { mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
2
- import path, { join } from "node:path";
2
+ import path, { join } from "pathe";
3
3
  import { createHash } from "node:crypto";
4
4
  import os, { homedir } from "node:os";
5
5
  import { Buffer } from "node:buffer";
6
- import tinify from "tinify";
7
6
  import https from "node:https";
7
+ import UserAgent from "user-agents";
8
8
  import pLimit from "p-limit";
9
9
  import process from "node:process";
10
10
  import fs from "node:fs";
11
11
  import sharp from "sharp";
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
12
  //#region src/cache/buffer-storage.ts
21
13
  /**
22
14
  * Cache storage for reading and writing compressed image data by hash.
@@ -85,10 +77,7 @@ var BufferCacheStorage = class {
85
77
  async function readCacheByHash(hash, cacheDirs) {
86
78
  for (const cacheDir of cacheDirs) {
87
79
  const data = await new BufferCacheStorage(cacheDir).read(hash);
88
- if (data !== null) {
89
- logInfo(`ℹ Cache hit: ${hash.substring(0, 8)}`);
90
- return data;
91
- }
80
+ if (data !== null) return data;
92
81
  }
93
82
  return null;
94
83
  }
@@ -101,7 +90,6 @@ async function readCacheByHash(hash, cacheDirs) {
101
90
  */
102
91
  async function writeCacheByHash(hash, data, cacheDir) {
103
92
  await new BufferCacheStorage(cacheDir).write(hash, data);
104
- logInfo(`ℹ Cached: ${hash.substring(0, 8)}`);
105
93
  }
106
94
  //#endregion
107
95
  //#region src/cache/hash.ts
@@ -213,7 +201,7 @@ async function getCacheStats(cacheDir) {
213
201
  let count = 0;
214
202
  let size = 0;
215
203
  for (const file of files) {
216
- const stats = await stat(`${cacheDir}/${file}`);
204
+ const stats = await stat(join(cacheDir, file));
217
205
  if (stats.isFile()) {
218
206
  count++;
219
207
  size += stats.size;
@@ -326,10 +314,7 @@ var CacheStorage = class {
326
314
  async function readCache(imagePath, cacheDirs) {
327
315
  for (const cacheDir of cacheDirs) {
328
316
  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
- }
317
+ if (data !== null) return data;
333
318
  }
334
319
  return null;
335
320
  }
@@ -342,7 +327,253 @@ async function readCache(imagePath, cacheDirs) {
342
327
  */
343
328
  async function writeCache(imagePath, data, cacheDir) {
344
329
  await new CacheStorage(cacheDir).write(imagePath, data);
345
- logInfo(`ℹ️ cache miss: ${(await calculateMD5(imagePath)).substring(0, 8)}, compressed`);
330
+ }
331
+ //#endregion
332
+ //#region src/utils/http-request.ts
333
+ const MAX_REDIRECTS = 5;
334
+ /**
335
+ * Generic HTTPS request utility function.
336
+ * Supports JSON and Buffer responses, follows redirects (up to 5), and handles errors.
337
+ *
338
+ * @param url - The URL to request
339
+ * @param options - Request options (method, headers, body)
340
+ * @param redirectCount - Internal counter for redirect following (default: 0)
341
+ * @returns Promise<HttpResponse<T>> with status code, headers, and data
342
+ */
343
+ async function httpRequest(url, options, redirectCount = 0) {
344
+ return new Promise((resolve, reject) => {
345
+ const req = https.request(url, {
346
+ method: options.method,
347
+ headers: options.headers
348
+ }, (res) => {
349
+ const statusCode = res.statusCode || 0;
350
+ if (statusCode >= 300 && statusCode < 400) {
351
+ const redirectUrl = res.headers.location;
352
+ if (!redirectUrl) return reject(/* @__PURE__ */ new Error(`Redirect (${statusCode}) but no Location header`));
353
+ if (redirectCount >= MAX_REDIRECTS) return reject(/* @__PURE__ */ new Error(`Maximum redirects (${MAX_REDIRECTS}) exceeded`));
354
+ return httpRequest(redirectUrl, options, redirectCount + 1).then(resolve).catch(reject);
355
+ }
356
+ if (statusCode >= 200 && statusCode < 300) {
357
+ const chunks = [];
358
+ res.on("data", (chunk) => {
359
+ chunks.push(chunk);
360
+ });
361
+ res.on("end", () => {
362
+ const buffer = Buffer.concat(chunks);
363
+ try {
364
+ const json = JSON.parse(buffer.toString());
365
+ resolve({
366
+ statusCode,
367
+ headers: res.headers,
368
+ data: json
369
+ });
370
+ } catch {
371
+ resolve({
372
+ statusCode,
373
+ headers: res.headers,
374
+ data: buffer
375
+ });
376
+ }
377
+ });
378
+ res.on("error", (error) => {
379
+ reject(error);
380
+ });
381
+ return;
382
+ }
383
+ const chunks = [];
384
+ res.on("data", (chunk) => {
385
+ chunks.push(chunk);
386
+ });
387
+ res.on("end", () => {
388
+ const buffer = Buffer.concat(chunks);
389
+ try {
390
+ const json = JSON.parse(buffer.toString());
391
+ resolve({
392
+ statusCode,
393
+ headers: res.headers,
394
+ data: json
395
+ });
396
+ } catch {
397
+ resolve({
398
+ statusCode,
399
+ headers: res.headers,
400
+ data: buffer
401
+ });
402
+ }
403
+ });
404
+ res.on("error", (error) => {
405
+ reject(error);
406
+ });
407
+ });
408
+ req.on("error", (error) => {
409
+ reject(error);
410
+ });
411
+ if (options.body) req.write(options.body);
412
+ req.end();
413
+ });
414
+ }
415
+ //#endregion
416
+ //#region src/compress/http-client.ts
417
+ const TINYPNG_API_URL = "https://api.tinify.com/shrink";
418
+ var TinyPngError = class extends Error {
419
+ statusCode;
420
+ errorCode;
421
+ constructor(message, statusCode, errorCode) {
422
+ super(message);
423
+ this.name = "TinyPngError";
424
+ this.statusCode = statusCode;
425
+ this.errorCode = errorCode;
426
+ }
427
+ };
428
+ var TinyPngHttpClient = class {
429
+ /**
430
+ * Compress an image by uploading to TinyPNG API and downloading the result.
431
+ *
432
+ * @param key - TinyPNG API key
433
+ * @param buffer - Image buffer to compress
434
+ * @returns Compressed image buffer and compression count
435
+ */
436
+ async compress(key, buffer) {
437
+ const { url, compressionCount } = await this.uploadImage(key, buffer);
438
+ return this.downloadImage(url, key, compressionCount);
439
+ }
440
+ /**
441
+ * Validate if an API key is valid.
442
+ *
443
+ * @param key - TinyPNG API key to validate
444
+ * @returns true if key is valid, false otherwise
445
+ */
446
+ async validateKey(key) {
447
+ const response = await httpRequest(TINYPNG_API_URL, {
448
+ method: "POST",
449
+ headers: {
450
+ "Authorization": this.createAuthHeader(key),
451
+ "Content-Type": "application/octet-stream"
452
+ },
453
+ body: Buffer.alloc(0)
454
+ });
455
+ if (response.statusCode >= 200 && response.statusCode < 300) return true;
456
+ if (response.statusCode === 401 || response.statusCode === 403) return false;
457
+ if (response.statusCode >= 400 && response.statusCode < 500) return false;
458
+ throw new Error(`TinyPNG 服务器错误: HTTP ${response.statusCode}`);
459
+ }
460
+ /**
461
+ * Get the number of compressions used this month for a given API key.
462
+ *
463
+ * @param key - TinyPNG API key
464
+ * @returns Number of compressions used this month
465
+ */
466
+ async getCompressionCount(key) {
467
+ const response = await httpRequest(TINYPNG_API_URL, {
468
+ method: "POST",
469
+ headers: {
470
+ "Authorization": this.createAuthHeader(key),
471
+ "Content-Type": "application/octet-stream"
472
+ },
473
+ body: Buffer.alloc(0)
474
+ });
475
+ if (response.statusCode >= 200 && response.statusCode < 300) return response.data.compressionCount ?? 0;
476
+ if (response.statusCode === 401 || response.statusCode === 403) return 0;
477
+ if (response.statusCode >= 400 && response.statusCode < 500) return 0;
478
+ throw new Error(`TinyPNG 服务器错误: HTTP ${response.statusCode}`);
479
+ }
480
+ /**
481
+ * Create Basic Auth header for TinyPNG API.
482
+ *
483
+ * @param key - TinyPNG API key
484
+ * @returns Basic Auth header string
485
+ */
486
+ createAuthHeader(key) {
487
+ return `Basic ${Buffer.from(`api:${key}`).toString("base64")}`;
488
+ }
489
+ /**
490
+ * Upload image to TinyPNG API and get the compressed image URL.
491
+ *
492
+ * @param key - TinyPNG API key
493
+ * @param buffer - Image buffer to upload
494
+ * @returns URL of compressed image and compression count
495
+ */
496
+ async uploadImage(key, buffer) {
497
+ const response = await httpRequest(TINYPNG_API_URL, {
498
+ method: "POST",
499
+ headers: {
500
+ "Authorization": this.createAuthHeader(key),
501
+ "Content-Type": "application/octet-stream",
502
+ "Content-Length": String(buffer.byteLength)
503
+ },
504
+ body: buffer
505
+ });
506
+ if (!response.data.output?.url) {
507
+ if (response.statusCode >= 400 && response.statusCode < 500) throw new TinyPngError(`TinyPNG 客户端错误: HTTP ${response.statusCode}`, response.statusCode, "CLIENT_ERROR");
508
+ if (response.statusCode >= 500) throw new TinyPngError(`TinyPNG 服务器错误: HTTP ${response.statusCode}`, response.statusCode, "SERVER_ERROR");
509
+ throw new Error("No output URL in response");
510
+ }
511
+ const compressionCount = response.data.compressionCount ?? 0;
512
+ return {
513
+ url: response.data.output.url,
514
+ compressionCount
515
+ };
516
+ }
517
+ /**
518
+ * Download compressed image from TinyPNG API.
519
+ * Supports following redirects (handled by httpRequest utility).
520
+ *
521
+ * @param url - URL to download from
522
+ * @param key - TinyPNG API key
523
+ * @param compressionCount - Compression count from upload response
524
+ * @returns Compressed image buffer and compression count
525
+ */
526
+ async downloadImage(url, key, compressionCount) {
527
+ return {
528
+ buffer: (await httpRequest(url, {
529
+ method: "GET",
530
+ headers: { Authorization: this.createAuthHeader(key) }
531
+ })).data,
532
+ compressionCount
533
+ };
534
+ }
535
+ };
536
+ //#endregion
537
+ //#region src/keys/quota.ts
538
+ const MONTHLY_LIMIT = 500;
539
+ const compressionCountCache = /* @__PURE__ */ new Map();
540
+ async function queryQuota(key) {
541
+ try {
542
+ const client = new TinyPngHttpClient();
543
+ if (compressionCountCache.has(key)) {
544
+ const usedThisMonth = compressionCountCache.get(key);
545
+ return Math.max(0, MONTHLY_LIMIT - usedThisMonth);
546
+ }
547
+ if (!await client.validateKey(key)) return 0;
548
+ const usedThisMonth = await client.getCompressionCount(key);
549
+ compressionCountCache.set(key, usedThisMonth);
550
+ return Math.max(0, MONTHLY_LIMIT - usedThisMonth);
551
+ } catch {
552
+ return 0;
553
+ }
554
+ }
555
+ /**
556
+ * Update the compression-count cache for a given API key.
557
+ * Called after successful compression to keep cache fresh.
558
+ *
559
+ * @param key - API key
560
+ * @param count - New compression count value
561
+ */
562
+ function updateCompressionCountCache(key, count) {
563
+ compressionCountCache.set(key, count);
564
+ }
565
+ function createQuotaTracker(key, remaining) {
566
+ return {
567
+ key,
568
+ remaining,
569
+ localCounter: remaining,
570
+ decrement() {
571
+ if (this.localCounter > 0) this.localCounter--;
572
+ },
573
+ isZero() {
574
+ return this.localCounter === 0;
575
+ }
576
+ };
346
577
  }
347
578
  //#endregion
348
579
  //#region src/compress/retry.ts
@@ -361,7 +592,6 @@ var RetryManager = class {
361
592
  this.failureCount++;
362
593
  if (attempt === this.maxRetries || !this.shouldRetry(error)) throw error;
363
594
  const delay = this.baseDelay * 2 ** attempt;
364
- logWarning(`Retry ${attempt + 1}/${this.maxRetries} after ${delay}ms`);
365
595
  await this.sleep(delay);
366
596
  }
367
597
  throw new Error("Max retries exceeded");
@@ -373,6 +603,7 @@ var RetryManager = class {
373
603
  "ENOTFOUND"
374
604
  ].includes(error.code)) return true;
375
605
  if (error.statusCode && error.statusCode >= 500 && error.statusCode < 600) return true;
606
+ if (error.statusCode === 429 || error.errorCode === "RATE_LIMITED") return true;
376
607
  return false;
377
608
  }
378
609
  sleep(ms) {
@@ -391,17 +622,15 @@ const TINYPNG_WEB_URL = "https://tinypng.com/backend/opt/shrink";
391
622
  var TinyPngWebCompressor = class {
392
623
  retryManager;
393
624
  requestHeaders;
625
+ userAgentGenerator;
394
626
  constructor(maxRetries = 8) {
395
627
  this.retryManager = new RetryManager(maxRetries);
628
+ this.userAgentGenerator = new UserAgent({ deviceCategory: "desktop" });
396
629
  }
397
630
  async compress(buffer) {
398
631
  return this.retryManager.execute(async () => {
399
632
  const uploadUrl = await this.uploadToTinyPngWeb(buffer);
400
- const compressedBuffer = await this.downloadCompressedImage(uploadUrl);
401
- const originalSize = buffer.byteLength;
402
- const compressedSize = compressedBuffer.byteLength;
403
- logInfo(`Compressed with [TinyPngWebCompressor]: ${originalSize} → ${compressedSize} (saved ${((1 - compressedSize / originalSize) * 100).toFixed(1)}%)`);
404
- return compressedBuffer;
633
+ return await this.downloadCompressedImage(uploadUrl);
405
634
  });
406
635
  }
407
636
  getRandomHeaders() {
@@ -411,90 +640,45 @@ var TinyPngWebCompressor = class {
411
640
  };
412
641
  }
413
642
  getRandomUserAgent() {
414
- const userAgents = [
415
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
416
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
417
- "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
418
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0",
419
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 14.2; rv:121.0) Gecko/20100101 Firefox/121.0",
420
- "Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0",
421
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15",
422
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36",
423
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36",
424
- "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36",
425
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0",
426
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 14.1; rv:120.0) Gecko/20100101 Firefox/120.0",
427
- "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0",
428
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15",
429
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36",
430
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36",
431
- "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36",
432
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:119.0) Gecko/20100101 Firefox/119.0",
433
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 14.0; rv:119.0) Gecko/20100101 Firefox/119.0",
434
- "Mozilla/5.0 (X11; Linux x86_64; rv:119.0) Gecko/20100101 Firefox/119.0"
435
- ];
436
- return userAgents[Math.floor(Math.random() * userAgents.length)];
643
+ return this.userAgentGenerator.random().toString();
437
644
  }
438
645
  getRandomIPv4() {
439
646
  const octet = () => Math.floor(Math.random() * 256);
440
647
  return `${octet()}.${octet()}.${octet()}.${octet()}`;
441
648
  }
442
649
  async uploadToTinyPngWeb(buffer) {
443
- return new Promise((resolve, reject) => {
444
- this.requestHeaders = this.getRandomHeaders();
445
- const req = https.request(TINYPNG_WEB_URL, {
446
- method: "POST",
447
- headers: {
448
- "Content-Type": "application/octet-stream",
449
- "Content-Length": buffer.byteLength,
450
- ...this.requestHeaders
451
- }
452
- }, (res) => {
453
- let data = "";
454
- res.on("data", (chunk) => {
455
- data += chunk;
456
- });
457
- res.on("end", () => {
458
- if (res.statusCode !== 200) {
459
- const error = /* @__PURE__ */ new Error(`HTTP ${res.statusCode}: ${data}`);
460
- error.statusCode = res.statusCode;
461
- return reject(error);
462
- }
463
- try {
464
- const response = JSON.parse(data);
465
- if (!response.output?.url) return reject(/* @__PURE__ */ new Error("No output URL in response"));
466
- resolve(response.output.url);
467
- } catch (error) {
468
- reject(/* @__PURE__ */ new Error(`Failed to parse response: ${error.message}`));
469
- }
470
- });
471
- });
472
- req.on("error", (error) => {
473
- reject(error);
474
- });
475
- req.write(buffer);
476
- req.end();
650
+ this.requestHeaders = this.getRandomHeaders();
651
+ const response = await httpRequest(TINYPNG_WEB_URL, {
652
+ method: "POST",
653
+ headers: {
654
+ "Content-Type": "application/octet-stream",
655
+ "Content-Length": String(buffer.byteLength),
656
+ ...this.requestHeaders
657
+ },
658
+ body: buffer
477
659
  });
660
+ if (response.statusCode >= 400) {
661
+ const error = /* @__PURE__ */ new Error(`HTTP ${response.statusCode}: ${JSON.stringify(response.data)}`);
662
+ error.statusCode = response.statusCode;
663
+ throw error;
664
+ }
665
+ if (!response.data.output?.url) throw new Error("No output URL in response");
666
+ return response.data.output.url;
478
667
  }
479
668
  async downloadCompressedImage(url) {
480
- return new Promise((resolve, reject) => {
481
- const req = https.request(url, { headers: {
669
+ const response = await httpRequest(url, {
670
+ method: "GET",
671
+ headers: {
482
672
  "Content-Type": "application/octet-stream",
483
673
  ...this.requestHeaders
484
- } }, (res) => {
485
- if (res.statusCode !== 200) {
486
- const error = /* @__PURE__ */ new Error(`HTTP ${res.statusCode} downloading compressed image`);
487
- error.statusCode = res.statusCode;
488
- return reject(error);
489
- }
490
- const chunks = [];
491
- res.on("data", (chunk) => chunks.push(chunk));
492
- res.on("end", () => resolve(Buffer.concat(chunks)));
493
- res.on("error", reject);
494
- });
495
- req.on("error", reject);
496
- req.end();
674
+ }
497
675
  });
676
+ if (response.statusCode >= 400) {
677
+ const error = /* @__PURE__ */ new Error(`HTTP ${response.statusCode} downloading compressed image`);
678
+ error.statusCode = response.statusCode;
679
+ throw error;
680
+ }
681
+ return response.data;
498
682
  }
499
683
  getFailureCount() {
500
684
  return this.retryManager.getFailureCount();
@@ -505,29 +689,18 @@ var TinyPngWebCompressor = class {
505
689
  const MAX_FILE_SIZE = 5 * 1024 * 1024;
506
690
  var TinyPngApiCompressor = class {
507
691
  retryManager;
508
- currentKey = null;
509
692
  constructor(keyPool, maxRetries = 8) {
510
693
  this.keyPool = keyPool;
511
694
  this.retryManager = new RetryManager(maxRetries);
512
695
  }
513
696
  async compress(buffer) {
514
- if (buffer.byteLength > MAX_FILE_SIZE) {
515
- logWarning(`File exceeds 5MB limit for API compressor (${(buffer.byteLength / 1024 / 1024).toFixed(2)}MB)`);
516
- throw new Error("File size exceeds 5MB limit");
517
- }
697
+ if (buffer.byteLength > MAX_FILE_SIZE) throw new Error("File size exceeds 5MB limit");
518
698
  return this.retryManager.execute(async () => {
519
699
  const key = await this.keyPool.selectKey();
520
- if (this.currentKey !== key) try {
521
- tinify.key = key;
522
- this.currentKey = key;
523
- } catch {}
524
- const originalSize = buffer.byteLength;
525
- const result = await tinify.fromBuffer(buffer).toBuffer();
526
- const compressedSize = result.byteLength;
527
- const saved = ((1 - compressedSize / originalSize) * 100).toFixed(1);
700
+ const { buffer: compressedBuffer, compressionCount } = await new TinyPngHttpClient().compress(key, buffer);
701
+ if (typeof compressionCount === "number") updateCompressionCountCache(key, compressionCount);
528
702
  this.keyPool.decrementQuota();
529
- logInfo(`Compressed with [TinyPngApiCompressor]: ${originalSize} → ${compressedSize} (saved ${saved}%)`);
530
- return Buffer.from(result);
703
+ return compressedBuffer;
531
704
  });
532
705
  }
533
706
  getFailureCount() {
@@ -578,10 +751,11 @@ var AllCompressionFailedError = class extends Error {
578
751
  async function compressWithFallback(buffer, options = {}) {
579
752
  const compressors = options.compressors ?? [];
580
753
  for (const compressor of compressors) try {
581
- logInfo(`Attempting compression with [${compressor.constructor.name}]`);
582
- return await compressor.compress(buffer);
754
+ return {
755
+ buffer: await compressor.compress(buffer),
756
+ compressorName: compressor.constructor.name
757
+ };
583
758
  } catch (error) {
584
- logWarning(`[${compressor.constructor.name}] failed: ${error.message}`);
585
759
  if (error.name === "AllCompressionFailedError") throw error;
586
760
  continue;
587
761
  }
@@ -704,54 +878,13 @@ function loadKeys() {
704
878
  }
705
879
  }
706
880
  //#endregion
707
- //#region src/keys/masker.ts
708
- function maskKey(key) {
709
- if (key.length < 8) return "****";
710
- return `${key.substring(0, 4)}****${key.substring(key.length - 4)}`;
711
- }
712
- //#endregion
713
- //#region src/keys/quota.ts
714
- const MONTHLY_LIMIT = 500;
715
- async function queryQuota(key) {
716
- try {
717
- tinify.key = key;
718
- await tinify.validate();
719
- const usedThisMonth = tinify.compressionCount ?? 0;
720
- return Math.max(0, MONTHLY_LIMIT - usedThisMonth);
721
- } catch (error) {
722
- if (error?.message?.includes("credentials") || error?.message?.includes("Unauthorized") || error?.constructor?.name === "AccountError") return 0;
723
- throw error;
724
- }
725
- }
726
- function createQuotaTracker(key, remaining) {
727
- return {
728
- key,
729
- remaining,
730
- localCounter: remaining,
731
- decrement() {
732
- if (this.localCounter > 0) {
733
- this.localCounter--;
734
- if (this.localCounter === 0) console.warn(`⚠ Key ${maskKey(this.key)} quota exhausted, switching to next key`);
735
- }
736
- },
737
- isZero() {
738
- return this.localCounter === 0;
739
- }
740
- };
741
- }
742
- //#endregion
743
881
  //#region src/keys/validator.ts
744
882
  async function validateKey(key) {
745
883
  try {
746
- tinify.key = key;
747
- await tinify.validate();
748
- console.log(`✓ API key ${maskKey(key)} validated successfully`);
749
- return true;
884
+ if (await new TinyPngHttpClient().validateKey(key)) return true;
885
+ else return false;
750
886
  } catch (error) {
751
- if (error?.message?.includes("credentials") || error?.message?.includes("Unauthorized") || error?.constructor?.name === "AccountError") {
752
- console.warn(`⚠ Invalid API key ${maskKey(key)} marked and skipped`);
753
- return false;
754
- }
887
+ if (error?.statusCode === 401 || error?.statusCode === 403 || error?.errorCode === "AUTH_FAILED") return false;
755
888
  throw error;
756
889
  }
757
890
  }
@@ -768,10 +901,7 @@ var RandomSelector = class {
768
901
  for (const key of keys) {
769
902
  if (!await validateKey(key)) continue;
770
903
  const remaining = await queryQuota(key);
771
- if (remaining === 0) {
772
- logWarning(`Key ${maskKey(key)} has no quota remaining`);
773
- continue;
774
- }
904
+ if (remaining === 0) continue;
775
905
  available.push({
776
906
  key,
777
907
  tracker: createQuotaTracker(key, remaining)
@@ -845,36 +975,48 @@ var KeyPool = class {
845
975
  async function compressImage(buffer, options = {}) {
846
976
  const { cache = true, projectCacheOnly = false, mode = "auto", maxRetries = 8 } = options;
847
977
  const hash = await calculateMD5FromBuffer(buffer);
848
- const hashPrefix = hash.substring(0, 8);
978
+ const originalSize = buffer.byteLength;
849
979
  if (cache) try {
850
980
  const cached = await readCacheByHash(hash, [getProjectCachePath(process.cwd())]);
851
- if (cached) {
852
- logInfo(`ℹ Cache hit: ${hashPrefix}`);
853
- return cached;
854
- }
981
+ if (cached) return {
982
+ buffer: cached,
983
+ meta: {
984
+ cached: true,
985
+ compressorName: null,
986
+ originalSize,
987
+ compressedSize: cached.byteLength
988
+ }
989
+ };
855
990
  if (!projectCacheOnly) {
856
991
  const globalCached = await readCacheByHash(hash, [getGlobalCachePath()]);
857
- if (globalCached) {
858
- logInfo(`ℹ Cache hit (global): ${hashPrefix}`);
859
- return globalCached;
860
- }
992
+ if (globalCached) return {
993
+ buffer: globalCached,
994
+ meta: {
995
+ cached: true,
996
+ compressorName: null,
997
+ originalSize,
998
+ compressedSize: globalCached.byteLength
999
+ }
1000
+ };
861
1001
  }
862
- } catch (error) {
863
- logWarning(`Cache read failed: ${error.message}`);
864
- }
865
- logInfo(`ℹ Cache miss: ${hashPrefix}, compressing...`);
866
- const compressed = await compressWithFallback(buffer, {
1002
+ } catch {}
1003
+ const { buffer: compressed, compressorName } = await compressWithFallback(buffer, {
867
1004
  mode,
868
1005
  maxRetries,
869
1006
  compressors: createCompressors(options)
870
1007
  });
871
1008
  if (cache) try {
872
1009
  await writeCacheByHash(hash, compressed, getProjectCachePath(process.cwd()));
873
- logInfo(`ℹ Cached: ${hashPrefix}`);
874
- } catch (error) {
875
- logWarning(`Cache write failed: ${error.message}`);
876
- }
877
- return compressed;
1010
+ } catch {}
1011
+ return {
1012
+ buffer: compressed,
1013
+ meta: {
1014
+ cached: false,
1015
+ compressorName,
1016
+ originalSize,
1017
+ compressedSize: compressed.byteLength
1018
+ }
1019
+ };
878
1020
  }
879
1021
  /**
880
1022
  * Compress multiple images with concurrency control
@@ -943,6 +1085,12 @@ async function detectAlphas(filePaths, options) {
943
1085
  return new Map(filePaths.map((path, i) => [path, results[i]]));
944
1086
  }
945
1087
  //#endregion
946
- export { AllCompressionFailedError, AllKeysExhaustedError, BufferCacheStorage, CacheStorage, KeyPool, NoValidKeysError, PrioritySelector, RandomSelector, RetryManager, RoundRobinSelector, TinyPngApiCompressor, TinyPngWebCompressor, calculateMD5, calculateMD5FromBuffer, compressImage, compressImages, compressWithFallback, createConcurrencyLimiter, createQuotaTracker, detectAlpha, detectAlphas, ensureConfigFile, executeWithConcurrency, formatBytes, getAllCacheStats, getCacheStats, getCompressorTypesForMode, getGlobalCachePath, getProjectCachePath, loadKeys, logInfo, logWarning, maskKey, queryQuota, readCache, readCacheByHash, readConfig, validateKey, writeCache, writeCacheByHash, writeConfig };
1088
+ //#region src/keys/masker.ts
1089
+ function maskKey(key) {
1090
+ if (key.length < 8) return "****";
1091
+ return `${key.substring(0, 4)}****${key.substring(key.length - 4)}`;
1092
+ }
1093
+ //#endregion
1094
+ export { AllCompressionFailedError, AllKeysExhaustedError, BufferCacheStorage, CacheStorage, KeyPool, NoValidKeysError, PrioritySelector, RandomSelector, RetryManager, RoundRobinSelector, TinyPngApiCompressor, TinyPngWebCompressor, calculateMD5, calculateMD5FromBuffer, compressImage, compressImages, compressWithFallback, createConcurrencyLimiter, createQuotaTracker, detectAlpha, detectAlphas, ensureConfigFile, executeWithConcurrency, formatBytes, getAllCacheStats, getCacheStats, getCompressorTypesForMode, getGlobalCachePath, getProjectCachePath, loadKeys, maskKey, queryQuota, readCache, readCacheByHash, readConfig, validateKey, writeCache, writeCacheByHash, writeConfig };
947
1095
 
948
1096
  //# sourceMappingURL=index.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../src/utils/logger.ts","../src/cache/buffer-storage.ts","../src/cache/hash.ts","../src/cache/paths.ts","../src/cache/stats.ts","../src/cache/storage.ts","../src/compress/retry.ts","../src/compress/web-compressor.ts","../src/compress/api-compressor.ts","../src/errors/types.ts","../src/compress/compose.ts","../src/compress/concurrency.ts","../src/config/storage.ts","../src/config/loader.ts","../src/keys/masker.ts","../src/keys/quota.ts","../src/keys/validator.ts","../src/keys/selector.ts","../src/keys/pool.ts","../src/compress/service.ts","../src/detect/service.ts"],"sourcesContent":["export function logWarning(message: string): void {\n console.warn(`⚠ ${message}`)\n}\n\nexport function logInfo(message: string): void {\n console.log(`ℹ ${message}`)\n}\n","import type { Buffer } from 'node:buffer'\nimport { mkdir, readFile, rename, writeFile } from 'node:fs/promises'\nimport { join } from 'node:path'\nimport { logInfo } from '../utils/logger'\n\n/**\n * Cache storage for reading and writing compressed image data by hash.\n *\n * Uses atomic writes (temp file + rename) for concurrent safety.\n * Handles corruption gracefully by returning null on read failures.\n */\nexport class BufferCacheStorage {\n constructor(private readonly cacheDir: string) {}\n\n /**\n * Ensure cache directory exists.\n */\n private async ensureDir(): Promise<void> {\n await mkdir(this.cacheDir, { recursive: true, mode: 0o755 })\n }\n\n /**\n * Get the cache file path for an image hash.\n *\n * @param hash - MD5 hash of the image buffer\n * @returns Path to cache file (MD5 hash as filename, no extension)\n */\n getCachePath(hash: string): string {\n return join(this.cacheDir, hash)\n }\n\n /**\n * Read cached compressed image data by hash.\n *\n * @param hash - MD5 hash of the image buffer\n * @returns Cached Buffer or null if not found/corrupted\n */\n async read(hash: string): Promise<Buffer | null> {\n try {\n const cachePath = this.getCachePath(hash)\n const data = await readFile(cachePath)\n return data\n }\n catch {\n // Silent failure on cache miss or corruption\n return null\n }\n }\n\n /**\n * Write compressed image data to cache by hash.\n *\n * Uses atomic write pattern: temp file + rename.\n *\n * @param hash - MD5 hash of the image buffer\n * @param data - Compressed image data to cache\n */\n async write(hash: string, data: Buffer): Promise<void> {\n await this.ensureDir()\n\n const cachePath = this.getCachePath(hash)\n const tmpPath = `${cachePath}.tmp`\n\n // Atomic write: temp file + rename\n await writeFile(tmpPath, data)\n await rename(tmpPath, cachePath)\n }\n}\n\n/**\n * Read cached image data from multiple cache directories in priority order.\n *\n * @param hash - MD5 hash of the image buffer\n * @param cacheDirs - Array of cache directories (priority order)\n * @returns First successful Buffer read or null if all miss\n */\nexport async function readCacheByHash(\n hash: string,\n cacheDirs: string[],\n): Promise<Buffer | null> {\n for (const cacheDir of cacheDirs) {\n const storage = new BufferCacheStorage(cacheDir)\n const data = await storage.read(hash)\n if (data !== null) {\n const prefix = hash.substring(0, 8)\n logInfo(`ℹ Cache hit: ${prefix}`)\n return data\n }\n }\n\n return null\n}\n\n/**\n * Write compressed image data to cache by hash.\n *\n * @param hash - MD5 hash of the image buffer\n * @param data - Compressed image data to cache\n * @param cacheDir - Cache directory to write to\n */\nexport async function writeCacheByHash(\n hash: string,\n data: Buffer,\n cacheDir: string,\n): Promise<void> {\n const storage = new BufferCacheStorage(cacheDir)\n await storage.write(hash, data)\n\n const prefix = hash.substring(0, 8)\n logInfo(`ℹ Cached: ${prefix}`)\n}\n","import type { Buffer } from 'node:buffer'\nimport { createHash } from 'node:crypto'\nimport { readFile } from 'node:fs/promises'\n\n/**\n * Calculate MD5 hash of a file's content.\n *\n * @param filePath - Absolute path to the file\n * @returns MD5 hash as a 32-character hexadecimal string\n *\n * @example\n * ```ts\n * const hash = await calculateMD5('/path/to/image.png')\n * console.log(hash) // 'a1b2c3d4e5f6...'\n * ```\n */\nexport async function calculateMD5(filePath: string): Promise<string> {\n const content = await readFile(filePath)\n const hash = createHash('md5')\n hash.update(content)\n return hash.digest('hex')\n}\n\n/**\n * Calculate MD5 hash of a buffer's content.\n *\n * @param buffer - Buffer to hash\n * @returns MD5 hash as a 32-character hexadecimal string\n *\n * @example\n * ```ts\n * const hash = await calculateMD5FromBuffer(buffer)\n * console.log(hash) // 'a1b2c3d4e5f6...'\n * ```\n */\nexport async function calculateMD5FromBuffer(buffer: Buffer): Promise<string> {\n const hash = createHash('md5')\n hash.update(buffer)\n return hash.digest('hex')\n}\n","import { homedir } from 'node:os'\nimport { join } from 'node:path'\n\n/**\n * Get the project-level cache directory path.\n *\n * @param projectRoot - Absolute path to the project root directory\n * @returns Path to project cache directory: `node_modules/.tinyimg_cache/`\n *\n * @example\n * ```ts\n * const cachePath = getProjectCachePath('/Users/test/project')\n * // Returns: '/Users/test/project/node_modules/.tinyimg_cache'\n * ```\n */\nexport function getProjectCachePath(projectRoot: string): string {\n return join(projectRoot, 'node_modules', '.tinyimg_cache')\n}\n\n/**\n * Get the global cache directory path.\n *\n * @returns Path to global cache directory: `~/.tinyimg/cache/`\n *\n * @example\n * ```ts\n * const cachePath = getGlobalCachePath()\n * // Returns: '/Users/username/.tinyimg/cache'\n * ```\n */\nexport function getGlobalCachePath(): string {\n return join(homedir(), '.tinyimg', 'cache')\n}\n","import { readdir, stat } from 'node:fs/promises'\nimport { getGlobalCachePath, getProjectCachePath } from './paths'\n\n/**\n * Cache statistics interface.\n */\nexport interface CacheStats {\n count: number\n size: number\n}\n\n/**\n * Format bytes to human-readable format.\n *\n * @param bytes - Number of bytes\n * @returns Formatted string (e.g., \"1.23 MB\", \"456 KB\")\n *\n * @example\n * ```ts\n * formatBytes(0) // \"0 B\"\n * formatBytes(512) // \"512 B\"\n * formatBytes(1024) // \"1.00 KB\"\n * formatBytes(1024 * 1024) // \"1.00 MB\"\n * formatBytes(1024 * 1024 * 1024) // \"1.00 GB\"\n * ```\n */\nexport function formatBytes(bytes: number): string {\n if (bytes === 0) {\n return '0 B'\n }\n\n if (bytes < 1024) {\n return `${bytes} B`\n }\n\n if (bytes < 1024 * 1024) {\n return `${(bytes / 1024).toFixed(2)} KB`\n }\n\n if (bytes < 1024 * 1024 * 1024) {\n return `${(bytes / (1024 * 1024)).toFixed(2)} MB`\n }\n\n return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`\n}\n\n/**\n * Get cache statistics for a directory.\n *\n * @param cacheDir - Cache directory path\n * @returns Cache statistics (count and size)\n *\n * @example\n * ```ts\n * const stats = await getCacheStats('/path/to/cache')\n * console.log(`Files: ${stats.count}, Size: ${formatBytes(stats.size)}`)\n * ```\n */\nexport async function getCacheStats(cacheDir: string): Promise<CacheStats> {\n try {\n const files = await readdir(cacheDir)\n\n let count = 0\n let size = 0\n\n for (const file of files) {\n const filePath = `${cacheDir}/${file}`\n const stats = await stat(filePath)\n\n if (stats.isFile()) {\n count++\n size += stats.size\n }\n }\n\n return { count, size }\n }\n catch {\n // Directory doesn't exist or is not accessible\n return { count: 0, size: 0 }\n }\n}\n\n/**\n * Get cache statistics for both project and global cache.\n *\n * @param projectRoot - Optional project root directory\n * @returns Object with project and global cache statistics\n *\n * @example\n * ```ts\n * // Get both project and global stats\n * const stats = await getAllCacheStats('/project/path')\n * console.log(`Project: ${stats.project?.count}, Global: ${stats.global.count}`)\n *\n * // Get only global stats\n * const globalOnly = await getAllCacheStats()\n * console.log(`Global: ${globalOnly.global.count}`)\n * ```\n */\nexport async function getAllCacheStats(projectRoot?: string): Promise<{\n project: CacheStats | null\n global: CacheStats\n}> {\n const global = await getCacheStats(getGlobalCachePath())\n\n let project: CacheStats | null = null\n if (projectRoot) {\n project = await getCacheStats(getProjectCachePath(projectRoot))\n }\n\n return { project, global }\n}\n","import type { Buffer } from 'node:buffer'\nimport { mkdir, readFile, rename, writeFile } from 'node:fs/promises'\nimport { join } from 'node:path'\nimport { logInfo } from '../utils/logger'\nimport { calculateMD5 } from './hash'\n\n/**\n * Cache storage for reading and writing compressed image data.\n *\n * Uses atomic writes (temp file + rename) for concurrent safety.\n * Handles corruption gracefully by returning null on read failures.\n */\nexport class CacheStorage {\n constructor(private readonly cacheDir: string) {}\n\n /**\n * Ensure cache directory exists.\n */\n private async ensureDir(): Promise<void> {\n await mkdir(this.cacheDir, { recursive: true, mode: 0o755 })\n }\n\n /**\n * Get the cache file path for an image.\n *\n * @param imagePath - Absolute path to the source image\n * @returns Path to cache file (MD5 hash as filename, no extension)\n */\n async getCachePath(imagePath: string): Promise<string> {\n const md5Hash = await calculateMD5(imagePath)\n return join(this.cacheDir, md5Hash)\n }\n\n /**\n * Read cached compressed image data.\n *\n * @param imagePath - Absolute path to the source image\n * @returns Cached Buffer or null if not found/corrupted\n */\n async read(imagePath: string): Promise<Buffer | null> {\n try {\n const cachePath = await this.getCachePath(imagePath)\n const data = await readFile(cachePath)\n return data\n }\n catch {\n // Silent failure on cache miss or corruption\n return null\n }\n }\n\n /**\n * Write compressed image data to cache.\n *\n * Uses atomic write pattern: temp file + rename.\n *\n * @param imagePath - Absolute path to the source image\n * @param data - Compressed image data to cache\n */\n async write(imagePath: string, data: Buffer): Promise<void> {\n await this.ensureDir()\n\n const cachePath = await this.getCachePath(imagePath)\n const tmpPath = `${cachePath}.tmp`\n\n // Atomic write: temp file + rename\n await writeFile(tmpPath, data)\n await rename(tmpPath, cachePath)\n }\n}\n\n/**\n * Read cached image data from multiple cache directories in priority order.\n *\n * @param imagePath - Absolute path to the source image\n * @param cacheDirs - Array of cache directories (priority order)\n * @returns First successful Buffer read or null if all miss\n */\nexport async function readCache(\n imagePath: string,\n cacheDirs: string[],\n): Promise<Buffer | null> {\n for (const cacheDir of cacheDirs) {\n const storage = new CacheStorage(cacheDir)\n const data = await storage.read(imagePath)\n if (data !== null) {\n const md5Hash = await calculateMD5(imagePath)\n const prefix = md5Hash.substring(0, 8)\n logInfo(`ℹ️ cache hit: ${prefix}`)\n return data\n }\n }\n\n return null\n}\n\n/**\n * Write compressed image data to cache.\n *\n * @param imagePath - Absolute path to the source image\n * @param data - Compressed image data to cache\n * @param cacheDir - Cache directory to write to\n */\nexport async function writeCache(\n imagePath: string,\n data: Buffer,\n cacheDir: string,\n): Promise<void> {\n const storage = new CacheStorage(cacheDir)\n await storage.write(imagePath, data)\n\n const md5Hash = await calculateMD5(imagePath)\n const prefix = md5Hash.substring(0, 8)\n logInfo(`ℹ️ cache miss: ${prefix}, compressed`)\n}\n","import { logWarning } from '../utils/logger'\n\nexport class RetryManager {\n private failureCount = 0\n\n constructor(\n private maxRetries: number = 8,\n private baseDelay: number = 1000, // 1 second\n ) {}\n\n async execute<T>(operation: () => Promise<T>): Promise<T> {\n for (let attempt = 0; attempt <= this.maxRetries; attempt++) {\n try {\n const result = await operation()\n this.failureCount = 0 // Reset on success\n return result\n }\n catch (error) {\n this.failureCount++\n\n if (attempt === this.maxRetries || !this.shouldRetry(error)) {\n throw error\n }\n\n const delay = this.baseDelay * 2 ** attempt\n logWarning(`Retry ${attempt + 1}/${this.maxRetries} after ${delay}ms`)\n await this.sleep(delay)\n }\n }\n\n throw new Error('Max retries exceeded')\n }\n\n private shouldRetry(error: any): boolean {\n // Network errors\n if (error.code && ['ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND'].includes(error.code)) {\n return true\n }\n\n // HTTP 5xx server errors\n if (error.statusCode && error.statusCode >= 500 && error.statusCode < 600) {\n return true\n }\n\n // Don't retry on 4xx client errors\n return false\n }\n\n private sleep(ms: number): Promise<void> {\n return new Promise(resolve => setTimeout(resolve, ms))\n }\n\n getFailureCount(): number {\n return this.failureCount\n }\n\n reset(): void {\n this.failureCount = 0\n }\n}\n","import type { ICompressor } from './types'\nimport { Buffer } from 'node:buffer'\nimport https from 'node:https'\nimport { logInfo } from '../utils/logger'\nimport { RetryManager } from './retry'\n\nconst TINYPNG_WEB_URL = 'https://tinypng.com/backend/opt/shrink'\n\nexport class TinyPngWebCompressor implements ICompressor {\n private retryManager: RetryManager\n private requestHeaders?: Record<string, string>\n\n constructor(maxRetries: number = 8) {\n this.retryManager = new RetryManager(maxRetries)\n }\n\n async compress(buffer: Buffer): Promise<Buffer> {\n return this.retryManager.execute(async () => {\n // Step 1: Upload image to get compressed URL\n const uploadUrl = await this.uploadToTinyPngWeb(buffer)\n\n // Step 2: Download compressed image\n const compressedBuffer = await this.downloadCompressedImage(uploadUrl)\n\n const originalSize = buffer.byteLength\n const compressedSize = compressedBuffer.byteLength\n const saved = ((1 - compressedSize / originalSize) * 100).toFixed(1)\n\n logInfo(`Compressed with [TinyPngWebCompressor]: ${originalSize} → ${compressedSize} (saved ${saved}%)`)\n\n return compressedBuffer\n })\n }\n\n private getRandomHeaders(): Record<string, string> {\n return {\n 'User-Agent': this.getRandomUserAgent(),\n 'X-Forwarded-For': this.getRandomIPv4(),\n }\n }\n\n private getRandomUserAgent(): string {\n const userAgents = [\n 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',\n 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',\n 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',\n 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0',\n 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14.2; rv:121.0) Gecko/20100101 Firefox/121.0',\n 'Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0',\n 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15',\n 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36',\n 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36',\n 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36',\n 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0',\n 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14.1; rv:120.0) Gecko/20100101 Firefox/120.0',\n 'Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0',\n 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15',\n 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36',\n 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36',\n 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36',\n 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:119.0) Gecko/20100101 Firefox/119.0',\n 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14.0; rv:119.0) Gecko/20100101 Firefox/119.0',\n 'Mozilla/5.0 (X11; Linux x86_64; rv:119.0) Gecko/20100101 Firefox/119.0',\n ]\n\n const randomIndex = Math.floor(Math.random() * userAgents.length)\n return userAgents[randomIndex]\n }\n\n private getRandomIPv4(): string {\n const octet = () => Math.floor(Math.random() * 256)\n return `${octet()}.${octet()}.${octet()}.${octet()}`\n }\n\n private async uploadToTinyPngWeb(buffer: Buffer): Promise<string> {\n return new Promise((resolve, reject) => {\n // Generate random headers for this request\n this.requestHeaders = this.getRandomHeaders()\n\n const req = https.request(\n TINYPNG_WEB_URL,\n {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/octet-stream',\n 'Content-Length': buffer.byteLength,\n ...this.requestHeaders,\n },\n },\n (res) => {\n let data = ''\n\n res.on('data', (chunk) => {\n data += chunk\n })\n\n res.on('end', () => {\n if (res.statusCode !== 200) {\n const error = new Error(`HTTP ${res.statusCode}: ${data}`)\n ;(error as any).statusCode = res.statusCode\n return reject(error)\n }\n\n try {\n // Response contains JSON with output URL\n const response = JSON.parse(data)\n if (!response.output?.url) {\n return reject(new Error('No output URL in response'))\n }\n resolve(response.output.url)\n }\n catch (error: any) {\n reject(new Error(`Failed to parse response: ${error.message}`))\n }\n })\n },\n )\n\n req.on('error', (error) => {\n reject(error)\n })\n\n // Write raw buffer directly (not multipart)\n req.write(buffer)\n req.end()\n })\n }\n\n private async downloadCompressedImage(url: string): Promise<Buffer> {\n return new Promise((resolve, reject) => {\n // Use https.request instead of https.get to include custom headers\n const req = https.request(\n url,\n {\n headers: {\n 'Content-Type': 'application/octet-stream',\n ...this.requestHeaders,\n },\n },\n (res) => {\n if (res.statusCode !== 200) {\n const error = new Error(`HTTP ${res.statusCode} downloading compressed image`)\n ;(error as any).statusCode = res.statusCode\n return reject(error)\n }\n\n const chunks: Buffer[] = []\n res.on('data', chunk => chunks.push(chunk))\n res.on('end', () => resolve(Buffer.concat(chunks)))\n res.on('error', reject)\n },\n )\n\n req.on('error', reject)\n req.end()\n })\n }\n\n getFailureCount(): number {\n return this.retryManager.getFailureCount()\n }\n}\n","import type { KeyPool } from '../keys/pool'\nimport type { ICompressor } from './types'\nimport { Buffer } from 'node:buffer'\nimport tinify from 'tinify'\nimport { logInfo, logWarning } from '../utils/logger'\nimport { RetryManager } from './retry'\n\n// Re-export TinyPngWebCompressor for convenience\nexport { TinyPngWebCompressor } from './web-compressor'\n\n// 5MB limit per CONTEXT.md D-09 - design decision, not tinify API limit\n// tinify API supports up to 500MB, but we limit to 5MB for quota management\nconst MAX_FILE_SIZE = 5 * 1024 * 1024\n\nexport class TinyPngApiCompressor implements ICompressor {\n private retryManager: RetryManager\n private currentKey: string | null = null\n\n constructor(\n private keyPool: KeyPool,\n maxRetries: number = 8,\n ) {\n this.retryManager = new RetryManager(maxRetries)\n }\n\n async compress(buffer: Buffer): Promise<Buffer> {\n // Check 5MB limit per CONTEXT.md D-09\n if (buffer.byteLength > MAX_FILE_SIZE) {\n logWarning(`File exceeds 5MB limit for API compressor (${(buffer.byteLength / 1024 / 1024).toFixed(2)}MB)`)\n throw new Error('File size exceeds 5MB limit')\n }\n\n return this.retryManager.execute(async () => {\n // Only set tinify.key if it's different from current\n const key = await this.keyPool.selectKey()\n if (this.currentKey !== key) {\n try {\n tinify.key = key\n this.currentKey = key\n }\n catch {\n // In test environments, tinify.key might not be writable\n // This is OK as long as fromBuffer is mocked\n }\n }\n\n const originalSize = buffer.byteLength\n const result = await tinify.fromBuffer(buffer).toBuffer()\n const compressedSize = result.byteLength\n const saved = ((1 - compressedSize / originalSize) * 100).toFixed(1)\n\n this.keyPool.decrementQuota()\n logInfo(`Compressed with [TinyPngApiCompressor]: ${originalSize} → ${compressedSize} (saved ${saved}%)`)\n\n return Buffer.from(result)\n })\n }\n\n getFailureCount(): number {\n return this.retryManager.getFailureCount()\n }\n}\n","export class AllKeysExhaustedError extends Error {\n constructor(message = 'All API keys have exhausted quota') {\n super(message)\n this.name = 'AllKeysExhaustedError'\n }\n}\n\nexport class NoValidKeysError extends Error {\n constructor(message = 'No valid API keys available') {\n super(message)\n this.name = 'NoValidKeysError'\n }\n}\n\nexport class AllCompressionFailedError extends Error {\n constructor(message = 'All compression methods failed') {\n super(message)\n this.name = 'AllCompressionFailedError'\n }\n}\n","import type { Buffer } from 'node:buffer'\nimport type { CompressionMode, CompressOptions } from './types'\nimport { AllCompressionFailedError } from '../errors/types'\nimport { logInfo, logWarning } from '../utils/logger'\n/**\n * Compress buffer with automatic fallback through multiple compressors\n *\n * @param buffer - Original image data\n * @param options - Compression options (mode, compressors, maxRetries)\n * @returns Compressed image data\n * @throws AllCompressionFailedError when all compressors fail\n *\n * @example\n * ```ts\n * try {\n * const compressed = await compressWithFallback(buffer, { mode: 'auto' })\n * } catch (error) {\n * if (error instanceof AllCompressionFailedError) {\n * // All compression methods failed\n * }\n * }\n * ```\n */\nexport async function compressWithFallback(\n buffer: Buffer,\n options: CompressOptions = {},\n): Promise<Buffer> {\n const compressors = options.compressors ?? []\n\n for (const compressor of compressors) {\n try {\n logInfo(`Attempting compression with [${compressor.constructor.name}]`)\n const result = await compressor.compress(buffer)\n return result\n }\n catch (error: any) {\n logWarning(`[${compressor.constructor.name}] failed: ${error.message}`)\n\n // If this is AllCompressionFailedError, propagate immediately\n if (error.name === 'AllCompressionFailedError') {\n throw error\n }\n\n // Otherwise, try next compressor\n continue\n }\n }\n\n throw new AllCompressionFailedError('All compression methods failed')\n}\n\n/**\n * Get default compressor types for a given mode\n * This is a helper - actual compressor instances created in service layer\n *\n * @param mode - Compression mode\n * @returns Compressor type names (not instances)\n *\n * @example\n * ```ts\n * const types = getCompressorTypesForMode('auto')\n * // Returns: ['TinyPngApiCompressor', 'TinyPngWebCompressor']\n * ```\n */\nexport function getCompressorTypesForMode(mode: CompressionMode = 'auto'): string[] {\n switch (mode) {\n case 'api':\n return ['TinyPngApiCompressor']\n case 'web':\n return ['TinyPngWebCompressor']\n case 'auto':\n default:\n return ['TinyPngApiCompressor', 'TinyPngWebCompressor']\n }\n}\n","import pLimit from 'p-limit'\n\n/**\n * Create a concurrency limiter for async operations\n *\n * @param concurrency - Max concurrent operations (default: 8)\n * @returns Limit function that wraps async operations\n *\n * @example\n * ```ts\n * const limit = createConcurrencyLimiter(2)\n * const task1 = limit(() => asyncOperation1())\n * const task2 = limit(() => asyncOperation2())\n * await Promise.all([task1, task2])\n * ```\n */\nexport function createConcurrencyLimiter(concurrency: number = 8) {\n return pLimit(concurrency)\n}\n\n/**\n * Execute tasks with concurrency control\n *\n * @param tasks - Array of async functions to execute\n * @param concurrency - Max concurrent tasks (default: 8)\n * @returns Promise resolving to array of results\n *\n * @example\n * ```ts\n * const tasks = [\n * () => compressImage(buffer1),\n * () => compressImage(buffer2),\n * () => compressImage(buffer3)\n * ]\n * const results = await executeWithConcurrency(tasks, 2)\n * // Only 2 compressions run at a time\n * ```\n */\nexport async function executeWithConcurrency<T>(\n tasks: (() => Promise<T>)[],\n concurrency: number = 8,\n): Promise<T[]> {\n const limit = createConcurrencyLimiter(concurrency)\n\n // Map each task to a limited execution\n const limitedTasks = tasks.map(task => limit(task))\n\n // Wait for all to complete\n return Promise.all(limitedTasks)\n}\n","import type { ConfigFile } from './types'\nimport fs from 'node:fs'\nimport os from 'node:os'\nimport path from 'node:path'\n\nconst CONFIG_DIR = '.tinyimg'\nconst CONFIG_FILE = 'keys.json'\n\nexport function getConfigPath(): string {\n const homeDir = os.homedir()\n return path.join(homeDir, CONFIG_DIR, CONFIG_FILE)\n}\n\nexport function ensureConfigFile(): void {\n const configPath = getConfigPath()\n const configDir = path.dirname(configPath)\n\n if (!fs.existsSync(configDir)) {\n fs.mkdirSync(configDir, { recursive: true, mode: 0o700 })\n }\n\n if (!fs.existsSync(configPath)) {\n const initialContent: ConfigFile = { keys: [] }\n fs.writeFileSync(\n configPath,\n JSON.stringify(initialContent, null, 2),\n { mode: 0o600 },\n )\n }\n}\n\nexport function readConfig(): ConfigFile {\n ensureConfigFile()\n const configPath = getConfigPath()\n const content = fs.readFileSync(configPath, 'utf-8')\n return JSON.parse(content) as ConfigFile\n}\n\nexport function writeConfig(config: ConfigFile): void {\n ensureConfigFile()\n const configPath = getConfigPath()\n fs.writeFileSync(\n configPath,\n JSON.stringify(config, null, 2),\n { mode: 0o600 },\n )\n}\n","import process from 'node:process'\nimport { readConfig } from './storage'\n\nexport interface LoadedKey {\n key: string\n valid?: boolean\n lastCheck?: string\n}\n\nfunction parseEnvVar(value: string | undefined, isMultiple: boolean): string[] | null {\n if (!value?.trim())\n return null\n if (isMultiple) {\n return value.split(',').map(k => k.trim()).filter(k => k.length > 0)\n }\n return [value.trim()]\n}\n\nexport function loadKeys(): LoadedKey[] {\n // Priority 1: TINYIMG_KEYS (highest priority)\n const tinyimgKeys = parseEnvVar(process.env.TINYIMG_KEYS, true)\n if (tinyimgKeys)\n return tinyimgKeys.map(key => ({ key }))\n\n // Priority 2: TINYIMG_KEY\n const tinyimgKey = parseEnvVar(process.env.TINYIMG_KEY, false)\n if (tinyimgKey)\n return tinyimgKey.map(key => ({ key }))\n\n // Priority 3: TINYPNG_KEYS\n const tinypngKeys = parseEnvVar(process.env.TINYPNG_KEYS, true)\n if (tinypngKeys)\n return tinypngKeys.map(key => ({ key }))\n\n // Priority 4: TINYPNG_KEY\n const tinypngKey = parseEnvVar(process.env.TINYPNG_KEY, false)\n if (tinypngKey)\n return tinypngKey.map(key => ({ key }))\n\n // Priority 5: Global config file (lowest priority)\n try {\n const config = readConfig()\n return config.keys.map(metadata => ({\n key: metadata.key,\n valid: metadata.valid,\n lastCheck: metadata.lastCheck,\n }))\n }\n catch {\n // Config file doesn't exist or is invalid\n return []\n }\n}\n","export function maskKey(key: string): string {\n if (key.length < 8) {\n return '****'\n }\n const first = key.substring(0, 4)\n const last = key.substring(key.length - 4)\n return `${first}****${last}`\n}\n","import tinify from 'tinify'\nimport { maskKey } from './masker'\n\nconst MONTHLY_LIMIT = 500 // Free tier limit\n\nexport async function queryQuota(key: string): Promise<number> {\n try {\n tinify.key = key\n await tinify.validate() // Required to set compressionCount\n const usedThisMonth = tinify.compressionCount ?? 0\n const remaining = Math.max(0, MONTHLY_LIMIT - usedThisMonth)\n return remaining\n }\n catch (error: any) {\n // Invalid key or quota exhausted - return 0\n if (error?.message?.includes('credentials') || error?.message?.includes('Unauthorized') || error?.constructor?.name === 'AccountError') {\n return 0\n }\n throw error\n }\n}\n\nexport interface QuotaTracker {\n key: string\n remaining: number\n localCounter: number\n decrement: () => void\n isZero: () => boolean\n}\n\nexport function createQuotaTracker(key: string, remaining: number): QuotaTracker {\n const localCounter = remaining\n\n return {\n key,\n remaining,\n localCounter,\n decrement() {\n if (this.localCounter > 0) {\n this.localCounter--\n\n if (this.localCounter === 0) {\n console.warn(`⚠ Key ${maskKey(this.key)} quota exhausted, switching to next key`)\n }\n }\n },\n isZero() {\n return this.localCounter === 0\n },\n }\n}\n","import tinify from 'tinify'\nimport { maskKey } from './masker'\n\nexport async function validateKey(key: string): Promise<boolean> {\n try {\n tinify.key = key\n await tinify.validate()\n\n console.log(`✓ API key ${maskKey(key)} validated successfully`)\n return true\n }\n catch (error: any) {\n // Check if it's an AccountError (invalid credentials)\n if (error?.message?.includes('credentials') || error?.message?.includes('Unauthorized') || error?.constructor?.name === 'AccountError') {\n console.warn(`⚠ Invalid API key ${maskKey(key)} marked and skipped`)\n return false\n }\n // Re-throw network and server errors\n throw error\n }\n}\n","import { logWarning } from '../utils/logger'\nimport { maskKey } from './masker'\nimport { createQuotaTracker, queryQuota } from './quota'\nimport { validateKey } from './validator'\n\nexport interface KeySelection {\n key: string\n tracker: ReturnType<typeof createQuotaTracker>\n}\n\n// Strategy 1: Random (default)\nexport class RandomSelector {\n async select(keys: string[]): Promise<KeySelection | null> {\n const available = await this.getAvailableKeys(keys)\n if (available.length === 0)\n return null\n\n const randomIndex = Math.floor(Math.random() * available.length)\n const selected = available[randomIndex]\n return selected\n }\n\n protected async getAvailableKeys(keys: string[]): Promise<KeySelection[]> {\n const available: KeySelection[] = []\n\n for (const key of keys) {\n const isValid = await validateKey(key)\n if (!isValid)\n continue\n\n const remaining = await queryQuota(key)\n if (remaining === 0) {\n logWarning(`Key ${maskKey(key)} has no quota remaining`)\n continue\n }\n\n available.push({\n key,\n tracker: createQuotaTracker(key, remaining),\n })\n }\n\n return available\n }\n}\n\n// Strategy 2: Round-Robin\nexport class RoundRobinSelector extends RandomSelector {\n private currentIndex = 0\n\n async select(keys: string[]): Promise<KeySelection | null> {\n const available = await this.getAvailableKeys(keys)\n if (available.length === 0)\n return null\n\n const selected = available[this.currentIndex % available.length]\n this.currentIndex++\n return selected\n }\n\n reset(): void {\n this.currentIndex = 0\n }\n}\n\n// Strategy 3: Priority (use first available)\nexport class PrioritySelector extends RandomSelector {\n async select(keys: string[]): Promise<KeySelection | null> {\n const available = await this.getAvailableKeys(keys)\n if (available.length === 0)\n return null\n\n // Return first available key\n return available[0]\n }\n}\n","import type { KeySelection } from './selector'\nimport { loadKeys } from '../config/loader'\nimport { AllKeysExhaustedError, NoValidKeysError } from '../errors/types'\nimport { PrioritySelector, RandomSelector, RoundRobinSelector } from './selector'\n\nexport type KeyStrategy = 'random' | 'round-robin' | 'priority'\n\nexport class KeyPool {\n private keys: string[]\n private selector: RandomSelector | RoundRobinSelector | PrioritySelector\n private currentSelection: KeySelection | null = null\n\n constructor(strategy: KeyStrategy = 'random') {\n this.keys = loadKeys().map(k => k.key)\n\n if (this.keys.length === 0) {\n throw new NoValidKeysError('No API keys configured')\n }\n\n this.selector = this.createSelector(strategy)\n }\n\n private createSelector(strategy: KeyStrategy) {\n switch (strategy) {\n case 'random':\n return new RandomSelector()\n case 'round-robin':\n return new RoundRobinSelector()\n case 'priority':\n return new PrioritySelector()\n default:\n return new RandomSelector()\n }\n }\n\n async selectKey(): Promise<string> {\n // If current key has quota, use it\n if (this.currentSelection && !this.currentSelection.tracker.isZero()) {\n return this.currentSelection.key\n }\n\n // Need to select new key\n const selection = await this.selector.select(this.keys)\n\n if (!selection) {\n throw new AllKeysExhaustedError()\n }\n\n this.currentSelection = selection\n return selection.key\n }\n\n decrementQuota(): void {\n if (this.currentSelection) {\n this.currentSelection.tracker.decrement()\n }\n }\n\n getCurrentKey(): string | null {\n return this.currentSelection?.key ?? null\n }\n}\n","import type { Buffer } from 'node:buffer'\nimport type { CompressOptions, ICompressor } from './types'\nimport process from 'node:process'\nimport { readCacheByHash, writeCacheByHash } from '../cache/buffer-storage'\nimport { calculateMD5FromBuffer } from '../cache/hash'\nimport { getGlobalCachePath, getProjectCachePath } from '../cache/paths'\nimport { KeyPool } from '../keys/pool'\nimport { logInfo, logWarning } from '../utils/logger'\nimport { TinyPngApiCompressor, TinyPngWebCompressor } from './api-compressor'\nimport { compressWithFallback } from './compose'\nimport { createConcurrencyLimiter } from './concurrency'\n\nexport interface CompressServiceOptions extends CompressOptions {\n /**\n * Enable cache (default: true)\n */\n cache?: boolean\n\n /**\n * Use project cache only, ignore global cache (default: false)\n */\n projectCacheOnly?: boolean\n\n /**\n * Concurrency limit for batch operations (default: 8)\n */\n concurrency?: number\n\n /**\n * Optional KeyPool instance for testing or advanced usage\n * If not provided, a new KeyPool will be created with 'random' strategy\n */\n keyPool?: KeyPool\n}\n\n/**\n * Compress a single image with cache integration and fallback\n *\n * @param buffer - Original image data\n * @param options - Compression options\n * @returns Compressed image data\n */\nexport async function compressImage(\n buffer: Buffer,\n options: CompressServiceOptions = {},\n): Promise<Buffer> {\n const {\n cache = true,\n projectCacheOnly = false,\n mode = 'auto',\n maxRetries = 8,\n } = options\n\n // Step 1: Calculate MD5 for cache key\n const hash = await calculateMD5FromBuffer(buffer)\n const hashPrefix = hash.substring(0, 8)\n\n // Step 2: Check cache if enabled\n if (cache) {\n try {\n // Try project cache first\n const projectCachePath = getProjectCachePath(process.cwd())\n const cached = await readCacheByHash(hash, [projectCachePath])\n if (cached) {\n logInfo(`ℹ Cache hit: ${hashPrefix}`)\n return cached\n }\n\n // Try global cache if not project-only\n if (!projectCacheOnly) {\n const globalCachePath = getGlobalCachePath()\n const globalCached = await readCacheByHash(hash, [globalCachePath])\n if (globalCached) {\n logInfo(`ℹ Cache hit (global): ${hashPrefix}`)\n return globalCached\n }\n }\n }\n catch (error: any) {\n logWarning(`Cache read failed: ${error.message}`)\n // Continue to compression on cache errors\n }\n }\n\n // Step 3: Compress with fallback\n logInfo(`ℹ Cache miss: ${hashPrefix}, compressing...`)\n\n const compressed = await compressWithFallback(buffer, {\n mode,\n maxRetries,\n compressors: createCompressors(options),\n })\n\n // Step 4: Write to project cache if enabled\n if (cache) {\n try {\n const projectCachePath = getProjectCachePath(process.cwd())\n await writeCacheByHash(hash, compressed, projectCachePath)\n logInfo(`ℹ Cached: ${hashPrefix}`)\n }\n catch (error: any) {\n logWarning(`Cache write failed: ${error.message}`)\n // Don't fail compression on cache write errors\n }\n }\n\n return compressed\n}\n\n/**\n * Compress multiple images with concurrency control\n *\n * @param buffers - Array of image buffers\n * @param options - Compression options\n * @returns Array of compressed buffers\n */\nexport async function compressImages(\n buffers: Buffer[],\n options: CompressServiceOptions = {},\n): Promise<Buffer[]> {\n const { concurrency = 8 } = options\n const limit = createConcurrencyLimiter(concurrency)\n\n const tasks = buffers.map(buffer =>\n limit(() => compressImage(buffer, options)),\n )\n\n return Promise.all(tasks)\n}\n\n/**\n * Create compressor instances based on options\n * Factory function to inject KeyPool for API compressor\n */\nfunction createCompressors(options: CompressServiceOptions): ICompressor[] {\n const { mode = 'auto', maxRetries = 8, keyPool } = options\n const compressors: ICompressor[] = []\n\n // Only create/use KeyPool when mode requires API compression\n const needsApiCompressor = mode === 'auto' || mode === 'api'\n const needsWebCompressor = mode === 'auto' || mode === 'web'\n\n if (needsApiCompressor) {\n const pool = keyPool || new KeyPool('random')\n compressors.push(new TinyPngApiCompressor(pool, maxRetries))\n }\n\n if (needsWebCompressor) {\n compressors.push(new TinyPngWebCompressor(maxRetries))\n }\n\n return compressors\n}\n","import type { DetectOptions } from './types'\nimport sharp from 'sharp'\nimport { createConcurrencyLimiter } from '../compress/concurrency'\n\n/**\n * Detect if a PNG file has alpha channel transparency\n *\n * Uses pixel sampling (not just metadata) to avoid false positives.\n * Strategy:\n * 1. Quick reject: non-PNG format -> false\n * 2. Quick reject: no alpha channel in metadata -> false\n * 3. Pixel sampling: downsample to 100x100 and scan for alpha < 255\n *\n * @param filePath - Path to the image file\n * @param _options - Detection options (reserved for future use)\n * @returns Promise<boolean> - true if PNG has transparent pixels, false otherwise\n */\nexport async function detectAlpha(\n filePath: string,\n _options?: DetectOptions,\n): Promise<boolean> {\n // Get metadata first\n const metadata = await sharp(filePath).metadata()\n\n // Non-PNG: return false (no alpha for non-PNG formats)\n if (metadata.format !== 'png') {\n return false\n }\n\n // Quick reject: no alpha channel in metadata\n if (!metadata.hasAlpha) {\n return false\n }\n\n // Pixel sampling: downsample to small size for performance\n // Resize to max 100x100 (fit inside preserves aspect ratio)\n const { data } = await sharp(filePath)\n .resize(100, 100, { fit: 'inside' })\n .raw()\n .toBuffer({ resolveWithObject: true })\n\n // Scan alpha channel: every 4th byte starting at index 3\n for (let i = 3; i < data.length; i += 4) {\n if (data[i] < 255) {\n return true\n }\n }\n\n return false\n}\n\n/**\n * Detect alpha channel transparency for multiple PNG files\n *\n * @param filePaths - Array of file paths\n * @param options - Detection options including concurrency\n * @returns Promise<Map<string, boolean>> - Map of file paths to transparency results\n */\nexport async function detectAlphas(\n filePaths: string[],\n options?: DetectOptions,\n): Promise<Map<string, boolean>> {\n const { concurrency = 8 } = options ?? {}\n const limit = createConcurrencyLimiter(concurrency)\n\n const tasks = filePaths.map(path =>\n limit(() => detectAlpha(path, options)),\n )\n const results = await Promise.all(tasks)\n return new Map(filePaths.map((path, i) => [path, results[i]]))\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/cache/buffer-storage.ts","../src/cache/hash.ts","../src/cache/paths.ts","../src/cache/stats.ts","../src/cache/storage.ts","../src/utils/http-request.ts","../src/compress/http-client.ts","../src/keys/quota.ts","../src/compress/retry.ts","../src/compress/web-compressor.ts","../src/compress/api-compressor.ts","../src/errors/types.ts","../src/compress/compose.ts","../src/compress/concurrency.ts","../src/config/storage.ts","../src/config/loader.ts","../src/keys/validator.ts","../src/keys/selector.ts","../src/keys/pool.ts","../src/compress/service.ts","../src/detect/service.ts","../src/keys/masker.ts"],"sourcesContent":["import type { Buffer } from 'node:buffer'\nimport { mkdir, readFile, rename, writeFile } from 'node:fs/promises'\nimport { join } from 'pathe'\n\n/**\n * Cache storage for reading and writing compressed image data by hash.\n *\n * Uses atomic writes (temp file + rename) for concurrent safety.\n * Handles corruption gracefully by returning null on read failures.\n */\nexport class BufferCacheStorage {\n constructor(private readonly cacheDir: string) {}\n\n /**\n * Ensure cache directory exists.\n */\n private async ensureDir(): Promise<void> {\n await mkdir(this.cacheDir, { recursive: true, mode: 0o755 })\n }\n\n /**\n * Get the cache file path for an image hash.\n *\n * @param hash - MD5 hash of the image buffer\n * @returns Path to cache file (MD5 hash as filename, no extension)\n */\n getCachePath(hash: string): string {\n return join(this.cacheDir, hash)\n }\n\n /**\n * Read cached compressed image data by hash.\n *\n * @param hash - MD5 hash of the image buffer\n * @returns Cached Buffer or null if not found/corrupted\n */\n async read(hash: string): Promise<Buffer | null> {\n try {\n const cachePath = this.getCachePath(hash)\n const data = await readFile(cachePath)\n return data\n }\n catch {\n // Silent failure on cache miss or corruption\n return null\n }\n }\n\n /**\n * Write compressed image data to cache by hash.\n *\n * Uses atomic write pattern: temp file + rename.\n *\n * @param hash - MD5 hash of the image buffer\n * @param data - Compressed image data to cache\n */\n async write(hash: string, data: Buffer): Promise<void> {\n await this.ensureDir()\n\n const cachePath = this.getCachePath(hash)\n const tmpPath = `${cachePath}.tmp`\n\n // Atomic write: temp file + rename\n await writeFile(tmpPath, data)\n await rename(tmpPath, cachePath)\n }\n}\n\n/**\n * Read cached image data from multiple cache directories in priority order.\n *\n * @param hash - MD5 hash of the image buffer\n * @param cacheDirs - Array of cache directories (priority order)\n * @returns First successful Buffer read or null if all miss\n */\nexport async function readCacheByHash(\n hash: string,\n cacheDirs: string[],\n): Promise<Buffer | null> {\n for (const cacheDir of cacheDirs) {\n const storage = new BufferCacheStorage(cacheDir)\n const data = await storage.read(hash)\n if (data !== null) {\n return data\n }\n }\n\n return null\n}\n\n/**\n * Write compressed image data to cache by hash.\n *\n * @param hash - MD5 hash of the image buffer\n * @param data - Compressed image data to cache\n * @param cacheDir - Cache directory to write to\n */\nexport async function writeCacheByHash(\n hash: string,\n data: Buffer,\n cacheDir: string,\n): Promise<void> {\n const storage = new BufferCacheStorage(cacheDir)\n await storage.write(hash, data)\n}\n","import type { Buffer } from 'node:buffer'\nimport { createHash } from 'node:crypto'\nimport { readFile } from 'node:fs/promises'\n\n/**\n * Calculate MD5 hash of a file's content.\n *\n * @param filePath - Absolute path to the file\n * @returns MD5 hash as a 32-character hexadecimal string\n *\n * @example\n * ```ts\n * const hash = await calculateMD5('/path/to/image.png')\n * console.log(hash) // 'a1b2c3d4e5f6...'\n * ```\n */\nexport async function calculateMD5(filePath: string): Promise<string> {\n const content = await readFile(filePath)\n const hash = createHash('md5')\n hash.update(content)\n return hash.digest('hex')\n}\n\n/**\n * Calculate MD5 hash of a buffer's content.\n *\n * @param buffer - Buffer to hash\n * @returns MD5 hash as a 32-character hexadecimal string\n *\n * @example\n * ```ts\n * const hash = await calculateMD5FromBuffer(buffer)\n * console.log(hash) // 'a1b2c3d4e5f6...'\n * ```\n */\nexport async function calculateMD5FromBuffer(buffer: Buffer): Promise<string> {\n const hash = createHash('md5')\n hash.update(buffer)\n return hash.digest('hex')\n}\n","import { homedir } from 'node:os'\nimport { join } from 'pathe'\n\n/**\n * Get the project-level cache directory path.\n *\n * @param projectRoot - Absolute path to the project root directory\n * @returns Path to project cache directory: `node_modules/.tinyimg_cache/`\n *\n * @example\n * ```ts\n * const cachePath = getProjectCachePath('/Users/test/project')\n * // Returns: '/Users/test/project/node_modules/.tinyimg_cache'\n * ```\n */\nexport function getProjectCachePath(projectRoot: string): string {\n return join(projectRoot, 'node_modules', '.tinyimg_cache')\n}\n\n/**\n * Get the global cache directory path.\n *\n * @returns Path to global cache directory: `~/.tinyimg/cache/`\n *\n * @example\n * ```ts\n * const cachePath = getGlobalCachePath()\n * // Returns: '/Users/username/.tinyimg/cache'\n * ```\n */\nexport function getGlobalCachePath(): string {\n return join(homedir(), '.tinyimg', 'cache')\n}\n","import { readdir, stat } from 'node:fs/promises'\nimport { join } from 'pathe'\nimport { getGlobalCachePath, getProjectCachePath } from './paths'\n\n/**\n * Cache statistics interface.\n */\nexport interface CacheStats {\n count: number\n size: number\n}\n\n/**\n * Format bytes to human-readable format.\n *\n * @param bytes - Number of bytes\n * @returns Formatted string (e.g., \"1.23 MB\", \"456 KB\")\n *\n * @example\n * ```ts\n * formatBytes(0) // \"0 B\"\n * formatBytes(512) // \"512 B\"\n * formatBytes(1024) // \"1.00 KB\"\n * formatBytes(1024 * 1024) // \"1.00 MB\"\n * formatBytes(1024 * 1024 * 1024) // \"1.00 GB\"\n * ```\n */\nexport function formatBytes(bytes: number): string {\n if (bytes === 0) {\n return '0 B'\n }\n\n if (bytes < 1024) {\n return `${bytes} B`\n }\n\n if (bytes < 1024 * 1024) {\n return `${(bytes / 1024).toFixed(2)} KB`\n }\n\n if (bytes < 1024 * 1024 * 1024) {\n return `${(bytes / (1024 * 1024)).toFixed(2)} MB`\n }\n\n return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`\n}\n\n/**\n * Get cache statistics for a directory.\n *\n * @param cacheDir - Cache directory path\n * @returns Cache statistics (count and size)\n *\n * @example\n * ```ts\n * const stats = await getCacheStats('/path/to/cache')\n * console.log(`Files: ${stats.count}, Size: ${formatBytes(stats.size)}`)\n * ```\n */\nexport async function getCacheStats(cacheDir: string): Promise<CacheStats> {\n try {\n const files = await readdir(cacheDir)\n\n let count = 0\n let size = 0\n\n for (const file of files) {\n const filePath = join(cacheDir, file)\n const stats = await stat(filePath)\n\n if (stats.isFile()) {\n count++\n size += stats.size\n }\n }\n\n return { count, size }\n }\n catch {\n // Directory doesn't exist or is not accessible\n return { count: 0, size: 0 }\n }\n}\n\n/**\n * Get cache statistics for both project and global cache.\n *\n * @param projectRoot - Optional project root directory\n * @returns Object with project and global cache statistics\n *\n * @example\n * ```ts\n * // Get both project and global stats\n * const stats = await getAllCacheStats('/project/path')\n * console.log(`Project: ${stats.project?.count}, Global: ${stats.global.count}`)\n *\n * // Get only global stats\n * const globalOnly = await getAllCacheStats()\n * console.log(`Global: ${globalOnly.global.count}`)\n * ```\n */\nexport async function getAllCacheStats(projectRoot?: string): Promise<{\n project: CacheStats | null\n global: CacheStats\n}> {\n const global = await getCacheStats(getGlobalCachePath())\n\n let project: CacheStats | null = null\n if (projectRoot) {\n project = await getCacheStats(getProjectCachePath(projectRoot))\n }\n\n return { project, global }\n}\n","import type { Buffer } from 'node:buffer'\nimport { mkdir, readFile, rename, writeFile } from 'node:fs/promises'\nimport { join } from 'pathe'\nimport { calculateMD5 } from './hash'\n\n/**\n * Cache storage for reading and writing compressed image data.\n *\n * Uses atomic writes (temp file + rename) for concurrent safety.\n * Handles corruption gracefully by returning null on read failures.\n */\nexport class CacheStorage {\n constructor(private readonly cacheDir: string) {}\n\n /**\n * Ensure cache directory exists.\n */\n private async ensureDir(): Promise<void> {\n await mkdir(this.cacheDir, { recursive: true, mode: 0o755 })\n }\n\n /**\n * Get the cache file path for an image.\n *\n * @param imagePath - Absolute path to the source image\n * @returns Path to cache file (MD5 hash as filename, no extension)\n */\n async getCachePath(imagePath: string): Promise<string> {\n const md5Hash = await calculateMD5(imagePath)\n return join(this.cacheDir, md5Hash)\n }\n\n /**\n * Read cached compressed image data.\n *\n * @param imagePath - Absolute path to the source image\n * @returns Cached Buffer or null if not found/corrupted\n */\n async read(imagePath: string): Promise<Buffer | null> {\n try {\n const cachePath = await this.getCachePath(imagePath)\n const data = await readFile(cachePath)\n return data\n }\n catch {\n // Silent failure on cache miss or corruption\n return null\n }\n }\n\n /**\n * Write compressed image data to cache.\n *\n * Uses atomic write pattern: temp file + rename.\n *\n * @param imagePath - Absolute path to the source image\n * @param data - Compressed image data to cache\n */\n async write(imagePath: string, data: Buffer): Promise<void> {\n await this.ensureDir()\n\n const cachePath = await this.getCachePath(imagePath)\n const tmpPath = `${cachePath}.tmp`\n\n // Atomic write: temp file + rename\n await writeFile(tmpPath, data)\n await rename(tmpPath, cachePath)\n }\n}\n\n/**\n * Read cached image data from multiple cache directories in priority order.\n *\n * @param imagePath - Absolute path to the source image\n * @param cacheDirs - Array of cache directories (priority order)\n * @returns First successful Buffer read or null if all miss\n */\nexport async function readCache(\n imagePath: string,\n cacheDirs: string[],\n): Promise<Buffer | null> {\n for (const cacheDir of cacheDirs) {\n const storage = new CacheStorage(cacheDir)\n const data = await storage.read(imagePath)\n if (data !== null) {\n return data\n }\n }\n\n return null\n}\n\n/**\n * Write compressed image data to cache.\n *\n * @param imagePath - Absolute path to the source image\n * @param data - Compressed image data to cache\n * @param cacheDir - Cache directory to write to\n */\nexport async function writeCache(\n imagePath: string,\n data: Buffer,\n cacheDir: string,\n): Promise<void> {\n const storage = new CacheStorage(cacheDir)\n await storage.write(imagePath, data)\n}\n","import type { IncomingMessage } from 'node:http'\nimport { Buffer } from 'node:buffer'\nimport https from 'node:https'\n\nconst MAX_REDIRECTS = 5\n\nexport interface RequestOptions {\n method: 'GET' | 'POST'\n headers?: Record<string, string>\n body?: Buffer\n}\n\nexport interface HttpResponse<T = Buffer> {\n statusCode: number\n headers: Record<string, string | string[]>\n data: T\n}\n\n/**\n * Generic HTTPS request utility function.\n * Supports JSON and Buffer responses, follows redirects (up to 5), and handles errors.\n *\n * @param url - The URL to request\n * @param options - Request options (method, headers, body)\n * @param redirectCount - Internal counter for redirect following (default: 0)\n * @returns Promise<HttpResponse<T>> with status code, headers, and data\n */\nexport async function httpRequest<T = Buffer>(\n url: string,\n options: RequestOptions,\n redirectCount: number = 0,\n): Promise<HttpResponse<T>> {\n return new Promise((resolve, reject) => {\n const req = https.request(\n url,\n {\n method: options.method,\n headers: options.headers,\n },\n (res: IncomingMessage) => {\n const statusCode = res.statusCode || 0\n\n // Handle redirects (3xx)\n if (statusCode >= 300 && statusCode < 400) {\n const redirectUrl = res.headers.location\n if (!redirectUrl) {\n return reject(new Error(`Redirect (${statusCode}) but no Location header`))\n }\n\n // Check redirect limit\n if (redirectCount >= MAX_REDIRECTS) {\n return reject(new Error(`Maximum redirects (${MAX_REDIRECTS}) exceeded`))\n }\n\n // Follow redirect recursively\n return httpRequest<T>(redirectUrl, options, redirectCount + 1).then(resolve).catch(reject)\n }\n\n // Handle success (2xx)\n if (statusCode >= 200 && statusCode < 300) {\n const chunks: Buffer[] = []\n\n res.on('data', (chunk: Buffer) => {\n chunks.push(chunk)\n })\n\n res.on('end', () => {\n const buffer = Buffer.concat(chunks)\n\n // Try to parse as JSON first\n try {\n const json = JSON.parse(buffer.toString()) as T\n resolve({\n statusCode,\n headers: res.headers as Record<string, string | string[]>,\n data: json,\n })\n }\n catch {\n // If JSON parse fails, return buffer\n resolve({\n statusCode,\n headers: res.headers as Record<string, string | string[]>,\n data: buffer as T,\n })\n }\n })\n\n res.on('error', (error: Error) => {\n reject(error)\n })\n\n return\n }\n\n // Handle errors (4xx/5xx) - return response for caller to handle\n const chunks: Buffer[] = []\n\n res.on('data', (chunk: Buffer) => {\n chunks.push(chunk)\n })\n\n res.on('end', () => {\n const buffer = Buffer.concat(chunks)\n\n // Try to parse as JSON first\n try {\n const json = JSON.parse(buffer.toString()) as T\n resolve({\n statusCode,\n headers: res.headers as Record<string, string | string[]>,\n data: json,\n })\n }\n catch {\n // If JSON parse fails, return buffer\n resolve({\n statusCode,\n headers: res.headers as Record<string, string | string[]>,\n data: buffer as T,\n })\n }\n })\n\n res.on('error', (error: Error) => {\n reject(error)\n })\n },\n )\n\n req.on('error', (error: Error) => {\n // Network errors preserve error.code\n reject(error)\n })\n\n // Write body if provided\n if (options.body) {\n req.write(options.body)\n }\n\n req.end()\n })\n}\n","import { Buffer } from 'node:buffer'\nimport { httpRequest } from '../utils/http-request'\n\nconst TINYPNG_API_URL = 'https://api.tinify.com/shrink'\n\nexport interface CompressResult {\n buffer: Buffer\n compressionCount: number\n}\n\nclass TinyPngError extends Error {\n statusCode: number\n errorCode: string\n\n constructor(message: string, statusCode: number, errorCode: string) {\n super(message)\n this.name = 'TinyPngError'\n this.statusCode = statusCode\n this.errorCode = errorCode\n }\n}\n\nexport class TinyPngHttpClient {\n /**\n * Compress an image by uploading to TinyPNG API and downloading the result.\n *\n * @param key - TinyPNG API key\n * @param buffer - Image buffer to compress\n * @returns Compressed image buffer and compression count\n */\n async compress(key: string, buffer: Buffer): Promise<CompressResult> {\n const { url, compressionCount } = await this.uploadImage(key, buffer)\n return this.downloadImage(url, key, compressionCount)\n }\n\n /**\n * Validate if an API key is valid.\n *\n * @param key - TinyPNG API key to validate\n * @returns true if key is valid, false otherwise\n */\n async validateKey(key: string): Promise<boolean> {\n const response = await httpRequest<{}>( // eslint-disable-line ts/no-empty-object-type\n TINYPNG_API_URL,\n {\n method: 'POST',\n headers: {\n 'Authorization': this.createAuthHeader(key),\n 'Content-Type': 'application/octet-stream',\n },\n body: Buffer.alloc(0),\n },\n )\n\n // Success: 2xx status codes\n if (response.statusCode >= 200 && response.statusCode < 300) {\n return true\n }\n\n // Auth failed: 401/403 return false\n if (response.statusCode === 401 || response.statusCode === 403) {\n return false\n }\n\n // Other 4xx errors return false\n if (response.statusCode >= 400 && response.statusCode < 500) {\n return false\n }\n\n // 5xx errors should be retried\n throw new Error(`TinyPNG 服务器错误: HTTP ${response.statusCode}`)\n }\n\n /**\n * Get the number of compressions used this month for a given API key.\n *\n * @param key - TinyPNG API key\n * @returns Number of compressions used this month\n */\n async getCompressionCount(key: string): Promise<number> {\n const response = await httpRequest<{ compressionCount?: number }>(\n TINYPNG_API_URL,\n {\n method: 'POST',\n headers: {\n 'Authorization': this.createAuthHeader(key),\n 'Content-Type': 'application/octet-stream',\n },\n body: Buffer.alloc(0),\n },\n )\n\n // Success: 2xx status codes\n if (response.statusCode >= 200 && response.statusCode < 300) {\n // Handle undefined compressionCount (TinyPNG API may not return this field)\n return response.data.compressionCount ?? 0\n }\n\n // Auth failed: 401/403 return 0\n if (response.statusCode === 401 || response.statusCode === 403) {\n return 0\n }\n\n // Other 4xx errors return 0\n if (response.statusCode >= 400 && response.statusCode < 500) {\n return 0\n }\n\n // 5xx errors should be retried\n throw new Error(`TinyPNG 服务器错误: HTTP ${response.statusCode}`)\n }\n\n /**\n * Create Basic Auth header for TinyPNG API.\n *\n * @param key - TinyPNG API key\n * @returns Basic Auth header string\n */\n private createAuthHeader(key: string): string {\n const auth = Buffer.from(`api:${key}`).toString('base64')\n return `Basic ${auth}`\n }\n\n /**\n * Upload image to TinyPNG API and get the compressed image URL.\n *\n * @param key - TinyPNG API key\n * @param buffer - Image buffer to upload\n * @returns URL of compressed image and compression count\n */\n private async uploadImage(key: string, buffer: Buffer): Promise<{ url: string, compressionCount: number }> {\n const response = await httpRequest<{ output: { url: string }, compressionCount?: number }>(\n TINYPNG_API_URL,\n {\n method: 'POST',\n headers: {\n 'Authorization': this.createAuthHeader(key),\n 'Content-Type': 'application/octet-stream',\n 'Content-Length': String(buffer.byteLength),\n },\n body: buffer,\n },\n )\n\n if (!response.data.output?.url) {\n // Determine error type based on statusCode\n if (response.statusCode >= 400 && response.statusCode < 500) {\n throw new TinyPngError(\n `TinyPNG 客户端错误: HTTP ${response.statusCode}`,\n response.statusCode,\n 'CLIENT_ERROR',\n )\n }\n if (response.statusCode >= 500) {\n throw new TinyPngError(\n `TinyPNG 服务器错误: HTTP ${response.statusCode}`,\n response.statusCode,\n 'SERVER_ERROR',\n )\n }\n throw new Error('No output URL in response')\n }\n\n // Handle undefined compressionCount (TinyPNG API may not return this field)\n const compressionCount = response.data.compressionCount ?? 0\n\n return {\n url: response.data.output.url,\n compressionCount,\n }\n }\n\n /**\n * Download compressed image from TinyPNG API.\n * Supports following redirects (handled by httpRequest utility).\n *\n * @param url - URL to download from\n * @param key - TinyPNG API key\n * @param compressionCount - Compression count from upload response\n * @returns Compressed image buffer and compression count\n */\n private async downloadImage(url: string, key: string, compressionCount: number): Promise<CompressResult> {\n const response = await httpRequest<Buffer>(\n url,\n {\n method: 'GET',\n headers: {\n Authorization: this.createAuthHeader(key),\n },\n },\n )\n\n return {\n buffer: response.data,\n compressionCount,\n }\n }\n}\n","import { TinyPngHttpClient } from '../compress/http-client'\n\nconst MONTHLY_LIMIT = 500 // Free tier limit\n\n// Compression-count cache (D-12)\nconst compressionCountCache = new Map<string, number>()\n\nexport async function queryQuota(key: string): Promise<number> {\n try {\n const client = new TinyPngHttpClient()\n\n // Check cache first (D-15)\n if (compressionCountCache.has(key)) {\n const usedThisMonth = compressionCountCache.get(key)!\n const remaining = Math.max(0, MONTHLY_LIMIT - usedThisMonth)\n return remaining\n }\n\n // First time: validate key and get compression count (D-13)\n const isValid = await client.validateKey(key)\n if (!isValid) {\n return 0\n }\n\n const usedThisMonth = await client.getCompressionCount(key)\n\n // Cache the result (D-12)\n compressionCountCache.set(key, usedThisMonth)\n\n const remaining = Math.max(0, MONTHLY_LIMIT - usedThisMonth)\n return remaining\n }\n catch {\n // TinyPngHttpClient.validateKey() 对 5xx 抛出异常\n // 保持与旧代码一致的行为:所有错误情况返回 0\n return 0\n }\n}\n\n/**\n * Update the compression-count cache for a given API key.\n * Called after successful compression to keep cache fresh.\n *\n * @param key - API key\n * @param count - New compression count value\n */\nexport function updateCompressionCountCache(key: string, count: number): void {\n compressionCountCache.set(key, count)\n}\n\n/**\n * Get the cached compression count for a given API key.\n * Returns undefined if not cached.\n *\n * @internal\n */\nexport function getCachedCompressionCount(key: string): number | undefined {\n return compressionCountCache.get(key)\n}\n\n/**\n * Clear the compression-count cache.\n * Useful for testing and resetting state.\n *\n * @internal\n */\nexport function clearCompressionCountCache(): void {\n compressionCountCache.clear()\n}\n\nexport interface QuotaTracker {\n key: string\n remaining: number\n localCounter: number\n decrement: () => void\n isZero: () => boolean\n}\n\nexport function createQuotaTracker(key: string, remaining: number): QuotaTracker {\n const localCounter = remaining\n\n return {\n key,\n remaining,\n localCounter,\n decrement() {\n if (this.localCounter > 0) {\n this.localCounter--\n }\n },\n isZero() {\n return this.localCounter === 0\n },\n }\n}\n","export class RetryManager {\n private failureCount = 0\n\n constructor(\n private maxRetries: number = 8,\n private baseDelay: number = 1000, // 1 second\n ) {}\n\n async execute<T>(operation: () => Promise<T>): Promise<T> {\n for (let attempt = 0; attempt <= this.maxRetries; attempt++) {\n try {\n const result = await operation()\n this.failureCount = 0 // Reset on success\n return result\n }\n catch (error) {\n this.failureCount++\n\n if (attempt === this.maxRetries || !this.shouldRetry(error)) {\n throw error\n }\n\n const delay = this.baseDelay * 2 ** attempt\n await this.sleep(delay)\n }\n }\n\n throw new Error('Max retries exceeded')\n }\n\n private shouldRetry(error: any): boolean {\n // Network errors\n if (error.code && ['ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND'].includes(error.code)) {\n return true\n }\n\n // HTTP 5xx server errors\n if (error.statusCode && error.statusCode >= 500 && error.statusCode < 600) {\n return true\n }\n\n // Rate limited (429) should be retried\n if (error.statusCode === 429 || error.errorCode === 'RATE_LIMITED') {\n return true\n }\n\n // Don't retry on other 4xx client errors\n return false\n }\n\n private sleep(ms: number): Promise<void> {\n return new Promise(resolve => setTimeout(resolve, ms))\n }\n\n getFailureCount(): number {\n return this.failureCount\n }\n\n reset(): void {\n this.failureCount = 0\n }\n}\n","import type { Buffer } from 'node:buffer'\nimport type { ICompressor } from './types'\nimport UserAgent from 'user-agents'\nimport { httpRequest } from '../utils/http-request'\nimport { RetryManager } from './retry'\n\nconst TINYPNG_WEB_URL = 'https://tinypng.com/backend/opt/shrink'\n\nexport class TinyPngWebCompressor implements ICompressor {\n private retryManager: RetryManager\n private requestHeaders?: Record<string, string>\n private userAgentGenerator: UserAgent\n\n constructor(maxRetries: number = 8) {\n this.retryManager = new RetryManager(maxRetries)\n // Initialize user-agents with desktop filter (UA-03)\n this.userAgentGenerator = new UserAgent({ deviceCategory: 'desktop' })\n }\n\n async compress(buffer: Buffer): Promise<Buffer> {\n return this.retryManager.execute(async () => {\n // Step 1: Upload image to get compressed URL\n const uploadUrl = await this.uploadToTinyPngWeb(buffer)\n\n // Step 2: Download compressed image\n const compressedBuffer = await this.downloadCompressedImage(uploadUrl)\n\n return compressedBuffer\n })\n }\n\n private getRandomHeaders(): Record<string, string> {\n return {\n 'User-Agent': this.getRandomUserAgent(),\n 'X-Forwarded-For': this.getRandomIPv4(),\n }\n }\n\n private getRandomUserAgent(): string {\n // Generate new random user agent for each request (UA-02)\n const userAgent = this.userAgentGenerator.random()\n return userAgent.toString()\n }\n\n private getRandomIPv4(): string {\n const octet = () => Math.floor(Math.random() * 256)\n return `${octet()}.${octet()}.${octet()}.${octet()}`\n }\n\n private async uploadToTinyPngWeb(buffer: Buffer): Promise<string> {\n // Generate random headers for this request\n this.requestHeaders = this.getRandomHeaders()\n\n const response = await httpRequest<{ output: { url: string } }>(\n TINYPNG_WEB_URL,\n {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/octet-stream',\n 'Content-Length': String(buffer.byteLength),\n ...this.requestHeaders,\n },\n body: buffer,\n },\n )\n\n // Check for HTTP errors (4xx/5xx)\n if (response.statusCode >= 400) {\n const error = new Error(`HTTP ${response.statusCode}: ${JSON.stringify(response.data)}`)\n ;(error as any).statusCode = response.statusCode\n throw error\n }\n\n if (!response.data.output?.url) {\n throw new Error('No output URL in response')\n }\n\n return response.data.output.url\n }\n\n private async downloadCompressedImage(url: string): Promise<Buffer> {\n const response = await httpRequest<Buffer>(\n url,\n {\n method: 'GET',\n headers: {\n 'Content-Type': 'application/octet-stream',\n ...this.requestHeaders,\n },\n },\n )\n\n // Check for HTTP errors (4xx/5xx)\n if (response.statusCode >= 400) {\n const error = new Error(`HTTP ${response.statusCode} downloading compressed image`)\n ;(error as any).statusCode = response.statusCode\n throw error\n }\n\n return response.data\n }\n\n getFailureCount(): number {\n return this.retryManager.getFailureCount()\n }\n}\n","import type { Buffer } from 'node:buffer'\nimport type { KeyPool } from '../keys/pool'\nimport type { ICompressor } from './types'\nimport { updateCompressionCountCache } from '../keys/quota'\nimport { TinyPngHttpClient } from './http-client'\nimport { RetryManager } from './retry'\n\n// Re-export TinyPngWebCompressor for convenience\nexport { TinyPngWebCompressor } from './web-compressor'\n\n// 5MB limit per CONTEXT.md D-09 - design decision, not tinify API limit\n// tinify API supports up to 500MB, but we limit to 5MB for quota management\nconst MAX_FILE_SIZE = 5 * 1024 * 1024\n\nexport class TinyPngApiCompressor implements ICompressor {\n private retryManager: RetryManager\n\n constructor(\n private keyPool: KeyPool,\n maxRetries: number = 8,\n ) {\n this.retryManager = new RetryManager(maxRetries)\n }\n\n async compress(buffer: Buffer): Promise<Buffer> {\n // Check 5MB limit per CONTEXT.md D-09\n if (buffer.byteLength > MAX_FILE_SIZE) {\n throw new Error('File size exceeds 5MB limit')\n }\n\n return this.retryManager.execute(async () => {\n const key = await this.keyPool.selectKey()\n const client = new TinyPngHttpClient()\n\n const { buffer: compressedBuffer, compressionCount } = await client.compress(key, buffer)\n\n // Update compression-count cache (D-14, 解决 warning #2)\n // compressionCount 可能为 undefined(如果 TinyPNG API 不返回该字段)\n if (typeof compressionCount === 'number') {\n updateCompressionCountCache(key, compressionCount)\n }\n\n this.keyPool.decrementQuota()\n\n return compressedBuffer\n })\n }\n\n getFailureCount(): number {\n return this.retryManager.getFailureCount()\n }\n}\n","export class AllKeysExhaustedError extends Error {\n constructor(message = 'All API keys have exhausted quota') {\n super(message)\n this.name = 'AllKeysExhaustedError'\n }\n}\n\nexport class NoValidKeysError extends Error {\n constructor(message = 'No valid API keys available') {\n super(message)\n this.name = 'NoValidKeysError'\n }\n}\n\nexport class AllCompressionFailedError extends Error {\n constructor(message = 'All compression methods failed') {\n super(message)\n this.name = 'AllCompressionFailedError'\n }\n}\n","import type { Buffer } from 'node:buffer'\nimport type { CompressionMode, CompressOptions } from './types'\nimport { AllCompressionFailedError } from '../errors/types'\n/**\n * Compress buffer with automatic fallback through multiple compressors\n *\n * @param buffer - Original image data\n * @param options - Compression options (mode, compressors, maxRetries)\n * @returns Compressed image data\n * @throws AllCompressionFailedError when all compressors fail\n *\n * @example\n * ```ts\n * try {\n * const compressed = await compressWithFallback(buffer, { mode: 'auto' })\n * } catch (error) {\n * if (error instanceof AllCompressionFailedError) {\n * // All compression methods failed\n * }\n * }\n * ```\n */\nexport async function compressWithFallback(\n buffer: Buffer,\n options: CompressOptions = {},\n): Promise<{ buffer: Buffer, compressorName: string }> {\n const compressors = options.compressors ?? []\n\n for (const compressor of compressors) {\n try {\n const result = await compressor.compress(buffer)\n return { buffer: result, compressorName: compressor.constructor.name }\n }\n catch (error: any) {\n // If this is AllCompressionFailedError, propagate immediately\n if (error.name === 'AllCompressionFailedError') {\n throw error\n }\n\n // Otherwise, try next compressor\n continue\n }\n }\n\n throw new AllCompressionFailedError('All compression methods failed')\n}\n\n/**\n * Get default compressor types for a given mode\n * This is a helper - actual compressor instances created in service layer\n *\n * @param mode - Compression mode\n * @returns Compressor type names (not instances)\n *\n * @example\n * ```ts\n * const types = getCompressorTypesForMode('auto')\n * // Returns: ['TinyPngApiCompressor', 'TinyPngWebCompressor']\n * ```\n */\nexport function getCompressorTypesForMode(mode: CompressionMode = 'auto'): string[] {\n switch (mode) {\n case 'api':\n return ['TinyPngApiCompressor']\n case 'web':\n return ['TinyPngWebCompressor']\n case 'auto':\n default:\n return ['TinyPngApiCompressor', 'TinyPngWebCompressor']\n }\n}\n","import pLimit from 'p-limit'\n\n/**\n * Create a concurrency limiter for async operations\n *\n * @param concurrency - Max concurrent operations (default: 8)\n * @returns Limit function that wraps async operations\n *\n * @example\n * ```ts\n * const limit = createConcurrencyLimiter(2)\n * const task1 = limit(() => asyncOperation1())\n * const task2 = limit(() => asyncOperation2())\n * await Promise.all([task1, task2])\n * ```\n */\nexport function createConcurrencyLimiter(concurrency: number = 8) {\n return pLimit(concurrency)\n}\n\n/**\n * Execute tasks with concurrency control\n *\n * @param tasks - Array of async functions to execute\n * @param concurrency - Max concurrent tasks (default: 8)\n * @returns Promise resolving to array of results\n *\n * @example\n * ```ts\n * const tasks = [\n * () => compressImage(buffer1),\n * () => compressImage(buffer2),\n * () => compressImage(buffer3)\n * ]\n * const results = await executeWithConcurrency(tasks, 2)\n * // Only 2 compressions run at a time\n * ```\n */\nexport async function executeWithConcurrency<T>(\n tasks: (() => Promise<T>)[],\n concurrency: number = 8,\n): Promise<T[]> {\n const limit = createConcurrencyLimiter(concurrency)\n\n // Map each task to a limited execution\n const limitedTasks = tasks.map(task => limit(task))\n\n // Wait for all to complete\n return Promise.all(limitedTasks)\n}\n","import type { ConfigFile } from './types'\nimport fs from 'node:fs'\nimport os from 'node:os'\nimport path from 'pathe'\n\nconst CONFIG_DIR = '.tinyimg'\nconst CONFIG_FILE = 'keys.json'\n\nexport function getConfigPath(): string {\n const homeDir = os.homedir()\n return path.join(homeDir, CONFIG_DIR, CONFIG_FILE)\n}\n\nexport function ensureConfigFile(): void {\n const configPath = getConfigPath()\n const configDir = path.dirname(configPath)\n\n if (!fs.existsSync(configDir)) {\n fs.mkdirSync(configDir, { recursive: true, mode: 0o700 })\n }\n\n if (!fs.existsSync(configPath)) {\n const initialContent: ConfigFile = { keys: [] }\n fs.writeFileSync(\n configPath,\n JSON.stringify(initialContent, null, 2),\n { mode: 0o600 },\n )\n }\n}\n\nexport function readConfig(): ConfigFile {\n ensureConfigFile()\n const configPath = getConfigPath()\n const content = fs.readFileSync(configPath, 'utf-8')\n return JSON.parse(content) as ConfigFile\n}\n\nexport function writeConfig(config: ConfigFile): void {\n ensureConfigFile()\n const configPath = getConfigPath()\n fs.writeFileSync(\n configPath,\n JSON.stringify(config, null, 2),\n { mode: 0o600 },\n )\n}\n","import process from 'node:process'\nimport { readConfig } from './storage'\n\nexport interface LoadedKey {\n key: string\n valid?: boolean\n lastCheck?: string\n}\n\nfunction parseEnvVar(value: string | undefined, isMultiple: boolean): string[] | null {\n if (!value?.trim())\n return null\n if (isMultiple) {\n return value.split(',').map(k => k.trim()).filter(k => k.length > 0)\n }\n return [value.trim()]\n}\n\nexport function loadKeys(): LoadedKey[] {\n // Priority 1: TINYIMG_KEYS (highest priority)\n const tinyimgKeys = parseEnvVar(process.env.TINYIMG_KEYS, true)\n if (tinyimgKeys)\n return tinyimgKeys.map(key => ({ key }))\n\n // Priority 2: TINYIMG_KEY\n const tinyimgKey = parseEnvVar(process.env.TINYIMG_KEY, false)\n if (tinyimgKey)\n return tinyimgKey.map(key => ({ key }))\n\n // Priority 3: TINYPNG_KEYS\n const tinypngKeys = parseEnvVar(process.env.TINYPNG_KEYS, true)\n if (tinypngKeys)\n return tinypngKeys.map(key => ({ key }))\n\n // Priority 4: TINYPNG_KEY\n const tinypngKey = parseEnvVar(process.env.TINYPNG_KEY, false)\n if (tinypngKey)\n return tinypngKey.map(key => ({ key }))\n\n // Priority 5: Global config file (lowest priority)\n try {\n const config = readConfig()\n return config.keys.map(metadata => ({\n key: metadata.key,\n valid: metadata.valid,\n lastCheck: metadata.lastCheck,\n }))\n }\n catch {\n // Config file doesn't exist or is invalid\n return []\n }\n}\n","import { TinyPngHttpClient } from '../compress/http-client'\n\nexport async function validateKey(key: string): Promise<boolean> {\n try {\n const client = new TinyPngHttpClient()\n const isValid = await client.validateKey(key)\n\n if (isValid) {\n return true\n }\n else {\n return false\n }\n }\n catch (error: any) {\n // TinyPngHttpClient.validateKey() 对 5xx 抛出错误,对网络错误也抛出\n // 检查是否是认证错误(401/403),如果是则返回 false\n if (error?.statusCode === 401 || error?.statusCode === 403 || error?.errorCode === 'AUTH_FAILED') {\n return false\n }\n // Re-throw network and server errors\n throw error\n }\n}\n","import { createQuotaTracker, queryQuota } from './quota'\nimport { validateKey } from './validator'\n\nexport interface KeySelection {\n key: string\n tracker: ReturnType<typeof createQuotaTracker>\n}\n\n// Strategy 1: Random (default)\nexport class RandomSelector {\n async select(keys: string[]): Promise<KeySelection | null> {\n const available = await this.getAvailableKeys(keys)\n if (available.length === 0)\n return null\n\n const randomIndex = Math.floor(Math.random() * available.length)\n const selected = available[randomIndex]\n return selected\n }\n\n protected async getAvailableKeys(keys: string[]): Promise<KeySelection[]> {\n const available: KeySelection[] = []\n\n for (const key of keys) {\n const isValid = await validateKey(key)\n if (!isValid)\n continue\n\n const remaining = await queryQuota(key)\n if (remaining === 0) {\n continue\n }\n\n available.push({\n key,\n tracker: createQuotaTracker(key, remaining),\n })\n }\n\n return available\n }\n}\n\n// Strategy 2: Round-Robin\nexport class RoundRobinSelector extends RandomSelector {\n private currentIndex = 0\n\n async select(keys: string[]): Promise<KeySelection | null> {\n const available = await this.getAvailableKeys(keys)\n if (available.length === 0)\n return null\n\n const selected = available[this.currentIndex % available.length]\n this.currentIndex++\n return selected\n }\n\n reset(): void {\n this.currentIndex = 0\n }\n}\n\n// Strategy 3: Priority (use first available)\nexport class PrioritySelector extends RandomSelector {\n async select(keys: string[]): Promise<KeySelection | null> {\n const available = await this.getAvailableKeys(keys)\n if (available.length === 0)\n return null\n\n // Return first available key\n return available[0]\n }\n}\n","import type { KeySelection } from './selector'\nimport { loadKeys } from '../config/loader'\nimport { AllKeysExhaustedError, NoValidKeysError } from '../errors/types'\nimport { PrioritySelector, RandomSelector, RoundRobinSelector } from './selector'\n\nexport type KeyStrategy = 'random' | 'round-robin' | 'priority'\n\nexport class KeyPool {\n private keys: string[]\n private selector: RandomSelector | RoundRobinSelector | PrioritySelector\n private currentSelection: KeySelection | null = null\n\n constructor(strategy: KeyStrategy = 'random') {\n this.keys = loadKeys().map(k => k.key)\n\n if (this.keys.length === 0) {\n throw new NoValidKeysError('No API keys configured')\n }\n\n this.selector = this.createSelector(strategy)\n }\n\n private createSelector(strategy: KeyStrategy) {\n switch (strategy) {\n case 'random':\n return new RandomSelector()\n case 'round-robin':\n return new RoundRobinSelector()\n case 'priority':\n return new PrioritySelector()\n default:\n return new RandomSelector()\n }\n }\n\n async selectKey(): Promise<string> {\n // If current key has quota, use it\n if (this.currentSelection && !this.currentSelection.tracker.isZero()) {\n return this.currentSelection.key\n }\n\n // Need to select new key\n const selection = await this.selector.select(this.keys)\n\n if (!selection) {\n throw new AllKeysExhaustedError()\n }\n\n this.currentSelection = selection\n return selection.key\n }\n\n decrementQuota(): void {\n if (this.currentSelection) {\n this.currentSelection.tracker.decrement()\n }\n }\n\n getCurrentKey(): string | null {\n return this.currentSelection?.key ?? null\n }\n}\n","import type { Buffer } from 'node:buffer'\nimport type { CompressOptions, CompressResult, ICompressor } from './types'\nimport process from 'node:process'\nimport { readCacheByHash, writeCacheByHash } from '../cache/buffer-storage'\nimport { calculateMD5FromBuffer } from '../cache/hash'\nimport { getGlobalCachePath, getProjectCachePath } from '../cache/paths'\nimport { KeyPool } from '../keys/pool'\nimport { TinyPngApiCompressor, TinyPngWebCompressor } from './api-compressor'\nimport { compressWithFallback } from './compose'\nimport { createConcurrencyLimiter } from './concurrency'\n\nexport interface CompressServiceOptions extends CompressOptions {\n /**\n * Enable cache (default: true)\n */\n cache?: boolean\n\n /**\n * Use project cache only, ignore global cache (default: false)\n */\n projectCacheOnly?: boolean\n\n /**\n * Concurrency limit for batch operations (default: 8)\n */\n concurrency?: number\n\n /**\n * Optional KeyPool instance for testing or advanced usage\n * If not provided, a new KeyPool will be created with 'random' strategy\n */\n keyPool?: KeyPool\n}\n\n/**\n * Compress a single image with cache integration and fallback\n *\n * @param buffer - Original image data\n * @param options - Compression options\n * @returns Compressed image data\n */\nexport async function compressImage(\n buffer: Buffer,\n options: CompressServiceOptions = {},\n): Promise<CompressResult> {\n const {\n cache = true,\n projectCacheOnly = false,\n mode = 'auto',\n maxRetries = 8,\n } = options\n\n // Step 1: Calculate MD5 for cache key and record original size\n const hash = await calculateMD5FromBuffer(buffer)\n const originalSize = buffer.byteLength\n\n // Step 2: Check cache if enabled\n if (cache) {\n try {\n // Try project cache first\n const projectCachePath = getProjectCachePath(process.cwd())\n const cached = await readCacheByHash(hash, [projectCachePath])\n if (cached) {\n return { buffer: cached, meta: { cached: true, compressorName: null, originalSize, compressedSize: cached.byteLength } }\n }\n\n // Try global cache if not project-only\n if (!projectCacheOnly) {\n const globalCachePath = getGlobalCachePath()\n const globalCached = await readCacheByHash(hash, [globalCachePath])\n if (globalCached) {\n return { buffer: globalCached, meta: { cached: true, compressorName: null, originalSize, compressedSize: globalCached.byteLength } }\n }\n }\n }\n catch {\n // Continue to compression on cache errors\n }\n }\n\n // Step 3: Compress with fallback\n const { buffer: compressed, compressorName } = await compressWithFallback(buffer, {\n mode,\n maxRetries,\n compressors: createCompressors(options),\n })\n\n // Step 4: Write to project cache if enabled\n if (cache) {\n try {\n const projectCachePath = getProjectCachePath(process.cwd())\n await writeCacheByHash(hash, compressed, projectCachePath)\n }\n catch {\n // Don't fail compression on cache write errors\n }\n }\n\n return { buffer: compressed, meta: { cached: false, compressorName, originalSize, compressedSize: compressed.byteLength } }\n}\n\n/**\n * Compress multiple images with concurrency control\n *\n * @param buffers - Array of image buffers\n * @param options - Compression options\n * @returns Array of compressed buffers\n */\nexport async function compressImages(\n buffers: Buffer[],\n options: CompressServiceOptions = {},\n): Promise<CompressResult[]> {\n const { concurrency = 8 } = options\n const limit = createConcurrencyLimiter(concurrency)\n\n const tasks = buffers.map(buffer =>\n limit(() => compressImage(buffer, options)),\n )\n\n return Promise.all(tasks)\n}\n\n/**\n * Create compressor instances based on options\n * Factory function to inject KeyPool for API compressor\n */\nfunction createCompressors(options: CompressServiceOptions): ICompressor[] {\n const { mode = 'auto', maxRetries = 8, keyPool } = options\n const compressors: ICompressor[] = []\n\n // Only create/use KeyPool when mode requires API compression\n const needsApiCompressor = mode === 'auto' || mode === 'api'\n const needsWebCompressor = mode === 'auto' || mode === 'web'\n\n if (needsApiCompressor) {\n const pool = keyPool || new KeyPool('random')\n compressors.push(new TinyPngApiCompressor(pool, maxRetries))\n }\n\n if (needsWebCompressor) {\n compressors.push(new TinyPngWebCompressor(maxRetries))\n }\n\n return compressors\n}\n","import type { DetectOptions } from './types'\nimport sharp from 'sharp'\nimport { createConcurrencyLimiter } from '../compress/concurrency'\n\n/**\n * Detect if a PNG file has alpha channel transparency\n *\n * Uses pixel sampling (not just metadata) to avoid false positives.\n * Strategy:\n * 1. Quick reject: non-PNG format -> false\n * 2. Quick reject: no alpha channel in metadata -> false\n * 3. Pixel sampling: downsample to 100x100 and scan for alpha < 255\n *\n * @param filePath - Path to the image file\n * @param _options - Detection options (reserved for future use)\n * @returns Promise<boolean> - true if PNG has transparent pixels, false otherwise\n */\nexport async function detectAlpha(\n filePath: string,\n _options?: DetectOptions,\n): Promise<boolean> {\n // Get metadata first\n const metadata = await sharp(filePath).metadata()\n\n // Non-PNG: return false (no alpha for non-PNG formats)\n if (metadata.format !== 'png') {\n return false\n }\n\n // Quick reject: no alpha channel in metadata\n if (!metadata.hasAlpha) {\n return false\n }\n\n // Pixel sampling: downsample to small size for performance\n // Resize to max 100x100 (fit inside preserves aspect ratio)\n const { data } = await sharp(filePath)\n .resize(100, 100, { fit: 'inside' })\n .raw()\n .toBuffer({ resolveWithObject: true })\n\n // Scan alpha channel: every 4th byte starting at index 3\n for (let i = 3; i < data.length; i += 4) {\n if (data[i] < 255) {\n return true\n }\n }\n\n return false\n}\n\n/**\n * Detect alpha channel transparency for multiple PNG files\n *\n * @param filePaths - Array of file paths\n * @param options - Detection options including concurrency\n * @returns Promise<Map<string, boolean>> - Map of file paths to transparency results\n */\nexport async function detectAlphas(\n filePaths: string[],\n options?: DetectOptions,\n): Promise<Map<string, boolean>> {\n const { concurrency = 8 } = options ?? {}\n const limit = createConcurrencyLimiter(concurrency)\n\n const tasks = filePaths.map(path =>\n limit(() => detectAlpha(path, options)),\n )\n const results = await Promise.all(tasks)\n return new Map(filePaths.map((path, i) => [path, results[i]]))\n}\n","export function maskKey(key: string): string {\n if (key.length < 8) {\n return '****'\n }\n const first = key.substring(0, 4)\n const last = key.substring(key.length - 4)\n return `${first}****${last}`\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pz4l/tinyimg-core",
3
3
  "type": "module",
4
- "version": "0.3.2",
4
+ "version": "0.3.6",
5
5
  "description": "Core library for TinyPNG image compression with multi-key management, intelligent caching, and fallback strategies",
6
6
  "author": "pzehrel",
7
7
  "license": "MIT",
@@ -46,8 +46,9 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "p-limit": "7.3.0",
49
+ "pathe": "^2.0.3",
49
50
  "sharp": "^0.34.5",
50
- "tinify": "1.8.2"
51
+ "user-agents": "2.1.17"
51
52
  },
52
53
  "scripts": {
53
54
  "build": "tsdown",