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 +80 -18
- package/cachel.js +89 -8
- package/lib/idb.js +12 -3
- package/package.json +1 -1
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
|

|
|
8
|
-

|
|
9
8
|

|
|
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
|
|
46
|
-
const
|
|
47
|
-
img.src =
|
|
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
|
|
121
|
+
Retrieves a cached asset. Returns `null` if not found.
|
|
113
122
|
|
|
114
123
|
```javascript
|
|
115
|
-
const
|
|
116
|
-
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
|
+
}
|
|
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
|
|
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
|
-
|
|
176
|
-
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;
|
|
177
239
|
}
|
|
178
240
|
|
|
179
241
|
ngOnDestroy() {
|
|
180
|
-
if (this.
|
|
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 [
|
|
259
|
+
const [record, setRecord] = useState(null);
|
|
198
260
|
|
|
199
261
|
useEffect(() => {
|
|
200
|
-
cache.load(url).then(() => cache.get(url)).then(
|
|
201
|
-
return () => { if (
|
|
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
|
|
266
|
+
return record;
|
|
205
267
|
}
|
|
206
268
|
|
|
207
269
|
// usage
|
|
208
|
-
const
|
|
209
|
-
<img 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
|
|
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);
|