@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/README.md +201 -35
- package/package.json +4 -3
- package/src/SmartOfflineSetup.ts +658 -0
- package/src/SmartOfflineTestUtils.ts +578 -0
- package/src/index.cjs +576 -0
- package/src/index.cjs.js +563 -97
- package/src/index.d.ts +320 -52
- package/src/index.js +781 -124
package/src/index.js
CHANGED
|
@@ -1,112 +1,307 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SmartOffline SDK
|
|
2
|
+
* SmartOffline SDK - v1.0.0
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
139
|
-
|
|
346
|
+
/**
|
|
347
|
+
* Clear all cached data
|
|
348
|
+
*/
|
|
349
|
+
export async function clearAllCache() {
|
|
140
350
|
const cacheNames = await caches.keys();
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
831
|
+
|
|
832
|
+
// Lifecycle
|
|
833
|
+
forceUpdate,
|
|
834
|
+
uninstall,
|
|
177
835
|
};
|
|
178
836
|
|
|
179
|
-
export
|
|
180
|
-
export default SmartOffline; // ✅ default export
|
|
837
|
+
export default SmartOffline;
|