shared-http-cache 1.0.3 → 1.0.5
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/index.js +12 -8
- package/package.json +1 -1
- package/readme.md +64 -19
- package/z.js +0 -1
package/index.js
CHANGED
|
@@ -9,11 +9,12 @@ class SharedHttpCache {
|
|
|
9
9
|
}
|
|
10
10
|
/**
|
|
11
11
|
* Fetch multiple resources with HTTP cache support.
|
|
12
|
-
* @param {Array<{url:string,integrity?:string,options?:RequestInit,callback:(
|
|
13
|
-
* @returns {Promise<this|{url:
|
|
12
|
+
* @param {Array<{url:string,integrity?:string,options?:RequestInit,callback:(response:{buffer:Buffer,headers:Headers,fromCache:boolean,index:number})=>void}>} requests
|
|
13
|
+
* @returns {Promise<this|{url:string,headers?:Headers,error:Error,index:number}[]>}
|
|
14
14
|
* @see [RequestInit](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit), [Headers](https://developer.mozilla.org/en-US/docs/Web/API/Headers) MDN references
|
|
15
15
|
*/
|
|
16
16
|
async fetch(requests) {
|
|
17
|
+
if (!Array.isArray(requests)) return Promise.reject([{ error: new TypeError('requests must be an array.') }]);
|
|
17
18
|
const errors = [];
|
|
18
19
|
const parseHeader = (string) => {
|
|
19
20
|
if (!string || typeof string !== 'string') return {};
|
|
@@ -47,8 +48,9 @@ class SharedHttpCache {
|
|
|
47
48
|
return false;
|
|
48
49
|
};
|
|
49
50
|
await Promise.all(
|
|
50
|
-
requests.map(async (request) => {
|
|
51
|
+
requests.map(async (request, index) => {
|
|
51
52
|
const { url, options = {}, integrity, callback } = request;
|
|
53
|
+
if (typeof url !== 'string') return errors.push({ error: new Error('Malformed request, url undefined.'), index });
|
|
52
54
|
if (!options.method) options.method = 'GET';
|
|
53
55
|
if (!options.headers) options.headers = {};
|
|
54
56
|
Object.keys(options.headers).forEach((key) => /\p{Lu}/u.test(key) && ((options.headers[key.toLowerCase()] = options.headers[key]), delete options.headers[key]));
|
|
@@ -64,7 +66,7 @@ class SharedHttpCache {
|
|
|
64
66
|
if (!isFreshFile || requestCacheControl['no-cache'] || responseCacheControl['no-cache']) fromCache = false;
|
|
65
67
|
if (requestCacheControl['max-stale'] && (responseCacheControl['must-revalidate'] || responseCacheControl['proxy-revalidate'])) fromCache = false;
|
|
66
68
|
if (fromCache) {
|
|
67
|
-
buffer = (await this.store.get(this.cacheDir, url)).data;
|
|
69
|
+
buffer = integrity && !requestCacheControl['max-stale'] ? await this.store.get.byDigest(this.cacheDir, integrity) : (await this.store.get(this.cacheDir, url)).data;
|
|
68
70
|
headers = file.metadata.headers;
|
|
69
71
|
}
|
|
70
72
|
} else {
|
|
@@ -72,6 +74,7 @@ class SharedHttpCache {
|
|
|
72
74
|
if (requestCacheControl['only-if-cached']) throw new Error('HTTP error! status: 504 Only-If-Cached');
|
|
73
75
|
}
|
|
74
76
|
if (!fromCache) {
|
|
77
|
+
if (!file && integrity) options.integrity = integrity;
|
|
75
78
|
if (file && file.metadata.headers['etag']) options.headers['if-none-match'] = file.metadata.headers['etag'];
|
|
76
79
|
if (file && file.metadata.headers['last-modified']) options.headers['if-modified-since'] = file.metadata.headers['last-modified'];
|
|
77
80
|
response = await fetch(url, options);
|
|
@@ -83,7 +86,7 @@ class SharedHttpCache {
|
|
|
83
86
|
buffer = Buffer.from(await response.arrayBuffer());
|
|
84
87
|
headers = Object.fromEntries(response.headers.entries());
|
|
85
88
|
} else {
|
|
86
|
-
if (response.status === 410) {
|
|
89
|
+
if (file && response.status === 410) {
|
|
87
90
|
this.store.rm.entry(this.cacheDir, url, { removeFully: true });
|
|
88
91
|
this.store.rm.content(this.cacheDir, file.integrity);
|
|
89
92
|
}
|
|
@@ -91,7 +94,7 @@ class SharedHttpCache {
|
|
|
91
94
|
}
|
|
92
95
|
}
|
|
93
96
|
// chance to preform content validation before saving it to disk
|
|
94
|
-
if (typeof callback === 'function') callback({ buffer, headers, fromCache });
|
|
97
|
+
if (typeof callback === 'function') callback({ buffer, headers, fromCache, index });
|
|
95
98
|
if (!fromCache || response?.status === 304) {
|
|
96
99
|
const responseCacheControl = parseHeader(headers['cache-control']);
|
|
97
100
|
if (options.method !== 'GET') return;
|
|
@@ -100,12 +103,13 @@ class SharedHttpCache {
|
|
|
100
103
|
if (requestCacheControl['no-store'] || options.headers['authorization']) return;
|
|
101
104
|
const store = async () => {
|
|
102
105
|
await this.store.rm.entry(this.cacheDir, url, { removeFully: true });
|
|
103
|
-
|
|
106
|
+
if (file && integrity && file.integrity !== integrity) this.store.rm.content(this.cacheDir, file.integrity);
|
|
107
|
+
await this.store.put(this.cacheDir, url, buffer, integrity ? { metadata: { headers }, integrity, algorithms: [integrity.split('-')[0]] } : { metadata: { headers } });
|
|
104
108
|
};
|
|
105
109
|
this.awaitStorage ? await store() : store();
|
|
106
110
|
}
|
|
107
111
|
} catch (error) {
|
|
108
|
-
errors.push({ url, headers, error });
|
|
112
|
+
errors.push({ url, headers, error, index });
|
|
109
113
|
}
|
|
110
114
|
}),
|
|
111
115
|
);
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
---
|
|
2
|
+
|
|
2
3
|
title: Shared HTTP Cache
|
|
3
4
|
|
|
4
5
|
description: Node.Js Utility for fetching multiple HTTP resources with browser-like cache management.
|
|
6
|
+
|
|
5
7
|
---
|
|
6
8
|
|
|
7
9
|
## Overview
|
|
@@ -15,7 +17,7 @@ This implementation models a shared HTTP cache that follows the semantics define
|
|
|
15
17
|
The cache is shared (not private) and applies shared-cache rules.
|
|
16
18
|
|
|
17
19
|
- Request `methods` other than `GET` are not stored.
|
|
18
|
-
- Private responses (request `Authorization
|
|
20
|
+
- Private responses (request `Authorization`, response `Cache-Control="private"` and `Set-Cookie` headers) are not stored.
|
|
19
21
|
- Variant responses (response header `Vary` present) are not stored.
|
|
20
22
|
- Partial content (response `Content-Range` header) is not stored.
|
|
21
23
|
- Time calculations rely exclusively on locally recorded timestamps, not on server-provided `Date`.
|
|
@@ -93,6 +95,16 @@ Even that request `Cache-Control` header's `max-stale` directive allows use of s
|
|
|
93
95
|
|
|
94
96
|
On revalidation, if the cached content includes `ETag` or `Last-Modified` automatically `If-None-Match` and `If-Modified-Since` headers are added to the request. Revalidated entries are explicitly replaced during each successful fetch to avoid unbounded growth in the index.
|
|
95
97
|
|
|
98
|
+
### Subresource integrity
|
|
99
|
+
|
|
100
|
+
The [subresosurce integrity specifications](https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Subresource_Integrity) are implemented on both, fetch and storage:
|
|
101
|
+
1. if a request does not provide an `integrity hash`, then `cacache` will compute it using its default algorithm: `sha512`, and subsequent requests may use that `hash` to retrieve the resource directly from cache using `store.get.byDigest` function along with the regular search by url `store.get`.
|
|
102
|
+
1. if a request does provide an `integrity hash`:
|
|
103
|
+
- if the related resource is not stored, then `node:fetch` will use it to verify the incomming response,
|
|
104
|
+
- if the related resource is stored and fresh, or is stale but revalidated with origin server, then `cacache` will use the `hash` to:
|
|
105
|
+
- get the resource directly from cache if the stored `integrity hash` matches the one provided, or,
|
|
106
|
+
- recompute the path and rebase the resource on the new path if the provided `integrity hash` is different. In other words, multiple `integrity hashes` may validate a resource, but only the last provided `hash` is responsible for its storage path, as `cacache` can work with a single `algorithm` at a time. This situation may only be encountered when a resource was initially stored without an `integrity hash` provided in the request, or when a different `integrity hash` is provided in the request for the same resource. An exception for resource rebase is the case when a different `integrity hash` is provided in the request along with `Cache-Control` header's `max-stale` directive.
|
|
107
|
+
|
|
96
108
|
### Origin request outcomes
|
|
97
109
|
|
|
98
110
|
When a request is sent to the origin:
|
|
@@ -191,7 +203,7 @@ Errors encountered during fetches are collected, and the returned `promise` eith
|
|
|
191
203
|
The response is converted into a [Buffer](https://nodejs.org/api/buffer.html) served to `callback`, then stored in the `cache` along with the `response headers`.
|
|
192
204
|
|
|
193
205
|
```ts
|
|
194
|
-
callback({ buffer: Buffer, headers: Headers, fromCache: boolean }) -> void
|
|
206
|
+
callback({ buffer: Buffer, headers: Headers, fromCache: boolean, index: number }) -> void
|
|
195
207
|
```
|
|
196
208
|
|
|
197
209
|
The `callback` provided for each request is executed before storing new content, allowing implementers to inspect, transform or validate the data before it's cached. The errors thrown by the `callback` are also catched and stored in the `errors` delivered by the `Promise.reject()`.
|
|
@@ -201,30 +213,29 @@ await sharedHttpCache
|
|
|
201
213
|
.fetch([
|
|
202
214
|
{
|
|
203
215
|
url: 'https://example.com/data.txt',
|
|
204
|
-
callback: ({ buffer, headers, fromCache }) => {
|
|
216
|
+
callback: ({ buffer, headers, fromCache, index }) => {
|
|
205
217
|
console.log(buffer.toString());
|
|
206
218
|
console.log(headers);
|
|
207
|
-
console.log(fromCache);
|
|
219
|
+
console.log(index, fromCache);
|
|
208
220
|
},
|
|
209
221
|
},
|
|
210
222
|
])
|
|
211
|
-
.catch((errors) => errors.forEach((entry) => console.error(entry.url, entry.error)));
|
|
223
|
+
.catch((errors) => errors.forEach((entry) => console.error(entry.index, entry.url, entry.error.message)));
|
|
212
224
|
```
|
|
213
225
|
|
|
214
226
|
### Fetch multiple files
|
|
215
227
|
|
|
216
228
|
```js
|
|
217
229
|
const urls = ['https://example.com/file1', 'https://example.com/file2'];
|
|
218
|
-
const parser = ({ url, buffer, headers, fromCache }) => {
|
|
219
|
-
console.log(url);
|
|
230
|
+
const parser = ({ url, buffer, headers, fromCache, index }) => {
|
|
231
|
+
console.log(index, fromCache, url);
|
|
220
232
|
console.log(headers);
|
|
221
|
-
console.log(fromCache);
|
|
222
233
|
console.log(buffer.toString());
|
|
223
234
|
};
|
|
224
235
|
|
|
225
236
|
const requests = urls.map((url) => ({ url, callback: (response) => parser({ ...response, url }) }));
|
|
226
237
|
|
|
227
|
-
sharedHttpCache.fetch(requests).catch((errors) => errors.forEach((entry) => console.error(entry.url, entry.error)));
|
|
238
|
+
sharedHttpCache.fetch(requests).catch((errors) => errors.forEach((entry) => console.error(entry.index, entry.url, entry.error.message)));
|
|
228
239
|
```
|
|
229
240
|
|
|
230
241
|
### Fetch with integrity
|
|
@@ -239,8 +250,6 @@ await sharedHttpCache.fetch([
|
|
|
239
250
|
]);
|
|
240
251
|
```
|
|
241
252
|
|
|
242
|
-
**Note:** `integrity `affects storage, not `callback` execution.
|
|
243
|
-
|
|
244
253
|
### Fetch options
|
|
245
254
|
|
|
246
255
|
```ts
|
|
@@ -306,6 +315,16 @@ The underlying cache store (`cacache`) is exposed directly.
|
|
|
306
315
|
sharedHttpCache.store -> cacache
|
|
307
316
|
```
|
|
308
317
|
|
|
318
|
+
**Listing (example with promise)**
|
|
319
|
+
|
|
320
|
+
```js
|
|
321
|
+
sharedHttpCache
|
|
322
|
+
.fetch(requests)
|
|
323
|
+
.then((sharedHttpCache) => sharedHttpCache.store.ls(sharedHttpCache.cacheDir))
|
|
324
|
+
.then(console.log)
|
|
325
|
+
.catch((errors) => console.error('Errors:', errors));
|
|
326
|
+
```
|
|
327
|
+
|
|
309
328
|
**Compacting (example with await)**
|
|
310
329
|
|
|
311
330
|
```ts
|
|
@@ -313,20 +332,38 @@ sharedHttpCache.store.verify(cacheDir) -> Promise<Object>
|
|
|
313
332
|
```
|
|
314
333
|
|
|
315
334
|
```js
|
|
316
|
-
|
|
335
|
+
// deadbeef collected, because of invalid checksum.
|
|
336
|
+
sharedHttpCache.store.verify(sharedHttpCache.cacheDir).then((stats) => {
|
|
337
|
+
console.log('cache is much nicer now! stats:', stats);
|
|
338
|
+
});
|
|
317
339
|
```
|
|
318
340
|
|
|
319
|
-
**
|
|
341
|
+
**Basic cleanup strategy**
|
|
320
342
|
|
|
321
343
|
```js
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
344
|
+
const SharedHttpCache = require('shared-http-cache');
|
|
345
|
+
// only-if-cached also means ... and is not stale!
|
|
346
|
+
(async () => {
|
|
347
|
+
const cache = new SharedHttpCache({ cacheDir: '.cache', awaitStorage: true });
|
|
348
|
+
const entries = await cache.store.ls(cache.cacheDir);
|
|
349
|
+
const requests = Object.keys(entries).map((url) => ({ url, options: { headers: { 'cache-control': 'only-if-cached' } } }));
|
|
350
|
+
await cache.fetch(requests).catch(async (errors) => {
|
|
351
|
+
for (const { url } of errors) {
|
|
352
|
+
const file = url && await cache.store.get.info(cache.cacheDir, url);
|
|
353
|
+
if (file) {
|
|
354
|
+
await cache.store.rm.entry(cache.cacheDir, url, { removeFully: true });
|
|
355
|
+
await cache.store.rm.content(cache.cacheDir, file.integrity);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
})();
|
|
327
360
|
```
|
|
328
361
|
|
|
329
|
-
|
|
362
|
+
**Note:**
|
|
363
|
+
|
|
364
|
+
- This is a fully `RFC 9111` compliant strategy that cleans up all the resources that can be determined as expired based on the stored response headers. For a more flexible approach, `max-stale=$acceptedStaleness` directive can be used in conjunction with `only-if-cached`. Cleanup strategies that rely on empirical calculations, such as `least recently used`, are `NOT RECOMMENDED`.
|
|
365
|
+
|
|
366
|
+
**Other available operations**
|
|
330
367
|
|
|
331
368
|
- sharedHttpCache.store.put(...)
|
|
332
369
|
- sharedHttpCache.store.get(...)
|
|
@@ -335,3 +372,11 @@ Other available operations
|
|
|
335
372
|
- sharedHttpCache.store.rm.content(...)
|
|
336
373
|
|
|
337
374
|
See full list of [cacache options](https://github.com/npm/cacache?tab=readme-ov-file#api).
|
|
375
|
+
|
|
376
|
+
## Bottom line
|
|
377
|
+
|
|
378
|
+
- `max-stale` is intended to be used: many servers enforce `max-age=0`, but clients usually know how much staleness they can tolerate. Using `max-stale` (recommended up to 24 h) can significantly reduce network requests.
|
|
379
|
+
- providing `integrity` on requests enables fast loads by allowing cached content to be read directly from store.
|
|
380
|
+
- `SharedHttpCache` instantiation with `awaitStorage: true` is important when `fetch` is continued with `store` actions.
|
|
381
|
+
- private or sensitive content is served, but not stored.
|
|
382
|
+
- cache cleanup and eviction are deliberately left to the consumer; a well-chosen cleanup strategy is essential for maintaining good performance.
|
package/z.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
sharedHttpCache.fetch(requests).catch((errors) => errors.forEach((entry) => console.error(entry.url, entry.error)));
|