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 +80 -17
- package/cachel.js +89 -8
- package/lib/idb.js +12 -3
- package/package.json +1 -1
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
|
|
45
|
-
const
|
|
46
|
-
img.src =
|
|
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
|
|
121
|
+
Retrieves a cached asset. Returns `null` if not found.
|
|
112
122
|
|
|
113
123
|
```javascript
|
|
114
|
-
const
|
|
115
|
-
if (
|
|
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
|
|
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
|
-
|
|
175
|
-
this.el.nativeElement.src =
|
|
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.
|
|
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 [
|
|
259
|
+
const [record, setRecord] = useState(null);
|
|
197
260
|
|
|
198
261
|
useEffect(() => {
|
|
199
|
-
cache.load(url).then(() => cache.get(url)).then(
|
|
200
|
-
return () => { if (
|
|
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
|
|
266
|
+
return record;
|
|
204
267
|
}
|
|
205
268
|
|
|
206
269
|
// usage
|
|
207
|
-
const
|
|
208
|
-
<img 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
|
|
40
|
-
if(!
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
59
|
+
return record || null;
|
|
51
60
|
}
|
|
52
61
|
catch(err){
|
|
53
62
|
console.error(err.message);
|