@soham20/smart-offline-sdk 0.2.1 → 1.0.1

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,510 @@
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
- }
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
+ printStartupBanner();
96
+ console.log("%c🔧 Registering service worker...", "color: #94a3b8;");
97
+ }
98
+
99
+ serviceWorkerRegistration = await navigator.serviceWorker.register(
100
+ currentConfig.serviceWorkerPath,
101
+ { scope: currentConfig.serviceWorkerScope },
102
+ );
103
+
104
+ if (currentConfig.debug) {
105
+ console.log(
106
+ "%c✓ Service worker registered", "color: #22c55e;",
107
+ `(scope: ${serviceWorkerRegistration.scope})`
108
+ );
109
+ }
110
+
111
+ // Wait for the service worker to be ready
112
+ await navigator.serviceWorker.ready;
113
+
114
+ if (currentConfig.debug) {
115
+ console.log("%c✓ Service worker ready", "color: #22c55e;");
116
+ }
117
+
118
+ // Send configuration to service worker
119
+ await sendConfigToServiceWorker();
120
+
121
+ // Set up event listeners
122
+ setupEventListenersInternal();
123
+
124
+ isInitialized = true;
125
+
126
+ if (currentConfig.debug) {
127
+ printConfigSummary();
128
+ }
129
+
130
+ return { success: true, registration: serviceWorkerRegistration };
131
+ } catch (error) {
132
+ console.error("[SmartOffline] Setup failed:", error);
133
+ return {
134
+ success: false,
135
+ error: error instanceof Error ? error.message : String(error),
136
+ };
137
+ }
138
+ }
139
+
140
+ // ============================================================================
141
+ // HELPER FUNCTIONS
142
+ // ============================================================================
143
+
144
+ /**
145
+ * Print startup banner
146
+ */
147
+ function printStartupBanner() {
148
+ console.log(
149
+ `%c
150
+ ╔═══════════════════════════════════════════════════════════╗
151
+ ║ ║
152
+ ║ 🚀 SmartOffline SDK v1.0.0 ║
153
+ ║ Intelligent offline-first caching ║
154
+ ║ ║
155
+ ╚═══════════════════════════════════════════════════════════╝
156
+ `,
157
+ "color: #3b82f6; font-weight: bold;"
158
+ );
159
+ }
160
+
161
+ /**
162
+ * Print configuration summary
163
+ */
164
+ function printConfigSummary() {
165
+ console.log(
166
+ `%c━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`,
167
+ "color: #475569;"
168
+ );
169
+ console.log(
170
+ `%c📋 Configuration Summary`,
171
+ "color: #22c55e; font-weight: bold; font-size: 14px;"
172
+ );
173
+ console.log(
174
+ `%c━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`,
175
+ "color: #475569;"
176
+ );
177
+
178
+ // Pages
179
+ console.log("%c📄 Pages to cache:", "color: #60a5fa; font-weight: bold;");
180
+ if (currentConfig.pages.length > 0) {
181
+ currentConfig.pages.forEach((p) =>
182
+ console.log(` %c• ${p}`, "color: #94a3b8;")
183
+ );
184
+ } else {
185
+ console.log(" %c(none)", "color: #64748b; font-style: italic;");
186
+ }
187
+
188
+ // APIs
189
+ console.log("%c🔌 APIs to cache:", "color: #60a5fa; font-weight: bold;");
190
+ if (currentConfig.apis.length > 0) {
191
+ currentConfig.apis.forEach((a) =>
192
+ console.log(` %c• ${a}`, "color: #94a3b8;")
193
+ );
194
+ } else {
195
+ console.log(" %c(none)", "color: #64748b; font-style: italic;");
196
+ }
197
+
198
+ // Priority settings
199
+ console.log("%c⚡ Priority settings:", "color: #60a5fa; font-weight: bold;");
200
+ console.log(
201
+ ` %cFrequency threshold: ${currentConfig.frequencyThreshold} accesses`,
202
+ "color: #94a3b8;"
203
+ );
204
+ console.log(
205
+ ` %cRecency threshold: ${currentConfig.recencyThreshold / (60 * 60 * 1000)}h`,
206
+ "color: #94a3b8;"
207
+ );
208
+ console.log(
209
+ ` %cMax resource size: ${formatBytes(currentConfig.maxResourceSize)}`,
210
+ "color: #94a3b8;"
211
+ );
212
+ console.log(
213
+ ` %cNetwork quality: ${currentConfig.networkQuality}`,
214
+ "color: #94a3b8;"
215
+ );
216
+
217
+ // Significance overrides
218
+ const sigKeys = Object.keys(currentConfig.significance || {});
219
+ if (sigKeys.length > 0) {
220
+ console.log(
221
+ "%c🎯 Significance overrides:",
222
+ "color: #60a5fa; font-weight: bold;"
223
+ );
224
+ sigKeys.forEach((key) => {
225
+ const val = currentConfig.significance[key];
226
+ const icon = val === "high" ? "🔴" : val === "low" ? "🔵" : "⚪";
227
+ console.log(` %c${icon} ${key} → ${val}`, "color: #94a3b8;");
69
228
  });
229
+ }
230
+
231
+ console.log(
232
+ `%c━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`,
233
+ "color: #475569;"
234
+ );
235
+ console.log(
236
+ "%c✅ SmartOffline is now active! Cache events will appear below.",
237
+ "color: #22c55e; font-weight: bold;"
238
+ );
239
+ console.log(
240
+ `%c━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`,
241
+ "color: #475569;"
242
+ );
243
+ console.log(""); // Empty line for spacing
244
+ }
245
+
246
+ async function sendConfigToServiceWorker() {
247
+ const controller = navigator.serviceWorker.controller;
248
+
249
+ if (!controller) {
250
+ await new Promise((resolve) => setTimeout(resolve, 100));
251
+ const reg = await navigator.serviceWorker.ready;
252
+ const worker = reg.active;
253
+
254
+ if (worker) {
255
+ sendConfigMessage(worker);
256
+ }
257
+ } else {
258
+ sendConfigMessage(controller);
259
+ }
260
+ }
261
+
262
+ function sendConfigMessage(worker) {
263
+ const transferableConfig = {
264
+ ...currentConfig,
265
+ customPriorityFn: currentConfig.customPriorityFn
266
+ ? currentConfig.customPriorityFn.toString()
267
+ : null,
268
+ onCacheEvent: undefined,
269
+ };
270
+
271
+ worker.postMessage({
272
+ type: "INIT_CONFIG",
273
+ payload: transferableConfig,
274
+ });
275
+
276
+ if (currentConfig.debug) {
277
+ console.log("[SmartOffline] Config sent to service worker");
278
+ }
279
+ }
70
280
 
