@hkdigital/lib-sveltekit 0.1.70 → 0.1.72

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/dist/classes/cache/IndexedDbCache.d.ts +212 -0
  2. package/dist/classes/cache/IndexedDbCache.js +673 -0
  3. package/dist/classes/cache/MemoryResponseCache.d.ts +101 -14
  4. package/dist/classes/cache/MemoryResponseCache.js +97 -12
  5. package/dist/classes/cache/index.d.ts +1 -1
  6. package/dist/classes/cache/index.js +2 -1
  7. package/dist/classes/events/EventEmitter.d.ts +142 -0
  8. package/dist/classes/events/EventEmitter.js +275 -0
  9. package/dist/classes/events/index.d.ts +1 -0
  10. package/dist/classes/events/index.js +2 -0
  11. package/dist/classes/logging/Logger.d.ts +74 -0
  12. package/dist/classes/logging/Logger.js +158 -0
  13. package/dist/classes/logging/constants.d.ts +14 -0
  14. package/dist/classes/logging/constants.js +18 -0
  15. package/dist/classes/logging/index.d.ts +2 -0
  16. package/dist/classes/logging/index.js +4 -0
  17. package/dist/classes/services/ServiceBase.d.ts +153 -0
  18. package/dist/classes/services/ServiceBase.js +409 -0
  19. package/dist/classes/services/ServiceManager.d.ts +350 -0
  20. package/dist/classes/services/ServiceManager.js +1114 -0
  21. package/dist/classes/services/constants.d.ts +11 -0
  22. package/dist/classes/services/constants.js +12 -0
  23. package/dist/classes/services/index.d.ts +3 -0
  24. package/dist/classes/services/index.js +5 -0
  25. package/dist/util/env/index.d.ts +1 -0
  26. package/dist/util/env/index.js +9 -0
  27. package/dist/util/http/caching.js +24 -12
  28. package/dist/util/http/http-request.js +12 -7
  29. package/package.json +2 -1
  30. package/dist/classes/cache/PersistentResponseCache.d.ts +0 -46
  31. /package/dist/classes/cache/{PersistentResponseCache.js → PersistentResponseCache.js__} +0 -0
