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