@soham20/smart-offline-sdk 0.2.1 → 1.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/src/index.js CHANGED
@@ -1,112 +1,307 @@
1
1
  /**
2
- * SmartOffline SDK
2
+ * SmartOffline SDK - v1.0.0
3
3
  *
4
- * Priority config options:
5
- * - frequencyThreshold: number of accesses before resource is considered "frequent" (default 3)
6
- * - recencyThreshold: milliseconds within which resource is considered "recent" (default 24h)
7
- * - maxResourceSize: max bytes to cache per resource; larger resources skipped (default Infinity)
8
- * - networkQuality: 'auto' | 'fast' | 'slow' — affects caching aggressiveness (default 'auto')
9
- * - significance: { [urlPattern]: 'high' | 'normal' | 'low' } — manual priority overrides
10
- * - weights: { frequency: number, recency: number, size: number } — priority weights (default all 1)
11
- * - customPriorityFn: (usage, url, config) => number — custom priority function (0-100)
12
- * - onCacheEvent: (event) => void — callback for cache events (cache, skip, serve, clear)
4
+ * Complete, reliable offline-first caching SDK for web applications.
5
+ *
6
+ * Usage:
7
+ * ```javascript
8
+ * import { setupSmartOffline, SmartOffline } from '@soham20/smart-offline-sdk'
9
+ *
10
+ * // Initialize early in your app
11
+ * await setupSmartOffline({
12
+ * pages: ['/dashboard/*', '/products/*'],
13
+ * apis: ['/api/v1/*'],
14
+ * debug: true
15
+ * })
16
+ * ```
13
17
  */
14
18
 
19
+ // ============================================================================
20
+ // DEFAULT CONFIGURATION
21
+ // ============================================================================
22
+
23
+ const DEFAULT_CONFIG = {
24
+ pages: [],
25
+ apis: [],
26
+ debug:
27
+ typeof process !== "undefined" && process.env?.NODE_ENV !== "production",
28
+ frequencyThreshold: 3,
29
+ recencyThreshold: 24 * 60 * 60 * 1000, // 24 hours
30
+ maxResourceSize: 10 * 1024 * 1024, // 10MB
31
+ networkQuality: "auto",
32
+ significance: {},
33
+ weights: { frequency: 1, recency: 1, size: 1 },
34
+ customPriorityFn: null,
35
+ enableDetailedLogs: false,
36
+ serviceWorkerPath: "/smart-offline-sw.js",
37
+ serviceWorkerScope: "/",
38
+ };
39
+
40
+ // ============================================================================
41
+ // STATE
42
+ // ============================================================================
43
+
44
+ let isInitialized = false;
45
+ let serviceWorkerRegistration = null;
46
+ let currentConfig = { ...DEFAULT_CONFIG };
47
+
15
48
  // Event listener registry
16
49
  const eventListeners = {
17
50
  cache: [],
18
51
  skip: [],
19
52
  serve: [],
20
53
  clear: [],
21
- error: []
54
+ error: [],
22
55
  };
23
56
 