@@ -0,0 +1,673 @@
1
+ /**
2
+ * @fileoverview IndexedDbCache provides efficient persistent caching with
3
+ * automatic, non-blocking background cleanup.
4
+ *
5
+ * This cache automatically manages storage limits and entry expiration
6
+ * in the background using requestIdleCallback to avoid impacting application
7
+ * performance. It supports incremental cleanup, storage monitoring, and
8
+ * graceful degradation on older browsers.
9
+ *
10
+ * @example
11
+ * // Create a cache instance
12
+ * const cache = new IndexedDbCache({
13
+ * dbName: 'app-cache',
14
+ * storeName: 'http-responses',
15
+ * maxSize: 100 * 1024 * 1024, // 100MB
16
+ * maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days
17
+ * });
18
+ *
19
+ * // Store a response
20
+ * const response = await fetch('https://api.example.com/data');
21
+ * await cache.set('api-data', response, {
22
+ * expiresIn: 3600000 // 1 hour
23
+ * });
24
+ *
25
+ * // Retrieve cached response
26
+ * const cached = await cache.get('api-data');
27
+ * if (cached) {
28
+ * console.log('Cache hit', cached.response);
29
+ * } else {
30
+ * console.log('Cache miss');
31
+ * }
32
+ */
33
+
34
+ /**
35
+ * @typedef {Object} CacheEntry
36
+ * @property {Response} response - Cached Response object
37
+ * @property {Object} metadata - Cache entry metadata
38
+ * @property {string} url - Original URL
39
+ * @property {number} timestamp - When the entry was cached
40
+ * @property {number|null} expires - Expiration timestamp (null if no expiration)
41
+ * @property {string|null} etag - ETag header if present
42
+ * @property {string|null} lastModified - Last-Modified header if present
43
+ */
44
+
45
+ /**
46
+ * IndexedDbCache with automatic background cleanup
47
+ */
48
+ export default class IndexedDbCache {
49
+ /**
50
+ * Create a new IndexedDB cache storage
51
+ *
52
+ * @param {Object} [options] - Cache options
53
+ * @param {string} [options.dbName='http-cache'] - Database name
54
+ * @param {string} [options.storeName='responses'] - Store name
55
+ * @param {number} [options.maxSize=50000000] - Max cache size in bytes (50MB)
56
+ * @param {number} [options.maxAge=604800000] - Max age in ms (7 days)
57
+ * @param {number} [options.cleanupBatchSize=100] - Items per cleanup batch
58
+ * @param {number} [options.cleanupInterval=300000] - Time between cleanup attempts (5min)
59
+ */
60
+ constructor(options = {}) {
61
+ this.dbName = options.dbName || 'http-cache';
62
+ this.storeName = options.storeName || 'responses';
63
+ this.maxSize = options.maxSize || 50 * 1024 * 1024; // 50MB
64
+ this.maxAge = options.maxAge || 7 * 24 * 60 * 60 * 1000; // 7 days
65
+ this.cleanupBatchSize = options.cleanupBatchSize || 100;
66
+ this.cleanupInterval = options.cleanupInterval || 5 * 60 * 1000; // 5 minutes
67
+
68
+ /**
69
+ * Database connection promise
70
+ * @type {Promise<IDBDatabase>}
71
+ * @private
72
+ */
73
+ this.dbPromise = this._openDatabase();
74
+
75
+ /**
76
+ * Cleanup state tracker
77
+ * @type {Object}
78
+ * @private
79
+ */
80
+ this.cleanupState = {
81
+ inProgress: false,
82
+ lastRun: 0,
83
+ lastCursor: null,
84
+ totalRemoved: 0,
85
+ nextScheduled: false
86
+ };
87
+
88
+ // Schedule initial cleanup
89
+ this._scheduleCleanup();
90
+ }
91
+
92
+ /**
93
+ * Open the IndexedDB database
94
+ *
95
+ * @private
96
+ * @returns {Promise<IDBDatabase>}
97
+ */
98
+ async _openDatabase() {
99
+ return new Promise((resolve, reject) => {
100
+ const request = indexedDB.open(this.dbName, 1);
101
+
102
+ request.onerror = () => reject(request.error);
103
+ request.onsuccess = () => resolve(request.result);
104
+
105
+ request.onupgradeneeded = (event) => {
106
+ const db = event.target.result;
107
+ if (!db.objectStoreNames.contains(this.storeName)) {
108
+ // Create object store with indexes to help with cleanup
109
+ const store = db.createObjectStore(this.storeName, { keyPath: 'key' });
110
+
111
+ // Index for expiration-based cleanup
112
+ store.createIndex('expires', 'expires', { unique: false });
113
+
114
+ // Index for age-based cleanup
115
+ store.createIndex('timestamp', 'timestamp', { unique: false });
116
+
117
+ // Index for size-based estimate (rough approximation)
118
+ store.createIndex('size', 'size', { unique: false });
119
+ }
120
+ };
121
+ });
122
+ }
123
+
124
+ /**
125
+ * Get a cached response
126
+ *
127
+ * @param {string} key - Cache key
128
+ * @returns {Promise<CacheEntry|null>} Cache entry or null if not found/expired
129
+ */
130
+ async get(key) {
131
+ try {
132
+ const db = await this.dbPromise;
133
+
134
+ return new Promise((resolve, reject) => {
135
+ const transaction = db.transaction(this.storeName, 'readonly');
136
+ const store = transaction.objectStore(this.storeName);
137
+ const request = store.get(key);
138
+
139
+ request.onerror = () => reject(request.error);
140
+ request.onsuccess = () => {
141
+ const entry = request.result;
142
+
143
+ if (!entry) {
144
+ resolve(null);
145
+ return;
146
+ }
147
+
148
+ // Check if expired
149
+ if (entry.expires && Date.now() > entry.expires) {
150
+ // Delete expired entry
151
+ this._deleteEntry(key).catch(console.error);
152
+ resolve(null);
153
+ return;
154
+ }
155
+
156
+ // Update access timestamp (but don't block)
157
+ this._updateAccessTime(key).catch(console.error);
158
+
159
+ // Deserialize the response
160
+ try {
161
+ // Create headers safely
162
+ let responseHeaders;
163
+ try {
164
+ responseHeaders = new Headers(entry.headers);
165
+ } catch (err) {
166
+ // Fallback for environments without Headers support
167
+ responseHeaders = {
168
+ get: (name) => {
169
+ const header = entry.headers?.find(h => h[0].toLowerCase() === name.toLowerCase());
170
+ return header ? header[1] : null;
171
+ }
172
+ };
173
+ }
174
+
175
+ // Create Response safely
176
+ let response;
177
+ try {
178
+ response = new Response(entry.body, {
179
+ status: entry.status,
180
+ statusText: entry.statusText,
181
+ headers: responseHeaders
182
+ });
183
+ } catch (err) {
184
+ // Simplified mock response for test environments
185
+ response = {
186
+ status: entry.status,
187
+ statusText: entry.statusText,
188
+ headers: responseHeaders,
189
+ body: entry.body,
190
+ url: entry.url,
191
+ clone() { return this; }
192
+ };
193
+ }
194
+
195
+ resolve({
196
+ response,
197
+ metadata: entry.metadata,
198
+ url: entry.url,
199
+ timestamp: entry.timestamp,
200
+ expires: entry.expires,
201
+ etag: entry.etag,
202
+ lastModified: entry.lastModified
203
+ });
204
+ } catch (err) {
205
+ console.error('Failed to deserialize cached response:', err);
206
+
207
+ // Delete corrupted entry
208
+ this._deleteEntry(key).catch(console.error);
209
+ resolve(null);
210
+ }
211
+ };
212
+ });
213
+ } catch (err) {
214
+ console.error('Cache get error:', err);
215
+ return null;
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Store a response in the cache
221
+ *
222
+ * @param {string} key - Cache key
223
+ * @param {Response} response - Response to cache
224
+ * @param {Object} [metadata={}] - Cache metadata
225
+ * @returns {Promise<void>}
226
+ */
227
+ async set(key, response, metadata = {}) {
228
+ try {
229
+ const db = await this.dbPromise;
230
+
231
+ // Clone the response to avoid consuming it
232
+ const clonedResponse = response.clone();
233
+
234
+ // Extract response data - handle both browser Response and test mocks
235
+ let body;
236
+ try {
237
+ // Try standard Response.blob() first (browser environment)
238
+ body = await clonedResponse.blob();
239
+ } catch (err) {
240
+ // Fallback for test environment
241
+ if (typeof clonedResponse.body === 'string' ||
242
+ clonedResponse.body instanceof ArrayBuffer ||
243
+ clonedResponse.body instanceof Uint8Array) {
244
+ body = new Blob([clonedResponse.body]);
245
+ } else {
246
+ // Last resort - store as-is and hope it's serializable
247
+ body = clonedResponse.body || new Blob([]);
248
+ }
249
+ }
250
+
251
+ // Extract headers safely
252
+ let headers = [];
253
+ try {
254
+ headers = Array.from(clonedResponse.headers.entries());
255
+ } catch (err) {
256
+ // Fallback for test environment - extract headers if available
257
+ if (clonedResponse._headers && typeof clonedResponse._headers.entries === 'function') {
258
+ headers = Array.from(clonedResponse._headers.entries());
259
+ }
260
+ }
261
+
262
+ // Calculate rough size estimate
263
+ const headerSize = JSON.stringify(headers).length * 2;
264
+ const size = (body.size || 0) + headerSize + key.length * 2;
265
+
266
+ const entry = {
267
+ key,
268
+ url: clonedResponse.url || '',
269
+ status: clonedResponse.status || 200,
270
+ statusText: clonedResponse.statusText || '',
271
+ headers,
272
+ body,
273
+ metadata,
274
+ timestamp: Date.now(),
275
+ lastAccessed: Date.now(),
276
+ expires: metadata.expires || (metadata.expiresIn ? Date.now() + metadata.expiresIn : null),
277
+ etag: clonedResponse.headers?.get?.('ETag') || null,
278
+ lastModified: clonedResponse.headers?.get?.('Last-Modified') || null,
279
+ size // Store estimated size for cleanup
280
+ };
281
+
282
+ return new Promise((resolve, reject) => {
283
+ const transaction = db.transaction(this.storeName, 'readwrite');
284
+ const store = transaction.objectStore(this.storeName);
285
+ const request = store.put(entry);
286
+
287
+ request.onerror = () => reject(request.error);
288
+ request.onsuccess = () => {
289
+ resolve();
290
+
291
+ // Check if we need cleanup after adding new entries
292
+ this._checkAndScheduleCleanup();
293
+ };
294
+ });
295
+ } catch (err) {
296
+ console.error('Cache set error:', err);
297
+ throw err;
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Update last accessed timestamp (without blocking)
303
+ *
304
+ * @private
305
+ * @param {string} key - Cache key
306
+ * @returns {Promise<void>}
307
+ */
308
+ async _updateAccessTime(key) {
309
+ const db = await this.dbPromise;
310
+
311
+ return new Promise((resolve) => {
312
+ const transaction = db.transaction(this.storeName, 'readwrite');
313
+ const store = transaction.objectStore(this.storeName);
314
+ const request = store.get(key);
315
+
316
+ request.onerror = () => resolve(); // Don't block on errors
317
+
318
+ request.onsuccess = () => {
319
+ const entry = request.result;
320
+ if (!entry) return resolve();
321
+
322
+ entry.lastAccessed = Date.now();
323
+
324
+ const updateRequest = store.put(entry);
325
+ updateRequest.onerror = () => resolve(); // Don't block
326
+ updateRequest.onsuccess = () => resolve();
327
+ };
328
+ });
329
+ }
330
+
331
+ /**
332
+ * Delete a cached entry
333
+ *
334
+ * @param {string} key - Cache key
335
+ * @returns {Promise<boolean>}
336
+ */
337
+ async delete(key) {
338
+ return this._deleteEntry(key);
339
+ }
340
+
341
+ /**
342
+ * Delete a cached entry (internal implementation)
343
+ *
344
+ * @private
345
+ * @param {string} key - Cache key
346
+ * @returns {Promise<boolean>}
347
+ */
348
+ async _deleteEntry(key) {
349
+ try {
350
+ const db = await this.dbPromise;
351
+
352
+ return new Promise((resolve, reject) => {
353
+ const transaction = db.transaction(this.storeName, 'readwrite');
354
+ const store = transaction.objectStore(this.storeName);
355
+ const request = store.delete(key);
356
+
357
+ request.onerror = () => reject(request.error);
358
+ request.onsuccess = () => resolve(true);
359
+ });
360
+ } catch (err) {
361
+ console.error('Cache delete error:', err);
362
+ return false;
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Clear all cached responses
368
+ *
369
+ * @returns {Promise<void>}
370
+ */
371
+ async clear() {
372
+ try {
373
+ const db = await this.dbPromise;
374
+
375
+ return new Promise((resolve, reject) => {
376
+ const transaction = db.transaction(this.storeName, 'readwrite');
377
+ const store = transaction.objectStore(this.storeName);
378
+ const request = store.clear();
379
+
380
+ request.onerror = () => reject(request.error);
381
+ request.onsuccess = () => {
382
+ this.cleanupState.lastCursor = null;
383
+ this.cleanupState.totalRemoved = 0;
384
+ resolve();
385
+ };
386
+ });
387
+ } catch (err) {
388
+ console.error('Cache clear error:', err);
389
+ throw err;
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Check storage usage and schedule cleanup if needed
395
+ *
396
+ * @private
397
+ */
398
+ async _checkAndScheduleCleanup() {
399
+ // Avoid multiple concurrent checks
400
+ if (this.cleanupState.inProgress || this.cleanupState.nextScheduled) {
401
+ return;
402
+ }
403
+
404
+ // Only check periodically
405
+ const now = Date.now();
406
+ if (now - this.cleanupState.lastRun < this.cleanupInterval) {
407
+ return;
408
+ }
409
+
410
+ // Use storage estimate API if available in browser environment
411
+ if (typeof navigator !== 'undefined' &&
412
+ navigator.storage &&
413
+ typeof navigator.storage.estimate === 'function') {
414
+ try {
415
+ const estimate = await navigator.storage.estimate();
416
+ const usageRatio = estimate.usage / estimate.quota;
417
+
418
+ // If using more than 80% of quota, schedule urgent cleanup
419
+ if (usageRatio > 0.8) {
420
+ this._scheduleCleanup(true);
421
+ return;
422
+ }
423
+ } catch (err) {
424
+ // Fall back to regular scheduling if estimate fails
425
+ console.error('Storage estimate error:', err);
426
+ }
427
+ }
428
+
429
+ // Schedule normal cleanup
430
+ this._scheduleCleanup();
431
+ }
432
+
433
+ /**
434
+ * Schedule a cleanup to run during idle time
435
+ *
436
+ * @private
437
+ * @param {boolean} [urgent=false] - If true, clean up sooner
438
+ */
439
+ _scheduleCleanup(urgent = false) {
440
+ if (this.cleanupState.nextScheduled) {
441
+ return;
442
+ }
443
+
444
+ this.cleanupState.nextScheduled = true;
445
+
446
+ // Check if we're in a browser environment with requestIdleCallback
447
+ if (typeof window !== 'undefined' &&
448
+ typeof window.requestIdleCallback === 'function') {
449
+ window.requestIdleCallback(
450
+ () => {
451
+ this.cleanupState.nextScheduled = false;
452
+ this._performCleanupStep();
453
+ },
454
+ { timeout: urgent ? 1000 : 10000 }
455
+ );
456
+ } else {
457
+ // Fallback for Node.js or browsers without requestIdleCallback
458
+ setTimeout(() => {
459
+ this.cleanupState.nextScheduled = false;
460
+ this._performCleanupStep();
461
+ }, urgent ? 100 : 1000);
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Perform a single cleanup step
467
+ *
468
+ * @private
469
+ */
470
+ async _performCleanupStep() {
471
+ if (this.cleanupState.inProgress) {
472
+ return;
473
+ }
474
+
475
+ this.cleanupState.inProgress = true;
476
+
477
+ try {
478
+ const now = Date.now();
479
+ const db = await this.dbPromise;
480
+ let removedCount = 0;
481
+
482
+ // Step 1: Remove expired entries first
483
+ const expiredRemoved = await this._removeExpiredEntries(
484
+ this.cleanupBatchSize / 2
485
+ );
486
+ removedCount += expiredRemoved;
487
+
488
+ // If we have a lot of expired entries, focus on those first
489
+ if (expiredRemoved >= this.cleanupBatchSize / 2) {
490
+ this.cleanupState.inProgress = false;
491
+ this.cleanupState.lastRun = now;
492
+ this.cleanupState.totalRemoved += removedCount;
493
+
494
+ // Schedule next cleanup step immediately
495
+ this._scheduleCleanup();
496
+ return;
497
+ }
498
+
499
+ // Step 2: Remove old entries if we're over size/age limits
500
+ const remainingBatch = this.cleanupBatchSize - expiredRemoved;
501
+ if (remainingBatch > 0) {
502
+ const oldRemoved = await this._removeOldEntries(remainingBatch);
503
+ removedCount += oldRemoved;
504
+ }
505
+
506
+ // Update cleanup state
507
+ this.cleanupState.lastRun = now;
508
+ this.cleanupState.totalRemoved += removedCount;
509
+
510
+ // If we removed entries in this batch, schedule another cleanup
511
+ if (removedCount > 0) {
512
+ this._scheduleCleanup();
513
+ } else {
514
+ // Reset cursor if we didn't find anything to clean
515
+ this.cleanupState.lastCursor = null;
516
+
517
+ // Schedule a check later
518
+ setTimeout(() => {
519
+ this._checkAndScheduleCleanup();
520
+ }, this.cleanupInterval);
521
+ }
522
+ } catch (err) {
523
+ console.error('Cache cleanup error:', err);
524
+ } finally {
525
+ this.cleanupState.inProgress = false;
526
+ }
527
+ }
528
+
529
+ /**
530
+ * Remove expired entries
531
+ *
532
+ * @private
533
+ * @param {number} limit - Maximum number of entries to remove
534
+ * @returns {Promise<number>} Number of entries removed
535
+ */
536
+ async _removeExpiredEntries(limit) {
537
+ const now = Date.now();
538
+ const db = await this.dbPromise;
539
+ let removed = 0;
540
+
541
+ return new Promise((resolve) => {
542
+ const transaction = db.transaction(this.storeName, 'readwrite');
543
+ const store = transaction.objectStore(this.storeName);
544
+ const index = store.index('expires');
545
+
546
+ // Create range for all entries with expiration before now
547
+ const range = IDBKeyRange.upperBound(now);
548
+
549
+ // Skip non-expiring entries (null expiration)
550
+ const request = index.openCursor(range);
551
+
552
+ request.onerror = () => resolve(removed);
553
+
554
+ request.onsuccess = (event) => {
555
+ const cursor = event.target.result;
556
+
557
+ if (!cursor || removed >= limit) {
558
+ resolve(removed);
559
+ return;
560
+ }
561
+
562
+ // Delete the expired entry
563
+ const deleteRequest = cursor.delete();
564
+ deleteRequest.onsuccess = () => {
565
+ removed++;
566
+ };
567
+
568
+ // Move to next entry
569
+ cursor.continue();
570
+ };
571
+ });
572
+ }
573
+
574
+ /**
575
+ * Remove old entries based on age and size constraints
576
+ *
577
+ * @private
578
+ * @param {number} limit - Maximum number of entries to remove
579
+ * @returns {Promise<number>} Number of entries removed
580
+ */
581
+ async _removeOldEntries(limit) {
582
+ const db = await this.dbPromise;
583
+ let removed = 0;
584
+
585
+ // Get total cache size estimate (rough)
586
+ const sizeEstimate = await this._getCacheSizeEstimate();
587
+
588
+ // If we're under limits, don't remove anything
589
+ if (sizeEstimate < this.maxSize) {
590
+ return 0;
591
+ }
592
+
593
+ return new Promise((resolve) => {
594
+ const transaction = db.transaction(this.storeName, 'readwrite');
595
+ const store = transaction.objectStore(this.storeName);
596
+ const index = store.index('timestamp');
597
+
598
+ // Start from the oldest entries
599
+ const request = index.openCursor();
600
+
601
+ request.onerror = () => resolve(removed);
602
+
603
+ request.onsuccess = (event) => {
604
+ const cursor = event.target.result;
605
+
606
+ if (!cursor || removed >= limit) {
607
+ resolve(removed);
608
+ return;
609
+ }
610
+
611
+ const entry = cursor.value;
612
+ const now = Date.now();
613
+ const age = now - entry.timestamp;
614
+
615
+ // Delete if older than max age
616
+ if (age > this.maxAge) {
617
+ const deleteRequest = cursor.delete();
618
+ deleteRequest.onsuccess = () => {
619
+ removed++;
620
+ };
621
+ }
622
+
623
+ // Move to next entry
624
+ cursor.continue();
625
+ };
626
+ });
627
+ }
628
+
629
+ /**
630
+ * Get an estimate of the total cache size
631
+ *
632
+ * @private
633
+ * @returns {Promise<number>} Size estimate in bytes
634
+ */
635
+ async _getCacheSizeEstimate() {
636
+ const db = await this.dbPromise;
637
+
638
+ return new Promise((resolve) => {
639
+ const transaction = db.transaction(this.storeName, 'readonly');
640
+ const store = transaction.objectStore(this.storeName);
641
+ const index = store.index('size');
642
+
643
+ // Get the sum of all entry sizes
644
+ const request = index.openCursor();
645
+ let totalSize = 0;
646
+
647
+ request.onerror = () => resolve(totalSize);
648
+
649
+ request.onsuccess = (event) => {
650
+ const cursor = event.target.result;
651
+
652
+ if (!cursor) {
653
+ resolve(totalSize);
654
+ return;
655
+ }
656
+
657
+ const entry = cursor.value;
658
+ totalSize += entry.size || 0;
659
+
660
+ cursor.continue();
661
+ };
662
+ });
663
+ }
664
+
665
+ /**
666
+ * Close the database connection
667
+ */
668
+ close() {
669
+ this.dbPromise.then(db => {
670
+ db.close();
671
+ }).catch(console.error);
672
+ }
673
+ }