@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 +21 -0
- package/README.md +131 -0
- package/client/index.js +536 -0
- package/client/snapcache-sw.js +43 -0
- package/package.json +52 -0
- package/server/index.js +107 -0
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
|
+
[](https://www.npmjs.com/package/@ogensync/snapcache)
|
|
8
|
+
[](./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)
|
package/client/index.js
ADDED
|
@@ -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
|
+
}
|
package/server/index.js
ADDED
|
@@ -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 };
|