@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 +44 -9
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +333 -185
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -2
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<
|
|
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<
|
|
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<
|
|
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
|
-
|
|
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
|
package/dist/index.d.mts.map
CHANGED
|
@@ -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"
|
|
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 "
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
481
|
-
|
|
669
|
+
const response = await httpRequest(url, {
|
|
670
|
+
method: "GET",
|
|
671
|
+
headers: {
|
|
482
672
|
"Content-Type": "application/octet-stream",
|
|
483
673
|
...this.requestHeaders
|
|
484
|
-
}
|
|
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
|
-
|
|
521
|
-
|
|
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
|
-
|
|
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
|
-
|
|
582
|
-
|
|
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
|
-
|
|
747
|
-
|
|
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?.
|
|
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
|
|
978
|
+
const originalSize = buffer.byteLength;
|
|
849
979
|
if (cache) try {
|
|
850
980
|
const cached = await readCacheByHash(hash, [getProjectCachePath(process.cwd())]);
|
|
851
|
-
if (cached) {
|
|
852
|
-
|
|
853
|
-
|
|
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
|
-
|
|
859
|
-
|
|
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
|
|
863
|
-
|
|
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
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
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
|
-
|
|
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
|
package/dist/index.mjs.map
CHANGED
|
@@ -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.
|
|
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
|
-
"
|
|
51
|
+
"user-agents": "2.1.17"
|
|
51
52
|
},
|
|
52
53
|
"scripts": {
|
|
53
54
|
"build": "tsdown",
|