71
- navigator.serviceWorker.ready.then(() => {
72
- if (navigator.serviceWorker.controller) {
73
- navigator.serviceWorker.controller.postMessage({
74
- type: "INIT_CONFIG",
75
- payload: sdkConfig,
76
- });
281
+ function setupEventListenersInternal() {
282
+ navigator.serviceWorker.addEventListener("message", (event) => {
283
+ if (event.data && typeof event.data.type === "string") {
284
+ if (event.data.type.startsWith("CACHE_")) {
285
+ const cacheEvent = {
286
+ type: event.data.type,
287
+ url: event.data.url,
288
+ reason: event.data.reason,
289
+ metadata: event.data.metadata || {},
290
+ timestamp: event.data.timestamp || Date.now(),
291
+ };
77
292
 
78
- if (sdkConfig.debug) {
79
- console.log("[SmartOffline] Config sent to SW:", sdkConfig);
293
+ // Call user callback if provided
294
+ if (currentConfig.onCacheEvent) {
295
+ currentConfig.onCacheEvent(cacheEvent);
80
296
  }
81
- }
82
- });
83
297
 
84
- navigator.serviceWorker.addEventListener("controllerchange", () => {
85
- if (navigator.serviceWorker.controller) {
86
- navigator.serviceWorker.controller.postMessage({
87
- type: "INIT_CONFIG",
88
- payload: sdkConfig,
89
- });
90
-
91
- if (sdkConfig.debug) {
92
- console.log(
93
- "[SmartOffline] Config sent after controllerchange:",
94
- sdkConfig,
95
- );
298
+ // Call registered event listeners
299
+ const eventType = cacheEvent.type.replace("CACHE_", "").toLowerCase();
300
+ const listeners = eventListeners[eventType] || [];
301
+ listeners.forEach((fn) => fn(cacheEvent));
302
+
303
+ // Rich debug logging with colors
304
+ if (currentConfig.debug) {
305
+ logCacheEventToConsole(cacheEvent);
96
306
  }
97
307
  }
308
+ }
309
+ });
310
+ }
311
+
312
+ /**
313
+ * Rich console logging for cache events with colors and formatting
314
+ */
315
+ function logCacheEventToConsole(cacheEvent) {
316
+ const origin = typeof window !== "undefined" ? window.location.origin : "";
317
+ const shortUrl = cacheEvent.url.replace(origin, "") || cacheEvent.url;
318
+ const time = new Date(cacheEvent.timestamp).toLocaleTimeString();
319
+
320
+ // Style configurations for each event type
321
+ const styles = {
322
+ CACHE_INTERCEPT: {
323
+ icon: "🔍",
324
+ label: "INTERCEPT",
325
+ bgColor: "#6366f1",
326
+ textColor: "#fff",
327
+ },
328
+ CACHE_CACHE: {
329
+ icon: "💾",
330
+ label: "CACHED",
331
+ bgColor: "#22c55e",
332
+ textColor: "#fff",
333
+ },
334
+ CACHE_SKIP: {
335
+ icon: "⏭️",
336
+ label: "SKIPPED",
337
+ bgColor: "#f59e0b",
338
+ textColor: "#000",
339
+ },
340
+ CACHE_SERVE: {
341
+ icon: "📤",
342
+ label: "SERVED",
343
+ bgColor: "#3b82f6",
344
+ textColor: "#fff",
345
+ },
346
+ CACHE_ERROR: {
347
+ icon: "❌",
348
+ label: "MISS",
349
+ bgColor: "#ef4444",
350
+ textColor: "#fff",
351
+ },
352
+ };
353
+
354
+ const style = styles[cacheEvent.type] || {
355
+ icon: "📝",
356
+ label: "EVENT",
357
+ bgColor: "#64748b",
358
+ textColor: "#fff",
359
+ };
360
+
361
+ // Build metadata string
362
+ let metaInfo = "";
363
+ if (cacheEvent.metadata) {
364
+ const meta = cacheEvent.metadata;
365
+ const parts = [];
366
+ if (meta.type) parts.push(`type: ${meta.type}`);
367
+ if (meta.priority) parts.push(`priority: ${meta.priority}`);
368
+ if (meta.size) parts.push(`size: ${formatBytes(meta.size)}`);
369
+ if (meta.networkQuality) parts.push(`network: ${meta.networkQuality}`);
370
+ if (meta.isPage !== undefined) parts.push(meta.isPage ? "PAGE" : "API");
371
+ if (parts.length > 0) metaInfo = ` [${parts.join(", ")}]`;
372
+ }
373
+
374
+ // Reason badge
375
+ const reasonText = cacheEvent.reason ? ` → ${formatReason(cacheEvent.reason)}` : "";
376
+
377
+ // Log with styling
378
+ console.log(
379
+ `%c ${style.icon} SmartOffline %c ${style.label} %c ${time} %c ${shortUrl}${reasonText}${metaInfo}`,
380
+ `background: #1e293b; color: #fff; padding: 2px 6px; border-radius: 3px 0 0 3px; font-weight: bold;`,
381
+ `background: ${style.bgColor}; color: ${style.textColor}; padding: 2px 8px; font-weight: bold;`,
382
+ `background: #334155; color: #94a3b8; padding: 2px 6px;`,
383
+ `background: transparent; color: #e2e8f0; padding: 2px 6px;`
384
+ );
385
+
386
+ // For cached items, show additional details in a group
387
+ if (cacheEvent.type === "CACHE_CACHE" && cacheEvent.metadata) {
388
+ console.groupCollapsed(` ↳ Details for ${shortUrl}`);
389
+ console.table({
390
+ URL: shortUrl,
391
+ Type: cacheEvent.metadata.type || "unknown",
392
+ Priority: cacheEvent.metadata.priority || "normal",
393
+ Size: cacheEvent.metadata.size ? formatBytes(cacheEvent.metadata.size) : "unknown",
394
+ Timestamp: new Date(cacheEvent.timestamp).toISOString(),
395
+ });
396
+ console.groupEnd();
397
+ }
398
+
399
+ // For serve events, show usage data if available
400
+ if (cacheEvent.type === "CACHE_SERVE" && cacheEvent.metadata?.usage) {
401
+ console.groupCollapsed(` ↳ Usage data for ${shortUrl}`);
402
+ console.table({
403
+ "Access Count": cacheEvent.metadata.usage.count,
404
+ "Last Accessed": new Date(cacheEvent.metadata.usage.lastAccessed).toISOString(),
405
+ Priority: cacheEvent.metadata.priority || "normal",
98
406
  });
407
+ console.groupEnd();
408
+ }
409
+ }
410
+
411
+ /**
412
+ * Format bytes to human readable string
413
+ */
414
+ function formatBytes(bytes) {
415
+ if (bytes === 0) return "0 B";
416
+ const k = 1024;
417
+ const sizes = ["B", "KB", "MB", "GB"];
418
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
419
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
420
+ }
421
+
422
+ /**
423
+ * Format reason string to be more readable
424
+ */
425
+ function formatReason(reason) {
426
+ return reason
427
+ .replace(/_/g, " ")
428
+ .replace(/\b\w/g, (l) => l.toUpperCase());
429
+ }
430
+
431
+ function openDB(name, version) {
432
+ return new Promise((resolve, reject) => {
433
+ const request = indexedDB.open(name, version);
434
+ request.onupgradeneeded = () => {
435
+ const db = request.result;
436
+ if (
437
+ name === "smart-offline-logs-v2" &&
438
+ !db.objectStoreNames.contains("logs")
439
+ ) {
440
+ db.createObjectStore("logs", { autoIncrement: true });
441
+ }
442
+ if (
443
+ name === "smart-offline-usage-v2" &&
444
+ !db.objectStoreNames.contains("usage")
445
+ ) {
446
+ db.createObjectStore("usage", { keyPath: "url" });
447
+ }
448
+ };
449
+ request.onsuccess = () => resolve(request.result);
450
+ request.onerror = () => reject(request.error);
99
451
  });
100
452
  }
101
453
 
102
- // API for managing event listeners
103
- function on(eventType, callback) {
454
+ // ============================================================================
455
+ // PUBLIC API
456
+ // ============================================================================
457
+
458
+ /**
459
+ * Update configuration at runtime
460
+ * @param {Partial<SmartOfflineConfig>} newConfig
461
+ */
462
+ export async function updateConfig(newConfig) {
463
+ currentConfig = { ...currentConfig, ...newConfig };
464
+ await sendConfigToServiceWorker();
465
+ }
466
+
467
+ /**
468
+ * Get current configuration
469
+ * @returns {SmartOfflineConfig}
470
+ */
471
+ export function getConfig() {
472
+ return { ...currentConfig };
473
+ }
474
+
475
+ /**
476
+ * Check if SDK is initialized
477
+ * @returns {boolean}
478
+ */
479
+ export function isSmartOfflineReady() {
480
+ return isInitialized;
481
+ }
482
+
483
+ /**
484
+ * Get service worker registration
485
+ * @returns {ServiceWorkerRegistration | null}
486
+ */
487
+ export function getServiceWorkerRegistration() {
488
+ return serviceWorkerRegistration;
489
+ }
490
+
491
+ /**
492
+ * Add event listener for cache events
493
+ * @param {'cache' | 'skip' | 'serve' | 'clear' | 'error'} eventType
494
+ * @param {(event: CacheEvent) => void} callback
495
+ */
496
+ export function on(eventType, callback) {
104
497
  if (eventListeners[eventType]) {
105
498
  eventListeners[eventType].push(callback);
106
499
  }
107
500
  }
108
501
 
109
- function off(eventType, callback) {
502
+ /**
503
+ * Remove event listener for cache events
504
+ * @param {'cache' | 'skip' | 'serve' | 'clear' | 'error'} eventType
505
+ * @param {(event: CacheEvent) => void} callback
506
+ */
507
+ export function off(eventType, callback) {
110
508
  if (eventListeners[eventType]) {
111
509
  const index = eventListeners[eventType].indexOf(callback);
112
510
  if (index > -1) {
@@ -115,66 +513,528 @@ function off(eventType, callback) {
115
513
  }
116
514
  }
117
515
 
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
- });
516
+ /**
517
+ * Get cache logs from IndexedDB
518
+ * @returns {Promise<CacheLog[]>}
519
+ */
520
+ export async function getCacheLogs() {
521
+ try {
522
+ const db = await openDB("smart-offline-logs-v2", 1);
523
+ const tx = db.transaction("logs", "readonly");
524
+ const store = tx.objectStore("logs");
525
+ return new Promise((resolve) => {
526
+ const request = store.getAll();
527
+ request.onsuccess = () => resolve(request.result || []);
528
+ request.onerror = () => resolve([]);
529
+ });
530
+ } catch {
531
+ return [];
532
+ }
128
533
  }
129
534
 
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();
535
+ /**
536
+ * Clear cache logs from IndexedDB
537
+ */
538
+ export async function clearCacheLogs() {
539
+ try {
540
+ const db = await openDB("smart-offline-logs-v2", 1);
541
+ const tx = db.transaction("logs", "readwrite");
542
+ const store = tx.objectStore("logs");
543
+ store.clear();
544
+ } catch {
545
+ // Ignore errors
546
+ }
136
547
  }
137
548
 
138
- // API to clear all caches
139
- async function clearCache() {
549
+ /**
550
+ * Clear all cached data
551
+ */
552
+ export async function clearAllCache() {
140
553
  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');
554
+ for (const name of cacheNames) {
555
+ if (name.startsWith("smart-offline")) {
556
+ await caches.delete(name);
557
+ }
558
+ }
559
+
560
+ const dbNames = ["smart-offline-usage-v2", "smart-offline-logs-v2"];
561
+ for (const dbName of dbNames) {
562
+ try {
563
+ indexedDB.deleteDatabase(dbName);
564
+ } catch {
565
+ // Ignore errors
566
+ }
567
+ }
568
+
569
+ if (currentConfig.debug) {
570
+ console.log("[SmartOffline] All cache data cleared");
571
+ }
151
572
  }
152
573
 
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 });
574
+ /**
575
+ * Get cache statistics
576
+ * @returns {Promise<{cachedItems: number, trackedUrls: number, cacheSize: number}>}
577
+ */
578
+ export async function getCacheStats() {
579
+ let cachedItems = 0;
580
+ let cacheSize = 0;
581
+ let trackedUrls = 0;
582
+
583
+ try {
584
+ const cache = await caches.open("smart-offline-cache-v2");
585
+ const keys = await cache.keys();
586
+ cachedItems = keys.length;
587
+
588
+ for (const request of keys) {
589
+ const response = await cache.match(request);
590
+ if (response) {
591
+ const size = parseInt(
592
+ response.headers.get("content-length") || "0",
593
+ 10,
594
+ );
595
+ cacheSize += size;
596
+ }
597
+ }
598
+ } catch {
599
+ // Ignore errors
600
+ }
601
+
602
+ try {
603
+ const count = await new Promise((resolve) => {
604
+ const request = indexedDB.open("smart-offline-usage-v2", 1);
605
+ request.onsuccess = () => {
606
+ const db = request.result;
607
+ try {
608
+ const tx = db.transaction("usage", "readonly");
609
+ const store = tx.objectStore("usage");
610
+ const countReq = store.count();
611
+ countReq.onsuccess = () => resolve(countReq.result);
612
+ countReq.onerror = () => resolve(0);
613
+ } catch {
614
+ resolve(0);
615
+ }
616
+ };
617
+ request.onerror = () => resolve(0);
618
+ });
619
+ trackedUrls = count;
620
+ } catch {
621
+ // Ignore errors
622
+ }
623
+
624
+ return { cachedItems, trackedUrls, cacheSize };
625
+ }
626
+
627
+ /**
628
+ * Force update the service worker
629
+ */
630
+ export async function forceUpdate() {
631
+ if (serviceWorkerRegistration) {
632
+ await serviceWorkerRegistration.update();
633
+ if (currentConfig.debug) {
634
+ console.log("[SmartOffline] Service worker update triggered");
635
+ }
636
+ }
637
+ }
638
+
639
+ /**
640
+ * Unregister the service worker and clean up
641
+ */
642
+ export async function uninstall() {
643
+ if (serviceWorkerRegistration) {
644
+ await serviceWorkerRegistration.unregister();
645
+ }
646
+ await clearAllCache();
647
+ isInitialized = false;
648
+ serviceWorkerRegistration = null;
649
+
650
+ if (currentConfig.debug) {
651
+ console.log("[SmartOffline] Uninstalled and cleaned up");
652
+ }
653
+ }
654
+
655
+ /**
656
+ * Initialize the SDK (legacy API)
657
+ * @deprecated Use setupSmartOffline() instead
658
+ * @param {Partial<SmartOfflineConfig>} config
659
+ */
660
+ export function init(config = {}) {
661
+ setupSmartOffline(config);
662
+ }
663
+
664
+ // ============================================================================
665
+ // TEST UTILITIES
666
+ // ============================================================================
667
+
668
+ /**
669
+ * Matches URL against wildcard pattern
670
+ * @param {string} url
671
+ * @param {string} pattern
672
+ * @returns {boolean}
673
+ */
674
+ export function matchesPattern(url, pattern) {
675
+ if (!pattern.includes("*")) {
676
+ return url.includes(pattern);
677
+ }
678
+ const regexPattern = pattern
679
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
680
+ .replace(/\*/g, ".*");
681
+ return new RegExp(regexPattern).test(url);
682
+ }
683
+
684
+ /**
685
+ * Calculates if a URL should be treated as HIGH priority
686
+ * @param {{ url: string; count: number; lastAccessed: number } | null} usage
687
+ * @param {string} url
688
+ * @param {Partial<SmartOfflineConfig>} config
689
+ * @returns {boolean}
690
+ */
691
+ export function isHighPriority(usage, url, config = {}) {
692
+ const finalConfig = { ...DEFAULT_CONFIG, ...config };
693
+
694
+ if (
695
+ finalConfig.customPriorityFn &&
696
+ typeof finalConfig.customPriorityFn === "function"
697
+ ) {
698
+ try {
699
+ return finalConfig.customPriorityFn(usage, url, finalConfig) > 50;
700
+ } catch (e) {
701
+ console.error("Custom priority function error:", e);
702
+ }
703
+ }
704
+
705
+ for (const pattern in finalConfig.significance) {
706
+ if (url.includes(pattern)) {
707
+ if (finalConfig.significance[pattern] === "high") return true;
708
+ if (finalConfig.significance[pattern] === "low") return false;
709
+ }
710
+ }
711
+
712
+ if (!usage) return false;
713
+
714
+ const weights = finalConfig.weights || { frequency: 1, recency: 1, size: 1 };
715
+ const frequencyScore = Math.min(
716
+ 100,
717
+ (usage.count / finalConfig.frequencyThreshold) * 100,
718
+ );
719
+ const timeSinceAccess = Date.now() - usage.lastAccessed;
720
+ const recencyScore = Math.max(
721
+ 0,
722
+ 100 - (timeSinceAccess / finalConfig.recencyThreshold) * 100,
723
+ );
724
+ const totalWeight = weights.frequency + weights.recency;
725
+ const weightedScore =
726
+ (frequencyScore * weights.frequency + recencyScore * weights.recency) /
727
+ totalWeight;
728
+
729
+ return weightedScore > 50;
730
+ }
731
+
732
+ /**
733
+ * Determines if a URL should be cached based on pattern matching
734
+ * @param {string} url
735
+ * @param {Partial<SmartOfflineConfig>} config
736
+ * @returns {{ isPage: boolean; isAPI: boolean }}
737
+ */
738
+ export function shouldCacheUrl(url, config = {}) {
739
+ const pages = config.pages || [];
740
+ const apis = config.apis || [];
741
+ const isPage = pages.some((p) => matchesPattern(url, p));
742
+ const isAPI = apis.some((a) => matchesPattern(url, a));
743
+ return { isPage, isAPI };
744
+ }
745
+
746
+ /**
747
+ * Test suite class for SmartOffline SDK
748
+ */
749
+ export class SmartOfflineTestSuite {
750
+ constructor(config = {}) {
751
+ this.config = { ...DEFAULT_CONFIG, ...config };
752
+ this.results = [];
753
+ }
754
+
755
+ async runAll() {
756
+ this.results = [];
757
+ this.testPatternMatching();
758
+ this.testFrequencyPriority();
759
+ this.testRecencyPriority();
760
+
761
+ if (typeof navigator !== "undefined" && "serviceWorker" in navigator) {
762
+ await this.testServiceWorkerActive();
763
+ await this.testCacheAPIAvailable();
764
+ }
765
+
766
+ return this.results;
767
+ }
768
+
769
+ testPatternMatching() {
770
+ const tests = [
771
+ { url: "/admin/charts", pattern: "/admin/charts", expected: true },
772
+ { url: "/admin/charts/123", pattern: "/admin/charts/*", expected: true },
773
+ { url: "/grapher/gdp", pattern: "/grapher/*", expected: true },
774
+ { url: "/random", pattern: "/admin/*", expected: false },
775
+ ];
776
+
777
+ let passed = true;
778
+ let message = "All patterns matched correctly";
779
+
780
+ for (const test of tests) {
781
+ const result = matchesPattern(test.url, test.pattern);
782
+ if (result !== test.expected) {
783
+ passed = false;
784
+ message = `Pattern ${test.pattern} failed for ${test.url}`;
785
+ break;
160
786
  }
161
- if (name === 'smart-offline' && !db.objectStoreNames.contains('usage')) {
162
- db.createObjectStore('usage', { keyPath: 'url' });
787
+ }
788
+
789
+ this.results.push({ name: "Pattern Matching", passed, message });
790
+ }
791
+
792
+ testFrequencyPriority() {
793
+ const highUsage = { url: "/test", count: 5, lastAccessed: Date.now() };
794
+ const lowUsage = {
795
+ url: "/test",
796
+ count: 1,
797
+ lastAccessed: Date.now() - 25 * 60 * 60 * 1000,
798
+ };
799
+
800
+ const highResult = isHighPriority(highUsage, "/test", this.config);
801
+ const lowResult = isHighPriority(lowUsage, "/test", this.config);
802
+
803
+ this.results.push({
804
+ name: "Frequency Priority",
805
+ passed: highResult === true && lowResult === false,
806
+ message:
807
+ highResult === true && lowResult === false
808
+ ? "High frequency URLs correctly prioritized"
809
+ : `Expected high:true, low:false but got high:${highResult}, low:${lowResult}`,
810
+ });
811
+ }
812
+
813
+ testRecencyPriority() {
814
+ const recentUsage = {
815
+ url: "/test",
816
+ count: 1,
817
+ lastAccessed: Date.now() - 1000,
818
+ };
819
+ const oldUsage = {
820
+ url: "/test",
821
+ count: 1,
822
+ lastAccessed: Date.now() - 48 * 60 * 60 * 1000,
823
+ };
824
+
825
+ const recentResult = isHighPriority(recentUsage, "/test", this.config);
826
+ const oldResult = isHighPriority(oldUsage, "/test", this.config);
827
+
828
+ this.results.push({
829
+ name: "Recency Priority",
830
+ passed: recentResult === true && oldResult === false,
831
+ message:
832
+ recentResult === true && oldResult === false
833
+ ? "Recent URLs correctly prioritized"
834
+ : `Expected recent:true, old:false but got recent:${recentResult}, old:${oldResult}`,
835
+ });
836
+ }
837
+
838
+ async testServiceWorkerActive() {
839
+ try {
840
+ const registration = await navigator.serviceWorker.getRegistration();
841
+ const passed = !!registration?.active;
842
+
843
+ this.results.push({
844
+ name: "Service Worker Active",
845
+ passed,
846
+ message: passed
847
+ ? `Service worker active at scope: ${registration?.scope}`
848
+ : "No active service worker found",
849
+ });
850
+ } catch (e) {
851
+ this.results.push({
852
+ name: "Service Worker Active",
853
+ passed: false,
854
+ message: `Error: ${e}`,
855
+ });
856
+ }
857
+ }
858
+
859
+ async testCacheAPIAvailable() {
860
+ try {
861
+ const cache = await caches.open("smart-offline-test");
862
+ await caches.delete("smart-offline-test");
863
+ this.results.push({
864
+ name: "Cache API Available",
865
+ passed: true,
866
+ message: "Cache API is accessible",
867
+ });
868
+ } catch (e) {
869
+ this.results.push({
870
+ name: "Cache API Available",
871
+ passed: false,
872
+ message: `Error: ${e}`,
873
+ });
874
+ }
875
+ }
876
+
877
+ printResults() {
878
+ console.info("\n========================================");
879
+ console.info(" SmartOffline SDK Test Results");
880
+ console.info("========================================\n");
881
+
882
+ for (const result of this.results) {
883
+ console.info(`${result.passed ? "✅" : "❌"} ${result.name}`);
884
+ console.info(` ${result.message}\n`);
885
+ }
886
+
887
+ const passed = this.results.filter((r) => r.passed).length;
888
+ console.info("----------------------------------------");
889
+ console.info(`Total: ${passed}/${this.results.length} tests passed`);
890
+ console.info("========================================\n");
891
+ }
892
+ }
893
+
894
+ /**
895
+ * Cache inspector for debugging
896
+ */
897
+ export class CacheInspector {
898
+ async getCachedItems() {
899
+ try {
900
+ const cache = await caches.open("smart-offline-cache-v2");
901
+ const keys = await cache.keys();
902
+ const items = [];
903
+ for (const request of keys) {
904
+ const response = await cache.match(request);
905
+ if (response) {
906
+ items.push({
907
+ url: request.url,
908
+ size: parseInt(response.headers.get("content-length") || "0", 10),
909
+ contentType: response.headers.get("content-type") || "unknown",
910
+ });
911
+ }
912
+ }
913
+ return items;
914
+ } catch {
915
+ return [];
916
+ }
917
+ }
918
+
919
+ async getUsageData() {
920
+ return new Promise((resolve) => {
921
+ try {
922
+ const request = indexedDB.open("smart-offline-usage-v2", 1);
923
+ request.onsuccess = () => {
924
+ const db = request.result;
925
+ try {
926
+ const tx = db.transaction("usage", "readonly");
927
+ const store = tx.objectStore("usage");
928
+ const getAllReq = store.getAll();
929
+ getAllReq.onsuccess = () => resolve(getAllReq.result || []);
930
+ getAllReq.onerror = () => resolve([]);
931
+ } catch {
932
+ resolve([]);
933
+ }
934
+ };
935
+ request.onerror = () => resolve([]);
936
+ } catch {
937
+ resolve([]);
163
938
  }
939
+ });
940
+ }
941
+
942
+ async getLogs() {
943
+ return new Promise((resolve) => {
944
+ try {
945
+ const request = indexedDB.open("smart-offline-logs-v2", 1);
946
+ request.onsuccess = () => {
947
+ const db = request.result;
948
+ try {
949
+ const tx = db.transaction("logs", "readonly");
950
+ const store = tx.objectStore("logs");
951
+ const getAllReq = store.getAll();
952
+ getAllReq.onsuccess = () => resolve(getAllReq.result || []);
953
+ getAllReq.onerror = () => resolve([]);
954
+ } catch {
955
+ resolve([]);
956
+ }
957
+ };
958
+ request.onerror = () => resolve([]);
959
+ } catch {
960
+ resolve([]);
961
+ }
962
+ });
963
+ }
964
+
965
+ async showAll() {
966
+ console.info("\n🔍 SmartOffline Cache Inspector\n");
967
+
968
+ const cachedItems = await this.getCachedItems();
969
+ console.info(`📦 Cached Items (${cachedItems.length}):`);
970
+ console.table(cachedItems);
971
+
972
+ const usageData = await this.getUsageData();
973
+ console.info(`\n📊 Usage Tracking (${usageData.length} URLs):`);
974
+ console.table(
975
+ usageData.map((u) => ({
976
+ url: u.url.replace(window.location.origin, ""),
977
+ count: u.count,
978
+ lastAccessed: new Date(u.lastAccessed).toLocaleString(),
979
+ })),
980
+ );
981
+
982
+ const logs = await this.getLogs();
983
+ console.info(`\n📝 Recent Logs (${logs.length}):`);
984
+ console.table(logs.slice(-20));
985
+ }
986
+
987
+ async getAllData() {
988
+ return {
989
+ cachedItems: await this.getCachedItems(),
990
+ usageData: await this.getUsageData(),
991
+ logs: await this.getLogs(),
164
992
  };
165
- request.onsuccess = () => resolve(request.result);
166
- request.onerror = () => reject(request.error);
167
- });
993
+ }
168
994
  }
169
995
 
170
- const SmartOffline = {
171
- init,
172
- on,
173
- off,
174
- getCacheLogs,
996
+ /**
997
+ * Run all SmartOffline tests and print results
998
+ * @param {Partial<SmartOfflineConfig>} config
999
+ * @returns {Promise<TestResult[]>}
1000
+ */
1001
+ export async function runSmartOfflineTests(config) {
1002
+ const suite = new SmartOfflineTestSuite(config);
1003
+ const results = await suite.runAll();
1004
+ suite.printResults();
1005
+ return results;
1006
+ }
1007
+
1008
+ // ============================================================================
1009
+ // MAIN EXPORT OBJECT
1010
+ // ============================================================================
1011
+
1012
+ export const SmartOffline = {
1013
+ // Main setup
1014
+ setup: setupSmartOffline,
1015
+ init, // Legacy API
1016
+
1017
+ // Configuration
1018
+ updateConfig,
1019
+ getConfig,
1020
+
1021
+ // Status
1022
+ isReady: isSmartOfflineReady,
1023
+ getRegistration: getServiceWorkerRegistration,
1024
+
1025
+ // Event handling
1026
+ on,
1027
+ off,
1028
+
1029
+ // Cache management
1030
+ clearCache: clearAllCache,
1031
+ getStats: getCacheStats,
1032
+ getCacheLogs,
175
1033
  clearCacheLogs,
176
- clearCache
1034
+
1035
+ // Lifecycle
1036
+ forceUpdate,
1037
+ uninstall,
177
1038
  };
178
1039
 
179
- export { SmartOffline }; // ✅ named export
180
- export default SmartOffline; // ✅ default export
1040
+ export default SmartOffline;