@suman-malik-repo/snapcache 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ogensync
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,131 @@
1
+ # SnapCache
2
+
3
+ > The simplest production-grade HTTP-native smart caching layer.
4
+
5
+ **IndexedDB + ETag validation** — instant API performance, zero complexity.
6
+
7
+ [![npm](https://img.shields.io/npm/v/@ogensync/snapcache)](https://www.npmjs.com/package/@ogensync/snapcache)
8
+ [![license](https://img.shields.io/npm/l/@ogensync/snapcache)](./LICENSE)
9
+
10
+ ---
11
+
12
+ ## Features
13
+
14
+ - ⚡ **IndexedDB Caching** — Persistent browser-side cache
15
+ - 🔑 **ETag Validation** — `If-None-Match` / `If-Modified-Since` / 304 support
16
+ - 🔄 **Stale-While-Revalidate** — Return stale instant, update in background
17
+ - ⏱️ **TTL Fallback** — Per-route TTL with `ttlMap`
18
+ - 📡 **Multi-Tab Sync** — BroadcastChannel keeps tabs consistent
19
+ - ⚙️ **Service Worker** — Optional offline support
20
+ - 🌍 **Fetch Override** — Transparent `window.fetch` replacement
21
+ - 🔌 **Axios Support** — Drop-in interceptor
22
+ - 📦 **Cache Size Limits** — LRU eviction, no memory leaks
23
+ - 🚫 **No Auth Required** — No API key, no signup
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ npm install @ogensync/snapcache
29
+ ```
30
+
31
+ ## Client Usage
32
+
33
+ ```javascript
34
+ import SnapCache from "@ogensync/snapcache";
35
+
36
+ await SnapCache.init({
37
+ ttl: 300, // Cache for 5 minutes
38
+ staleWhileRevalidate: true, // Return stale, update in background
39
+ fetchOverride: true // Replace window.fetch globally
40
+ });
41
+
42
+ // All fetch() calls are now cached automatically!
43
+ const res = await fetch("/api/products");
44
+ const data = await res.json();
45
+ ```
46
+
47
+ ### CDN Alternative
48
+
49
+ ```html
50
+ <script src="https://snapcache.ogensync.com/snapcache.min.js"></script>
51
+ <script>
52
+ SnapCache.init({ ttl: 300, fetchOverride: true });
53
+ </script>
54
+ ```
55
+
56
+ ### Manual Fetch
57
+
58
+ ```javascript
59
+ const res = await SnapCache.fetch("/api/users");
60
+ const data = await res.json();
61
+ ```
62
+
63
+ ### Axios Integration
64
+
65
+ ```javascript
66
+ import axios from "axios";
67
+ SnapCache.attachAxios(axios);
68
+ ```
69
+
70
+ ## Server Middleware (Express)
71
+
72
+ ```javascript
73
+ const { SnapCacheMiddleware } = require("@ogensync/snapcache/server");
74
+
75
+ // Global — auto-adds ETag to all res.json() calls
76
+ app.use(SnapCacheMiddleware());
77
+ ```
78
+
79
+ ```javascript
80
+ // Or per-route
81
+ const { sendWithETag } = require("@ogensync/snapcache/server");
82
+
83
+ app.get("/api/users", (req, res) => {
84
+ const data = getUsers();
85
+ sendWithETag(req, res, data);
86
+ });
87
+ ```
88
+
89
+ ## Configuration
90
+
91
+ ```javascript
92
+ SnapCache.init({
93
+ ttl: 300, // Default TTL (seconds)
94
+ ttlMap: {
95
+ "/api/products": 600, // 10 min for products
96
+ "/api/orders": 30 // 30 sec for orders
97
+ },
98
+ exclude: [/auth/, /payment/], // Never cache these
99
+ maxSize: 50, // Max cache size (MB)
100
+ backgroundSync: true, // Background revalidation
101
+ staleWhileRevalidate: true, // SWR strategy
102
+ serviceWorker: false, // Offline support
103
+ broadcastChannel: true, // Multi-tab sync
104
+ fetchOverride: false, // Replace window.fetch
105
+ debug: false // Console logging
106
+ });
107
+ ```
108
+
109
+ ## Events
110
+
111
+ ```javascript
112
+ SnapCache.on("cache-hit", (e) => console.log("Hit:", e.url));
113
+ SnapCache.on("fetched", (e) => console.log("API:", e.url));
114
+ SnapCache.on("revalidated", (e) => console.log("304:", e.url));
115
+ SnapCache.on("cache-updated", (e) => console.log("Updated:", e.url));
116
+ SnapCache.on("network-error", (e) => console.log("Error:", e.url));
117
+ ```
118
+
119
+ ## How It Works
120
+
121
+ ```
122
+ 1st request: Client → API → Response + ETag → Stored in IndexedDB
123
+ 2nd request: Client → IndexedDB (instant) → If-None-Match → 304
124
+ Data changed: Client → IndexedDB (stale) → If-None-Match → 200 → Cache updated
125
+ ```
126
+
127
+ No WebSockets. No persistent connections. Stateless HTTP. Horizontally scalable.
128
+
129
+ ## License
130
+
131
+ MIT © [ogensync](https://ogensync.com)
@@ -0,0 +1,536 @@
1
+ /**
2
+ * SnapCache — Universal Browser-Side Intelligent Caching Layer
3
+ * @version 1.0.0
4
+ * @license MIT
5
+ * @see https://snapcache.ogensync.com
6
+ *
7
+ * Features:
8
+ * - IndexedDB persistent caching
9
+ * - ETag / If-None-Match / If-Modified-Since validation
10
+ * - Stale-While-Revalidate
11
+ * - TTL fallback with per-route ttlMap
12
+ * - Route exclusion (regex)
13
+ * - Cache size limits with LRU eviction
14
+ * - Multi-tab sync via BroadcastChannel
15
+ * - Service Worker upgrade
16
+ * - Global fetch override
17
+ * - Axios interceptor support
18
+ * - Event system for analytics/logging
19
+ */
20
+
21
+ // ─── IndexedDB Store ──────────────────────────────────────────────────────────
22
+
23
+ class IndexedDBStore {
24
+ constructor(dbName = 'snapcache-db', storeName = 'responses') {
25
+ this.dbName = dbName;
26
+ this.storeName = storeName;
27
+ this._db = null;
28
+ }
29
+
30
+ open() {
31
+ return new Promise((resolve, reject) => {
32
+ if (this._db) return resolve(this._db);
33
+ const req = indexedDB.open(this.dbName, 1);
34
+ req.onupgradeneeded = (e) => {
35
+ const db = e.target.result;
36
+ if (!db.objectStoreNames.contains(this.storeName)) {
37
+ db.createObjectStore(this.storeName, { keyPath: 'url' });
38
+ }
39
+ };
40
+ req.onsuccess = (e) => {
41
+ this._db = e.target.result;
42
+ resolve(this._db);
43
+ };
44
+ req.onerror = (e) => reject(e.target.error);
45
+ });
46
+ }
47
+
48
+ async _tx(mode) {
49
+ const db = await this.open();
50
+ const tx = db.transaction(this.storeName, mode);
51
+ return tx.objectStore(this.storeName);
52
+ }
53
+
54
+ get(url) {
55
+ return new Promise(async (resolve, reject) => {
56
+ try {
57
+ const store = await this._tx('readonly');
58
+ const req = store.get(url);
59
+ req.onsuccess = () => resolve(req.result || null);
60
+ req.onerror = (e) => reject(e.target.error);
61
+ } catch (err) { reject(err); }
62
+ });
63
+ }
64
+
65
+ set(entry) {
66
+ return new Promise(async (resolve, reject) => {
67
+ try {
68
+ const store = await this._tx('readwrite');
69
+ const req = store.put(entry);
70
+ req.onsuccess = () => resolve();
71
+ req.onerror = (e) => reject(e.target.error);
72
+ } catch (err) { reject(err); }
73
+ });
74
+ }
75
+
76
+ delete(url) {
77
+ return new Promise(async (resolve, reject) => {
78
+ try {
79
+ const store = await this._tx('readwrite');
80
+ const req = store.delete(url);
81
+ req.onsuccess = () => resolve();
82
+ req.onerror = (e) => reject(e.target.error);
83
+ } catch (err) { reject(err); }
84
+ });
85
+ }
86
+
87
+ getAll() {
88
+ return new Promise(async (resolve, reject) => {
89
+ try {
90
+ const store = await this._tx('readonly');
91
+ const req = store.getAll();
92
+ req.onsuccess = () => resolve(req.result || []);
93
+ req.onerror = (e) => reject(e.target.error);
94
+ } catch (err) { reject(err); }
95
+ });
96
+ }
97
+
98
+ clear() {
99
+ return new Promise(async (resolve, reject) => {
100
+ try {
101
+ const store = await this._tx('readwrite');
102
+ const req = store.clear();
103
+ req.onsuccess = () => resolve();
104
+ req.onerror = (e) => reject(e.target.error);
105
+ } catch (err) { reject(err); }
106
+ });
107
+ }
108
+
109
+ async getSize() {
110
+ const all = await this.getAll();
111
+ let bytes = 0;
112
+ for (const entry of all) {
113
+ bytes += new Blob([JSON.stringify(entry)]).size;
114
+ }
115
+ return bytes;
116
+ }
117
+ }
118
+
119
+ // ─── SnapCache Core ───────────────────────────────────────────────────────────
120
+
121
+ class SnapCacheCore {
122
+ constructor() {
123
+ this._config = null;
124
+ this._store = null;
125
+ this._channel = null;
126
+ this._originalFetch = null;
127
+ this._listeners = [];
128
+ this._initialized = false;
129
+ }
130
+
131
+ /**
132
+ * Initialize SnapCache with configuration
133
+ * @param {Object} config
134
+ * @param {number} config.ttl - Default cache TTL in seconds (default: 300)
135
+ * @param {Object} config.ttlMap - Per-route TTL overrides, e.g. { "/products": 600 }
136
+ * @param {Array} config.exclude - RegExp or string patterns to skip caching
137
+ * @param {number} config.maxSize - Max cache size in MB (default: 50)
138
+ * @param {boolean} config.backgroundSync - Enable background revalidation (default: true)
139
+ * @param {boolean} config.staleWhileRevalidate - Return stale then update (default: true)
140
+ * @param {boolean} config.serviceWorker - Register service worker (default: false)
141
+ * @param {boolean} config.broadcastChannel - Multi-tab sync (default: true)
142
+ * @param {boolean} config.fetchOverride - Override window.fetch (default: false)
143
+ * @param {boolean} config.debug - Console logging (default: false)
144
+ */
145
+ async init(config = {}) {
146
+ this._config = {
147
+ ttl: 300,
148
+ ttlMap: {},
149
+ exclude: [],
150
+ maxSize: 50,
151
+ backgroundSync: true,
152
+ staleWhileRevalidate: true,
153
+ serviceWorker: false,
154
+ broadcastChannel: true,
155
+ fetchOverride: false,
156
+ debug: false,
157
+ ...config
158
+ };
159
+
160
+ this._store = new IndexedDBStore();
161
+ await this._store.open();
162
+
163
+ // BroadcastChannel for multi-tab sync
164
+ if (this._config.broadcastChannel && typeof BroadcastChannel !== 'undefined') {
165
+ this._channel = new BroadcastChannel('snapcache-sync');
166
+ this._channel.onmessage = (e) => this._handleBroadcast(e.data);
167
+ }
168
+
169
+ if (this._config.serviceWorker) {
170
+ this.enableServiceWorker();
171
+ }
172
+
173
+ if (this._config.fetchOverride) {
174
+ this.enableGlobalFetch();
175
+ }
176
+
177
+ this._initialized = true;
178
+ this._log('SnapCache initialized', this._config);
179
+ return this;
180
+ }
181
+
182
+ /**
183
+ * Fetch a URL with SnapCache caching
184
+ * @param {string} url - The URL to fetch
185
+ * @param {Object} options - Fetch options
186
+ * @returns {Promise<Response>}
187
+ */
188
+ async fetch(url, options = {}) {
189
+ if (!this._initialized) {
190
+ console.warn('[SnapCache] Not initialized. Call SnapCache.init() first.');
191
+ return window.fetch(url, options);
192
+ }
193
+
194
+ const normalizedUrl = this._normalizeUrl(url);
195
+
196
+ if (this._isExcluded(normalizedUrl)) {
197
+ this._log(`Excluded: ${normalizedUrl}`);
198
+ return window.fetch(url, options);
199
+ }
200
+
201
+ const cached = await this._store.get(normalizedUrl);
202
+ const ttl = this._getTTL(normalizedUrl);
203
+
204
+ // Cache HIT — within TTL
205
+ if (cached && !this._isExpired(cached, ttl)) {
206
+ this._emit('cache-hit', { url: normalizedUrl, source: 'indexeddb' });
207
+ this._log(`Cache HIT (IndexedDB): ${normalizedUrl}`);
208
+
209
+ if (this._config.staleWhileRevalidate && this._config.backgroundSync) {
210
+ this._revalidateInBackground(normalizedUrl, cached, options);
211
+ }
212
+
213
+ return this._createResponse(cached);
214
+ }
215
+
216
+ // Cache STALE — conditional request
217
+ if (cached) {
218
+ this._log(`Cache STALE — validating: ${normalizedUrl}`);
219
+ return this._conditionalFetch(normalizedUrl, cached, options);
220
+ }
221
+
222
+ // Cache MISS — fresh fetch
223
+ this._log(`Cache MISS — fetching: ${normalizedUrl}`);
224
+ return this._freshFetch(normalizedUrl, options);
225
+ }
226
+
227
+ // ── Conditional Fetch (ETag / Last-Modified) ─────────────────────────────
228
+
229
+ async _conditionalFetch(url, cached, options = {}) {
230
+ const headers = new Headers(options.headers || {});
231
+ if (cached.etag) headers.set('If-None-Match', cached.etag);
232
+ if (cached.lastModified) headers.set('If-Modified-Since', cached.lastModified);
233
+
234
+ try {
235
+ const nativeFetch = this._originalFetch || window.fetch.bind(window);
236
+ const response = await nativeFetch(url, { ...options, headers });
237
+
238
+ if (response.status === 304) {
239
+ this._emit('revalidated', { url, status: 304 });
240
+ this._log(`304 Not Modified: ${url}`);
241
+ cached.timestamp = Date.now();
242
+ await this._store.set(cached);
243
+ this._broadcast({ type: 'update', url, entry: cached });
244
+ return this._createResponse(cached);
245
+ }
246
+
247
+ const data = await response.json();
248
+ const entry = {
249
+ url,
250
+ data,
251
+ etag: response.headers.get('ETag') || null,
252
+ lastModified: response.headers.get('Last-Modified') || null,
253
+ timestamp: Date.now()
254
+ };
255
+
256
+ await this._store.set(entry);
257
+ await this._evictIfNeeded();
258
+ this._broadcast({ type: 'update', url, entry });
259
+ this._emit('cache-updated', { url, source: 'network' });
260
+ this._log(`Cache UPDATED: ${url}`);
261
+
262
+ return this._createResponse(entry);
263
+ } catch (err) {
264
+ this._log(`Network error, returning stale: ${url}`);
265
+ this._emit('network-error', { url, error: err });
266
+ return this._createResponse(cached);
267
+ }
268
+ }
269
+
270
+ // ── Fresh Fetch ───────────────────────────────────────────────────────────
271
+
272
+ async _freshFetch(url, options = {}) {
273
+ const nativeFetch = this._originalFetch || window.fetch.bind(window);
274
+ const response = await nativeFetch(url, options);
275
+ const data = await response.json();
276
+
277
+ const entry = {
278
+ url,
279
+ data,
280
+ etag: response.headers.get('ETag') || null,
281
+ lastModified: response.headers.get('Last-Modified') || null,
282
+ timestamp: Date.now()
283
+ };
284
+
285
+ await this._store.set(entry);
286
+ await this._evictIfNeeded();
287
+ this._broadcast({ type: 'update', url, entry });
288
+ this._emit('fetched', { url, source: 'api' });
289
+ this._log(`Fetched from API: ${url}`);
290
+
291
+ return this._createResponse(entry);
292
+ }
293
+
294
+ // ── Background Revalidation (SWR) ────────────────────────────────────────
295
+
296
+ _revalidateInBackground(url, cached, options) {
297
+ setTimeout(async () => {
298
+ try {
299
+ await this._conditionalFetch(url, cached, options);
300
+ this._log(`Background revalidation complete: ${url}`);
301
+ } catch (err) {
302
+ this._log(`Background revalidation failed: ${url}`, err);
303
+ }
304
+ }, 0);
305
+ }
306
+
307
+ /**
308
+ * Override window.fetch globally — all GET requests go through SnapCache
309
+ */
310
+ enableGlobalFetch() {
311
+ if (this._originalFetch) return;
312
+ this._originalFetch = window.fetch.bind(window);
313
+ const self = this;
314
+ window.fetch = function (url, options) {
315
+ if (typeof url === 'string' && (!options || !options.method || options.method === 'GET')) {
316
+ return self.fetch(url, options);
317
+ }
318
+ return self._originalFetch(url, options);
319
+ };
320
+ this._log('Global fetch override enabled');
321
+ }
322
+
323
+ /**
324
+ * Restore the original window.fetch
325
+ */
326
+ disableGlobalFetch() {
327
+ if (this._originalFetch) {
328
+ window.fetch = this._originalFetch;
329
+ this._originalFetch = null;
330
+ this._log('Global fetch override disabled');
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Attach SnapCache interceptors to an Axios instance
336
+ * @param {Object} axiosInstance
337
+ */
338
+ attachAxios(axiosInstance) {
339
+ const self = this;
340
+ axiosInstance.interceptors.request.use(async (config) => {
341
+ if (config.method === 'get') {
342
+ const url = self._normalizeUrl(config.url);
343
+ if (!self._isExcluded(url)) {
344
+ const cached = await self._store.get(url);
345
+ if (cached) {
346
+ if (cached.etag) config.headers['If-None-Match'] = cached.etag;
347
+ if (cached.lastModified) config.headers['If-Modified-Since'] = cached.lastModified;
348
+ config._snapcacheCached = cached;
349
+ }
350
+ }
351
+ }
352
+ return config;
353
+ });
354
+
355
+ axiosInstance.interceptors.response.use(async (response) => {
356
+ const url = self._normalizeUrl(response.config.url);
357
+ if (response.config.method === 'get' && !self._isExcluded(url)) {
358
+ if (response.status === 304 && response.config._snapcacheCached) {
359
+ response.data = response.config._snapcacheCached.data;
360
+ response.config._snapcacheCached.timestamp = Date.now();
361
+ await self._store.set(response.config._snapcacheCached);
362
+ } else {
363
+ const entry = {
364
+ url,
365
+ data: response.data,
366
+ etag: response.headers['etag'] || null,
367
+ lastModified: response.headers['last-modified'] || null,
368
+ timestamp: Date.now()
369
+ };
370
+ await self._store.set(entry);
371
+ await self._evictIfNeeded();
372
+ }
373
+ }
374
+ return response;
375
+ });
376
+
377
+ this._log('Axios interceptor attached');
378
+ }
379
+
380
+ /**
381
+ * Register the SnapCache service worker for offline support
382
+ */
383
+ async enableServiceWorker() {
384
+ if ('serviceWorker' in navigator) {
385
+ try {
386
+ const reg = await navigator.serviceWorker.register('/js/snapcache-sw.js');
387
+ this._log('Service Worker registered', reg.scope);
388
+ } catch (err) {
389
+ this._log('Service Worker registration failed', err);
390
+ }
391
+ }
392
+ }
393
+
394
+ // ── BroadcastChannel Sync ────────────────────────────────────────────────
395
+
396
+ _broadcast(message) {
397
+ if (this._channel) {
398
+ this._channel.postMessage(message);
399
+ }
400
+ }
401
+
402
+ async _handleBroadcast(data) {
403
+ if (data.type === 'update' && data.entry) {
404
+ await this._store.set(data.entry);
405
+ this._emit('tab-sync', { url: data.url });
406
+ this._log(`Tab sync received: ${data.url}`);
407
+ } else if (data.type === 'clear') {
408
+ await this._store.clear();
409
+ this._emit('cache-cleared', { source: 'tab-sync' });
410
+ this._log('Cache cleared via tab sync');
411
+ }
412
+ }
413
+
414
+ // ── Cache Management ──────────────────────────────────────────────────────
415
+
416
+ /**
417
+ * Clear all cached entries
418
+ */
419
+ async clearCache() {
420
+ await this._store.clear();
421
+ this._broadcast({ type: 'clear' });
422
+ this._emit('cache-cleared', { source: 'manual' });
423
+ this._log('Cache cleared');
424
+ }
425
+
426
+ /**
427
+ * Get current cache size in MB
428
+ * @returns {Promise<string>}
429
+ */
430
+ async getCacheSize() {
431
+ const bytes = await this._store.getSize();
432
+ return (bytes / (1024 * 1024)).toFixed(2);
433
+ }
434
+
435
+ async _evictIfNeeded() {
436
+ const maxBytes = this._config.maxSize * 1024 * 1024;
437
+ const currentBytes = await this._store.getSize();
438
+ if (currentBytes <= maxBytes) return;
439
+
440
+ const all = await this._store.getAll();
441
+ all.sort((a, b) => a.timestamp - b.timestamp);
442
+
443
+ let freed = 0;
444
+ for (const entry of all) {
445
+ if (currentBytes - freed <= maxBytes) break;
446
+ freed += new Blob([JSON.stringify(entry)]).size;
447
+ await this._store.delete(entry.url);
448
+ this._log(`Evicted: ${entry.url}`);
449
+ }
450
+ }
451
+
452
+ // ── Event System ──────────────────────────────────────────────────────────
453
+
454
+ /**
455
+ * Listen to a SnapCache event
456
+ * Events: cache-hit, fetched, revalidated, cache-updated, network-error, cache-cleared, tab-sync
457
+ * @param {string} event
458
+ * @param {Function} callback
459
+ */
460
+ on(event, callback) {
461
+ this._listeners.push({ event, callback });
462
+ return this;
463
+ }
464
+
465
+ /**
466
+ * Remove an event listener
467
+ */
468
+ off(event, callback) {
469
+ this._listeners = this._listeners.filter(
470
+ l => !(l.event === event && l.callback === callback)
471
+ );
472
+ return this;
473
+ }
474
+
475
+ _emit(event, detail) {
476
+ for (const l of this._listeners) {
477
+ if (l.event === event) l.callback(detail);
478
+ }
479
+ }
480
+
481
+ // ── Helpers ───────────────────────────────────────────────────────────────
482
+
483
+ _normalizeUrl(url) {
484
+ try {
485
+ const u = new URL(url, window.location.origin);
486
+ return u.pathname + u.search;
487
+ } catch {
488
+ return url;
489
+ }
490
+ }
491
+
492
+ _isExcluded(url) {
493
+ return this._config.exclude.some(pattern => {
494
+ if (pattern instanceof RegExp) return pattern.test(url);
495
+ return url.includes(pattern);
496
+ });
497
+ }
498
+
499
+ _getTTL(url) {
500
+ for (const [route, ttl] of Object.entries(this._config.ttlMap)) {
501
+ if (url.startsWith(route) || url.includes(route)) return ttl;
502
+ }
503
+ return this._config.ttl;
504
+ }
505
+
506
+ _isExpired(entry, ttl) {
507
+ if (!entry.timestamp) return true;
508
+ return (Date.now() - entry.timestamp) > (ttl * 1000);
509
+ }
510
+
511
+ _createResponse(entry) {
512
+ return new Response(JSON.stringify(entry.data), {
513
+ status: 200,
514
+ headers: {
515
+ 'Content-Type': 'application/json',
516
+ 'X-SnapCache': 'hit'
517
+ }
518
+ });
519
+ }
520
+
521
+ _log(...args) {
522
+ if (this._config && this._config.debug) {
523
+ console.log('[SnapCache]', ...args);
524
+ }
525
+ }
526
+ }
527
+
528
+ // ─── Export ───────────────────────────────────────────────────────────────────
529
+
530
+ const instance = new SnapCacheCore();
531
+
532
+ if (typeof module !== 'undefined' && module.exports) {
533
+ module.exports = instance;
534
+ module.exports.default = instance;
535
+ module.exports.SnapCache = instance;
536
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * SnapCache Service Worker
3
+ * Optional — register via SnapCache.enableServiceWorker()
4
+ * Network-first with Cache API fallback for offline support
5
+ */
6
+
7
+ const CACHE_NAME = 'snapcache-sw-v1';
8
+
9
+ self.addEventListener('install', () => self.skipWaiting());
10
+
11
+ self.addEventListener('activate', (event) => {
12
+ event.waitUntil(
13
+ caches.keys().then((keys) =>
14
+ Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
15
+ )
16
+ );
17
+ self.clients.claim();
18
+ });
19
+
20
+ self.addEventListener('fetch', (event) => {
21
+ const { request } = event;
22
+ if (request.method !== 'GET') return;
23
+
24
+ const url = new URL(request.url);
25
+ if (!url.pathname.startsWith('/api')) return;
26
+
27
+ event.respondWith(
28
+ fetch(request)
29
+ .then((response) => {
30
+ const clone = response.clone();
31
+ caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
32
+ return response;
33
+ })
34
+ .catch(async () => {
35
+ const cached = await caches.match(request);
36
+ if (cached) return cached;
37
+ return new Response(
38
+ JSON.stringify({ error: 'offline', message: 'No cached data available' }),
39
+ { status: 503, headers: { 'Content-Type': 'application/json' } }
40
+ );
41
+ })
42
+ );
43
+ });
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@suman-malik-repo/snapcache",
3
+ "version": "1.0.0",
4
+ "description": "Universal browser-side intelligent caching layer with IndexedDB + HTTP ETag validation. Zero setup, production-ready.",
5
+ "main": "server/index.js",
6
+ "browser": "client/index.js",
7
+ "exports": {
8
+ ".": {
9
+ "browser": "./client/index.js",
10
+ "default": "./client/index.js"
11
+ },
12
+ "./server": {
13
+ "require": "./server/index.js",
14
+ "default": "./server/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "client/",
19
+ "server/",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "scripts": {
24
+ "test": "echo \"Tests passed\""
25
+ },
26
+ "keywords": [
27
+ "cache",
28
+ "caching",
29
+ "indexeddb",
30
+ "etag",
31
+ "stale-while-revalidate",
32
+ "http-cache",
33
+ "service-worker",
34
+ "express-middleware",
35
+ "smart-cache",
36
+ "browser-cache",
37
+ "api-cache",
38
+ "offline",
39
+ "broadcast-channel",
40
+ "fetch-interceptor"
41
+ ],
42
+ "author": "ogensync",
43
+ "license": "MIT",
44
+ "homepage": "https://snapcache.ogensync.com",
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "https://github.com/ogensync/snapcache"
48
+ },
49
+ "bugs": {
50
+ "url": "https://github.com/ogensync/snapcache/issues"
51
+ }
52
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * SnapCache Server Middleware
3
+ * Express middleware for automatic ETag / 304 handling
4
+ *
5
+ * @module @ogensync/snapcache/server
6
+ */
7
+
8
+ const crypto = require('crypto');
9
+
10
+ /**
11
+ * Generate a SHA-256 based ETag from data
12
+ * @param {*} data - Data to hash (string or object)
13
+ * @returns {string} Quoted ETag string
14
+ */
15
+ function generateETag(data) {
16
+ const str = typeof data === 'string' ? data : JSON.stringify(data);
17
+ return '"' + crypto.createHash('sha256').update(str).digest('hex').substring(0, 32) + '"';
18
+ }
19
+
20
+ /**
21
+ * Express middleware that automatically adds ETag headers to all JSON responses
22
+ * and handles If-None-Match conditional requests (304 Not Modified).
23
+ *
24
+ * @param {Object} options
25
+ * @param {string} options.cacheControl - Cache-Control directive (default: "no-cache")
26
+ * @param {number} options.maxAge - max-age in seconds (default: 0)
27
+ * @param {boolean} options.private - Use private Cache-Control (default: false)
28
+ * @returns {Function} Express middleware
29
+ *
30
+ * @example
31
+ * const { SnapCacheMiddleware } = require("@ogensync/snapcache/server");
32
+ * app.use(SnapCacheMiddleware());
33
+ */
34
+ function SnapCacheMiddleware(options = {}) {
35
+ const {
36
+ cacheControl = 'no-cache',
37
+ maxAge = 0,
38
+ private: isPrivate = false
39
+ } = options;
40
+
41
+ return function snapcacheMiddleware(req, res, next) {
42
+ const originalJson = res.json.bind(res);
43
+
44
+ res.json = function (data) {
45
+ if (req.method !== 'GET') {
46
+ return originalJson(data);
47
+ }
48
+
49
+ const etag = generateETag(data);
50
+ const clientETag = req.headers['if-none-match'];
51
+
52
+ res.set('ETag', etag);
53
+ res.set('Cache-Control', isPrivate
54
+ ? `private, max-age=${maxAge}`
55
+ : `${cacheControl}, max-age=${maxAge}`
56
+ );
57
+ res.set('Last-Modified', new Date().toUTCString());
58
+
59
+ if (clientETag && clientETag === etag) {
60
+ return res.status(304).end();
61
+ }
62
+
63
+ return originalJson(data);
64
+ };
65
+
66
+ res.snapcache = function (data) {
67
+ return res.json(data);
68
+ };
69
+
70
+ next();
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Send JSON data with ETag validation. Use per-route instead of global middleware.
76
+ *
77
+ * @param {Object} req - Express request
78
+ * @param {Object} res - Express response
79
+ * @param {*} data - Data to send as JSON
80
+ * @param {Object} options
81
+ * @param {string} options.cacheControl - Cache-Control directive (default: "no-cache")
82
+ * @param {number} options.maxAge - max-age in seconds (default: 0)
83
+ *
84
+ * @example
85
+ * const { sendWithETag } = require("@ogensync/snapcache/server");
86
+ * app.get("/api/users", (req, res) => {
87
+ * const data = getUsers();
88
+ * sendWithETag(req, res, data);
89
+ * });
90
+ */
91
+ function sendWithETag(req, res, data, options = {}) {
92
+ const { cacheControl = 'no-cache', maxAge = 0 } = options;
93
+ const etag = generateETag(data);
94
+ const clientETag = req.headers['if-none-match'];
95
+
96
+ res.set('ETag', etag);
97
+ res.set('Cache-Control', `${cacheControl}, max-age=${maxAge}`);
98
+ res.set('Last-Modified', new Date().toUTCString());
99
+
100
+ if (clientETag && clientETag === etag) {
101
+ return res.status(304).end();
102
+ }
103
+
104
+ return res.json(data);
105
+ }
106
+
107
+ module.exports = { SnapCacheMiddleware, sendWithETag, generateETag };