cachel 1.1.1 → 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
@@ -15,6 +15,8 @@ Fetch remote assets once, serve them forever from local cache. Works with any fr
15
15
  - Batch cache multiple assets with controlled concurrency via `loadMany`
16
16
  - Serve cached assets as object URLs, works fully offline
17
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
18
20
  - Singleton per database name
19
21
  - Supports images, videos, audio and fonts
20
22
  - Failed assets in batch processing are bypassed, successful ones are always cached
@@ -41,9 +43,9 @@ const cache = new Cachel('my-app');
41
43
  // fetch and cache a remote asset
42
44
  await cache.load('https://example.com/logo.png');
43
45
 
44
- // retrieve cached asset as an object URL
45
- const url = await cache.get('https://example.com/logo.png');
46
- 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
47
49
  ```
48
50
 
49
51
  ---
@@ -64,18 +66,26 @@ Defaults to `'idb'` if no name is provided.
64
66
 
65
67
  ---
66
68
 
67
- ### `cache.load(url)`
69
+ ### `cache.load(url, options?)`
68
70
 
69
71
  Fetches a remote asset and stores it in IndexedDB as a blob. If the asset is already cached, the network request is skipped.
70
72
 
71
73
  ```javascript
72
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'] } });
73
78
  ```
74
79
 
75
80
  Supported content types: `image/*`, `video/*`, `audio/*`, `font/*`
76
81
 
77
82
  Throws if the resource cannot be fetched or the content type is not supported.
78
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
+
79
89
  ---
80
90
 
81
91
  ### `cache.loadMany(urls, chunkSize?)`
@@ -108,15 +118,67 @@ await cache.loadMany(urls, 4); // 4 parallel fetches per round
108
118
 
109
119
  ### `cache.get(url)`
110
120
 
111
- 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.
112
122
 
113
123
  ```javascript
114
- const url = await cache.get('https://example.com/hero.jpg');
115
- 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
+ }
116
130
  ```
117
131
 
118
132
  ---
119
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
+
120
182
  ### `cache.remove(url)`
121
183
 
122
184
  Removes a single cached asset.
@@ -166,17 +228,18 @@ await cache.delete();
166
228
  @Directive({ selector: '[cachel]' })
167
229
  export class CachelDirective implements OnInit, OnDestroy {
168
230
  @Input() cachel: string;
169
- private objectUrl: string;
231
+ private path: string;
170
232
  private cache = new Cachel('my-app');
171
233
 
172
234
  async ngOnInit() {
173
235
  await this.cache.load(this.cachel);
174
- this.objectUrl = await this.cache.get(this.cachel);
175
- 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;
176
239
  }
177
240
 
178
241
  ngOnDestroy() {
179
- if (this.objectUrl) URL.revokeObjectURL(this.objectUrl);
242
+ if (this.path) URL.revokeObjectURL(this.path);
180
243
  }
181
244
 
182
245
  constructor(private el: ElementRef) {}
@@ -193,19 +256,19 @@ export class CachelDirective implements OnInit, OnDestroy {
193
256
  const cache = new Cachel('my-app');
194
257
 
195
258
  export function useCachedAsset(url) {
196
- const [src, setSrc] = useState(null);
259
+ const [record, setRecord] = useState(null);
197
260
 
198
261
  useEffect(() => {
199
- cache.load(url).then(() => cache.get(url)).then(setSrc);
200
- 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); };
201
264
  }, [url]);
202
265
 
203
- return src;
266
+ return record;
204
267
  }
205
268
 
206
269
  // usage
207
- const src = useCachedAsset('https://example.com/logo.png');
208
- <img src={src} />
270
+ const record = useCachedAsset('https://example.com/logo.png');
271
+ <img src={record?.path} />
209
272
  ```
210
273
 
211
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.1",
3
+ "version": "1.2.0",
4
4
  "main": "cachel.js",
5
5
  "files": [
6
6
  "cachel.js",