shared-http-cache 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SorinGFS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
Binary file
package/index.js ADDED
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+ // utility for fetching multiple URLs with HTTP caching management
3
+ const cacache = require('cacache');
4
+ // cacache wrapper
5
+ class SharedHttpCache {
6
+ constructor(options = {}) {
7
+ Object.assign(this, { cacheDir: '.cache', awaitStorage: false }, options);
8
+ this.store = cacache;
9
+ }
10
+ /**
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 }[]>}
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
+ */
16
+ async fetch(requests) {
17
+ const errors = [];
18
+ const parseHeader = (string) => {
19
+ if (!string || typeof string !== 'string') return {};
20
+ const result = {};
21
+ for (const part of string.split(',').reverse()) {
22
+ const [key, value] = part.trim().split('=');
23
+ result[key] = value === undefined ? true : Number.isNaN(+value) ? value : +value;
24
+ }
25
+ return result;
26
+ };
27
+ const isFresh = (file, requestCacheControl = {}) => {
28
+ const storedCacheControl = parseHeader(file.metadata.headers['cache-control']);
29
+ const previousAge = Number(file.metadata.headers['age'] || 0);
30
+ const currentAge = previousAge + (Date.now() - file.time) / 1000;
31
+ let lifetime; // Response freshness lifetime (s-maxage > max-age > Expires)
32
+ if (storedCacheControl['s-maxage'] !== undefined) lifetime = storedCacheControl['s-maxage'];
33
+ else if (storedCacheControl['max-age'] !== undefined) lifetime = storedCacheControl['max-age'];
34
+ else {
35
+ const expires = Date.parse(file.metadata.headers['expires'] || '');
36
+ lifetime = !Number.isNaN(expires) ? Math.max(0, (expires - file.time) / 1000) : 0;
37
+ }
38
+ if (requestCacheControl['max-age'] !== undefined) lifetime = Math.min(lifetime, requestCacheControl['max-age']);
39
+ const remainingLifetime = lifetime - currentAge;
40
+ if (requestCacheControl['min-fresh'] !== undefined && remainingLifetime < requestCacheControl['min-fresh']) return false;
41
+ if (remainingLifetime >= 0) return true; // not stale
42
+ const maxStale = requestCacheControl['max-stale'];
43
+ if (maxStale !== undefined) {
44
+ if (maxStale === true) return true; // unspecified max-stale → accept any staleness
45
+ if (currentAge <= lifetime + maxStale) return true;
46
+ }
47
+ return false;
48
+ };
49
+ await Promise.all(
50
+ requests.map(async (request) => {
51
+ const { url, options = {}, integrity, callback } = request;
52
+ if (!options.method) options.method = 'GET';
53
+ if (!options.headers) options.headers = {};
54
+ Object.keys(options.headers).some((key) => key.toLowerCase() === 'cache-control' && (options.headers['cache-control'] = options.headers[key]));
55
+ Object.keys(options.headers).some((key) => key.toLowerCase() === 'authorization' && (options.headers['authorization'] = options.headers[key]));
56
+ // prettier-ignore
57
+ let response, buffer, headers, fromCache = true;
58
+ try {
59
+ const requestCacheControl = parseHeader(options.headers['cache-control']);
60
+ const file = await this.store.get.info(this.cacheDir, url);
61
+ const isFreshFile = file && isFresh(file, requestCacheControl);
62
+ if (file) {
63
+ const responseCacheControl = parseHeader(file.metadata.headers['cache-control']);
64
+ if (!isFreshFile && requestCacheControl['only-if-cached']) throw new Error('HTTP error! status: 504 Only-If-Cached');
65
+ if (!isFreshFile || requestCacheControl['no-cache'] || responseCacheControl['no-cache']) fromCache = false;
66
+ if (requestCacheControl['max-stale'] && (responseCacheControl['must-revalidate'] || responseCacheControl['proxy-revalidate'])) fromCache = false;
67
+ if (fromCache) {
68
+ buffer = (await this.store.get(this.cacheDir, url)).data;
69
+ headers = file.metadata.headers;
70
+ }
71
+ } else {
72
+ fromCache = false;
73
+ if (requestCacheControl['only-if-cached']) throw new Error('HTTP error! status: 504 Only-If-Cached');
74
+ }
75
+ if (!fromCache) {
76
+ if (file && file.metadata.headers['etag']) options.headers['if-none-match'] = file.metadata.headers['etag'];
77
+ if (file && file.metadata.headers['last-modified']) options.headers['if-modified-since'] = file.metadata.headers['last-modified'];
78
+ response = await fetch(url, options);
79
+ if (response.status === 304) {
80
+ buffer = (await this.store.get(this.cacheDir, url)).data;
81
+ headers = { ...file.metadata.headers, ...Object.fromEntries(response.headers.entries()) };
82
+ fromCache = true;
83
+ } else if (response.ok) {
84
+ buffer = Buffer.from(await response.arrayBuffer());
85
+ headers = Object.fromEntries(response.headers.entries());
86
+ } else {
87
+ if (response.status === 410) {
88
+ this.store.rm.entry(this.cacheDir, url, { removeFully: true });
89
+ this.store.rm.content(this.cacheDir, file.integrity);
90
+ }
91
+ throw new Error(`HTTP error! status: ${response.status}`);
92
+ }
93
+ }
94
+ // chance to preform content validation before saving it to disk
95
+ if (typeof callback === 'function') callback({ buffer, headers, fromCache });
96
+ if (!fromCache || response?.status === 304) {
97
+ const responseCacheControl = parseHeader(headers['cache-control']);
98
+ if (options.method !== 'GET') return;
99
+ if (responseCacheControl['no-store'] || responseCacheControl['private']) return;
100
+ if (requestCacheControl['no-store'] || requestCacheControl['authorization']) return;
101
+ const store = async () => {
102
+ 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 } });
104
+ };
105
+ this.awaitStorage ? await store() : store();
106
+ }
107
+ } catch (error) {
108
+ errors.push({ url, headers, error });
109
+ }
110
+ }),
111
+ );
112
+ return errors.length ? Promise.reject(errors) : Promise.resolve(this);
113
+ }
114
+ }
115
+ // export
116
+ module.exports = SharedHttpCache;
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "shared-http-cache",
3
+ "version": "1.0.0",
4
+ "description": "Node.Js Utility for fetching multiple HTTP resources with browser-like cache management.",
5
+ "keywords": [
6
+ "http-cache",
7
+ "cache",
8
+ "cacache",
9
+ "rfc9111",
10
+ "cache-manager",
11
+ "http-cache-semantics"
12
+ ],
13
+ "homepage": "https://github.com/SorinGFS/shared-http-cache#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/SorinGFS/shared-http-cache/issues"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/SorinGFS/shared-http-cache.git"
20
+ },
21
+ "license": "MIT",
22
+ "author": "SorinGFS",
23
+ "type": "commonjs",
24
+ "main": "index.js",
25
+ "scripts": {
26
+ "test": "echo \"Error: no test specified\" && exit 1"
27
+ },
28
+ "dependencies": {
29
+ "cacache": "^20.0.3"
30
+ }
31
+ }
package/readme.md ADDED
@@ -0,0 +1,348 @@
1
+ ---
2
+
3
+ title: Shared HTTP Cache
4
+
5
+ description: Node.Js Utility for fetching multiple HTTP resources with browser-like cache management.
6
+
7
+ ---
8
+
9
+ ## Overview
10
+
11
+ ### Shared HTTP Cache Semantics
12
+
13
+ This implementation models a shared HTTP cache that follows the semantics defined in [RFC 9111](https://datatracker.ietf.org/doc/html/rfc9111.html), with explicitly documented assumptions and controlled decisions. It uses a content-addressed cache through [cacache](https://github.com/npm/cacache) providing lockless, high-concurrency cache access, that along with the downloaded `content` is storing the associated `HTTP response headers` in the cache metadata. Overall, the design aims to provide `browser-like` caching behavior in a `Node.js` environment.
14
+
15
+ ### Scope and assumptions
16
+
17
+ The cache is shared (not private) and applies shared-cache rules.
18
+
19
+ - Privacy factors are considered while storing responses (`safe method` constraints, request `authorization`, response `cache-control="private"`).
20
+ - No heuristic freshness is used.
21
+ - Time calculations rely exclusively on locally recorded timestamps, not on server-provided `Date`.
22
+ - Storage and eviction are deterministic; no background or implicit cleanup is assumed.
23
+
24
+ ### Request initialization and cache lookup
25
+
26
+ Each request begins by determining whether a cached response exists.
27
+
28
+ If no cached entry exists:
29
+
30
+ - If the request requires `only-if-cached`, the cache returns a `504 HTTP status`.
31
+ - Otherwise, the request is sent to the origin server.
32
+
33
+ ### Cache-control exclusions
34
+
35
+ Two cache-control directives may short-circuit normal cache usage:
36
+
37
+ - `no-cache` (request or response): cached data cannot be used without revalidation.
38
+ - `no-store` (request or response): the response must not be stored.
39
+ When `no-store` applies, the response is served directly and bypasses storage entirely.
40
+
41
+ ### Freshness evaluation
42
+
43
+ If a cached response exists and is not excluded, strict freshness is evaluated first, without considering `max-stale`.
44
+
45
+ Freshness lifetime is computed from response headers:
46
+
47
+ - `s-maxage` first, then `max-age`, if present
48
+ - otherwise `Expires`, if present
49
+
50
+ Current age is derived from local metadata:
51
+
52
+ ```excel-formula
53
+ currentAge = now − storedTime + incomingAge
54
+ ```
55
+
56
+ The `incomingAge` is taken from the stored response `age` header, if present.
57
+
58
+ Remaining freshness:
59
+
60
+ ```excel-formula
61
+ remainingFreshness = freshnessLifetime − currentAge
62
+ ```
63
+
64
+ If `remainingFreshness ≥ 0`, the response is served as fresh.
65
+
66
+ ### Stale handling
67
+
68
+ If the response is stale, `max-stale` on the request is evaluated.
69
+
70
+ If `max-stale` value is unspecified → accept any staleness; otherwise the response is acceptable if:
71
+
72
+ ```excel-formula
73
+ currentAge − freshnessLifetime ≤ max-stale
74
+ ```
75
+
76
+ If staleness exceeds the acceptable `max-stale`, the cache proceeds toward revalidation or origin fetch.
77
+
78
+ ### Revalidation constraints
79
+
80
+ Even if `max-stale` allows use of stale data:
81
+
82
+ - `must-revalidate` or `proxy-revalidate` on the response forbids serving stale.
83
+ - In that case, the cache must revalidate or fetch from the origin.
84
+ - If `only-if-cached` also applies, the cache returns a `504 HTTP status` instead of revalidating.
85
+ - If no revalidation constraint applies, stale content may be served.
86
+
87
+ 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.
88
+
89
+ ### Origin request outcomes
90
+
91
+ When a request is sent to the origin:
92
+
93
+ - `2xx`: response is stored (unless restricted) and served.
94
+ - `304 Not Modified`: cached metadata is updated; response is served as fresh.
95
+ - `410 Gone`: cached entry is removed.
96
+ - Other responses: treated as errors and returned directly.
97
+
98
+ ### Cleanup behavior
99
+
100
+ The cache does not:
101
+
102
+ - apply heuristic freshness
103
+ - perform automatic eviction based on staleness
104
+
105
+ However:
106
+
107
+ - a `410 Gone` response explicitly removes the cached entry.
108
+ - additional cleanup mechanisms are available to the user via the underlying storage system.
109
+
110
+ ## State diagram
111
+
112
+ The accompanying state diagram represents the full decision flow:
113
+
114
+ ![Diagram](./docs/images/cache.png)
115
+
116
+ Legend
117
+
118
+ 1. `no-cache` may appear on request or response and always requires revalidation.
119
+ 2. `no-store` may appear on request or response; [privacy factors](#scope-and-assumptions) are considered.
120
+ 3. [Freshness evaluation](#freshness-evaluation) excludes `max-stale` that is evaluated only after strict freshness fails.
121
+ 4. `410 Gone` [cleanup](#cleanup-behavior) is an explicit design choice to keep the cache coherent; no heuristic eviction is used.
122
+
123
+ ## Install
124
+
125
+ ```bash
126
+ npm i shared-http-cache
127
+ ```
128
+
129
+ ## Usage
130
+
131
+ ### Init
132
+
133
+ ```ts
134
+ new SharedHttpCache(options?) -> SharedHttpCache
135
+ ```
136
+
137
+ ### Create a shared HTTP cache instance.
138
+
139
+ ```js
140
+ const SharedHttpCache = require('shared-http-cache');
141
+ const sharedHttpCache = new SharedHttpCache();
142
+ ```
143
+
144
+ ### Init options
145
+
146
+ ```ts
147
+ new SharedHttpCache({ cacheDir?: string, awaitStorage?: boolean }) -> SharedHttpCache
148
+ ```
149
+
150
+ - `cacheDir`: cache storage directory (default `.cache`)
151
+ - `awaitStorage`: await cache writes before continuing (default `false`)
152
+
153
+ ```js
154
+ const sharedHttpCache = new SharedHttpCache({
155
+ cacheDir: '/tmp/http-cache',
156
+ awaitStorage: true,
157
+ });
158
+ ```
159
+
160
+ ### Fetch
161
+
162
+ `fetch` is the only method available. On success, `fetch` resolves to the same instance, enabling chained workflows.
163
+
164
+ ```ts
165
+ sharedHttpCache.fetch(requests) -> Promise<this | Error[]>
166
+ ```
167
+
168
+ **Syntax:**
169
+ ```ts
170
+ fetch([{ url: string, integrity?: string, options?: RequestInit, callback?: function }]) -> Promise<this | Error[]>
171
+ ```
172
+
173
+ ### Simple fetch call
174
+
175
+ ```js
176
+ await sharedHttpCache.fetch([
177
+ {
178
+ url: 'https://example.com/data.txt',
179
+ callback: ({ buffer }) => {
180
+ console.log(buffer.toString());
181
+ },
182
+ },
183
+ ]);
184
+ ```
185
+
186
+ ### Fetch with callback and error handling
187
+
188
+ Errors encountered during fetches are collected, and the returned `promise` either `resolves` with the instance itself for successful fetches or `rejects` with a list of `errors` for failed requests.
189
+
190
+ 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`. For any other status code, unless `stale-if-error` is present, an `error` is thrown.
191
+
192
+ ```ts
193
+ callback({ buffer: Buffer, headers: Headers, fromCache: boolean }) -> void
194
+ ```
195
+
196
+ 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()`.
197
+
198
+ ```js
199
+ await sharedHttpCache
200
+ .fetch([
201
+ {
202
+ url: 'https://example.com/data.txt',
203
+ callback: ({ buffer, headers, fromCache }) => {
204
+ console.log(buffer.toString());
205
+ console.log(headers);
206
+ console.log(fromCache);
207
+ },
208
+ },
209
+ ])
210
+ .catch((errors) => {
211
+ errors.forEach((entry) => {
212
+ console.error(entry.url, entry.error);
213
+ });
214
+ });
215
+ ```
216
+
217
+ ### Fetch with integrity
218
+
219
+ ```js
220
+ await sharedHttpCache.fetch([
221
+ {
222
+ url: 'https://example.com/file.bin',
223
+ integrity: 'sha256-abcdef...',
224
+ callback: ({ buffer }) => {
225
+ console.log(buffer.length);
226
+ },
227
+ },
228
+ ]);
229
+ ```
230
+
231
+ **Note:** `integrity `affects storage, not `callback` execution.
232
+
233
+ ### Fetch options
234
+
235
+ ```ts
236
+ fetch.options -> RequestInit
237
+ ```
238
+
239
+ `fetch.options` are passed directly to [node:fetch](https://nodejs.org/api/globals.html#fetch).
240
+ They follow standard [RequestInit](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit) semantics ([method](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Methods), [credentials](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit#credentials), [headers](https://developer.mozilla.org/en-US/docs/Web/API/Headers), [mode](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit#mode), [cache-mode](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit#cache), etc.).
241
+
242
+ ### Fetch with Accept: application/json
243
+
244
+ ```js
245
+ await sharedHttpCache.fetch([
246
+ {
247
+ url: 'https://api.example.com/list',
248
+ options: {
249
+ headers: {
250
+ Accept: 'application/json',
251
+ },
252
+ },
253
+ callback: ({ buffer }) => {
254
+ console.log(buffer.toString());
255
+ },
256
+ },
257
+ ]);
258
+ ```
259
+
260
+ ### Fetch with Cache-Control: no-cache
261
+
262
+ ```js
263
+ await sharedHttpCache.fetch([
264
+ {
265
+ url: 'https://example.com/data',
266
+ options: {
267
+ headers: {
268
+ 'Cache-Control': 'no-cache',
269
+ },
270
+ },
271
+ callback: ({ fromCache }) => {
272
+ console.log(fromCache);
273
+ },
274
+ },
275
+ ]);
276
+ ```
277
+
278
+ ### Fetch with Cache-Control: max-stale
279
+
280
+ ```js
281
+ await sharedHttpCache.fetch([
282
+ {
283
+ url: 'https://example.com/data',
284
+ options: {
285
+ headers: {
286
+ 'Cache-Control': 'max-stale=3600',
287
+ },
288
+ },
289
+ callback: ({ fromCache }) => {
290
+ console.log(fromCache);
291
+ },
292
+ },
293
+ ]);
294
+ ```
295
+
296
+ ### Fetch with HEAD method
297
+
298
+ ```js
299
+ await sharedHttpCache.fetch([
300
+ {
301
+ url: 'https://example.com/resource',
302
+ options: {
303
+ method: 'HEAD',
304
+ },
305
+ callback: ({ headers }) => {
306
+ console.log(headers);
307
+ },
308
+ },
309
+ ]);
310
+ ```
311
+
312
+ ### Storage management
313
+
314
+ The underlying cache store (`cacache`) is exposed directly.
315
+
316
+ ```ts
317
+ sharedHttpCache.store -> cacache
318
+ ```
319
+
320
+ **Compacting (example with await)**
321
+
322
+ ```ts
323
+ sharedHttpCache.store.verify(cacheDir) -> Promise<Object>
324
+ ```
325
+
326
+ ```js
327
+ await sharedHttpCache.store.verify(sharedHttpCache.cacheDir);
328
+ ```
329
+
330
+ **Listing (example with promise)**
331
+
332
+ ```js
333
+ sharedHttpCache
334
+ .fetch(requests)
335
+ .then((sharedHttpCache) => sharedHttpCache.store.ls(sharedHttpCache.cacheDir))
336
+ .then(console.log)
337
+ .catch((errors) => console.error('Errors:', errors));
338
+ ```
339
+
340
+ Other available operations
341
+
342
+ - sharedHttpCache.store.put(...)
343
+ - sharedHttpCache.store.get(...)
344
+ - sharedHttpCache.store.get.info(...)
345
+ - sharedHttpCache.store.rm.entry(...)
346
+ - sharedHttpCache.store.rm.content(...)
347
+
348
+ See full list of [cacache options](https://github.com/npm/cacache?tab=readme-ov-file#api).