cachel 1.1.2 → 1.2.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 CHANGED
@@ -5,7 +5,6 @@ Offline-first asset caching for the browser, powered by IndexedDB.
5
5
  Fetch remote assets once, serve them forever from local cache. Works with any framework or none at all.
6
6
 
7
7
  ![npm](https://img.shields.io/npm/v/cachel)
8
- ![bundle size](https://img.shields.io/badge/gzip-1.37kB-brightgreen)
9
8
  ![license](https://img.shields.io/npm/l/cachel)
10
9
 
11
10
  ---
@@ -16,6 +15,8 @@ Fetch remote assets once, serve them forever from local cache. Works with any fr
16
15
  - Batch cache multiple assets with controlled concurrency via `loadMany`
17
16
  - Serve cached assets as object URLs, works fully offline
18
17
  - Skips network requests for already cached assets
18
+ - Attach custom metadata to any cached asset
19
+ - Observable cache via `onChange`, react to any mutation
19
20
  - Singleton per database name
20
21
  - Supports images, videos, audio and fonts
21
22
  - Failed assets in batch processing are bypassed, successful ones are always cached
@@ -42,9 +43,9 @@ const cache = new Cachel('my-app');
42
43
  // fetch and cache a remote asset
43
44
  await cache.load('https://example.com/logo.png');
44
45
 
45
- // retrieve cached asset as an object URL
46
- const url = await cache.get('https://example.com/logo.png');
47
- img.src = url; // works offline
46
+ // retrieve cached asset
47
+ const { path, meta } = await cache.get('https://example.com/logo.png');
48
+ img.src = path; // works offline
48
49
  ```
49
50
 
50
51
  ---
@@ -65,18 +66,26 @@ Defaults to `'idb'` if no name is provided.
65
66
 
66
67
  ---
67
68
 
68
- ### `cache.load(url)`
69
+ ### `cache.load(url, options?)`
69
70
 
70
71
  Fetches a remote asset and stores it in IndexedDB as a blob. If the asset is already cached, the network request is skipped.
71
72
 
72
73
  ```javascript
73
74
  await cache.load('https://example.com/hero.jpg');
75
+
76
+ // with optional metadata
77
+ await cache.load('https://example.com/hero.jpg', { meta: { category: 'nature', tags: ['landscape'] } });
74
78
  ```
75
79
 
76
80
  Supported content types: `image/*`, `video/*`, `audio/*`, `font/*`
77
81
 
78
82
  Throws if the resource cannot be fetched or the content type is not supported.
79
83
 
84
+ | Option | Type | Default | Description |
85
+ |---|---|---|---|
86
+ | `meta` | `object` | `null` | Any metadata to associate with the cached asset |
87
+ | `silent` | `boolean` | `false` | Suppress `onChange` notifications |
88
+
80
89
  ---
81
90
 
82
91
  ### `cache.loadMany(urls, chunkSize?)`
@@ -109,15 +118,67 @@ await cache.loadMany(urls, 4); // 4 parallel fetches per round
109
118
 
110
119
  ### `cache.get(url)`
111
120
 
112
- Retrieves a cached asset and returns it as an object URL. Returns `null` if not found.
121
+ Retrieves a cached asset. Returns `null` if not found.
113
122
 
114
123
  ```javascript
115
- const url = await cache.get('https://example.com/hero.jpg');
116
- if (url) img.src = url;
124
+ const record = await cache.get('https://example.com/hero.jpg');
125
+ if (record) {
126
+ img.src = record.path; // object URL, ready to use
127
+ console.log(record.meta); // metadata attached on load
128
+ console.log(record.url); // original URL
129
+ }
117
130
  ```
118
131
 
119
132
  ---
120
133
 
134
+ ### `cache.updateRecord(url, meta)`
135
+
136
+ Updates the metadata of an already cached asset without re-fetching the blob. Merges with existing metadata.
137
+
138
+ ```javascript
139
+ await cache.updateRecord('https://example.com/hero.jpg', { category: 'updated' });
140
+ ```
141
+
142
+ ---
143
+
144
+ ### `cache.onChange(callback)`
145
+
146
+ Registers a listener that fires whenever the cache is mutated. Returns an unsubscribe function.
147
+
148
+ ```javascript
149
+ const unsubscribe = cache.onChange(({ version, timestamp }) => {
150
+ console.log(`cache updated, version ${version} at ${timestamp}`);
151
+ });
152
+
153
+ // cleanup
154
+ unsubscribe();
155
+ ```
156
+
157
+ Fires on: `load`, `loadMany`, `remove`, `clear`, `updateRecord`.
158
+
159
+ ---
160
+
161
+ ### `cache.checkStorage()`
162
+
163
+ Returns storage usage information for the current origin.
164
+
165
+ ```javascript
166
+ const info = await cache.checkStorage();
167
+ console.log(info);
168
+ // {
169
+ // quota: 123456789, // total available bytes
170
+ // usage: 12345, // total used bytes across all storage types
171
+ // free: 123444444, // available bytes
172
+ // percentUsed: 0.01, // percentage used
173
+ // percentFree: 99.99, // percentage free
174
+ // indexedDBUsage: 8192 // bytes used by IndexedDB specifically (Chrome only)
175
+ // }
176
+ ```
177
+
178
+ Note: `indexedDBUsage` is Chrome-only via the non-standard `usageDetails` API. Returns `0` in other browsers.
179
+
180
+ ---
181
+
121
182
  ### `cache.remove(url)`
122
183
 
123
184
  Removes a single cached asset.
@@ -167,17 +228,18 @@ await cache.delete();
167
228
  @Directive({ selector: '[cachel]' })
168
229
  export class CachelDirective implements OnInit, OnDestroy {
169
230
  @Input() cachel: string;
170
- private objectUrl: string;
231
+ private path: string;
171
232
  private cache = new Cachel('my-app');
172
233
 
173
234
  async ngOnInit() {
174
235
  await this.cache.load(this.cachel);
175
- this.objectUrl = await this.cache.get(this.cachel);
176
- this.el.nativeElement.src = this.objectUrl;
236
+ const record = await this.cache.get(this.cachel);
237
+ if (record) this.el.nativeElement.src = record.path;
238
+ this.path = record?.path;
177
239
  }
178
240
 
179
241
  ngOnDestroy() {
180
- if (this.objectUrl) URL.revokeObjectURL(this.objectUrl);
242
+ if (this.path) URL.revokeObjectURL(this.path);
181
243
  }
182
244
 
183
245
  constructor(private el: ElementRef) {}
@@ -194,19 +256,19 @@ export class CachelDirective implements OnInit, OnDestroy {
194
256
  const cache = new Cachel('my-app');
195
257
 
196
258
  export function useCachedAsset(url) {
197
- const [src, setSrc] = useState(null);
259
+ const [record, setRecord] = useState(null);
198
260
 
199
261
  useEffect(() => {
200
- cache.load(url).then(() => cache.get(url)).then(setSrc);
201
- return () => { if (src) URL.revokeObjectURL(src); };
262
+ cache.load(url).then(() => cache.get(url)).then(setRecord);
263
+ return () => { if (record?.path) URL.revokeObjectURL(record.path); };
202
264
  }, [url]);
203
265
 
204
- return src;
266
+ return record;
205
267
  }
206
268
 
207
269
  // usage
208
- const src = useCachedAsset('https://example.com/logo.png');
209
- <img src={src} />
270
+ const record = useCachedAsset('https://example.com/logo.png');
271
+ <img src={record?.path} />
210
272
  ```
211
273
 
212
274
  ---
package/cachel.js CHANGED
@@ -6,6 +6,8 @@ class Cachel {
6
6
  static #instances = {};
7
7
  #idb;
8
8
  #defaultChunkSize = 8;
9
+ #version = 0;
10
+ #listeners = new Set();
9
11
 
10
12
  constructor(name = 'idb'){
11
13
  if(Cachel.#instances[name]) return Cachel.#instances[name];
@@ -13,7 +15,7 @@ class Cachel {
13
15
  Cachel.#instances[name] = this;
14
16
  }
15
17
 
16
- async load(url){
18
+ async load(url, { silent = false, meta = null } = {}){
17
19
  if(typeof url !== 'string') return;
18
20
  url = url.trim();
19
21
  if(!url) return;
@@ -23,7 +25,8 @@ class Cachel {
23
25
  throw new Error(`cachel: caching failed for the requested resource - ${url}`);
24
26
  }
25
27
  try{
26
- const result = await this.#idb.set(url, blob);
28
+ const result = await this.#idb.set(url, blob, meta);
29
+ !silent && this.#bump();
27
30
  return result;
28
31
  }
29
32
  catch(err){
@@ -36,9 +39,13 @@ class Cachel {
36
39
  if(typeof url !== 'string') return;
37
40
  url = url.trim();
38
41
  if(!url) return;
39
- const blob = await this.#idb.get(url);
40
- if(!blob) return null;
41
- return URL.createObjectURL(blob);
42
+ const record = await this.#idb.get(url);
43
+ if(!record) return null;
44
+ const { blob, ...rest } = record;
45
+ return {
46
+ ...rest,
47
+ path: URL.createObjectURL(blob)
48
+ };
42
49
  }
43
50
 
44
51
  async keys() {
@@ -53,11 +60,27 @@ class Cachel {
53
60
  if(typeof url !== 'string') return;
54
61
  url = url.trim();
55
62
  if(!url) return;
56
- return this.#idb.remove(url);
63
+ let result = null;
64
+ try{
65
+ result = await this.#idb.remove(url);
66
+ this.#bump();
67
+ } catch(err){
68
+ console.error(`cachel: failed to remove ${url} - ${err.message}`);
69
+ } finally{
70
+ return result;
71
+ }
57
72
  }
58
73
 
59
74
  async clear(){
60
- return this.#idb.clear();
75
+ let result = null;
76
+ try{
77
+ result = await this.#idb.clear();
78
+ this.#bump();
79
+ } catch(err){
80
+ console.error(`cachel: failed to clear - ${err.message}`);
81
+ } finally {
82
+ return result;
83
+ }
61
84
  }
62
85
 
63
86
  async loadMany(urls, chunkSize = this.#defaultChunkSize){
@@ -70,7 +93,7 @@ class Cachel {
70
93
  const results = [];
71
94
  const startTime = performance.now();
72
95
  for(const chunk of chunks){
73
- const chunkResults = await Promise.allSettled(chunk.map(url => this.load(url)));
96
+ const chunkResults = await Promise.allSettled(chunk.map(url => this.load(url, { silent: true })));
74
97
  results.push(...chunkResults);
75
98
  }
76
99
  const timeElapsed = (performance.now() - startTime);
@@ -81,9 +104,67 @@ class Cachel {
81
104
  failed: results.length - success,
82
105
  timeElapsed
83
106
  }
107
+ success && this.#bump();
84
108
  return status;
85
109
  }
86
110
 
111
+ #bump() {
112
+ this.#version++;
113
+ this.#notify();
114
+ }
115
+
116
+ onChange(callback){
117
+ this.#listeners.add(callback);
118
+ return () => this.#listeners.delete(callback);
119
+ }
120
+
121
+ #notify(){
122
+ this.#listeners.forEach(callback => callback({
123
+ version: this.#version,
124
+ timestamp: Date.now()
125
+ }));
126
+ }
127
+
128
+ async updateRecord(url, meta){
129
+ if(typeof url !== 'string') return;
130
+ url = url.trim();
131
+ if(!url) return;
132
+ let result = null;
133
+ try{
134
+ result = await this.#idb.update(url, meta);
135
+ this.#bump();
136
+ } catch(err){
137
+ console.error(`cachel: failed to update ${url} - ${err.message}`);
138
+ } finally {
139
+ return result;
140
+ }
141
+ }
142
+
143
+ async checkStorage(){
144
+ const { storage } = window.navigator || {};
145
+ if(!storage) {
146
+ console.error(`cachel: no storage found.`);
147
+ return;
148
+ }
149
+ let result = null;
150
+ try{
151
+ const { quota, usage, usageDetails } = await storage.estimate();
152
+ const percentUsed = +((usage/quota)*100).toFixed(2);
153
+ result = {
154
+ quota,
155
+ usage,
156
+ free: quota - usage,
157
+ percentUsed,
158
+ percentFree: 100 - percentUsed,
159
+ indexedDBUsage: usageDetails?.indexedDB || 0
160
+ }
161
+ } catch(err){
162
+ console.error(`cachel: failed to get the storage information - ${err.message}`);
163
+ } finally {
164
+ return result;
165
+ }
166
+ }
167
+
87
168
  }
88
169
 
89
170
  export default Cachel;
package/lib/idb.js CHANGED
@@ -36,10 +36,19 @@ class Idb {
36
36
  return this.#db.transaction(this.#storeName, permission).objectStore(this.#storeName);
37
37
  }
38
38
 
39
- async set(url, blob){
39
+ async set(url, blob, meta = null){
40
40
  if(!await this.#isReady()) return;
41
41
  const store = this.#getStoreRef('readwrite');
42
- return promisify(store.put({ url, blob }));
42
+ return promisify(store.put({ url, blob, meta }));
43
+ }
44
+
45
+ async update(key, updatedMeta){
46
+ if(!await this.#isReady()) return;
47
+ const record = await this.get(key);
48
+ if(!record) return null;
49
+ const meta = { ...record.meta, ...updatedMeta };
50
+ const store = this.#getStoreRef('readwrite');
51
+ return promisify(store.put({ ...record, meta }));
43
52
  }
44
53
 
45
54
  async get(url){
@@ -47,7 +56,7 @@ class Idb {
47
56
  try{
48
57
  const store = this.#getStoreRef();
49
58
  const record = await promisify(store.get(url));
50
- return record ? record.blob : null;
59
+ return record || null;
51
60
  }
52
61
  catch(err){
53
62
  console.error(err.message);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cachel",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
4
  "main": "cachel.js",
5
5
  "files": [
6
6
  "cachel.js",