shared-http-cache 1.0.2 → 1.0.4

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.
Files changed (3) hide show
  1. package/index.js +12 -8
  2. package/package.json +1 -1
  3. package/readme.md +52 -74
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:(result:{buffer:Buffer,headers:Headers,fromCache:boolean})=>void}>} requests
13
- * @returns {Promise<this|{url: string, headers?: Headers, error: Error }[]>}
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
- await this.store.put(this.cacheDir, url, buffer, integrity ? { metadata: { headers }, integrity } : { metadata: { headers } });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shared-http-cache",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Node.Js Utility for fetching multiple HTTP resources with browser-like cache management.",
5
5
  "keywords": [
6
6
  "http-cache",
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,11 +17,11 @@ 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 are excluded from storage (request `Authorization` header, response `Cache-Control="private"` and `Set-Cookie` headers).
19
- - Variant responses are excluded from storage (response header `Vary` present).
20
- - Partial content is not stored (response `Content-Range` header).
21
- - No heuristic freshness is used.
20
+ - Private responses (request `Authorization`, response `Cache-Control="private"` and `Set-Cookie` headers) are not stored.
21
+ - Variant responses (response header `Vary` present) are not stored.
22
+ - Partial content (response `Content-Range` header) is not stored.
22
23
  - Time calculations rely exclusively on locally recorded timestamps, not on server-provided `Date`.
24
+ - No heuristic freshness is used.
23
25
  - Storage and eviction are deterministic; no background or implicit cleanup is assumed.
24
26
 
25
27
  ### Request initialization and cache lookup
@@ -28,7 +30,7 @@ Each request begins by determining whether a cached response exists.
28
30
 
29
31
  If no cached entry exists:
30
32
 
31
- - If the request requires `only-if-cached`, the cache returns a `504 HTTP status`.
33
+ - If the request `Cache-Control` header has `only-if-cached` directive, the cache returns a `504 HTTP status`.
32
34
  - Otherwise, the request is sent to the origin server.
33
35
 
34
36
  ### Cache-Control exclusions
@@ -41,12 +43,12 @@ Two `Cache-Control` directives may short-circuit normal cache usage:
41
43
 
42
44
  ### Freshness evaluation
43
45
 
44
- If a cached response exists and is not excluded, strict freshness is evaluated first, without considering `max-stale`.
46
+ If a cached response exists and is not excluded, strict freshness is evaluated first.
45
47
 
46
48
  Freshness lifetime is computed from response headers:
47
49
 
48
- - `s-maxage` first, then `max-age`, if present
49
- - otherwise `Expires`, if present
50
+ - `Cache-Control` header's`s-maxage` directive first, then `max-age`, if present
51
+ - otherwise `Expires` header, if present
50
52
 
51
53
  Current age is derived from local metadata:
52
54
 
@@ -62,7 +64,7 @@ Remaining freshness:
62
64
  remainingFreshness = freshnessLifetime − currentAge
63
65
  ```
64
66
 
65
- If request includes `min-fresh` its value is deducted from the `remainingFreshness`:
67
+ If request `Cache-Control` header includes `min-fresh` directive, its value is deducted from the `remainingFreshness`:
66
68
 
67
69
  ```excel-formula
68
70
  remainingFreshness = remainingFreshness − minimumFreshness
@@ -72,27 +74,37 @@ If `remainingFreshness ≥ 0`, the response is served as fresh.
72
74
 
73
75
  ### Stale handling
74
76
 
75
- If the response is stale, `max-stale` on the request is evaluated.
77
+ If the response is stale, request `Cache-Control` header's `max-stale` directive is evaluated, if present.
76
78
 
77
- If `max-stale` value is unspecified → accept any staleness; otherwise the response is acceptable if:
79
+ If `max-stale` is present, but its value is unspecified → accept any staleness; otherwise the response is acceptable if:
78
80
 
79
81
  ```excel-formula
80
- currentAge freshnessLifetime max-stale
82
+ currentAge freshnessLifetime + maximumStaleness
81
83
  ```
82
84
 
83
85
  If staleness exceeds the acceptable `max-stale`, the cache proceeds toward revalidation or origin fetch.
84
86
 
85
87
  ### Revalidation constraints
86
88
 
87
- Even if `max-stale` allows use of stale data:
89
+ Even that request `Cache-Control` header's `max-stale` directive allows use of stale data:
88
90
 
89
- - `must-revalidate` or `proxy-revalidate` on the response forbids serving stale.
91
+ - response `Cache-Control` header's `must-revalidate` or `proxy-revalidate` diretives forbids serving stale.
90
92
  - In that case, the cache must revalidate or fetch from the origin.
91
- - If `only-if-cached` also applies, the cache returns a `504 HTTP status` instead of revalidating.
93
+ - If request `Cache-Control` header's `only-if-cached` directive also applies, the cache returns a `504 HTTP status` instead of revalidating.
92
94
  - If no revalidation constraint applies, stale content may be served.
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:
@@ -123,7 +135,7 @@ The accompanying state diagram represents the full decision flow:
123
135
  Legend
124
136
 
125
137
  1. `no-cache` may appear on request or response and always requires revalidation.
126
- 2. `no-store` may appear on request or response; See [scope and assumptions](#scope-and-assumptions) to understand other storage limitations.
138
+ 2. `no-store` may appear on request or response; See [scope and assumptions](#scope-and-assumptions) for storage limitations.
127
139
  3. [Freshness evaluation](#freshness-evaluation) excludes `max-stale` that is evaluated only after strict freshness fails.
128
140
  4. `410 Gone` [cleanup](#cleanup-behavior) is an explicit design choice to keep the cache coherent; no heuristic eviction is used.
129
141
 
@@ -141,8 +153,6 @@ npm i shared-http-cache
141
153
  new SharedHttpCache(options?) -> SharedHttpCache
142
154
  ```
143
155
 
144
- ### Create a shared HTTP cache instance.
145
-
146
156
  ```js
147
157
  const SharedHttpCache = require('shared-http-cache');
148
158
  const sharedHttpCache = new SharedHttpCache();
@@ -158,10 +168,7 @@ new SharedHttpCache({ cacheDir?: string, awaitStorage?: boolean }) -> SharedHttp
158
168
  - `awaitStorage`: await cache writes before continuing (default `false`)
159
169
 
160
170
  ```js
161
- const sharedHttpCache = new SharedHttpCache({
162
- cacheDir: '/tmp/http-cache',
163
- awaitStorage: true,
164
- });
171
+ const sharedHttpCache = new SharedHttpCache({ cacheDir: '/tmp/http-cache', awaitStorage: true });
165
172
  ```
166
173
 
167
174
  ### Fetch
@@ -184,9 +191,7 @@ fetch([{ url: string, integrity?: string, options?: RequestInit, callback?: func
184
191
  await sharedHttpCache.fetch([
185
192
  {
186
193
  url: 'https://example.com/data.txt',
187
- callback: ({ buffer }) => {
188
- console.log(buffer.toString());
189
- },
194
+ callback: ({ buffer }) => console.log(buffer.toString()),
190
195
  },
191
196
  ]);
192
197
  ```
@@ -198,7 +203,7 @@ Errors encountered during fetches are collected, and the returned `promise` eith
198
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`.
199
204
 
200
205
  ```ts
201
- callback({ buffer: Buffer, headers: Headers, fromCache: boolean }) -> void
206
+ callback({ buffer: Buffer, headers: Headers, fromCache: boolean, index: number }) -> void
202
207
  ```
203
208
 
204
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()`.
@@ -208,36 +213,29 @@ await sharedHttpCache
208
213
  .fetch([
209
214
  {
210
215
  url: 'https://example.com/data.txt',
211
- callback: ({ buffer, headers, fromCache }) => {
216
+ callback: ({ buffer, headers, fromCache, index }) => {
212
217
  console.log(buffer.toString());
213
218
  console.log(headers);
214
- console.log(fromCache);
219
+ console.log(index, fromCache);
215
220
  },
216
221
  },
217
222
  ])
218
- .catch((errors) => {
219
- errors.forEach((entry) => {
220
- console.error(entry.url, entry.error);
221
- });
222
- });
223
+ .catch((errors) => errors.forEach((entry) => console.error(entry.index, entry.url, entry.error.message)));
223
224
  ```
224
225
 
225
226
  ### Fetch multiple files
226
227
 
227
228
  ```js
228
229
  const urls = ['https://example.com/file1', 'https://example.com/file2'];
229
- const parser = ({ url, buffer, headers, fromCache }) => {
230
- console.log(url);
230
+ const parser = ({ url, buffer, headers, fromCache, index }) => {
231
+ console.log(index, fromCache, url);
231
232
  console.log(headers);
232
- console.log(fromCache);
233
233
  console.log(buffer.toString());
234
234
  };
235
235
 
236
236
  const requests = urls.map((url) => ({ url, callback: (response) => parser({ ...response, url }) }));
237
237
 
238
- sharedHttpCache.fetch(requests).catch((errors) => {
239
- errors.forEach((entry) => console.error(entry.url, entry.error));
240
- });
238
+ sharedHttpCache.fetch(requests).catch((errors) => errors.forEach((entry) => console.error(entry.index, entry.url, entry.error.message)));
241
239
  ```
242
240
 
243
241
  ### Fetch with integrity
@@ -247,15 +245,11 @@ await sharedHttpCache.fetch([
247
245
  {
248
246
  url: 'https://example.com/file.bin',
249
247
  integrity: 'sha256-abcdef...',
250
- callback: ({ buffer }) => {
251
- console.log(buffer.length);
252
- },
248
+ callback: ({ buffer }) => console.log(buffer.length),
253
249
  },
254
250
  ]);
255
251
  ```
256
252
 
257
- **Note:** `integrity `affects storage, not `callback` execution.
258
-
259
253
  ### Fetch options
260
254
 
261
255
  ```ts
@@ -271,14 +265,8 @@ They follow standard [RequestInit](https://developer.mozilla.org/en-US/docs/Web/
271
265
  await sharedHttpCache.fetch([
272
266
  {
273
267
  url: 'https://api.example.com/list',
274
- options: {
275
- headers: {
276
- Accept: 'application/json',
277
- },
278
- },
279
- callback: ({ buffer }) => {
280
- console.log(buffer.toString());
281
- },
268
+ options: { headers: { Accept: 'application/json' } },
269
+ callback: ({ buffer }) => console.log(buffer.toString()),
282
270
  },
283
271
  ]);
284
272
  ```
@@ -289,14 +277,8 @@ await sharedHttpCache.fetch([
289
277
  await sharedHttpCache.fetch([
290
278
  {
291
279
  url: 'https://example.com/data',
292
- options: {
293
- headers: {
294
- 'Cache-Control': 'no-cache',
295
- },
296
- },
297
- callback: ({ fromCache }) => {
298
- console.log(fromCache);
299
- },
280
+ options: { headers: { 'Cache-Control': 'no-cache' } },
281
+ callback: ({ fromCache }) => console.log(fromCache),
300
282
  },
301
283
  ]);
302
284
  ```
@@ -307,14 +289,8 @@ await sharedHttpCache.fetch([
307
289
  await sharedHttpCache.fetch([
308
290
  {
309
291
  url: 'https://example.com/data',
310
- options: {
311
- headers: {
312
- 'Cache-Control': 'max-stale=3600',
313
- },
314
- },
315
- callback: ({ fromCache }) => {
316
- console.log(fromCache);
317
- },
292
+ options: { headers: { 'Cache-Control': 'max-stale=3600' } },
293
+ callback: ({ fromCache }) => console.log(fromCache),
318
294
  },
319
295
  ]);
320
296
  ```
@@ -325,12 +301,8 @@ await sharedHttpCache.fetch([
325
301
  await sharedHttpCache.fetch([
326
302
  {
327
303
  url: 'https://example.com/resource',
328
- options: {
329
- method: 'HEAD',
330
- },
331
- callback: ({ headers }) => {
332
- console.log(headers);
333
- },
304
+ options: { method: 'HEAD' },
305
+ callback: ({ headers }) => console.log(headers),
334
306
  },
335
307
  ]);
336
308
  ```
@@ -372,3 +344,9 @@ Other available operations
372
344
  - sharedHttpCache.store.rm.content(...)
373
345
 
374
346
  See full list of [cacache options](https://github.com/npm/cacache?tab=readme-ov-file#api).
347
+
348
+ ## Bottom line
349
+
350
+ - `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.
351
+ - providing `integrity` on requests enables fast loads by allowing cached content to be read directly from store.
352
+ - cache cleanup and eviction are deliberately left to the consumer; a well-chosen cleanup strategy is essential for maintaining good performance.