24
- function init(config = {}) {
25
- if (!("serviceWorker" in navigator)) {
26
- console.warn("Service Workers not supported");
27
- return;
28
- }
29
-
30
- const sdkConfig = {
31
- pages: config.pages || [],
32
- apis: config.apis || [],
33
- debug: config.debug || false,
34
- frequencyThreshold: config.frequencyThreshold ?? 3,
35
- recencyThreshold: config.recencyThreshold ?? 24 * 60 * 60 * 1000,
36
- maxResourceSize: config.maxResourceSize ?? Infinity,
37
- networkQuality: config.networkQuality ?? "auto",
38
- significance: config.significance ?? {},
39
-
40
- // New features
41
- weights: {
42
- frequency: config.weights?.frequency ?? 1,
43
- recency: config.weights?.recency ?? 1,
44
- size: config.weights?.size ?? 1
45
- },
46
- customPriorityFn: config.customPriorityFn ? config.customPriorityFn.toString() : null,
47
- enableDetailedLogs: config.enableDetailedLogs ?? false
48
- };
57
+ // ============================================================================
58
+ // MAIN SETUP FUNCTION
59
+ // ============================================================================
49
60
 
50
- // Register event listener if provided
51
- if (config.onCacheEvent) {
52
- eventListeners.cache.push(config.onCacheEvent);
53
- eventListeners.skip.push(config.onCacheEvent);
54
- eventListeners.serve.push(config.onCacheEvent);
55
- eventListeners.clear.push(config.onCacheEvent);
56
- eventListeners.error.push(config.onCacheEvent);
61
+ /**
62
+ * Initialize the SmartOffline SDK with complete setup
63
+ * @param {Partial<SmartOfflineConfig>} config - Configuration options
64
+ * @returns {Promise<{success: boolean, registration?: ServiceWorkerRegistration, error?: string}>}
65
+ */
66
+ export async function setupSmartOffline(config = {}) {
67
+ // Merge with defaults
68
+ currentConfig = { ...DEFAULT_CONFIG, ...config };
69
+
70
+ // Check if already initialized
71
+ if (isInitialized) {
72
+ if (currentConfig.debug) {
73
+ console.warn("[SmartOffline] Already initialized, updating config...");
74
+ }
75
+ await sendConfigToServiceWorker();
76
+ return { success: true, registration: serviceWorkerRegistration };
57
77
  }
58
78
 
59
- navigator.serviceWorker.register("/smart-offline-sw.js").then((registration) => {
60
- console.log("Smart Offline Service Worker registered");
79
+ // Check browser support
80
+ if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) {
81
+ console.error(
82
+ "[SmartOffline] Service Workers not supported in this browser",
83
+ );
84
+ return { success: false, error: "Service Workers not supported" };
85
+ }
61
86
 
62
- // Listen for messages from SW
63
- navigator.serviceWorker.addEventListener('message', (event) => {
64
- if (event.data && event.data.type) {
65
- const eventType = event.data.type.replace('CACHE_', '').toLowerCase();
66
- const listeners = eventListeners[eventType] || [];
67
- listeners.forEach(fn => fn(event.data));
68
- }
69
- });
87
+ if (typeof window === "undefined" || !("caches" in window)) {
88
+ console.error("[SmartOffline] Cache API not supported in this browser");
89
+ return { success: false, error: "Cache API not supported" };
90
+ }
91
+
92
+ try {
93
+ // Register service worker
94
+ if (currentConfig.debug) {
95
+ console.log("[SmartOffline] Registering service worker...");
96
+ }
97
+
98
+ serviceWorkerRegistration = await navigator.serviceWorker.register(
99
+ currentConfig.serviceWorkerPath,
100
+ { scope: currentConfig.serviceWorkerScope },
101
+ );
102
+
103
+ if (currentConfig.debug) {
104
+ console.log(
105
+ "[SmartOffline] Service worker registered:",
106
+ serviceWorkerRegistration.scope,
107
+ );
108
+ }
109
+
110
+ // Wait for the service worker to be ready
111
+ await navigator.serviceWorker.ready;
112
+
113
+ if (currentConfig.debug) {
114
+ console.log("[SmartOffline] Service worker ready");
115
+ }
116
+
117
+ // Send configuration to service worker
118
+ await sendConfigToServiceWorker();
119
+
120
+ // Set up event listeners
121
+ setupEventListenersInternal();
122
+
123
+ isInitialized = true;
124
+
125
+ if (currentConfig.debug) {
126
+ console.log("[SmartOffline] Setup complete! Configuration:", {
127
+ pages: currentConfig.pages,
128
+ apis: currentConfig.apis,
129
+ frequencyThreshold: currentConfig.frequencyThreshold,
130
+ recencyThreshold: `${currentConfig.recencyThreshold / (60 * 60 * 1000)}h`,
131
+ });
132
+ }
70
133
 
71
- navigator.serviceWorker.ready.then(() => {
72
- if (navigator.serviceWorker.controller) {
73
- navigator.serviceWorker.controller.postMessage({
74
- type: "INIT_CONFIG",
75
- payload: sdkConfig,
76
- });
134
+ return { success: true, registration: serviceWorkerRegistration };
135
+ } catch (error) {
136
+ console.error("[SmartOffline] Setup failed:", error);
137
+ return {
138
+ success: false,
139
+ error: error instanceof Error ? error.message : String(error),
140
+ };
141
+ }
142
+ }
143
+
144
+ // ============================================================================
145
+ // HELPER FUNCTIONS
146
+ // ============================================================================
147
+
148
+ async function sendConfigToServiceWorker() {
149
+ const controller = navigator.serviceWorker.controller;
150
+
151
+ if (!controller) {
152
+ await new Promise((resolve) => setTimeout(resolve, 100));
153
+ const reg = await navigator.serviceWorker.ready;
154
+ const worker = reg.active;
155
+
156
+ if (worker) {
157
+ sendConfigMessage(worker);
158
+ }
159
+ } else {
160
+ sendConfigMessage(controller);
161
+ }
162
+ }
163
+
164
+ function sendConfigMessage(worker) {
165
+ const transferableConfig = {
166
+ ...currentConfig,
167
+ customPriorityFn: currentConfig.customPriorityFn
168
+ ? currentConfig.customPriorityFn.toString()
169
+ : null,
170
+ onCacheEvent: undefined,
171
+ };
172
+
173
+ worker.postMessage({
174
+ type: "INIT_CONFIG",
175
+ payload: transferableConfig,
176
+ });
177
+
178
+ if (currentConfig.debug) {
179
+ console.log("[SmartOffline] Config sent to service worker");
180
+ }
181
+ }
77
182
 
78
- if (sdkConfig.debug) {
79
- console.log("[SmartOffline] Config sent to SW:", sdkConfig);
183
+ function setupEventListenersInternal() {
184
+ navigator.serviceWorker.addEventListener("message", (event) => {
185
+ if (event.data && typeof event.data.type === "string") {
186
+ if (event.data.type.startsWith("CACHE_")) {
187
+ const cacheEvent = {
188
+ type: event.data.type,
189
+ url: event.data.url,
190
+ reason: event.data.reason,
191
+ metadata: event.data.metadata || {},
192
+ timestamp: event.data.timestamp || Date.now(),
193
+ };
194
+
195
+ // Call user callback if provided
196
+ if (currentConfig.onCacheEvent) {
197
+ currentConfig.onCacheEvent(cacheEvent);
80
198
  }
81
- }
82
- });
83
199
 
84
- navigator.serviceWorker.addEventListener("controllerchange", () => {
85
- if (navigator.serviceWorker.controller) {
86
- navigator.serviceWorker.controller.postMessage({
87
- type: "INIT_CONFIG",
88
- payload: sdkConfig,
89
- });
200
+ // Call registered event listeners
201
+ const eventType = cacheEvent.type.replace("CACHE_", "").toLowerCase();
202
+ const listeners = eventListeners[eventType] || [];
203
+ listeners.forEach((fn) => fn(cacheEvent));
204
+
205
+ // Debug logging
206
+ if (currentConfig.debug) {
207
+ const icon = {
208
+ CACHE_CACHE: "💾",
209
+ CACHE_SKIP: "⏭️",
210
+ CACHE_SERVE: "📤",
211
+ CACHE_ERROR: "❌",
212
+ };
90
213
 
91
- if (sdkConfig.debug) {
92
214
  console.log(
93
- "[SmartOffline] Config sent after controllerchange:",
94
- sdkConfig,
215
+ `[SmartOffline] ${icon[cacheEvent.type] || "📝"} ${cacheEvent.type.replace("CACHE_", "")}:`,
216
+ cacheEvent.url.replace(
217
+ typeof window !== "undefined" ? window.location.origin : "",
218
+ "",
219
+ ),
220
+ `(${cacheEvent.reason})`,
95
221
  );
96
222
  }
97
223
  }
98
- });
224
+ }
99
225
  });
100
226
  }
101
227
 
102
- // API for managing event listeners
103
- function on(eventType, callback) {
228
+ function openDB(name, version) {
229
+ return new Promise((resolve, reject) => {
230
+ const request = indexedDB.open(name, version);
231
+ request.onupgradeneeded = () => {
232
+ const db = request.result;
233
+ if (
234
+ name === "smart-offline-logs-v2" &&
235
+ !db.objectStoreNames.contains("logs")
236
+ ) {
237
+ db.createObjectStore("logs", { autoIncrement: true });
238
+ }
239
+ if (
240
+ name === "smart-offline-usage-v2" &&
241
+ !db.objectStoreNames.contains("usage")
242
+ ) {
243
+ db.createObjectStore("usage", { keyPath: "url" });
244
+ }
245
+ };
246
+ request.onsuccess = () => resolve(request.result);
247
+ request.onerror = () => reject(request.error);
248
+ });
249
+ }
250
+
251
+ // ============================================================================
252
+ // PUBLIC API
253
+ // ============================================================================
254
+
255
+ /**
256
+ * Update configuration at runtime
257
+ * @param {Partial<SmartOfflineConfig>} newConfig
258
+ */
259
+ export async function updateConfig(newConfig) {
260
+ currentConfig = { ...currentConfig, ...newConfig };
261
+ await sendConfigToServiceWorker();
262
+ }
263
+
264
+ /**
265
+ * Get current configuration
266
+ * @returns {SmartOfflineConfig}
267
+ */
268
+ export function getConfig() {
269
+ return { ...currentConfig };
270
+ }
271
+
272
+ /**
273
+ * Check if SDK is initialized
274
+ * @returns {boolean}
275
+ */
276
+ export function isSmartOfflineReady() {
277
+ return isInitialized;
278
+ }
279
+
280
+ /**
281
+ * Get service worker registration
282
+ * @returns {ServiceWorkerRegistration | null}
283
+ */
284
+ export function getServiceWorkerRegistration() {
285
+ return serviceWorkerRegistration;
286
+ }
287
+
288
+ /**
289
+ * Add event listener for cache events
290
+ * @param {'cache' | 'skip' | 'serve' | 'clear' | 'error'} eventType
291
+ * @param {(event: CacheEvent) => void} callback
292
+ */
293
+ export function on(eventType, callback) {
104
294
  if (eventListeners[eventType]) {
105
295
  eventListeners[eventType].push(callback);
106
296
  }
107
297
  }
108
298
 
109
- function off(eventType, callback) {
299
+ /**
300
+ * Remove event listener for cache events
301
+ * @param {'cache' | 'skip' | 'serve' | 'clear' | 'error'} eventType
302
+ * @param {(event: CacheEvent) => void} callback
303
+ */
304
+ export function off(eventType, callback) {
110
305
  if (eventListeners[eventType]) {
111
306
  const index = eventListeners[eventType].indexOf(callback);
112
307
  if (index > -1) {
@@ -115,66 +310,528 @@ function off(eventType, callback) {
115
310
  }
116
311
  }
117
312
 
118
- // API to get cache logs
119
- async function getCacheLogs() {
120
- const db = await openDB('smart-offline-logs', 1);
121
- const tx = db.transaction('logs', 'readonly');
122
- const store = tx.objectStore('logs');
123
- return new Promise((resolve) => {
124
- const request = store.getAll();
125
- request.onsuccess = () => resolve(request.result || []);
126
- request.onerror = () => resolve([]);
127
- });
313
+ /**
314
+ * Get cache logs from IndexedDB
315
+ * @returns {Promise<CacheLog[]>}
316
+ */
317
+ export async function getCacheLogs() {
318
+ try {
319
+ const db = await openDB("smart-offline-logs-v2", 1);
320
+ const tx = db.transaction("logs", "readonly");
321
+ const store = tx.objectStore("logs");
322
+ return new Promise((resolve) => {
323
+ const request = store.getAll();
324
+ request.onsuccess = () => resolve(request.result || []);
325
+ request.onerror = () => resolve([]);
326
+ });
327
+ } catch {
328
+ return [];
329
+ }
128
330
  }
129
331
 
130
- // API to clear cache logs
131
- async function clearCacheLogs() {
132
- const db = await openDB('smart-offline-logs', 1);
133
- const tx = db.transaction('logs', 'readwrite');
134
- const store = tx.objectStore('logs');
135
- store.clear();
332
+ /**
333
+ * Clear cache logs from IndexedDB
334
+ */
335
+ export async function clearCacheLogs() {
336
+ try {
337
+ const db = await openDB("smart-offline-logs-v2", 1);
338
+ const tx = db.transaction("logs", "readwrite");
339
+ const store = tx.objectStore("logs");
340
+ store.clear();
341
+ } catch {
342
+ // Ignore errors
343
+ }
136
344
  }
137
345
 
138
- // API to clear all caches
139
- async function clearCache() {
346
+ /**
347
+ * Clear all cached data
348
+ */
349
+ export async function clearAllCache() {
140
350
  const cacheNames = await caches.keys();
141
- const smartOfflineCaches = cacheNames.filter(name => name.includes('smart-offline'));
142
- await Promise.all(smartOfflineCaches.map(name => caches.delete(name)));
143
-
144
- // Also clear usage tracking database
145
- const db = await openDB('smart-offline', 1);
146
- const tx = db.transaction('usage', 'readwrite');
147
- const store = tx.objectStore('usage');
148
- store.clear();
149
-
150
- console.log('🗑️ All SmartOffline caches and usage data cleared');
351
+ for (const name of cacheNames) {
352
+ if (name.startsWith("smart-offline")) {
353
+ await caches.delete(name);
354
+ }
355
+ }
356
+
357
+ const dbNames = ["smart-offline-usage-v2", "smart-offline-logs-v2"];
358
+ for (const dbName of dbNames) {
359
+ try {
360
+ indexedDB.deleteDatabase(dbName);
361
+ } catch {
362
+ // Ignore errors
363
+ }
364
+ }
365
+
366
+ if (currentConfig.debug) {
367
+ console.log("[SmartOffline] All cache data cleared");
368
+ }
151
369
  }
152
370
 
153
- function openDB(name, version) {
154
- return new Promise((resolve, reject) => {
155
- const request = indexedDB.open(name, version);
156
- request.onupgradeneeded = () => {
157
- const db = request.result;
158
- if (name === 'smart-offline-logs' && !db.objectStoreNames.contains('logs')) {
159
- db.createObjectStore('logs', { autoIncrement: true });
371
+ /**
372
+ * Get cache statistics
373
+ * @returns {Promise<{cachedItems: number, trackedUrls: number, cacheSize: number}>}
374
+ */
375
+ export async function getCacheStats() {
376
+ let cachedItems = 0;
377
+ let cacheSize = 0;
378
+ let trackedUrls = 0;
379
+
380
+ try {
381
+ const cache = await caches.open("smart-offline-cache-v2");
382
+ const keys = await cache.keys();
383
+ cachedItems = keys.length;
384
+
385
+ for (const request of keys) {
386
+ const response = await cache.match(request);
387
+ if (response) {
388
+ const size = parseInt(
389
+ response.headers.get("content-length") || "0",
390
+ 10,
391
+ );
392
+ cacheSize += size;
393
+ }
394
+ }
395
+ } catch {
396
+ // Ignore errors
397
+ }
398
+
399
+ try {
400
+ const count = await new Promise((resolve) => {
401
+ const request = indexedDB.open("smart-offline-usage-v2", 1);
402
+ request.onsuccess = () => {
403
+ const db = request.result;
404
+ try {
405
+ const tx = db.transaction("usage", "readonly");
406
+ const store = tx.objectStore("usage");
407
+ const countReq = store.count();
408
+ countReq.onsuccess = () => resolve(countReq.result);
409
+ countReq.onerror = () => resolve(0);
410
+ } catch {
411
+ resolve(0);
412
+ }
413
+ };
414
+ request.onerror = () => resolve(0);
415
+ });
416
+ trackedUrls = count;
417
+ } catch {
418
+ // Ignore errors
419
+ }
420
+
421
+ return { cachedItems, trackedUrls, cacheSize };
422
+ }
423
+
424
+ /**
425
+ * Force update the service worker
426
+ */
427
+ export async function forceUpdate() {
428
+ if (serviceWorkerRegistration) {
429
+ await serviceWorkerRegistration.update();
430
+ if (currentConfig.debug) {
431
+ console.log("[SmartOffline] Service worker update triggered");
432
+ }
433
+ }
434
+ }
435
+
436
+ /**
437
+ * Unregister the service worker and clean up
438
+ */
439
+ export async function uninstall() {
440
+ if (serviceWorkerRegistration) {
441
+ await serviceWorkerRegistration.unregister();
442
+ }
443
+ await clearAllCache();
444
+ isInitialized = false;
445
+ serviceWorkerRegistration = null;
446
+
447
+ if (currentConfig.debug) {
448
+ console.log("[SmartOffline] Uninstalled and cleaned up");
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Initialize the SDK (legacy API)
454
+ * @deprecated Use setupSmartOffline() instead
455
+ * @param {Partial<SmartOfflineConfig>} config
456
+ */
457
+ export function init(config = {}) {
458
+ setupSmartOffline(config);
459
+ }
460
+
461
+ // ============================================================================
462
+ // TEST UTILITIES
463
+ // ============================================================================
464
+
465
+ /**
466
+ * Matches URL against wildcard pattern
467
+ * @param {string} url
468
+ * @param {string} pattern
469
+ * @returns {boolean}
470
+ */
471
+ export function matchesPattern(url, pattern) {
472
+ if (!pattern.includes("*")) {
473
+ return url.includes(pattern);
474
+ }
475
+ const regexPattern = pattern
476
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
477
+ .replace(/\*/g, ".*");
478
+ return new RegExp(regexPattern).test(url);
479
+ }
480
+
481
+ /**
482
+ * Calculates if a URL should be treated as HIGH priority
483
+ * @param {{ url: string; count: number; lastAccessed: number } | null} usage
484
+ * @param {string} url
485
+ * @param {Partial<SmartOfflineConfig>} config
486
+ * @returns {boolean}
487
+ */
488
+ export function isHighPriority(usage, url, config = {}) {
489
+ const finalConfig = { ...DEFAULT_CONFIG, ...config };
490
+
491
+ if (
492
+ finalConfig.customPriorityFn &&
493
+ typeof finalConfig.customPriorityFn === "function"
494
+ ) {
495
+ try {
496
+ return finalConfig.customPriorityFn(usage, url, finalConfig) > 50;
497
+ } catch (e) {
498
+ console.error("Custom priority function error:", e);
499
+ }
500
+ }
501
+
502
+ for (const pattern in finalConfig.significance) {
503
+ if (url.includes(pattern)) {
504
+ if (finalConfig.significance[pattern] === "high") return true;
505
+ if (finalConfig.significance[pattern] === "low") return false;
506
+ }
507
+ }
508
+
509
+ if (!usage) return false;
510
+
511
+ const weights = finalConfig.weights || { frequency: 1, recency: 1, size: 1 };
512
+ const frequencyScore = Math.min(
513
+ 100,
514
+ (usage.count / finalConfig.frequencyThreshold) * 100,
515
+ );
516
+ const timeSinceAccess = Date.now() - usage.lastAccessed;
517
+ const recencyScore = Math.max(
518
+ 0,
519
+ 100 - (timeSinceAccess / finalConfig.recencyThreshold) * 100,
520
+ );
521
+ const totalWeight = weights.frequency + weights.recency;
522
+ const weightedScore =
523
+ (frequencyScore * weights.frequency + recencyScore * weights.recency) /
524
+ totalWeight;
525
+
526
+ return weightedScore > 50;
527
+ }
528
+
529
+ /**
530
+ * Determines if a URL should be cached based on pattern matching
531
+ * @param {string} url
532
+ * @param {Partial<SmartOfflineConfig>} config
533
+ * @returns {{ isPage: boolean; isAPI: boolean }}
534
+ */
535
+ export function shouldCacheUrl(url, config = {}) {
536
+ const pages = config.pages || [];
537
+ const apis = config.apis || [];
538
+ const isPage = pages.some((p) => matchesPattern(url, p));
539
+ const isAPI = apis.some((a) => matchesPattern(url, a));
540
+ return { isPage, isAPI };
541
+ }
542
+
543
+ /**
544
+ * Test suite class for SmartOffline SDK
545
+ */
546
+ export class SmartOfflineTestSuite {
547
+ constructor(config = {}) {
548
+ this.config = { ...DEFAULT_CONFIG, ...config };
549
+ this.results = [];
550
+ }
551
+
552
+ async runAll() {
553
+ this.results = [];
554
+ this.testPatternMatching();
555
+ this.testFrequencyPriority();
556
+ this.testRecencyPriority();
557
+
558
+ if (typeof navigator !== "undefined" && "serviceWorker" in navigator) {
559
+ await this.testServiceWorkerActive();
560
+ await this.testCacheAPIAvailable();
561
+ }
562
+
563
+ return this.results;
564
+ }
565
+
566
+ testPatternMatching() {
567
+ const tests = [
568
+ { url: "/admin/charts", pattern: "/admin/charts", expected: true },
569
+ { url: "/admin/charts/123", pattern: "/admin/charts/*", expected: true },
570
+ { url: "/grapher/gdp", pattern: "/grapher/*", expected: true },
571
+ { url: "/random", pattern: "/admin/*", expected: false },
572
+ ];
573
+
574
+ let passed = true;
575
+ let message = "All patterns matched correctly";
576
+
577
+ for (const test of tests) {
578
+ const result = matchesPattern(test.url, test.pattern);
579
+ if (result !== test.expected) {
580
+ passed = false;
581
+ message = `Pattern ${test.pattern} failed for ${test.url}`;
582
+ break;
160
583
  }
161
- if (name === 'smart-offline' && !db.objectStoreNames.contains('usage')) {
162
- db.createObjectStore('usage', { keyPath: 'url' });
584
+ }
585
+
586
+ this.results.push({ name: "Pattern Matching", passed, message });
587
+ }
588
+
589
+ testFrequencyPriority() {
590
+ const highUsage = { url: "/test", count: 5, lastAccessed: Date.now() };
591
+ const lowUsage = {
592
+ url: "/test",
593
+ count: 1,
594
+ lastAccessed: Date.now() - 25 * 60 * 60 * 1000,
595
+ };
596
+
597
+ const highResult = isHighPriority(highUsage, "/test", this.config);
598
+ const lowResult = isHighPriority(lowUsage, "/test", this.config);
599
+
600
+ this.results.push({
601
+ name: "Frequency Priority",
602
+ passed: highResult === true && lowResult === false,
603
+ message:
604
+ highResult === true && lowResult === false
605
+ ? "High frequency URLs correctly prioritized"
606
+ : `Expected high:true, low:false but got high:${highResult}, low:${lowResult}`,
607
+ });
608
+ }
609
+
610
+ testRecencyPriority() {
611
+ const recentUsage = {
612
+ url: "/test",
613
+ count: 1,
614
+ lastAccessed: Date.now() - 1000,
615
+ };
616
+ const oldUsage = {
617
+ url: "/test",
618
+ count: 1,
619
+ lastAccessed: Date.now() - 48 * 60 * 60 * 1000,
620
+ };
621
+
622
+ const recentResult = isHighPriority(recentUsage, "/test", this.config);
623
+ const oldResult = isHighPriority(oldUsage, "/test", this.config);
624
+
625
+ this.results.push({
626
+ name: "Recency Priority",
627
+ passed: recentResult === true && oldResult === false,
628
+ message:
629
+ recentResult === true && oldResult === false
630
+ ? "Recent URLs correctly prioritized"
631
+ : `Expected recent:true, old:false but got recent:${recentResult}, old:${oldResult}`,
632
+ });
633
+ }
634
+
635
+ async testServiceWorkerActive() {
636
+ try {
637
+ const registration = await navigator.serviceWorker.getRegistration();
638
+ const passed = !!registration?.active;
639
+
640
+ this.results.push({
641
+ name: "Service Worker Active",
642
+ passed,
643
+ message: passed
644
+ ? `Service worker active at scope: ${registration?.scope}`
645
+ : "No active service worker found",
646
+ });
647
+ } catch (e) {
648
+ this.results.push({
649
+ name: "Service Worker Active",
650
+ passed: false,
651
+ message: `Error: ${e}`,
652
+ });
653
+ }
654
+ }
655
+
656
+ async testCacheAPIAvailable() {
657
+ try {
658
+ const cache = await caches.open("smart-offline-test");
659
+ await caches.delete("smart-offline-test");
660
+ this.results.push({
661
+ name: "Cache API Available",
662
+ passed: true,
663
+ message: "Cache API is accessible",
664
+ });
665
+ } catch (e) {
666
+ this.results.push({
667
+ name: "Cache API Available",
668
+ passed: false,
669
+ message: `Error: ${e}`,
670
+ });
671
+ }
672
+ }
673
+
674
+ printResults() {
675
+ console.info("\n========================================");
676
+ console.info(" SmartOffline SDK Test Results");
677
+ console.info("========================================\n");
678
+
679
+ for (const result of this.results) {
680
+ console.info(`${result.passed ? "✅" : "❌"} ${result.name}`);
681
+ console.info(` ${result.message}\n`);
682
+ }
683
+
684
+ const passed = this.results.filter((r) => r.passed).length;
685
+ console.info("----------------------------------------");
686
+ console.info(`Total: ${passed}/${this.results.length} tests passed`);
687
+ console.info("========================================\n");
688
+ }
689
+ }
690
+
691
+ /**
692
+ * Cache inspector for debugging
693
+ */
694
+ export class CacheInspector {
695
+ async getCachedItems() {
696
+ try {
697
+ const cache = await caches.open("smart-offline-cache-v2");
698
+ const keys = await cache.keys();
699
+ const items = [];
700
+ for (const request of keys) {
701
+ const response = await cache.match(request);
702
+ if (response) {
703
+ items.push({
704
+ url: request.url,
705
+ size: parseInt(response.headers.get("content-length") || "0", 10),
706
+ contentType: response.headers.get("content-type") || "unknown",
707
+ });
708
+ }
163
709
  }
710
+ return items;
711
+ } catch {
712
+ return [];
713
+ }
714
+ }
715
+
716
+ async getUsageData() {
717
+ return new Promise((resolve) => {
718
+ try {
719
+ const request = indexedDB.open("smart-offline-usage-v2", 1);
720
+ request.onsuccess = () => {
721
+ const db = request.result;
722
+ try {
723
+ const tx = db.transaction("usage", "readonly");
724
+ const store = tx.objectStore("usage");
725
+ const getAllReq = store.getAll();
726
+ getAllReq.onsuccess = () => resolve(getAllReq.result || []);
727
+ getAllReq.onerror = () => resolve([]);
728
+ } catch {
729
+ resolve([]);
730
+ }
731
+ };
732
+ request.onerror = () => resolve([]);
733
+ } catch {
734
+ resolve([]);
735
+ }
736
+ });
737
+ }
738
+
739
+ async getLogs() {
740
+ return new Promise((resolve) => {
741
+ try {
742
+ const request = indexedDB.open("smart-offline-logs-v2", 1);
743
+ request.onsuccess = () => {
744
+ const db = request.result;
745
+ try {
746
+ const tx = db.transaction("logs", "readonly");
747
+ const store = tx.objectStore("logs");
748
+ const getAllReq = store.getAll();
749
+ getAllReq.onsuccess = () => resolve(getAllReq.result || []);
750
+ getAllReq.onerror = () => resolve([]);
751
+ } catch {
752
+ resolve([]);
753
+ }
754
+ };
755
+ request.onerror = () => resolve([]);
756
+ } catch {
757
+ resolve([]);
758
+ }
759
+ });
760
+ }
761
+
762
+ async showAll() {
763
+ console.info("\n🔍 SmartOffline Cache Inspector\n");
764
+
765
+ const cachedItems = await this.getCachedItems();
766
+ console.info(`📦 Cached Items (${cachedItems.length}):`);
767
+ console.table(cachedItems);
768
+
769
+ const usageData = await this.getUsageData();
770
+ console.info(`\n📊 Usage Tracking (${usageData.length} URLs):`);
771
+ console.table(
772
+ usageData.map((u) => ({
773
+ url: u.url.replace(window.location.origin, ""),
774
+ count: u.count,
775
+ lastAccessed: new Date(u.lastAccessed).toLocaleString(),
776
+ })),
777
+ );
778
+
779
+ const logs = await this.getLogs();
780
+ console.info(`\n📝 Recent Logs (${logs.length}):`);
781
+ console.table(logs.slice(-20));
782
+ }
783
+
784
+ async getAllData() {
785
+ return {
786
+ cachedItems: await this.getCachedItems(),
787
+ usageData: await this.getUsageData(),
788
+ logs: await this.getLogs(),
164
789
  };
165
- request.onsuccess = () => resolve(request.result);
166
- request.onerror = () => reject(request.error);
167
- });
790
+ }
791
+ }
792
+
793
+ /**
794
+ * Run all SmartOffline tests and print results
795
+ * @param {Partial<SmartOfflineConfig>} config
796
+ * @returns {Promise<TestResult[]>}
797
+ */
798
+ export async function runSmartOfflineTests(config) {
799
+ const suite = new SmartOfflineTestSuite(config);
800
+ const results = await suite.runAll();
801
+ suite.printResults();
802
+ return results;
168
803
  }
169
804
 
170
- const SmartOffline = {
171
- init,
172
- on,
173
- off,
174
- getCacheLogs,
805
+ // ============================================================================
806
+ // MAIN EXPORT OBJECT
807
+ // ============================================================================
808
+
809
+ export const SmartOffline = {
810
+ // Main setup
811
+ setup: setupSmartOffline,
812
+ init, // Legacy API
813
+
814
+ // Configuration
815
+ updateConfig,
816
+ getConfig,
817
+
818
+ // Status
819
+ isReady: isSmartOfflineReady,
820
+ getRegistration: getServiceWorkerRegistration,
821
+
822
+ // Event handling
823
+ on,
824
+ off,
825
+
826
+ // Cache management
827
+ clearCache: clearAllCache,
828
+ getStats: getCacheStats,
829
+ getCacheLogs,
175
830
  clearCacheLogs,
176
- clearCache
831
+
832
+ // Lifecycle
833
+ forceUpdate,
834
+ uninstall,
177
835
  };
178
836
 
179
- export { SmartOffline }; // ✅ named export
180
- export default SmartOffline; // ✅ default export
837
+ export default SmartOffline;